buttery hero animation for remote assets

This commit is contained in:
mertalev 2025-08-01 19:26:14 -04:00
parent abb6f671fe
commit 663f684a4b
No known key found for this signature in database
GPG Key ID: DF6ABC77AAD98C95
5 changed files with 62 additions and 70 deletions

View File

@ -473,10 +473,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
if (stackChildren != null && stackChildren.isNotEmpty) { if (stackChildren != null && stackChildren.isNotEmpty) {
asset = stackChildren.elementAt(ref.read(assetViewerProvider.select((s) => s.stackIndex))); asset = stackChildren.elementAt(ref.read(assetViewerProvider.select((s) => s.stackIndex)));
} }
return Hero( return Thumbnail.fromBaseAsset(asset: asset, fit: BoxFit.contain);
tag: '${asset.heroTag}_$heroOffset',
child: Thumbnail.fromBaseAsset(asset: asset, fit: BoxFit.contain),
);
} }
void _onScaleStateChanged(PhotoViewScaleState scaleState) { void _onScaleStateChanged(PhotoViewScaleState scaleState) {

View File

@ -57,3 +57,25 @@ ImageProvider getThumbnailImageProvider({
bool _shouldUseLocalAsset(BaseAsset asset) => bool _shouldUseLocalAsset(BaseAsset asset) =>
asset.hasLocal && (!asset.hasRemote || !AppSetting.get(Setting.preferRemoteImage)); 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;
}

View File

@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/infrastructure/repositories/asset_media.repository.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'; import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
class LocalThumbProvider extends ImageProvider<LocalThumbProvider> { class LocalThumbProvider extends ImageProvider<LocalThumbProvider> {
@ -65,29 +66,9 @@ class LocalFullImageProvider extends ImageProvider<LocalFullImageProvider> {
@override @override
ImageStreamCompleter loadImage(LocalFullImageProvider key, ImageDecoderCallback decode) { 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( return OneFramePlaceholderImageStreamCompleter(
_codec(key, decode), _codec(key, decode),
initialImage: thumbnail, initialImage: getCachedImage(LocalThumbProvider(id: key.id)),
informationCollector: () => <DiagnosticsNode>[ informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this), DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Id', key.id), DiagnosticsProperty<String>('Id', key.id),
@ -96,14 +77,14 @@ class LocalFullImageProvider extends ImageProvider<LocalFullImageProvider> {
); );
} }
Future<ImageInfo> _codec(LocalFullImageProvider key, ImageDecoderCallback decode) async { Stream<ImageInfo> _codec(LocalFullImageProvider key, ImageDecoderCallback decode) async* {
final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio; final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio;
final codec = await _assetMediaRepository.getLocalThumbnail( final codec = await _assetMediaRepository.getLocalThumbnail(
key.id, key.id,
Size(size.width * devicePixelRatio, size.height * devicePixelRatio), Size(size.width * devicePixelRatio, size.height * devicePixelRatio),
); );
final frame = await codec.getNextFrame(); final frame = await codec.getNextFrame();
return ImageInfo(image: frame.image, scale: 1.0); yield ImageInfo(image: frame.image, scale: 1.0);
} }
@override @override

View File

@ -15,12 +15,12 @@ class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter {
/// should be the primary image to display. The [initialImage] is an optional /// should be the primary image to display. The [initialImage] is an optional
/// image that will be emitted synchronously, useful as a thumbnail or placeholder. /// image that will be emitted synchronously, useful as a thumbnail or placeholder.
OneFramePlaceholderImageStreamCompleter( OneFramePlaceholderImageStreamCompleter(
Future<ImageInfo> image, { Stream<ImageInfo> image, {
ImageInfo? initialImage, ImageInfo? initialImage,
InformationCollector? informationCollector, InformationCollector? informationCollector,
}) { }) {
_initialImage = initialImage; _initialImage = initialImage;
image.then<void>( image.listen(
setImage, setImage,
onError: (Object error, StackTrace stack) { onError: (Object error, StackTrace stack) {
reportError( reportError(

View File

@ -1,12 +1,13 @@
import 'dart:async'; import 'dart:async';
import 'dart:ui'; import 'dart:ui';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart'; import 'package:flutter/painting.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/domain/services/setting.service.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/image_loader.dart';
import 'package:immich_mobile/providers/image/cache/remote_image_cache_manager.dart'; import 'package:immich_mobile/providers/image/cache/remote_image_cache_manager.dart';
import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/utils/image_url_builder.dart';
@ -25,11 +26,9 @@ class RemoteThumbProvider extends ImageProvider<RemoteThumbProvider> {
@override @override
ImageStreamCompleter loadImage(RemoteThumbProvider key, ImageDecoderCallback decode) { ImageStreamCompleter loadImage(RemoteThumbProvider key, ImageDecoderCallback decode) {
final cache = cacheManager ?? RemoteImageCacheManager(); final cache = cacheManager ?? RemoteImageCacheManager();
final chunkController = StreamController<ImageChunkEvent>();
return MultiFrameImageStreamCompleter( return MultiFrameImageStreamCompleter(
codec: _codec(key, cache, decode, chunkController), codec: _codec(key, cache, decode),
scale: 1.0, scale: 1.0,
chunkEvents: chunkController.stream,
informationCollector: () => <DiagnosticsNode>[ informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this), DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Asset Id', key.assetId), DiagnosticsProperty<String>('Asset Id', key.assetId),
@ -37,20 +36,10 @@ class RemoteThumbProvider extends ImageProvider<RemoteThumbProvider> {
); );
} }
Future<Codec> _codec( Future<Codec> _codec(RemoteThumbProvider key, CacheManager cache, ImageDecoderCallback decode) async {
RemoteThumbProvider key,
CacheManager cache,
ImageDecoderCallback decode,
StreamController<ImageChunkEvent> chunkController,
) async {
final preview = getThumbnailUrlForRemoteId(key.assetId); final preview = getThumbnailUrlForRemoteId(key.assetId);
return ImageLoader.loadImageFromCache( return ImageLoader.loadImageFromCache(preview, cache: cache, decode: decode);
preview,
cache: cache,
decode: decode,
chunkEvents: chunkController,
).whenComplete(chunkController.close);
} }
@override @override
@ -81,36 +70,39 @@ class RemoteFullImageProvider extends ImageProvider<RemoteFullImageProvider> {
@override @override
ImageStreamCompleter loadImage(RemoteFullImageProvider key, ImageDecoderCallback decode) { ImageStreamCompleter loadImage(RemoteFullImageProvider key, ImageDecoderCallback decode) {
final cache = cacheManager ?? RemoteImageCacheManager(); final cache = cacheManager ?? RemoteImageCacheManager();
final chunkEvents = StreamController<ImageChunkEvent>(); return OneFramePlaceholderImageStreamCompleter(
return MultiImageStreamCompleter( _codec(key, cache, decode),
codec: _codec(key, cache, decode, chunkEvents), initialImage: getCachedImage(RemoteThumbProvider(assetId: assetId)),
scale: 1.0, informationCollector: () => <DiagnosticsNode>[
chunkEvents: chunkEvents.stream, DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Asset Id', key.assetId),
],
); );
} }
Stream<Codec> _codec( Stream<ImageInfo> _codec(RemoteFullImageProvider key, CacheManager cache, ImageDecoderCallback decode) async* {
RemoteFullImageProvider key, ImageInfo? imageInfo;
CacheManager cache, final originalImageFuture = AppSetting.get(Setting.loadOriginal)
ImageDecoderCallback decode, ? ImageLoader.loadImageFromCache(
StreamController<ImageChunkEvent> chunkController, getOriginalUrlForRemoteId(key.assetId),
) async* { cache: cache,
yield await ImageLoader.loadImageFromCache( decode: decode,
getPreviewUrlForRemoteId(key.assetId), ).then((image) => image.getNextFrame()).then((frame) => imageInfo = ImageInfo(image: frame.image, scale: 1.0))
cache: cache, : null;
decode: decode,
chunkEvents: chunkController,
);
if (AppSetting.get(Setting.loadOriginal)) { final previewImageFuture =
yield await ImageLoader.loadImageFromCache( ImageLoader.loadImageFromCache(getPreviewUrlForRemoteId(key.assetId), cache: cache, decode: decode)
getOriginalUrlForRemoteId(key.assetId), .then((image) async => imageInfo == null ? await image.getNextFrame() : null)
cache: cache, .then((frame) => imageInfo == null ? ImageInfo(image: frame!.image, scale: 1.0) : null);
decode: decode,
chunkEvents: chunkController, final previewImage = await previewImageFuture;
); if (previewImage != null) {
yield previewImage;
}
if (originalImageFuture != null) {
yield await originalImageFuture;
} }
await chunkController.close();
} }
@override @override