diff --git a/mobile/lib/constants/constants.dart b/mobile/lib/constants/constants.dart index 609ac711fd..94b3074603 100644 --- a/mobile/lib/constants/constants.dart +++ b/mobile/lib/constants/constants.dart @@ -1,3 +1,5 @@ +import 'dart:ui'; + const int noDbId = -9223372036854775808; // from Isar const double downloadCompleted = -1; const double downloadFailed = -2; @@ -28,8 +30,8 @@ const String kDownloadGroupLivePhoto = 'group_livephoto'; const int kTimelineNoneSegmentSize = 120; const int kTimelineAssetLoadBatchSize = 1024; const int kTimelineAssetLoadOppositeSize = 64; -const double kTimelineThumbnailTileSize = 256.0; -const double kTimelineThumbnailSize = 384.0; +const Size kTimelineThumbnailTileSize = Size.square(256.0); +const Size kTimelineThumbnailSize = Size.square(384.0); const int kTimelineImageCacheMemory = 250 * 1024 * 1024; // Widget keys diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart index bf92dcfa40..cbcd387cd7 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -473,10 +473,8 @@ class _AssetViewerState extends ConsumerState { if (stackChildren != null && stackChildren.isNotEmpty) { asset = stackChildren.elementAt(ref.read(assetViewerProvider.select((s) => s.stackIndex))); } - return Container( - width: double.infinity, - height: double.infinity, - color: backgroundColor, + return Hero( + tag: '${asset.heroTag}_$heroOffset', child: Thumbnail.fromBaseAsset(asset: asset, fit: BoxFit.contain), ); } diff --git a/mobile/lib/presentation/widgets/images/image_provider.dart b/mobile/lib/presentation/widgets/images/image_provider.dart index 853816959a..68738c33d1 100644 --- a/mobile/lib/presentation/widgets/images/image_provider.dart +++ b/mobile/lib/presentation/widgets/images/image_provider.dart @@ -30,7 +30,7 @@ ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080 ImageProvider getThumbnailImageProvider({ BaseAsset? asset, String? remoteId, - Size size = const Size.square(kTimelineThumbnailSize), + Size size = kTimelineThumbnailSize, }) { assert(asset != null || remoteId != null, 'Either asset or remoteId must be provided'); diff --git a/mobile/lib/presentation/widgets/images/local_image_provider.dart b/mobile/lib/presentation/widgets/images/local_image_provider.dart index 9e02f3eba7..a0ed69a150 100644 --- a/mobile/lib/presentation/widgets/images/local_image_provider.dart +++ b/mobile/lib/presentation/widgets/images/local_image_provider.dart @@ -3,7 +3,9 @@ import 'dart:ui'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; +import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/infrastructure/repositories/asset_media.repository.dart'; +import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart'; class LocalThumbProvider extends ImageProvider { static const _assetMediaRepository = AssetMediaRepository(); @@ -11,7 +13,7 @@ class LocalThumbProvider extends ImageProvider { final String id; final Size size; - const LocalThumbProvider({required this.id, required this.size}); + const LocalThumbProvider({required this.id, this.size = kTimelineThumbnailSize}); @override Future obtainKey(ImageConfiguration configuration) { @@ -63,16 +65,45 @@ class LocalFullImageProvider extends ImageProvider { @override ImageStreamCompleter loadImage(LocalFullImageProvider key, ImageDecoderCallback decode) { - return OneFrameImageStreamCompleter(_codec(key)); + ImageInfo? thumbnail; + final thumbnailProvider = LocalThumbProvider(id: key.id); + + final ImageStreamCompleter? stream = PaintingBinding.instance.imageCache.putIfAbsent( + thumbnailProvider, + () => throw Exception(), // don't bother loading the thumbnail if it isn't cacched + ); + + if (stream != null) { + void listener(ImageInfo info, bool synchronousCall) { + thumbnail = info; + } + + try { + stream.addListener(ImageStreamListener(listener)); + } finally { + stream.removeListener(ImageStreamListener(listener)); + } + } + + return OneFramePlaceholderImageStreamCompleter( + _codec(key, decode), + initialImage: thumbnail, + informationCollector: () => [ + DiagnosticsProperty('Image provider', this), + DiagnosticsProperty('Id', key.id), + DiagnosticsProperty('Size', key.size), + ], + ); } - Future _codec(LocalFullImageProvider key) async { + Future _codec(LocalFullImageProvider key, ImageDecoderCallback decode) async { final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio; final codec = await _assetMediaRepository.getLocalThumbnail( key.id, Size(size.width * devicePixelRatio, size.height * devicePixelRatio), ); - return ImageInfo(image: (await codec.getNextFrame()).image, scale: 1.0); + final frame = await codec.getNextFrame(); + return ImageInfo(image: frame.image, scale: 1.0); } @override diff --git a/mobile/lib/presentation/widgets/images/one_frame_multi_image_stream_completer.dart b/mobile/lib/presentation/widgets/images/one_frame_multi_image_stream_completer.dart new file mode 100644 index 0000000000..908fb8008a --- /dev/null +++ b/mobile/lib/presentation/widgets/images/one_frame_multi_image_stream_completer.dart @@ -0,0 +1,117 @@ +// The below code is adapted from cached_network_image package's +// MultiImageStreamCompleter to better suit one-frame image loading. +// In particular, it allows providing an initial image to emit synchronously. + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/painting.dart'; + +/// An ImageStreamCompleter with support for loading multiple images. +class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter { + ImageInfo? _initialImage; + + /// The constructor to create an OneFramePlaceholderImageStreamCompleter. The [image] + /// should be the primary image to display. The [initialImage] is an optional + /// image that will be emitted synchronously, useful as a thumbnail or placeholder. + OneFramePlaceholderImageStreamCompleter( + Future image, { + ImageInfo? initialImage, + InformationCollector? informationCollector, + }) { + _initialImage = initialImage; + image.then( + setImage, + onError: (Object error, StackTrace stack) { + reportError( + context: ErrorDescription('resolving a single-frame image stream'), + exception: error, + stack: stack, + informationCollector: informationCollector, + silent: true, + ); + }, + ); + } + + /// We must avoid disposing a completer if it never had a listener, even + /// if all [keepAlive] handles get disposed. + bool __hadAtLeastOneListener = false; + + bool __disposed = false; + + @override + void addListener(ImageStreamListener listener) { + __hadAtLeastOneListener = true; + final initialImage = _initialImage; + if (initialImage != null) { + try { + listener.onImage(initialImage.clone(), true); + } catch (exception, stack) { + reportError( + context: ErrorDescription('by a synchronously-called image listener'), + exception: exception, + stack: stack, + ); + } + } + super.addListener(listener); + } + + @override + void removeListener(ImageStreamListener listener) { + super.removeListener(listener); + if (!hasListeners) { + __maybeDispose(); + } + } + + int __keepAliveHandles = 0; + + @override + ImageStreamCompleterHandle keepAlive() { + final delegateHandle = super.keepAlive(); + return _OneFramePlaceholderImageStreamCompleterHandle(this, delegateHandle); + } + + void __maybeDispose() { + if (!__hadAtLeastOneListener || __disposed || hasListeners || __keepAliveHandles != 0) { + return; + } + + __disposed = true; + } + + @override + void onDisposed() { + _initialImage?.dispose(); + _initialImage = null; + super.onDisposed(); + } +} + +class _OneFramePlaceholderImageStreamCompleterHandle implements ImageStreamCompleterHandle { + _OneFramePlaceholderImageStreamCompleterHandle(this._completer, this._delegateHandle) { + _completer!.__keepAliveHandles += 1; + } + + OneFramePlaceholderImageStreamCompleter? _completer; + final ImageStreamCompleterHandle _delegateHandle; + + /// Call this method to signal the [ImageStreamCompleter] that it can now be + /// disposed when its last listener drops. + /// + /// This method must only be called once per object. + @override + void dispose() { + assert(_completer != null); + assert(_completer!.__keepAliveHandles > 0); + assert(!_completer!.__disposed); + + _delegateHandle.dispose(); + + _completer!.__keepAliveHandles -= 1; + _completer!.__maybeDispose(); + _completer = null; + } +} diff --git a/mobile/lib/presentation/widgets/images/thumbnail.widget.dart b/mobile/lib/presentation/widgets/images/thumbnail.widget.dart index d370bc4a87..b6724c0a00 100644 --- a/mobile/lib/presentation/widgets/images/thumbnail.widget.dart +++ b/mobile/lib/presentation/widgets/images/thumbnail.widget.dart @@ -29,7 +29,7 @@ class Thumbnail extends StatefulWidget { const Thumbnail({ this.imageProvider, this.fit = BoxFit.cover, - this.size = const ui.Size.square(kTimelineThumbnailSize), + this.size = kTimelineThumbnailSize, this.blurhash, this.thumbhashMode = ThumbhashMode.enabled, super.key, @@ -38,7 +38,7 @@ class Thumbnail extends StatefulWidget { Thumbnail.fromAsset({ required Asset asset, this.fit = BoxFit.cover, - this.size = const ui.Size.square(kTimelineThumbnailSize), + this.size = kTimelineThumbnailSize, this.thumbhashMode = ThumbhashMode.enabled, super.key, }) : blurhash = asset.thumbhash, @@ -47,7 +47,7 @@ class Thumbnail extends StatefulWidget { Thumbnail.fromBaseAsset({ required BaseAsset? asset, this.fit = BoxFit.cover, - this.size = const ui.Size.square(kTimelineThumbnailSize), + this.size = kTimelineThumbnailSize, this.thumbhashMode = ThumbhashMode.enabled, super.key, }) : blurhash = switch (asset) { diff --git a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart index cc5c38d8c3..681a1c473c 100644 --- a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart +++ b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart @@ -13,7 +13,7 @@ import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; class ThumbnailTile extends ConsumerWidget { const ThumbnailTile( this.asset, { - this.size = const Size.square(kTimelineThumbnailTileSize), + this.size = kTimelineThumbnailTileSize, this.fit = BoxFit.cover, this.showStorageIndicator = true, this.lockSelection = false,