diff --git a/mobile/lib/domain/models/asset/base_asset.model.dart b/mobile/lib/domain/models/asset/base_asset.model.dart index 5dd34c04ba..cb40c8f76a 100644 --- a/mobile/lib/domain/models/asset/base_asset.model.dart +++ b/mobile/lib/domain/models/asset/base_asset.model.dart @@ -46,6 +46,7 @@ sealed class BaseAsset { bool get isVideo => type == AssetType.video; bool get isMotionPhoto => livePhotoVideoId != null; + bool get isAnimatedImage => playbackStyle == AssetPlaybackStyle.imageAnimated; AssetPlaybackStyle get playbackStyle { if (isVideo) return AssetPlaybackStyle.video; diff --git a/mobile/lib/presentation/widgets/images/animated_image_stream_completer.dart b/mobile/lib/presentation/widgets/images/animated_image_stream_completer.dart new file mode 100644 index 0000000000..be4fbff8cf --- /dev/null +++ b/mobile/lib/presentation/widgets/images/animated_image_stream_completer.dart @@ -0,0 +1,96 @@ +import 'dart:async'; +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart' show InformationCollector; +import 'package:flutter/painting.dart'; + +/// A [MultiFrameImageStreamCompleter] with support for listener tracking +/// which makes resource cleanup possible when no longer needed. +/// Codec is disposed through the MultiFrameImageStreamCompleter's internals onDispose method +class AnimatedImageStreamCompleter extends MultiFrameImageStreamCompleter { + void Function()? _onLastListenerRemoved; + int _listenerCount = 0; + // True once any image or the codec has been provided. + // Until then the image cache holds one listener, so "last real listener gone" + // is _listenerCount == 1, not 0. + bool didProvideImage = false; + + AnimatedImageStreamCompleter._({ + required super.codec, + required super.scale, + super.informationCollector, + void Function()? onLastListenerRemoved, + }) : _onLastListenerRemoved = onLastListenerRemoved; + + factory AnimatedImageStreamCompleter({ + required Stream stream, + required double scale, + ImageInfo? initialImage, + InformationCollector? informationCollector, + void Function()? onLastListenerRemoved, + }) { + final codecCompleter = Completer(); + final self = AnimatedImageStreamCompleter._( + codec: codecCompleter.future, + scale: scale, + informationCollector: informationCollector, + onLastListenerRemoved: onLastListenerRemoved, + ); + + if (initialImage != null) { + self.didProvideImage = true; + self.setImage(initialImage); + } + + stream.listen( + (item) { + if (item is ImageInfo) { + self.didProvideImage = true; + self.setImage(item); + } else if (item is ui.Codec) { + if (!codecCompleter.isCompleted) { + self.didProvideImage = true; + codecCompleter.complete(item); + } + } + }, + onError: (Object error, StackTrace stack) { + if (!codecCompleter.isCompleted) { + codecCompleter.completeError(error, stack); + } + }, + onDone: () { + // also complete if we are done but no error occurred, and we didn't call complete yet + // could happen on cancellation + if (!codecCompleter.isCompleted) { + codecCompleter.completeError(StateError('Stream closed without providing a codec')); + } + }, + ); + + return self; + } + + @override + void addListener(ImageStreamListener listener) { + super.addListener(listener); + _listenerCount++; + } + + @override + void removeListener(ImageStreamListener listener) { + super.removeListener(listener); + _listenerCount--; + + final bool onlyCacheListenerLeft = _listenerCount == 1 && !didProvideImage; + final bool noListenersAfterCodec = _listenerCount == 0 && didProvideImage; + + if (onlyCacheListenerLeft || noListenersAfterCodec) { + final onLastListenerRemoved = _onLastListenerRemoved; + if (onLastListenerRemoved != null) { + _onLastListenerRemoved = null; + onLastListenerRemoved(); + } + } + } +} diff --git a/mobile/lib/presentation/widgets/images/image_provider.dart b/mobile/lib/presentation/widgets/images/image_provider.dart index 259ac824bb..bf29f9482f 100644 --- a/mobile/lib/presentation/widgets/images/image_provider.dart +++ b/mobile/lib/presentation/widgets/images/image_provider.dart @@ -140,7 +140,7 @@ ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080 final ImageProvider provider; if (_shouldUseLocalAsset(asset)) { final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!; - provider = LocalFullImageProvider(id: id, size: size, assetType: asset.type); + provider = LocalFullImageProvider(id: id, size: size, assetType: asset.type, isAnimated: asset.isAnimatedImage); } else { final String assetId; final String thumbhash; @@ -153,7 +153,12 @@ ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080 } else { throw ArgumentError("Unsupported asset type: ${asset.runtimeType}"); } - provider = RemoteFullImageProvider(assetId: assetId, thumbhash: thumbhash, assetType: asset.type); + provider = RemoteFullImageProvider( + assetId: assetId, + thumbhash: thumbhash, + assetType: asset.type, + isAnimated: asset.isAnimatedImage, + ); } return provider; diff --git a/mobile/lib/presentation/widgets/images/local_image_provider.dart b/mobile/lib/presentation/widgets/images/local_image_provider.dart index 1c7d102239..1ed2c361ff 100644 --- a/mobile/lib/presentation/widgets/images/local_image_provider.dart +++ b/mobile/lib/presentation/widgets/images/local_image_provider.dart @@ -1,11 +1,10 @@ -import 'dart:ui'; - import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/infrastructure/loaders/image_request.dart'; +import 'package:immich_mobile/presentation/widgets/images/animated_image_stream_completer.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/timeline/constants.dart'; @@ -58,8 +57,9 @@ class LocalFullImageProvider extends CancellableImageProvider obtainKey(ImageConfiguration configuration) { @@ -68,6 +68,21 @@ class LocalFullImageProvider extends CancellableImageProvider [ + DiagnosticsProperty('Image provider', this), + DiagnosticsProperty('Id', key.id), + DiagnosticsProperty('Size', key.size), + DiagnosticsProperty('isAnimated', key.isAnimated), + ], + onLastListenerRemoved: cancel, + ); + } + return OneFramePlaceholderImageStreamCompleter( _codec(key, decode), initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType)), @@ -75,6 +90,7 @@ class LocalFullImageProvider extends CancellableImageProvider('Image provider', this), DiagnosticsProperty('Id', key.id), DiagnosticsProperty('Size', key.size), + DiagnosticsProperty('isAnimated', key.isAnimated), ], onLastListenerRemoved: cancel, ); @@ -110,15 +126,45 @@ class LocalFullImageProvider extends CancellableImageProvider _animatedCodec(LocalFullImageProvider key, ImageDecoderCallback decode) async* { + yield* initialImageStream(); + + if (isCancelled) { + PaintingBinding.instance.imageCache.evict(this); + return; + } + + final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio; + final previewRequest = request = LocalImageRequest( + localId: key.id, + size: Size(size.width * devicePixelRatio, size.height * devicePixelRatio), + assetType: key.assetType, + ); + yield* loadRequest(previewRequest, decode); + + if (isCancelled) { + PaintingBinding.instance.imageCache.evict(this); + return; + } + + // always try original for animated, since previews don't support animation + final originalRequest = request = LocalImageRequest(localId: key.id, size: Size.zero, assetType: key.assetType); + final codec = await loadCodecRequest(originalRequest); + if (codec == null) { + throw StateError('Failed to load animated codec for local asset ${key.id}'); + } + yield codec; + } + @override bool operator ==(Object other) { if (identical(this, other)) return true; if (other is LocalFullImageProvider) { - return id == other.id && size == other.size; + return id == other.id && size == other.size && isAnimated == other.isAnimated; } return false; } @override - int get hashCode => id.hashCode ^ size.hashCode; + int get hashCode => id.hashCode ^ size.hashCode ^ isAnimated.hashCode; } diff --git a/mobile/lib/presentation/widgets/images/remote_image_provider.dart b/mobile/lib/presentation/widgets/images/remote_image_provider.dart index f3877f2ad2..65ef4e28eb 100644 --- a/mobile/lib/presentation/widgets/images/remote_image_provider.dart +++ b/mobile/lib/presentation/widgets/images/remote_image_provider.dart @@ -4,6 +4,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/domain/services/setting.service.dart'; import 'package:immich_mobile/infrastructure/loaders/image_request.dart'; +import 'package:immich_mobile/presentation/widgets/images/animated_image_stream_completer.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/utils/image_url_builder.dart'; @@ -58,8 +59,14 @@ class RemoteFullImageProvider extends CancellableImageProvider obtainKey(ImageConfiguration configuration) { @@ -68,12 +75,27 @@ class RemoteFullImageProvider extends CancellableImageProvider [ + DiagnosticsProperty('Image provider', this), + DiagnosticsProperty('Asset Id', key.assetId), + DiagnosticsProperty('isAnimated', key.isAnimated), + ], + onLastListenerRemoved: cancel, + ); + } + return OneFramePlaceholderImageStreamCompleter( _codec(key, decode), initialImage: getInitialImage(RemoteImageProvider.thumbnail(assetId: key.assetId, thumbhash: key.thumbhash)), informationCollector: () => [ DiagnosticsProperty('Image provider', this), DiagnosticsProperty('Asset Id', key.assetId), + DiagnosticsProperty('isAnimated', key.isAnimated), ], onLastListenerRemoved: cancel, ); @@ -106,16 +128,43 @@ class RemoteFullImageProvider extends CancellableImageProvider _animatedCodec(RemoteFullImageProvider key, ImageDecoderCallback decode) async* { + yield* initialImageStream(); + + if (isCancelled) { + PaintingBinding.instance.imageCache.evict(this); + return; + } + + final previewRequest = request = RemoteImageRequest( + uri: getThumbnailUrlForRemoteId(key.assetId, type: AssetMediaSize.preview, thumbhash: key.thumbhash), + ); + yield* loadRequest(previewRequest, decode, evictOnError: false); + + if (isCancelled) { + PaintingBinding.instance.imageCache.evict(this); + return; + } + + // always try original for animated, since previews don't support animation + final originalRequest = request = RemoteImageRequest(uri: getOriginalUrlForRemoteId(key.assetId)); + final codec = await loadCodecRequest(originalRequest); + if (codec == null) { + throw StateError('Failed to load animated codec for asset ${key.assetId}'); + } + yield codec; + } + @override bool operator ==(Object other) { if (identical(this, other)) return true; if (other is RemoteFullImageProvider) { - return assetId == other.assetId && thumbhash == other.thumbhash; + return assetId == other.assetId && thumbhash == other.thumbhash && isAnimated == other.isAnimated; } return false; } @override - int get hashCode => assetId.hashCode ^ thumbhash.hashCode; + int get hashCode => assetId.hashCode ^ thumbhash.hashCode ^ isAnimated.hashCode; } diff --git a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart index 3593fc75e8..2ceaf80db0 100644 --- a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart +++ b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart @@ -305,6 +305,8 @@ class _AssetTypeIcons extends StatelessWidget { padding: EdgeInsets.only(right: 10.0, top: 6.0), child: _TileOverlayIcon(Icons.motion_photos_on_rounded), ), + if (asset.isAnimatedImage) + const Padding(padding: EdgeInsets.only(right: 10.0, top: 6.0), child: _TileOverlayIcon(Icons.gif_rounded)), ], ); } diff --git a/mobile/lib/widgets/common/immich_image.dart b/mobile/lib/widgets/common/immich_image.dart index 141a2ac7d4..57978e83ff 100644 --- a/mobile/lib/widgets/common/immich_image.dart +++ b/mobile/lib/widgets/common/immich_image.dart @@ -35,7 +35,12 @@ class ImmichImage extends StatelessWidget { } if (asset == null) { - return RemoteFullImageProvider(assetId: assetId!, thumbhash: '', assetType: base_asset.AssetType.video); + return RemoteFullImageProvider( + assetId: assetId!, + thumbhash: '', + assetType: base_asset.AssetType.video, + isAnimated: false, + ); } if (useLocal(asset)) { @@ -43,12 +48,14 @@ class ImmichImage extends StatelessWidget { id: asset.localId!, assetType: base_asset.AssetType.video, size: Size(width, height), + isAnimated: false, ); } else { return RemoteFullImageProvider( assetId: asset.remoteId!, thumbhash: asset.thumbhash ?? '', assetType: base_asset.AssetType.video, + isAnimated: false, ); } }