thumbhash improvements
thumbhash render box refactor wip rebase
This commit is contained in:
@@ -477,7 +477,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
color: backgroundColor,
|
||||
child: Thumbnail(asset: asset, fit: BoxFit.contain),
|
||||
child: Thumbnail.fromBaseAsset(asset: asset, fit: BoxFit.contain),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -527,7 +527,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
color: backgroundColor,
|
||||
child: Thumbnail(asset: asset, fit: BoxFit.contain),
|
||||
child: Thumbnail.fromBaseAsset(asset: asset, fit: BoxFit.contain),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||
import 'package:octo_image/octo_image.dart';
|
||||
|
||||
class FullImage extends StatelessWidget {
|
||||
@@ -9,7 +9,7 @@ class FullImage extends StatelessWidget {
|
||||
this.asset, {
|
||||
required this.size,
|
||||
this.fit = BoxFit.cover,
|
||||
this.placeholder = const ThumbnailPlaceholder(),
|
||||
this.placeholder = const Thumbnail(),
|
||||
super.key,
|
||||
});
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ class LocalAlbumThumbnail extends ConsumerWidget {
|
||||
|
||||
return ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(16)),
|
||||
child: Thumbnail(asset: data),
|
||||
child: Thumbnail.fromBaseAsset(asset: data),
|
||||
);
|
||||
},
|
||||
error: (error, stack) {
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import 'dart:convert' hide Codec;
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:thumbhash/thumbhash.dart';
|
||||
|
||||
class ThumbHashProvider extends ImageProvider<ThumbHashProvider> {
|
||||
final String thumbHash;
|
||||
|
||||
const ThumbHashProvider({required this.thumbHash});
|
||||
|
||||
@override
|
||||
Future<ThumbHashProvider> obtainKey(ImageConfiguration configuration) {
|
||||
return SynchronousFuture(this);
|
||||
}
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(ThumbHashProvider key, ImageDecoderCallback decode) {
|
||||
return MultiFrameImageStreamCompleter(codec: _loadCodec(key, decode), scale: 1.0);
|
||||
}
|
||||
|
||||
Future<Codec> _loadCodec(ThumbHashProvider key, ImageDecoderCallback decode) async {
|
||||
final image = thumbHashToRGBA(base64Decode(key.thumbHash));
|
||||
return decode(await ImmutableBuffer.fromUint8List(rgbaToBmp(image)));
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
if (other is ThumbHashProvider) {
|
||||
return thumbHash == other.thumbHash;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => thumbHash.hashCode;
|
||||
}
|
||||
@@ -1,61 +1,402 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:ui' as ui;
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumb_hash_provider.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
|
||||
import 'package:immich_mobile/widgets/common/fade_in_placeholder_image.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
import 'package:immich_mobile/providers/image/cache/thumbnail_image_cache_manager.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:octo_image/octo_image.dart';
|
||||
import 'package:thumbhash/thumbhash.dart' as thumbhash;
|
||||
|
||||
class Thumbnail extends StatelessWidget {
|
||||
const Thumbnail({this.asset, this.remoteId, this.size = const Size.square(256), this.fit = BoxFit.cover, super.key})
|
||||
: assert(asset != null || remoteId != null, 'Either asset or remoteId must be provided');
|
||||
final log = Logger('ThumbnailWidget');
|
||||
|
||||
final BaseAsset? asset;
|
||||
final String? remoteId;
|
||||
final Size size;
|
||||
class Thumbnail extends StatefulWidget {
|
||||
final BoxFit fit;
|
||||
final Size size;
|
||||
final String? blurhash;
|
||||
final String? localId;
|
||||
final String? remoteId;
|
||||
final bool thumbhashOnly;
|
||||
|
||||
const Thumbnail({
|
||||
this.fit = BoxFit.cover,
|
||||
this.size = const Size.square(256),
|
||||
this.blurhash,
|
||||
this.localId,
|
||||
this.remoteId,
|
||||
this.thumbhashOnly = false,
|
||||
super.key,
|
||||
});
|
||||
|
||||
Thumbnail.fromAsset({
|
||||
required Asset asset,
|
||||
this.fit = BoxFit.cover,
|
||||
this.size = const Size.square(256),
|
||||
this.thumbhashOnly = false,
|
||||
super.key,
|
||||
}) : blurhash = asset.thumbhash,
|
||||
localId = asset.localId,
|
||||
remoteId = asset.remoteId;
|
||||
|
||||
Thumbnail.fromBaseAsset({
|
||||
required BaseAsset? asset,
|
||||
this.fit = BoxFit.cover,
|
||||
this.size = const Size.square(256),
|
||||
this.thumbhashOnly = false,
|
||||
super.key,
|
||||
}) : blurhash = switch (asset) {
|
||||
RemoteAsset() => asset.thumbHash,
|
||||
_ => null,
|
||||
},
|
||||
localId = switch (asset) {
|
||||
RemoteAsset() => asset.localId,
|
||||
LocalAsset() => asset.id,
|
||||
_ => null,
|
||||
},
|
||||
remoteId = switch (asset) {
|
||||
RemoteAsset() => asset.id,
|
||||
LocalAsset() => asset.remoteId,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
@override
|
||||
State<Thumbnail> createState() => _ThumbnailState();
|
||||
}
|
||||
|
||||
class _ThumbnailState extends State<Thumbnail> {
|
||||
ui.Image? _image;
|
||||
|
||||
static final _gradientCache = <ColorScheme, Gradient>{};
|
||||
static final _imageCache = ThumbnailImageCacheManager();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_decode();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(Thumbnail oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.blurhash != widget.blurhash ||
|
||||
oldWidget.localId != widget.localId ||
|
||||
oldWidget.remoteId != widget.remoteId ||
|
||||
oldWidget.thumbhashOnly != widget.thumbhashOnly) {
|
||||
_decode();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _decode() async {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final thumbhashOnly = widget.thumbhashOnly;
|
||||
final blurhash = widget.blurhash;
|
||||
final imageFuture = thumbhashOnly ? Future.value(null) : _decodeFromFile();
|
||||
|
||||
if (blurhash != null) {
|
||||
final image = thumbhash.thumbHashToRGBA(base64.decode(blurhash));
|
||||
try {
|
||||
await _decodeThumbhash(
|
||||
await ImmutableBuffer.fromUint8List(image.rgba),
|
||||
image.width,
|
||||
image.height,
|
||||
);
|
||||
} catch (e) {
|
||||
log.info('Error decoding thumbhash for ${widget.remoteId}: $e');
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted || thumbhashOnly) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final image = await imageFuture;
|
||||
if (!mounted || image == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
_image?.dispose();
|
||||
setState(() {
|
||||
_image = image;
|
||||
});
|
||||
} catch (e) {
|
||||
log.info('Error decoding thumbnail: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _decodeThumbhash(
|
||||
ImmutableBuffer buffer,
|
||||
int width,
|
||||
int height,
|
||||
) async {
|
||||
if (!mounted) {
|
||||
buffer.dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
final descriptor = ImageDescriptor.raw(
|
||||
buffer,
|
||||
width: width,
|
||||
height: height,
|
||||
pixelFormat: PixelFormat.rgba8888,
|
||||
);
|
||||
if (!mounted) {
|
||||
buffer.dispose();
|
||||
descriptor.dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
final codec = await descriptor.instantiateCodec(
|
||||
targetWidth: width,
|
||||
targetHeight: height,
|
||||
);
|
||||
|
||||
if (!mounted) {
|
||||
buffer.dispose();
|
||||
descriptor.dispose();
|
||||
codec.dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
final frame = (await codec.getNextFrame()).image;
|
||||
buffer.dispose();
|
||||
descriptor.dispose();
|
||||
codec.dispose();
|
||||
if (!mounted) {
|
||||
frame.dispose();
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_image = frame;
|
||||
});
|
||||
}
|
||||
|
||||
Future<ui.Image?> _decodeFromFile() async {
|
||||
final buffer = await _getFile();
|
||||
if (buffer == null) {
|
||||
return null;
|
||||
}
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final thumb = await _decodeThumbnail(buffer, 256);
|
||||
stopwatch.stop();
|
||||
return thumb;
|
||||
}
|
||||
|
||||
Future<ui.Image?> _decodeThumbnail(ImmutableBuffer buffer, int height) async {
|
||||
if (!mounted) {
|
||||
buffer.dispose();
|
||||
return null;
|
||||
}
|
||||
|
||||
final descriptor = await ImageDescriptor.encoded(buffer);
|
||||
if (!mounted) {
|
||||
buffer.dispose();
|
||||
descriptor.dispose();
|
||||
return null;
|
||||
}
|
||||
|
||||
final codec = await descriptor.instantiateCodec(targetHeight: height);
|
||||
|
||||
if (!mounted) {
|
||||
buffer.dispose();
|
||||
descriptor.dispose();
|
||||
codec.dispose();
|
||||
return null;
|
||||
}
|
||||
|
||||
final frame = (await codec.getNextFrame()).image;
|
||||
buffer.dispose();
|
||||
descriptor.dispose();
|
||||
codec.dispose();
|
||||
if (!mounted) {
|
||||
frame.dispose();
|
||||
return null;
|
||||
}
|
||||
|
||||
return frame;
|
||||
}
|
||||
|
||||
Future<ImmutableBuffer?> _getFile() async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final localId = widget.localId;
|
||||
if (localId != null) {
|
||||
try {
|
||||
final data =
|
||||
await thumbnailApi.getThumbnail(localId, width: 256, height: 256);
|
||||
stopwatch.stop();
|
||||
log.info(
|
||||
'Retrieved local image $localId in ${stopwatch.elapsedMilliseconds.toStringAsFixed(2)} ms',
|
||||
);
|
||||
return ImmutableBuffer.fromUint8List(data);
|
||||
} catch (e) {
|
||||
log.warning('Failed to retrieve local image $localId: $e');
|
||||
}
|
||||
}
|
||||
|
||||
final remoteId = widget.remoteId;
|
||||
if (remoteId != null) {
|
||||
final uri = getThumbnailUrlForRemoteId(remoteId);
|
||||
final headers = ApiService.getRequestHeaders();
|
||||
final stream = _imageCache.getFileStream(
|
||||
uri,
|
||||
key: uri,
|
||||
withProgress: true,
|
||||
headers: headers,
|
||||
);
|
||||
|
||||
await for (final result in stream) {
|
||||
if (!mounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (result is FileInfo) {
|
||||
stopwatch.stop();
|
||||
log.info(
|
||||
'Retrieved remote image $remoteId in ${stopwatch.elapsedMilliseconds.toStringAsFixed(2)} ms',
|
||||
);
|
||||
return ImmutableBuffer.fromFilePath(result.file.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final thumbHash = asset is RemoteAsset ? (asset as RemoteAsset).thumbHash : null;
|
||||
final provider = getThumbnailImageProvider(asset: asset, remoteId: remoteId);
|
||||
|
||||
return OctoImage.fromSet(
|
||||
image: provider,
|
||||
octoSet: OctoSet(
|
||||
placeholderBuilder: _blurHashPlaceholderBuilder(thumbHash, fit: fit),
|
||||
errorBuilder: _blurHashErrorBuilder(thumbHash, provider: provider, fit: fit, asset: asset),
|
||||
),
|
||||
fadeOutDuration: const Duration(milliseconds: 100),
|
||||
fadeInDuration: Duration.zero,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
fit: fit,
|
||||
placeholderFadeInDuration: Duration.zero,
|
||||
final colorScheme = context.colorScheme;
|
||||
final gradient = _gradientCache[colorScheme] ??= LinearGradient(
|
||||
colors: [
|
||||
colorScheme.surfaceContainer,
|
||||
colorScheme.surfaceContainer.darken(amount: .1),
|
||||
],
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
);
|
||||
|
||||
return _ThumbhashLeaf(
|
||||
image: _image,
|
||||
fit: widget.fit,
|
||||
placeholderGradient: gradient,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_image?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
OctoPlaceholderBuilder _blurHashPlaceholderBuilder(String? thumbHash, {BoxFit? fit}) {
|
||||
return (context) => thumbHash == null
|
||||
? const ThumbnailPlaceholder()
|
||||
: FadeInPlaceholderImage(
|
||||
placeholder: const ThumbnailPlaceholder(),
|
||||
image: ThumbHashProvider(thumbHash: thumbHash),
|
||||
fit: fit ?? BoxFit.cover,
|
||||
);
|
||||
class _ThumbhashLeaf extends LeafRenderObjectWidget {
|
||||
final ui.Image? image;
|
||||
final BoxFit fit;
|
||||
final Gradient placeholderGradient;
|
||||
|
||||
const _ThumbhashLeaf({
|
||||
required this.image,
|
||||
required this.fit,
|
||||
required this.placeholderGradient,
|
||||
});
|
||||
|
||||
@override
|
||||
RenderObject createRenderObject(BuildContext context) {
|
||||
return _ThumbhashRenderBox(
|
||||
image: image,
|
||||
fit: fit,
|
||||
placeholderGradient: placeholderGradient,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void updateRenderObject(
|
||||
BuildContext context,
|
||||
_ThumbhashRenderBox renderObject,
|
||||
) {
|
||||
renderObject.fit = fit;
|
||||
renderObject.image = image;
|
||||
renderObject.placeholderGradient = placeholderGradient;
|
||||
}
|
||||
}
|
||||
|
||||
OctoErrorBuilder _blurHashErrorBuilder(String? blurhash, {BaseAsset? asset, ImageProvider? provider, BoxFit? fit}) =>
|
||||
(context, e, s) {
|
||||
Logger("ImThumbnail").warning("Error loading thumbnail for ${asset?.name}", e, s);
|
||||
provider?.evict();
|
||||
return Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
_blurHashPlaceholderBuilder(blurhash, fit: fit)(context),
|
||||
const Opacity(opacity: 0.75, child: Icon(Icons.error_outline_rounded)),
|
||||
],
|
||||
);
|
||||
};
|
||||
class _ThumbhashRenderBox extends RenderBox {
|
||||
ui.Image? _image;
|
||||
BoxFit _fit;
|
||||
Gradient _placeholderGradient;
|
||||
|
||||
@override
|
||||
bool isRepaintBoundary = true;
|
||||
|
||||
_ThumbhashRenderBox({
|
||||
required ui.Image? image,
|
||||
required BoxFit fit,
|
||||
required Gradient placeholderGradient,
|
||||
}) : _image = image,
|
||||
_fit = fit,
|
||||
_placeholderGradient = placeholderGradient;
|
||||
|
||||
@override
|
||||
void paint(PaintingContext context, Offset offset) {
|
||||
final image = _image;
|
||||
final rect = offset & size;
|
||||
if (image == null) {
|
||||
final paint = Paint();
|
||||
paint.shader = _placeholderGradient.createShader(rect);
|
||||
context.canvas.drawRect(rect, paint);
|
||||
return;
|
||||
}
|
||||
|
||||
paintImage(
|
||||
canvas: context.canvas,
|
||||
rect: rect,
|
||||
image: image,
|
||||
fit: _fit,
|
||||
filterQuality: FilterQuality.low,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void performLayout() {
|
||||
size = constraints.biggest;
|
||||
}
|
||||
|
||||
set image(ui.Image? value) {
|
||||
if (_image != value) {
|
||||
_image = value;
|
||||
markNeedsPaint();
|
||||
}
|
||||
}
|
||||
|
||||
set fit(BoxFit value) {
|
||||
if (_fit == value) {
|
||||
return;
|
||||
}
|
||||
|
||||
_fit = value;
|
||||
if (_image != null) {
|
||||
markNeedsPaint();
|
||||
}
|
||||
}
|
||||
|
||||
set placeholderGradient(Gradient value) {
|
||||
if (_placeholderGradient == value) {
|
||||
return;
|
||||
}
|
||||
|
||||
_placeholderGradient = value;
|
||||
if (_image == null) {
|
||||
markNeedsPaint();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ class ThumbnailTile extends ConsumerWidget {
|
||||
super.key,
|
||||
});
|
||||
|
||||
final BaseAsset asset;
|
||||
final BaseAsset? asset;
|
||||
final Size size;
|
||||
final BoxFit fit;
|
||||
final bool? showStorageIndicator;
|
||||
@@ -30,6 +30,7 @@ class ThumbnailTile extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asset = this.asset;
|
||||
final heroIndex = heroOffset ?? TabsRouterScope.of(context)?.controller.activeIndex ?? 0;
|
||||
|
||||
final assetContainerColor = context.isDarkTheme
|
||||
@@ -71,8 +72,8 @@ class ThumbnailTile extends ConsumerWidget {
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Hero(
|
||||
tag: '${asset.heroTag}_$heroIndex',
|
||||
child: Thumbnail(asset: asset, fit: fit, size: size),
|
||||
tag: '${asset?.heroTag ?? ''}_$heroIndex',
|
||||
child: Thumbnail.fromBaseAsset(asset: asset, fit: fit, size: size),
|
||||
),
|
||||
),
|
||||
if (hasStack)
|
||||
@@ -83,7 +84,7 @@ class ThumbnailTile extends ConsumerWidget {
|
||||
child: const _TileOverlayIcon(Icons.burst_mode_rounded),
|
||||
),
|
||||
),
|
||||
if (asset.isVideo)
|
||||
if (asset != null && asset.isVideo)
|
||||
Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: Padding(
|
||||
@@ -91,7 +92,7 @@ class ThumbnailTile extends ConsumerWidget {
|
||||
child: _VideoIndicator(asset.duration),
|
||||
),
|
||||
),
|
||||
if (storageIndicator)
|
||||
if (storageIndicator && asset != null)
|
||||
switch (asset.storage) {
|
||||
AssetState.local => const Align(
|
||||
alignment: Alignment.bottomRight,
|
||||
@@ -115,7 +116,7 @@ class ThumbnailTile extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
},
|
||||
if (asset.isFavorite)
|
||||
if (asset != null && asset.isFavorite)
|
||||
const Align(
|
||||
alignment: Alignment.bottomLeft,
|
||||
child: Padding(
|
||||
|
||||
@@ -7,7 +7,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/full_image.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||
import 'package:immich_mobile/utils/hooks/blurhash_hook.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||
|
||||
class DriftMemoryCard extends StatelessWidget {
|
||||
final RemoteAsset asset;
|
||||
@@ -88,31 +88,29 @@ class _BlurredBackdrop extends HookWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final blurhash = useDriftBlurHashRef(asset).value;
|
||||
final blurhash = asset.thumbHash;
|
||||
if (blurhash != null) {
|
||||
// Use a nice cheap blur hash image decoration
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(image: MemoryImage(blurhash), fit: BoxFit.cover),
|
||||
),
|
||||
child: Container(color: Colors.black.withValues(alpha: 0.2)),
|
||||
);
|
||||
} else {
|
||||
// Fall back to using a more expensive image filtered
|
||||
// Since the ImmichImage is already precached, we can
|
||||
// safely use that as the image provider
|
||||
return ImageFiltered(
|
||||
imageFilter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: getFullImageProvider(asset, size: Size(context.width, context.height)),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
child: Container(color: Colors.black.withValues(alpha: 0.2)),
|
||||
),
|
||||
);
|
||||
return Thumbnail(blurhash: blurhash);
|
||||
}
|
||||
|
||||
// Fall back to using a more expensive image filtered
|
||||
// Since the ImmichImage is already precached, we can
|
||||
// safely use that as the image provider
|
||||
return ImageFiltered(
|
||||
imageFilter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: getFullImageProvider(
|
||||
asset,
|
||||
size: Size(context.width, context.height),
|
||||
),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
child: const ColoredBox(color: Color.fromRGBO(0, 0, 0, 0.2)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,12 +57,15 @@ class DriftMemoryCard extends ConsumerWidget {
|
||||
child: Stack(
|
||||
children: [
|
||||
ColorFiltered(
|
||||
colorFilter: ColorFilter.mode(Colors.black.withValues(alpha: 0.2), BlendMode.darken),
|
||||
child: SizedBox(
|
||||
width: 205,
|
||||
height: 200,
|
||||
child: Thumbnail(remoteId: memory.assets[0].id, fit: BoxFit.cover),
|
||||
colorFilter: ColorFilter.mode(
|
||||
Colors.black.withValues(alpha: 0.2),
|
||||
BlendMode.darken,
|
||||
),
|
||||
child: SizedBox(
|
||||
width: 205,
|
||||
height: 200,
|
||||
child: Thumbnail.fromBaseAsset(asset: memory.assets[0]),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 16,
|
||||
|
||||
@@ -4,13 +4,12 @@ import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail_tile.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/fixed/row.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/header.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/segment_builder.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline_drag_region.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
||||
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
||||
@@ -76,6 +75,21 @@ class FixedSegment extends Segment {
|
||||
spacing: spacing,
|
||||
);
|
||||
}
|
||||
|
||||
const FixedSegment.empty()
|
||||
: this(
|
||||
firstIndex: 0,
|
||||
lastIndex: 0,
|
||||
startOffset: 0,
|
||||
endOffset: 0,
|
||||
firstAssetIndex: 0,
|
||||
bucket: const Bucket(assetCount: 0),
|
||||
tileHeight: 1,
|
||||
columnCount: 0,
|
||||
headerExtent: 0,
|
||||
spacing: 0,
|
||||
header: HeaderType.none,
|
||||
);
|
||||
}
|
||||
|
||||
class _FixedSegmentRow extends ConsumerWidget {
|
||||
@@ -93,45 +107,44 @@ class _FixedSegmentRow extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isScrubbing = ref.watch(timelineStateProvider.select((s) => s.isScrubbing));
|
||||
final timelineService = ref.read(timelineServiceProvider);
|
||||
|
||||
if (isScrubbing) {
|
||||
return _buildPlaceholder(context);
|
||||
}
|
||||
|
||||
if (timelineService.hasRange(assetIndex, assetCount)) {
|
||||
return _buildAssetRow(context, timelineService.getAssets(assetIndex, assetCount), timelineService);
|
||||
}
|
||||
|
||||
return FutureBuilder<List<BaseAsset>>(
|
||||
future: timelineService.loadAssets(assetIndex, assetCount),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
return _buildPlaceholder(context);
|
||||
}
|
||||
return _buildAssetRow(context, snapshot.requireData, timelineService);
|
||||
try {
|
||||
final assets = timelineService.getAssets(assetIndex, assetCount);
|
||||
return FutureBuilder<List<BaseAsset>>(
|
||||
future: null,
|
||||
initialData: assets,
|
||||
builder: (context, snapshot) {
|
||||
return _buildAssetRow(context, snapshot.data, timelineService);
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
return FutureBuilder<List<BaseAsset>>(
|
||||
future: timelineService.loadAssets(assetIndex, assetCount),
|
||||
builder: (context, snapshot) {
|
||||
return _buildAssetRow(context, snapshot.data, timelineService);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildPlaceholder(BuildContext context) {
|
||||
return SegmentBuilder.buildPlaceholder(context, assetCount, size: Size.square(tileHeight), spacing: spacing);
|
||||
}
|
||||
|
||||
Widget _buildAssetRow(BuildContext context, List<BaseAsset> assets, TimelineService timelineService) {
|
||||
Widget _buildAssetRow(BuildContext context, List<BaseAsset>? assets, TimelineService timelineService) {
|
||||
return FixedTimelineRow(
|
||||
dimension: tileHeight,
|
||||
spacing: spacing,
|
||||
textDirection: Directionality.of(context),
|
||||
children: [
|
||||
for (int i = 0; i < assets.length; i++)
|
||||
for (int i = 0; i < assetCount; i++)
|
||||
TimelineAssetIndexWrapper(
|
||||
assetIndex: assetIndex + i,
|
||||
segmentIndex: 0, // For simplicity, using 0 for now
|
||||
child: _AssetTileWidget(
|
||||
key: ValueKey(Object.hash(assets[i].heroTag, assetIndex + i, timelineService.hashCode)),
|
||||
asset: assets[i],
|
||||
key: ValueKey(i.hashCode ^ (assetIndex + i).hashCode ^ timelineService.hashCode),
|
||||
asset: assets == null ? null : assets[i],
|
||||
assetIndex: assetIndex + i,
|
||||
),
|
||||
),
|
||||
@@ -141,7 +154,7 @@ class _FixedSegmentRow extends ConsumerWidget {
|
||||
}
|
||||
|
||||
class _AssetTileWidget extends ConsumerWidget {
|
||||
final BaseAsset asset;
|
||||
final BaseAsset? asset;
|
||||
final int assetIndex;
|
||||
|
||||
const _AssetTileWidget({super.key, required this.asset, required this.assetIndex});
|
||||
@@ -190,17 +203,18 @@ class _AssetTileWidget extends ConsumerWidget {
|
||||
|
||||
final lockSelection = _getLockSelectionStatus(ref);
|
||||
final showStorageIndicator = ref.watch(timelineArgsProvider.select((args) => args.showStorageIndicator));
|
||||
final asset = this.asset;
|
||||
|
||||
return RepaintBoundary(
|
||||
child: GestureDetector(
|
||||
onTap: () => lockSelection ? null : _handleOnTap(context, ref, assetIndex, asset, heroOffset),
|
||||
onLongPress: () => lockSelection ? null : _handleOnLongPress(ref, asset),
|
||||
return GestureDetector(
|
||||
onTap: () => lockSelection || asset == null ? null : _handleOnTap(context, ref, assetIndex, asset, heroOffset),
|
||||
onLongPress: () => lockSelection || asset == null
|
||||
? null
|
||||
: _handleOnLongPress(ref, asset),
|
||||
child: ThumbnailTile(
|
||||
asset,
|
||||
lockSelection: lockSelection,
|
||||
showStorageIndicator: showStorageIndicator,
|
||||
heroOffset: heroOffset,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/extensions/datetime_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/fixed/segment.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/segment_builder.dart';
|
||||
@@ -6,6 +7,7 @@ import 'package:immich_mobile/presentation/widgets/timeline/segment_builder.dart
|
||||
class FixedSegmentBuilder extends SegmentBuilder {
|
||||
final double tileHeight;
|
||||
final int columnCount;
|
||||
static final DateTime _dummyDate = DateTime.fromMicrosecondsSinceEpoch(0);
|
||||
|
||||
const FixedSegmentBuilder({
|
||||
required super.buckets,
|
||||
@@ -16,12 +18,11 @@ class FixedSegmentBuilder extends SegmentBuilder {
|
||||
});
|
||||
|
||||
List<Segment> generate() {
|
||||
final segments = <Segment>[];
|
||||
final segments = List.filled(buckets.length, const FixedSegment.empty());
|
||||
int firstIndex = 0;
|
||||
double startOffset = 0;
|
||||
int assetIndex = 0;
|
||||
DateTime? previousDate;
|
||||
|
||||
DateTime previousDate = _dummyDate;
|
||||
for (int i = 0; i < buckets.length; i++) {
|
||||
final bucket = buckets[i];
|
||||
|
||||
@@ -32,11 +33,10 @@ class FixedSegmentBuilder extends SegmentBuilder {
|
||||
final segmentFirstIndex = firstIndex;
|
||||
firstIndex += segmentCount;
|
||||
final segmentLastIndex = firstIndex - 1;
|
||||
|
||||
final timelineHeader = switch (groupBy) {
|
||||
GroupAssetsBy.month => HeaderType.month,
|
||||
GroupAssetsBy.day || GroupAssetsBy.auto =>
|
||||
bucket is TimeBucket && bucket.date.month != previousDate?.month ? HeaderType.monthAndDay : HeaderType.day,
|
||||
bucket is TimeBucket && !previousDate.isSameMonth(bucket.date) ? HeaderType.monthAndDay : HeaderType.day,
|
||||
GroupAssetsBy.none => HeaderType.none,
|
||||
};
|
||||
final headerExtent = SegmentBuilder.headerExtent(timelineHeader);
|
||||
@@ -45,20 +45,18 @@ class FixedSegmentBuilder extends SegmentBuilder {
|
||||
startOffset += headerExtent + (tileHeight * numberOfRows) + spacing * (numberOfRows - 1);
|
||||
final segmentEndOffset = startOffset;
|
||||
|
||||
segments.add(
|
||||
FixedSegment(
|
||||
firstIndex: segmentFirstIndex,
|
||||
lastIndex: segmentLastIndex,
|
||||
startOffset: segmentStartOffset,
|
||||
endOffset: segmentEndOffset,
|
||||
firstAssetIndex: assetIndex,
|
||||
bucket: bucket,
|
||||
tileHeight: tileHeight,
|
||||
columnCount: columnCount,
|
||||
headerExtent: headerExtent,
|
||||
spacing: spacing,
|
||||
header: timelineHeader,
|
||||
),
|
||||
segments[i] = FixedSegment(
|
||||
firstIndex: segmentFirstIndex,
|
||||
lastIndex: segmentLastIndex,
|
||||
startOffset: segmentStartOffset,
|
||||
endOffset: segmentEndOffset,
|
||||
firstAssetIndex: assetIndex,
|
||||
bucket: bucket,
|
||||
tileHeight: tileHeight,
|
||||
columnCount: columnCount,
|
||||
headerExtent: headerExtent,
|
||||
spacing: spacing,
|
||||
header: timelineHeader,
|
||||
);
|
||||
|
||||
assetIndex += assetCount;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/fixed/row.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
|
||||
|
||||
abstract class SegmentBuilder {
|
||||
final List<Bucket> buckets;
|
||||
@@ -23,12 +23,13 @@ abstract class SegmentBuilder {
|
||||
int count, {
|
||||
Size size = kTimelineFixedTileExtent,
|
||||
double spacing = kTimelineSpacing,
|
||||
}) => RepaintBoundary(
|
||||
child: FixedTimelineRow(
|
||||
dimension: size.height,
|
||||
spacing: spacing,
|
||||
textDirection: Directionality.of(context),
|
||||
children: List.generate(count, (_) => ThumbnailPlaceholder(width: size.width, height: size.height)),
|
||||
),
|
||||
);
|
||||
}) =>
|
||||
RepaintBoundary(
|
||||
child: FixedTimelineRow(
|
||||
dimension: size.height,
|
||||
spacing: spacing,
|
||||
textDirection: Directionality.of(context),
|
||||
children: List.filled(count, const Thumbnail()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user