From 98c1f3c4763dedf41655910900a2de90c45db722 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Fri, 1 Aug 2025 18:35:40 -0400 Subject: [PATCH] buttery hero animations buttery hero animation for remote assets --- mobile/lib/constants/constants.dart | 6 +- .../asset_viewer/asset_viewer.page.dart | 7 +-- .../widgets/images/local_image_provider.dart | 20 +++++-- .../widgets/images/remote_image_provider.dart | 58 +++++++++---------- .../widgets/images/thumbnail.widget.dart | 6 +- .../widgets/images/thumbnail_tile.widget.dart | 2 +- 6 files changed, 53 insertions(+), 46 deletions(-) diff --git a/mobile/lib/constants/constants.dart b/mobile/lib/constants/constants.dart index 3f78bf0bc6..52d4100dc1 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 f38f4552a6..0af71ab64b 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -472,12 +472,7 @@ 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, - 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/local_image_provider.dart b/mobile/lib/presentation/widgets/images/local_image_provider.dart index 839bd070f5..32a79be616 100644 --- a/mobile/lib/presentation/widgets/images/local_image_provider.dart +++ b/mobile/lib/presentation/widgets/images/local_image_provider.dart @@ -3,7 +3,10 @@ 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/image_provider.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 +14,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) { @@ -62,16 +65,25 @@ class LocalFullImageProvider extends ImageProvider { @override ImageStreamCompleter loadImage(LocalFullImageProvider key, ImageDecoderCallback decode) { - return OneFrameImageStreamCompleter(_codec(key)); + return OneFramePlaceholderImageStreamCompleter( + _codec(key, decode), + initialImage: getCachedImage(LocalThumbProvider(id: key.id)), + informationCollector: () => [ + DiagnosticsProperty('Image provider', this), + DiagnosticsProperty('Id', key.id), + DiagnosticsProperty('Size', key.size), + ], + ); } - Future _codec(LocalFullImageProvider key) 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), ); - return ImageInfo(image: (await codec.getNextFrame()).image, scale: 1.0); + final frame = await codec.getNextFrame(); + yield ImageInfo(image: frame.image, scale: 1.0); } @override diff --git a/mobile/lib/presentation/widgets/images/remote_image_provider.dart b/mobile/lib/presentation/widgets/images/remote_image_provider.dart index 71c5ad446e..d9ab78053f 100644 --- a/mobile/lib/presentation/widgets/images/remote_image_provider.dart +++ b/mobile/lib/presentation/widgets/images/remote_image_provider.dart @@ -6,7 +6,6 @@ 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/extensions/codec_extensions.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'; @@ -27,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), @@ -39,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 @@ -85,25 +72,36 @@ class RemoteFullImageProvider extends ImageProvider { final cache = cacheManager ?? RemoteImageCacheManager(); return OneFramePlaceholderImageStreamCompleter( _codec(key, cache, decode), - initialImage: getCachedImage(RemoteThumbProvider(assetId: key.assetId)), + initialImage: getCachedImage(RemoteThumbProvider(assetId: assetId)), + informationCollector: () => [ + DiagnosticsProperty('Image provider', this), + DiagnosticsProperty('Asset Id', key.assetId), + ], ); } Stream _codec(RemoteFullImageProvider key, CacheManager cache, ImageDecoderCallback decode) async* { - final codec = await ImageLoader.loadImageFromCache( - getPreviewUrlForRemoteId(key.assetId), - cache: cache, - decode: decode, - ); - yield await codec.getImageInfo(); + 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)) { - final codec = await ImageLoader.loadImageFromCache( - getOriginalUrlForRemoteId(key.assetId), - cache: cache, - decode: decode, - ); - yield await codec.getImageInfo(); + 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; } } 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 8f90d0f094..11ec6ca430 100644 --- a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart +++ b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart @@ -15,7 +15,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, this.lockSelection = false,