From 663f684a4bdc65a590266ef2c0347b99f3b96e52 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Fri, 1 Aug 2025 19:26:14 -0400 Subject: [PATCH] buttery hero animation for remote assets --- .../asset_viewer/asset_viewer.page.dart | 5 +- .../widgets/images/image_provider.dart | 22 ++++++ .../widgets/images/local_image_provider.dart | 27 +------ ...ne_frame_multi_image_stream_completer.dart | 4 +- .../widgets/images/remote_image_provider.dart | 74 +++++++++---------- 5 files changed, 62 insertions(+), 70 deletions(-) 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 cbcd387cd7..d836e630c1 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,7 @@ class _AssetViewerState extends ConsumerState { if (stackChildren != null && stackChildren.isNotEmpty) { asset = stackChildren.elementAt(ref.read(assetViewerProvider.select((s) => s.stackIndex))); } - return Hero( - tag: '${asset.heroTag}_$heroOffset', - child: Thumbnail.fromBaseAsset(asset: asset, fit: BoxFit.contain), - ); + return Thumbnail.fromBaseAsset(asset: asset, fit: BoxFit.contain); } void _onScaleStateChanged(PhotoViewScaleState scaleState) { diff --git a/mobile/lib/presentation/widgets/images/image_provider.dart b/mobile/lib/presentation/widgets/images/image_provider.dart index 68738c33d1..c1624489cc 100644 --- a/mobile/lib/presentation/widgets/images/image_provider.dart +++ b/mobile/lib/presentation/widgets/images/image_provider.dart @@ -57,3 +57,25 @@ ImageProvider getThumbnailImageProvider({ bool _shouldUseLocalAsset(BaseAsset asset) => asset.hasLocal && (!asset.hasRemote || !AppSetting.get(Setting.preferRemoteImage)); + +ImageInfo? getCachedImage(ImageProvider key) { + ImageInfo? thumbnail; + final ImageStreamCompleter? stream = PaintingBinding.instance.imageCache.putIfAbsent( + key, + () => throw Exception(), // don't bother loading 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 thumbnail; +} diff --git a/mobile/lib/presentation/widgets/images/local_image_provider.dart b/mobile/lib/presentation/widgets/images/local_image_provider.dart index a0ed69a150..4e0d30251c 100644 --- a/mobile/lib/presentation/widgets/images/local_image_provider.dart +++ b/mobile/lib/presentation/widgets/images/local_image_provider.dart @@ -5,6 +5,7 @@ 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/image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart'; class LocalThumbProvider extends ImageProvider { @@ -65,29 +66,9 @@ class LocalFullImageProvider extends ImageProvider { @override ImageStreamCompleter loadImage(LocalFullImageProvider key, ImageDecoderCallback decode) { - 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, + initialImage: getCachedImage(LocalThumbProvider(id: key.id)), informationCollector: () => [ DiagnosticsProperty('Image provider', this), DiagnosticsProperty('Id', key.id), @@ -96,14 +77,14 @@ class LocalFullImageProvider extends ImageProvider { ); } - Future _codec(LocalFullImageProvider key, ImageDecoderCallback decode) async { + Stream _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), ); final frame = await codec.getNextFrame(); - return ImageInfo(image: frame.image, scale: 1.0); + yield 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 index 908fb8008a..a78eaf35dc 100644 --- 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 @@ -15,12 +15,12 @@ class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter { /// 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, { + Stream image, { ImageInfo? initialImage, InformationCollector? informationCollector, }) { _initialImage = initialImage; - image.then( + image.listen( setImage, onError: (Object error, StackTrace stack) { reportError( diff --git a/mobile/lib/presentation/widgets/images/remote_image_provider.dart b/mobile/lib/presentation/widgets/images/remote_image_provider.dart index 27f310f4f2..d9ab78053f 100644 --- a/mobile/lib/presentation/widgets/images/remote_image_provider.dart +++ b/mobile/lib/presentation/widgets/images/remote_image_provider.dart @@ -1,12 +1,13 @@ import 'dart:async'; import 'dart:ui'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/painting.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/domain/services/setting.service.dart'; +import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; +import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart'; import 'package:immich_mobile/providers/image/cache/image_loader.dart'; import 'package:immich_mobile/providers/image/cache/remote_image_cache_manager.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; @@ -25,11 +26,9 @@ class RemoteThumbProvider extends ImageProvider { @override ImageStreamCompleter loadImage(RemoteThumbProvider key, ImageDecoderCallback decode) { final cache = cacheManager ?? RemoteImageCacheManager(); - final chunkController = StreamController(); return MultiFrameImageStreamCompleter( - codec: _codec(key, cache, decode, chunkController), + codec: _codec(key, cache, decode), scale: 1.0, - chunkEvents: chunkController.stream, informationCollector: () => [ DiagnosticsProperty('Image provider', this), DiagnosticsProperty('Asset Id', key.assetId), @@ -37,20 +36,10 @@ class RemoteThumbProvider extends ImageProvider { ); } - Future _codec( - RemoteThumbProvider key, - CacheManager cache, - ImageDecoderCallback decode, - StreamController chunkController, - ) async { + Future _codec(RemoteThumbProvider key, CacheManager cache, ImageDecoderCallback decode) async { final preview = getThumbnailUrlForRemoteId(key.assetId); - return ImageLoader.loadImageFromCache( - preview, - cache: cache, - decode: decode, - chunkEvents: chunkController, - ).whenComplete(chunkController.close); + return ImageLoader.loadImageFromCache(preview, cache: cache, decode: decode); } @override @@ -81,36 +70,39 @@ class RemoteFullImageProvider extends ImageProvider { @override ImageStreamCompleter loadImage(RemoteFullImageProvider key, ImageDecoderCallback decode) { final cache = cacheManager ?? RemoteImageCacheManager(); - final chunkEvents = StreamController(); - return MultiImageStreamCompleter( - codec: _codec(key, cache, decode, chunkEvents), - scale: 1.0, - chunkEvents: chunkEvents.stream, + return OneFramePlaceholderImageStreamCompleter( + _codec(key, cache, decode), + initialImage: getCachedImage(RemoteThumbProvider(assetId: assetId)), + informationCollector: () => [ + DiagnosticsProperty('Image provider', this), + DiagnosticsProperty('Asset Id', key.assetId), + ], ); } - Stream _codec( - RemoteFullImageProvider key, - CacheManager cache, - ImageDecoderCallback decode, - StreamController chunkController, - ) async* { - yield await ImageLoader.loadImageFromCache( - getPreviewUrlForRemoteId(key.assetId), - cache: cache, - decode: decode, - chunkEvents: chunkController, - ); + Stream _codec(RemoteFullImageProvider key, CacheManager cache, ImageDecoderCallback decode) async* { + ImageInfo? imageInfo; + final originalImageFuture = AppSetting.get(Setting.loadOriginal) + ? ImageLoader.loadImageFromCache( + getOriginalUrlForRemoteId(key.assetId), + cache: cache, + decode: decode, + ).then((image) => image.getNextFrame()).then((frame) => imageInfo = ImageInfo(image: frame.image, scale: 1.0)) + : null; - if (AppSetting.get(Setting.loadOriginal)) { - yield await ImageLoader.loadImageFromCache( - getOriginalUrlForRemoteId(key.assetId), - cache: cache, - decode: decode, - chunkEvents: chunkController, - ); + final previewImageFuture = + ImageLoader.loadImageFromCache(getPreviewUrlForRemoteId(key.assetId), cache: cache, decode: decode) + .then((image) async => imageInfo == null ? await image.getNextFrame() : null) + .then((frame) => imageInfo == null ? ImageInfo(image: frame!.image, scale: 1.0) : null); + + final previewImage = await previewImageFuture; + if (previewImage != null) { + yield previewImage; + } + + if (originalImageFuture != null) { + yield await originalImageFuture; } - await chunkController.close(); } @override