diff --git a/mobile/lib/extensions/build_context_extensions.dart b/mobile/lib/extensions/build_context_extensions.dart index a624337954..0a35bc1a86 100644 --- a/mobile/lib/extensions/build_context_extensions.dart +++ b/mobile/lib/extensions/build_context_extensions.dart @@ -13,6 +13,9 @@ extension ContextHelper on BuildContext { // Returns the current height from MediaQuery double get height => MediaQuery.sizeOf(this).height; + // Returns the current size from MediaQuery + Size get sizeData => MediaQuery.sizeOf(this); + // Returns true if the app is running on a mobile device (!tablets) bool get isMobile => width < 550; diff --git a/mobile/lib/extensions/codec_extensions.dart b/mobile/lib/extensions/codec_extensions.dart new file mode 100644 index 0000000000..00c1158f0c --- /dev/null +++ b/mobile/lib/extensions/codec_extensions.dart @@ -0,0 +1,10 @@ +import 'dart:ui'; + +import 'package:flutter/painting.dart'; + +extension CodecImageInfoExtension on Codec { + Future getImageInfo({double scale = 1.0}) async { + final frame = await getNextFrame(); + return ImageInfo(image: frame.image, scale: scale); + } +} 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 2fa54ad65d..d7e83e72ec 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -147,11 +147,7 @@ class _AssetViewerState extends ConsumerState { // Precache both thumbnail and full image for smooth transitions unawaited( Future.wait([ - precacheImage( - getThumbnailImageProvider(asset: asset, size: screenSize), - context, - onError: (_, __) {}, - ), + precacheImage(getThumbnailImageProvider(asset: asset), context, onError: (_, __) {}), precacheImage(getFullImageProvider(asset, size: screenSize), context, onError: (_, __) {}), ]), ); @@ -482,7 +478,7 @@ class _AssetViewerState extends ConsumerState { width: double.infinity, height: double.infinity, color: backgroundColor, - child: Thumbnail(asset: asset, fit: BoxFit.contain, size: Size(ctx.width, ctx.height)), + child: Thumbnail(asset: asset, fit: BoxFit.contain), ); } @@ -513,7 +509,7 @@ class _AssetViewerState extends ConsumerState { } PhotoViewGalleryPageOptions _imageBuilder(BuildContext ctx, BaseAsset asset) { - final size = Size(ctx.width, ctx.height); + final size = ctx.sizeData; return PhotoViewGalleryPageOptions( key: ValueKey(asset.heroTag), imageProvider: getFullImageProvider(asset, size: size), @@ -529,10 +525,10 @@ class _AssetViewerState extends ConsumerState { onTapDown: _onTapDown, onLongPressStart: asset.isMotionPhoto ? _onLongPress : null, errorBuilder: (_, __, ___) => Container( - width: ctx.width, - height: ctx.height, + width: size.width, + height: size.height, color: backgroundColor, - child: Thumbnail(asset: asset, fit: BoxFit.contain, size: size), + child: Thumbnail(asset: asset, fit: BoxFit.contain), ), ); } @@ -562,7 +558,7 @@ class _AssetViewerState extends ConsumerState { asset: asset, image: Image( key: ValueKey(asset), - image: getFullImageProvider(asset, size: Size(ctx.width, ctx.height)), + image: getFullImageProvider(asset, size: ctx.sizeData), fit: BoxFit.contain, height: ctx.height, width: ctx.width, diff --git a/mobile/lib/presentation/widgets/images/image_provider.dart b/mobile/lib/presentation/widgets/images/image_provider.dart index d94480b434..adb1e178ca 100644 --- a/mobile/lib/presentation/widgets/images/image_provider.dart +++ b/mobile/lib/presentation/widgets/images/image_provider.dart @@ -4,13 +4,14 @@ 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/local_image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080, 1920)}) { // Create new provider and cache it final ImageProvider provider; if (_shouldUseLocalAsset(asset)) { final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!; - provider = LocalFullImageProvider(id: id, name: asset.name, size: size, type: asset.type); + provider = LocalFullImageProvider(id: id, size: size, type: asset.type, updatedAt: asset.updatedAt); } else { final String assetId; if (asset is LocalAsset && asset.hasRemote) { @@ -26,7 +27,7 @@ ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080 return provider; } -ImageProvider getThumbnailImageProvider({BaseAsset? asset, String? remoteId, Size size = const Size.square(256)}) { +ImageProvider getThumbnailImageProvider({BaseAsset? asset, String? remoteId, Size size = kThumbnailResolution}) { assert(asset != null || remoteId != null, 'Either asset or remoteId must be provided'); if (remoteId != null) { @@ -35,7 +36,7 @@ ImageProvider getThumbnailImageProvider({BaseAsset? asset, String? remoteId, Siz if (_shouldUseLocalAsset(asset!)) { final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!; - return LocalThumbProvider(id: id, updatedAt: asset.updatedAt, name: asset.name, size: size); + return LocalThumbProvider(id: id, updatedAt: asset.updatedAt, size: size); } final String assetId; @@ -52,3 +53,25 @@ ImageProvider getThumbnailImageProvider({BaseAsset? asset, String? remoteId, Siz 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 cached + ); + + 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 350bcbb8fb..4da4b927f1 100644 --- a/mobile/lib/presentation/widgets/images/local_image_provider.dart +++ b/mobile/lib/presentation/widgets/images/local_image_provider.dart @@ -2,15 +2,17 @@ import 'dart:async'; import 'dart:io'; import 'dart:ui'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; 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/extensions/codec_extensions.dart'; import 'package:immich_mobile/infrastructure/repositories/asset_media.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.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/timeline/constants.dart'; import 'package:immich_mobile/providers/image/cache/thumbnail_image_cache_manager.dart'; import 'package:immich_mobile/providers/image/exceptions/image_loading_exception.dart'; @@ -22,14 +24,12 @@ class LocalThumbProvider extends ImageProvider { final String id; final DateTime updatedAt; - final String name; final Size size; const LocalThumbProvider({ required this.id, required this.updatedAt, - required this.name, - this.size = const Size.square(kTimelineFixedTileExtent), + this.size = kThumbnailResolution, this.cacheManager, }); @@ -45,10 +45,8 @@ class LocalThumbProvider extends ImageProvider { codec: _codec(key, cache, decode), scale: 1.0, informationCollector: () => [ - DiagnosticsProperty('Image provider', this), DiagnosticsProperty('Id', key.id), DiagnosticsProperty('Updated at', key.updatedAt), - DiagnosticsProperty('Name', key.name), DiagnosticsProperty('Size', key.size), ], ); @@ -68,7 +66,7 @@ class LocalThumbProvider extends ImageProvider { final thumbnailBytes = await _assetMediaRepository.getThumbnail(key.id, size: key.size); if (thumbnailBytes == null) { PaintingBinding.instance.imageCache.evict(key); - throw StateError("Loading thumb for local photo ${key.name} failed"); + throw StateError("Loading thumb for local photo ${key.id} failed"); } final buffer = await ImmutableBuffer.fromUint8List(thumbnailBytes); @@ -94,11 +92,11 @@ class LocalFullImageProvider extends ImageProvider { final StorageRepository _storageRepository = const StorageRepository(); final String id; - final String name; final Size size; final AssetType type; + final DateTime updatedAt; // temporary, only exists to fetch cached thumbnail until local disk cache is removed - const LocalFullImageProvider({required this.id, required this.name, required this.size, required this.type}); + const LocalFullImageProvider({required this.id, required this.size, required this.type, required this.updatedAt}); @override Future obtainKey(ImageConfiguration configuration) { @@ -107,52 +105,45 @@ class LocalFullImageProvider extends ImageProvider { @override ImageStreamCompleter loadImage(LocalFullImageProvider key, ImageDecoderCallback decode) { - return MultiImageStreamCompleter( - codec: _codec(key, decode), - scale: 1.0, - informationCollector: () sync* { - yield ErrorDescription(name); - }, + return OneFramePlaceholderImageStreamCompleter( + _codec(key, decode), + initialImage: getCachedImage(LocalThumbProvider(id: key.id, updatedAt: key.updatedAt)), + informationCollector: () => [ + DiagnosticsProperty('Id', key.id), + DiagnosticsProperty('Updated at', key.updatedAt), + DiagnosticsProperty('Size', key.size), + ], ); } // Streams in each stage of the image as we ask for it - Stream _codec(LocalFullImageProvider key, ImageDecoderCallback decode) async* { + Stream _codec(LocalFullImageProvider key, ImageDecoderCallback decode) { try { - switch (key.type) { - case AssetType.image: - yield* _decodeProgressive(key, decode); - break; - case AssetType.video: - final codec = await _getThumbnailCodec(key, decode); - if (codec == null) { - throw StateError("Failed to load preview for ${key.name}"); - } - yield codec; - break; - case AssetType.other: - case AssetType.audio: - throw StateError('Unsupported asset type ${key.type}'); - } + return switch (key.type) { + AssetType.image => _decodeProgressive(key, decode), + AssetType.video => _getThumbnailCodec(key, decode), + _ => throw StateError('Unsupported asset type ${key.type}'), + }; } catch (error, stack) { - Logger('ImmichLocalImageProvider').severe('Error loading local image ${key.name}', error, stack); + Logger('ImmichLocalImageProvider').severe('Error loading local image ${key.id}', error, stack); throw const ImageLoadingException('Could not load image from local storage'); } } - Future _getThumbnailCodec(LocalFullImageProvider key, ImageDecoderCallback decode) async { + Stream _getThumbnailCodec(LocalFullImageProvider key, ImageDecoderCallback decode) async* { final thumbBytes = await _assetMediaRepository.getThumbnail(key.id, size: key.size); if (thumbBytes == null) { - return null; + throw StateError("Failed to load preview for ${key.id}"); } final buffer = await ImmutableBuffer.fromUint8List(thumbBytes); - return decode(buffer); + final codec = await decode(buffer); + yield await codec.getImageInfo(); } - Stream _decodeProgressive(LocalFullImageProvider key, ImageDecoderCallback decode) async* { + Stream _decodeProgressive(LocalFullImageProvider key, ImageDecoderCallback decode) async* { final file = await _storageRepository.getFileForAsset(key.id); if (file == null) { - throw StateError("Opening file for asset ${key.name} failed"); + throw StateError("Opening file for asset ${key.id} failed"); } final fileSize = await file.length(); @@ -171,7 +162,8 @@ class LocalFullImageProvider extends ImageProvider { final mediumThumb = await _assetMediaRepository.getThumbnail(key.id, size: size); if (mediumThumb != null) { final mediumBuffer = await ImmutableBuffer.fromUint8List(mediumThumb); - yield await decode(mediumBuffer); + final codec = await decode(mediumBuffer); + yield await codec.getImageInfo(); } } catch (_) {} } @@ -187,24 +179,26 @@ class LocalFullImageProvider extends ImageProvider { final highThumb = await _assetMediaRepository.getThumbnail(key.id, size: size); if (highThumb != null) { final highBuffer = await ImmutableBuffer.fromUint8List(highThumb); - yield await decode(highBuffer); + final codec = await decode(highBuffer); + yield await codec.getImageInfo(); } return; } final buffer = await ImmutableBuffer.fromFilePath(file.path); - yield await decode(buffer); + final codec = await decode(buffer); + yield await codec.getImageInfo(); } @override bool operator ==(Object other) { if (identical(this, other)) return true; if (other is LocalFullImageProvider) { - return id == other.id && size == other.size && type == other.type && name == other.name; + return id == other.id && size == other.size && type == other.type; } return false; } @override - int get hashCode => id.hashCode ^ size.hashCode ^ type.hashCode ^ name.hashCode; + int get hashCode => id.hashCode ^ size.hashCode ^ type.hashCode; } 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..a78eaf35dc --- /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( + Stream image, { + ImageInfo? initialImage, + InformationCollector? informationCollector, + }) { + _initialImage = initialImage; + image.listen( + 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/remote_image_provider.dart b/mobile/lib/presentation/widgets/images/remote_image_provider.dart index 27f310f4f2..71c5ad446e 100644 --- a/mobile/lib/presentation/widgets/images/remote_image_provider.dart +++ b/mobile/lib/presentation/widgets/images/remote_image_provider.dart @@ -1,12 +1,14 @@ 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/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'; import 'package:immich_mobile/providers/image/cache/remote_image_cache_manager.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; @@ -81,36 +83,28 @@ 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: key.assetId)), ); } - Stream _codec( - RemoteFullImageProvider key, - CacheManager cache, - ImageDecoderCallback decode, - StreamController chunkController, - ) async* { - yield await ImageLoader.loadImageFromCache( + Stream _codec(RemoteFullImageProvider key, CacheManager cache, ImageDecoderCallback decode) async* { + final codec = await ImageLoader.loadImageFromCache( getPreviewUrlForRemoteId(key.assetId), cache: cache, decode: decode, - chunkEvents: chunkController, ); + yield await codec.getImageInfo(); if (AppSetting.get(Setting.loadOriginal)) { - yield await ImageLoader.loadImageFromCache( + final codec = await ImageLoader.loadImageFromCache( getOriginalUrlForRemoteId(key.assetId), cache: cache, decode: decode, - chunkEvents: chunkController, ); + yield await codec.getImageInfo(); } - await chunkController.close(); } @override diff --git a/mobile/lib/presentation/widgets/images/thumbnail.widget.dart b/mobile/lib/presentation/widgets/images/thumbnail.widget.dart index 8335bd406b..9965c1bfd5 100644 --- a/mobile/lib/presentation/widgets/images/thumbnail.widget.dart +++ b/mobile/lib/presentation/widgets/images/thumbnail.widget.dart @@ -19,7 +19,7 @@ class Thumbnail extends StatelessWidget { @override Widget build(BuildContext context) { final thumbHash = asset is RemoteAsset ? (asset as RemoteAsset).thumbHash : null; - final provider = getThumbnailImageProvider(asset: asset, remoteId: remoteId, size: size); + final provider = getThumbnailImageProvider(asset: asset, remoteId: remoteId); return OctoImage.fromSet( image: provider, diff --git a/mobile/lib/presentation/widgets/timeline/constants.dart b/mobile/lib/presentation/widgets/timeline/constants.dart index fb9034f179..e3bb5fe273 100644 --- a/mobile/lib/presentation/widgets/timeline/constants.dart +++ b/mobile/lib/presentation/widgets/timeline/constants.dart @@ -1,5 +1,8 @@ +import 'dart:ui'; + const double kTimelineHeaderExtent = 80.0; -const double kTimelineFixedTileExtent = 256; +const Size kTimelineFixedTileExtent = Size.square(256); +const Size kThumbnailResolution = kTimelineFixedTileExtent; const double kTimelineSpacing = 2.0; const int kTimelineColumnCount = 3; diff --git a/mobile/lib/presentation/widgets/timeline/segment_builder.dart b/mobile/lib/presentation/widgets/timeline/segment_builder.dart index c80595a446..79ffb47e95 100644 --- a/mobile/lib/presentation/widgets/timeline/segment_builder.dart +++ b/mobile/lib/presentation/widgets/timeline/segment_builder.dart @@ -21,7 +21,7 @@ abstract class SegmentBuilder { static Widget buildPlaceholder( BuildContext context, int count, { - Size size = const Size.square(kTimelineFixedTileExtent), + Size size = kTimelineFixedTileExtent, double spacing = kTimelineSpacing, }) => RepaintBoundary( child: FixedTimelineRow(