diff --git a/mobile/lib/infrastructure/loaders/image_request.dart b/mobile/lib/infrastructure/loaders/image_request.dart new file mode 100644 index 0000000000..4614b61930 --- /dev/null +++ b/mobile/lib/infrastructure/loaders/image_request.dart @@ -0,0 +1,81 @@ +import 'dart:async'; +import 'dart:ffi'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/foundation.dart'; +import 'package:ffi/ffi.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/providers/image/cache/remote_image_cache_manager.dart'; +import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; +import 'package:logging/logging.dart'; + +part 'local_image_request.dart'; +part 'thumbhash_image_request.dart'; +part 'remote_image_request.dart'; + +abstract class ImageRequest { + static int _nextRequestId = 0; + + final int requestId = _nextRequestId++; + bool _isCancelled = false; + + get isCancelled => _isCancelled; + + ImageRequest(); + + Future load(ImageDecoderCallback decode, {double scale = 1.0}); + + void cancel() { + if (_isCancelled) { + return; + } + _isCancelled = true; + return _onCancelled(); + } + + void _onCancelled(); + + Future _fromPlatformImage(Map info) async { + final address = info['pointer']; + if (address == null) { + if (!kReleaseMode) { + debugPrint('Platform image request for $requestId was cancelled'); + } + return null; + } + + final pointer = Pointer.fromAddress(address); + try { + if (_isCancelled) { + return null; + } + + final actualWidth = info['width']!; + final actualHeight = info['height']!; + final actualSize = actualWidth * actualHeight * 4; + + final buffer = await ImmutableBuffer.fromUint8List(pointer.asTypedList(actualSize)); + if (_isCancelled) { + return null; + } + + final descriptor = ui.ImageDescriptor.raw( + buffer, + width: actualWidth, + height: actualHeight, + pixelFormat: ui.PixelFormat.rgba8888, + ); + final codec = await descriptor.instantiateCodec(); + if (_isCancelled) { + return null; + } + + return await codec.getNextFrame(); + } finally { + malloc.free(pointer); + } + } +} diff --git a/mobile/lib/infrastructure/loaders/local_image_request.dart b/mobile/lib/infrastructure/loaders/local_image_request.dart new file mode 100644 index 0000000000..915b2b66be --- /dev/null +++ b/mobile/lib/infrastructure/loaders/local_image_request.dart @@ -0,0 +1,45 @@ +part of 'image_request.dart'; + +class LocalImageRequest extends ImageRequest { + final String localId; + final int width; + final int height; + final AssetType assetType; + + LocalImageRequest({required this.localId, required ui.Size size, required this.assetType}) + : width = size.width.toInt(), + height = size.height.toInt(); + + @override + Future load(ImageDecoderCallback decode, {double scale = 1.0}) async { + if (_isCancelled) { + return null; + } + + Stopwatch? stopwatch; + if (!kReleaseMode) { + stopwatch = Stopwatch()..start(); + } + final Map info = await thumbnailApi.requestImage( + localId, + requestId: requestId, + width: width, + height: height, + isVideo: assetType == AssetType.video, + ); + if (!kReleaseMode) { + stopwatch!.stop(); + debugPrint('Local request $requestId took ${stopwatch.elapsedMilliseconds}ms for $localId of $width x $height'); + } + final frame = await _fromPlatformImage(info); + return frame == null ? null : ImageInfo(image: frame.image, scale: scale); + } + + @override + Future _onCancelled() { + if (!kReleaseMode) { + debugPrint('Local image request $requestId for $localId of size $width x $height was cancelled'); + } + return thumbnailApi.cancelImageRequest(requestId); + } +} diff --git a/mobile/lib/infrastructure/loaders/remote_image_request.dart b/mobile/lib/infrastructure/loaders/remote_image_request.dart new file mode 100644 index 0000000000..2051e3eaa1 --- /dev/null +++ b/mobile/lib/infrastructure/loaders/remote_image_request.dart @@ -0,0 +1,146 @@ +part of 'image_request.dart'; + +class RemoteImageRequest extends ImageRequest { + static final log = Logger('RemoteImageRequest'); + static final client = HttpClient()..maxConnectionsPerHost = 32; + final RemoteCacheManager? cacheManager; + final String uri; + final Map headers; + HttpClientRequest? _request; + + RemoteImageRequest({required this.uri, required this.headers, this.cacheManager}); + + @override + Future load(ImageDecoderCallback decode, {double scale = 1.0}) async { + if (_isCancelled) { + return null; + } + + // TODO: the cache manager makes everything sequential with its DB calls and its operations cannot be cancelled, + // so it ends up being a bottleneck. We only prefer fetching from it when it can skip the DB call. + final cachedFileImage = await _loadCachedFile(uri, decode, scale, inMemoryOnly: true); + if (cachedFileImage != null) { + return cachedFileImage; + } + + try { + Stopwatch? stopwatch; + if (!kReleaseMode) { + stopwatch = Stopwatch()..start(); + } + final buffer = await _downloadImage(uri); + if (buffer == null) { + return null; + } + if (!kReleaseMode) { + stopwatch!.stop(); + debugPrint('Remote image download $requestId took ${stopwatch.elapsedMilliseconds}ms for $uri'); + } + return await _decodeBuffer(buffer, decode, scale); + } catch (e) { + if (_isCancelled) { + return null; + } + + final cachedFileImage = await _loadCachedFile(uri, decode, scale, inMemoryOnly: false); + if (cachedFileImage != null) { + return cachedFileImage; + } + + rethrow; + } finally { + _request = null; + } + } + + Future _downloadImage(String url) async { + if (_isCancelled) { + return null; + } + + final request = _request = await client.getUrl(Uri.parse(url)); + if (_isCancelled) { + request.abort(); + return _request = null; + } + + for (final entry in headers.entries) { + request.headers.set(entry.key, entry.value); + } + final response = await request.close(); + if (_isCancelled) { + return null; + } + final bytes = Uint8List(response.contentLength); + int offset = 0; + final subscription = response.listen((List chunk) { + // this is important to break the response stream if the request is cancelled + if (_isCancelled) { + throw StateError('Cancelled request'); + } + bytes.setAll(offset, chunk); + offset += chunk.length; + }, cancelOnError: true); + cacheManager?.putStreamedFile(url, response); + await subscription.asFuture(); + return await ImmutableBuffer.fromUint8List(bytes); + } + + Future _loadCachedFile( + String url, + ImageDecoderCallback decode, + double scale, { + required bool inMemoryOnly, + }) async { + final cacheManager = this.cacheManager; + if (_isCancelled || cacheManager == null) { + return null; + } + + final file = await (inMemoryOnly ? cacheManager.getFileFromMemory(url) : cacheManager.getFileFromCache(url)); + if (_isCancelled || file == null) { + return null; + } + + try { + final buffer = await ImmutableBuffer.fromFilePath(file.file.path); + return await _decodeBuffer(buffer, decode, scale); + } catch (e) { + log.severe('Failed to decode cached image', e); + _evictFile(url); + return null; + } + } + + Future _evictFile(String url) async { + try { + await cacheManager?.removeFile(url); + } catch (e) { + log.severe('Failed to remove cached image', e); + } + } + + Future _decodeBuffer(ImmutableBuffer buffer, ImageDecoderCallback decode, scale) async { + if (_isCancelled) { + buffer.dispose(); + return null; + } + final codec = await decode(buffer); + if (_isCancelled) { + buffer.dispose(); + codec.dispose(); + return null; + } + final frame = await codec.getNextFrame(); + return ImageInfo(image: frame.image, scale: scale); + } + + @override + void _onCancelled() { + _request?.abort(); + _request = null; + if (!kReleaseMode) { + debugPrint('Cancelled remote image request $requestId for $uri'); + } + } +} diff --git a/mobile/lib/infrastructure/loaders/thumbhash_image_request.dart b/mobile/lib/infrastructure/loaders/thumbhash_image_request.dart new file mode 100644 index 0000000000..083c0b2a96 --- /dev/null +++ b/mobile/lib/infrastructure/loaders/thumbhash_image_request.dart @@ -0,0 +1,25 @@ +part of 'image_request.dart'; + +class ThumbhashImageRequest extends ImageRequest { + final String thumbhash; + + ThumbhashImageRequest({required this.thumbhash}); + + @override + Future load(ImageDecoderCallback decode, {double scale = 1.0}) async { + if (_isCancelled) { + return null; + } + + final Map info = await thumbnailApi.getThumbhash(thumbhash); + final frame = await _fromPlatformImage(info); + return frame == null ? null : ImageInfo(image: frame.image, scale: scale); + } + + @override + void _onCancelled() { + if (!kReleaseMode) { + debugPrint('Thumbhash request $requestId for $thumbhash was cancelled'); + } + } +} diff --git a/mobile/lib/infrastructure/repositories/asset_media.repository.dart b/mobile/lib/infrastructure/repositories/asset_media.repository.dart deleted file mode 100644 index 6c81c7ff7f..0000000000 --- a/mobile/lib/infrastructure/repositories/asset_media.repository.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'dart:typed_data'; -import 'dart:ui'; - -import 'package:photo_manager/photo_manager.dart'; - -class AssetMediaRepository { - const AssetMediaRepository(); - - Future getThumbnail(String id, {int quality = 80, Size size = const Size.square(256)}) => AssetEntity( - id: id, - // The below fields are not used in thumbnailDataWithSize but are required - // to create an AssetEntity instance. It is faster to create a dummy AssetEntity - // instance than to fetch the asset from the device first. - typeInt: AssetType.image.index, - width: size.width.toInt(), - height: size.height.toInt(), - ).thumbnailDataWithSize(ThumbnailSize(size.width.toInt(), size.height.toInt()), quality: quality); -} 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 3555aff6e6..f1e8c99b2e 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -25,6 +25,7 @@ import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart'; import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; import 'package:immich_mobile/widgets/photo_view/photo_view_gallery.dart'; import 'package:platform/platform.dart'; @@ -63,6 +64,7 @@ const double _kBottomSheetMinimumExtent = 0.4; const double _kBottomSheetSnapExtent = 0.7; class _AssetViewerState extends ConsumerState { + static final _dummyListener = ImageStreamListener((image, _) => image.dispose()); late PageController pageController; late DraggableScrollableController bottomSheetController; PersistentBottomSheetController? sheetCloseController; @@ -90,6 +92,9 @@ class _AssetViewerState extends ConsumerState { // Delayed operations that should be cancelled on disposal final List _delayedOperations = []; + ImageStream? _prevPreCacheStream; + ImageStream? _nextPreCacheStream; + @override void initState() { super.initState(); @@ -110,6 +115,8 @@ class _AssetViewerState extends ConsumerState { bottomSheetController.dispose(); _cancelTimers(); reloadSubscription?.cancel(); + _prevPreCacheStream?.removeListener(_dummyListener); + _nextPreCacheStream?.removeListener(_dummyListener); super.dispose(); } @@ -130,27 +137,9 @@ class _AssetViewerState extends ConsumerState { double _getVerticalOffsetForBottomSheet(double extent) => (context.height * extent) - (context.height * _kBottomSheetMinimumExtent); - Future _precacheImage(int index) async { - if (!mounted) { - return; - } - - final timelineService = ref.read(timelineServiceProvider); - final asset = await timelineService.getAssetAsync(index); - - if (asset == null || !mounted) { - return; - } - - final screenSize = Size(context.width, context.height); - - // Precache both thumbnail and full image for smooth transitions - unawaited( - Future.wait([ - precacheImage(getThumbnailImageProvider(asset: asset), context, onError: (_, __) {}), - precacheImage(getFullImageProvider(asset, size: screenSize), context, onError: (_, __) {}), - ]), - ); + ImageStream _precacheImage(BaseAsset asset) { + final provider = getFullImageProvider(asset, size: context.sizeData); + return provider.resolve(ImageConfiguration.empty)..addListener(_dummyListener); } void _onAssetChanged(int index) async { @@ -176,13 +165,19 @@ class _AssetViewerState extends ConsumerState { _cancelTimers(); // This will trigger the pre-caching of adjacent assets ensuring // that they are ready when the user navigates to them. - final timer = Timer(Durations.medium4, () { + final timer = Timer(Durations.medium4, () async { // Check if widget is still mounted before proceeding if (!mounted) return; - for (final offset in [-1, 1]) { - unawaited(_precacheImage(index + offset)); - } + final (prevAsset, nextAsset) = await ( + timelineService.getAssetAsync(index - 1), + timelineService.getAssetAsync(index + 1), + ).wait; + if (!mounted) return; + _prevPreCacheStream?.removeListener(_dummyListener); + _nextPreCacheStream?.removeListener(_dummyListener); + _prevPreCacheStream = prevAsset != null ? _precacheImage(prevAsset) : null; + _nextPreCacheStream = nextAsset != null ? _precacheImage(nextAsset) : null; }); _delayedOperations.add(timer); @@ -478,30 +473,7 @@ class _AssetViewerState extends ConsumerState { } Widget _placeholderBuilder(BuildContext ctx, ImageChunkEvent? progress, int index) { - final timelineService = ref.read(timelineServiceProvider); - final asset = timelineService.getAssetSafe(index); - - // If asset is not available in buffer, show a loading container - if (asset == null) { - return Container( - width: double.infinity, - height: double.infinity, - color: backgroundColor, - child: const Center(child: CircularProgressIndicator()), - ); - } - - BaseAsset displayAsset = asset; - final stackChildren = ref.read(stackChildrenNotifier(asset)).valueOrNull; - if (stackChildren != null && stackChildren.isNotEmpty) { - displayAsset = stackChildren.elementAt(ref.read(assetViewerProvider.select((s) => s.stackIndex))); - } - return Container( - width: double.infinity, - height: double.infinity, - color: backgroundColor, - child: Thumbnail(asset: displayAsset, fit: BoxFit.contain), - ); + return const Center(child: ImmichLoadingIndicator()); } 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 19eed71d44..419b077c06 100644 --- a/mobile/lib/presentation/widgets/images/image_provider.dart +++ b/mobile/lib/presentation/widgets/images/image_provider.dart @@ -1,17 +1,114 @@ +import 'package:async/async.dart'; import 'package:flutter/widgets.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/infrastructure/loaders/image_request.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'; +import 'package:logging/logging.dart'; + +abstract class CancellableImageProvider extends ImageProvider { + void cancel(); +} + +mixin CancellableImageProviderMixin on CancellableImageProvider { + static final _log = Logger('CancellableImageProviderMixin'); + + bool isCancelled = false; + ImageRequest? request; + CancelableOperation? cachedOperation; + + ImageInfo? getInitialImage(CancellableImageProvider provider) { + final completer = CancelableCompleter(onCancel: provider.cancel); + final cachedStream = provider.resolve(const ImageConfiguration()); + ImageInfo? cachedImage; + final listener = ImageStreamListener((image, synchronousCall) { + if (synchronousCall) { + cachedImage = image; + } + + if (!completer.isCompleted) { + completer.complete(image); + } + }, onError: completer.completeError); + + cachedStream.addListener(listener); + if (cachedImage != null) { + cachedStream.removeListener(listener); + return cachedImage; + } + + completer.operation.valueOrCancellation().whenComplete(() { + cachedStream.removeListener(listener); + cachedOperation = null; + }); + cachedOperation = completer.operation; + return null; + } + + Stream loadRequest(ImageRequest request, ImageDecoderCallback decode) async* { + if (isCancelled) { + evict(); + return; + } + + this.request = request; + + try { + final image = await request.load(decode); + if (image == null || isCancelled) { + evict(); + return; + } + yield image; + } finally { + this.request = null; + } + } + + Stream initialImageStream() async* { + final cachedOperation = this.cachedOperation; + if (cachedOperation == null) { + return; + } + + try { + final cachedImage = await cachedOperation.valueOrCancellation(); + if (cachedImage != null && !isCancelled) { + yield cachedImage; + } + } catch (e, stack) { + _log.severe('Error loading initial image', e, stack); + } finally { + this.cachedOperation = null; + } + } + + @override + void cancel() { + isCancelled = true; + final request = this.request; + if (request != null) { + this.request = null; + request.cancel(); + } + + final operation = cachedOperation; + if (operation != null) { + this.cachedOperation = null; + operation.cancel(); + } + } +} 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, size: size, type: asset.type, updatedAt: asset.updatedAt); + provider = LocalFullImageProvider(id: id, size: size, assetType: asset.type); } else { final String assetId; if (asset is LocalAsset && asset.hasRemote) { @@ -36,7 +133,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, size: size); + return LocalThumbProvider(id: id, size: size, assetType: asset.type); } final String assetId; diff --git a/mobile/lib/presentation/widgets/images/local_image_provider.dart b/mobile/lib/presentation/widgets/images/local_image_provider.dart index 5508937dab..e4eb899ecb 100644 --- a/mobile/lib/presentation/widgets/images/local_image_provider.dart +++ b/mobile/lib/presentation/widgets/images/local_image_provider.dart @@ -1,36 +1,21 @@ import 'dart:async'; -import 'dart:io'; import 'dart:ui'; 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/infrastructure/loaders/image_request.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'; -import 'package:logging/logging.dart'; - -class LocalThumbProvider extends ImageProvider { - final AssetMediaRepository _assetMediaRepository = const AssetMediaRepository(); - final CacheManager? cacheManager; +class LocalThumbProvider extends CancellableImageProvider + with CancellableImageProviderMixin { final String id; - final DateTime updatedAt; final Size size; + final AssetType assetType; - const LocalThumbProvider({ - required this.id, - required this.updatedAt, - this.size = kThumbnailResolution, - this.cacheManager, - }); + LocalThumbProvider({required this.id, required this.assetType, this.size = kThumbnailResolution}); @override Future obtainKey(ImageConfiguration configuration) { @@ -39,63 +24,39 @@ class LocalThumbProvider extends ImageProvider { @override ImageStreamCompleter loadImage(LocalThumbProvider key, ImageDecoderCallback decode) { - final cache = cacheManager ?? ThumbnailImageCacheManager(); - return MultiFrameImageStreamCompleter( - codec: _codec(key, cache, decode), - scale: 1.0, + return OneFramePlaceholderImageStreamCompleter( + _codec(key, decode), informationCollector: () => [ DiagnosticsProperty('Id', key.id), - DiagnosticsProperty('Updated at', key.updatedAt), DiagnosticsProperty('Size', key.size), ], - ); + )..addOnLastListenerRemovedCallback(cancel); } - Future _codec(LocalThumbProvider key, CacheManager cache, ImageDecoderCallback decode) async { - final cacheKey = '${key.id}-${key.updatedAt}-${key.size.width}x${key.size.height}'; - - final fileFromCache = await cache.getFileFromCache(cacheKey); - if (fileFromCache != null) { - try { - final buffer = await ImmutableBuffer.fromFilePath(fileFromCache.file.path); - return decode(buffer); - } catch (_) {} - } - - 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.id} failed"); - } - - final buffer = await ImmutableBuffer.fromUint8List(thumbnailBytes); - unawaited(cache.putFile(cacheKey, thumbnailBytes)); - return decode(buffer); + Stream _codec(LocalThumbProvider key, ImageDecoderCallback decode) { + return loadRequest(LocalImageRequest(localId: key.id, size: size, assetType: key.assetType), decode); } @override bool operator ==(Object other) { if (identical(this, other)) return true; if (other is LocalThumbProvider) { - return id == other.id && updatedAt == other.updatedAt; + return id == other.id && size == other.size; } return false; } @override - int get hashCode => id.hashCode ^ updatedAt.hashCode; + int get hashCode => id.hashCode ^ size.hashCode; } -class LocalFullImageProvider extends ImageProvider { - final AssetMediaRepository _assetMediaRepository = const AssetMediaRepository(); - final StorageRepository _storageRepository = const StorageRepository(); - +class LocalFullImageProvider extends CancellableImageProvider + with CancellableImageProviderMixin { final String id; final Size size; - final AssetType type; - final DateTime updatedAt; // temporary, only exists to fetch cached thumbnail until local disk cache is removed + final AssetType assetType; - const LocalFullImageProvider({required this.id, required this.size, required this.type, required this.updatedAt}); + LocalFullImageProvider({required this.id, required this.assetType, required this.size}); @override Future obtainKey(ImageConfiguration configuration) { @@ -106,114 +67,43 @@ class LocalFullImageProvider extends ImageProvider { ImageStreamCompleter loadImage(LocalFullImageProvider key, ImageDecoderCallback decode) { return OneFramePlaceholderImageStreamCompleter( _codec(key, decode), + initialImage: getCachedImage(LocalThumbProvider(id: key.id, assetType: key.assetType)), informationCollector: () => [ + DiagnosticsProperty('Image provider', this), DiagnosticsProperty('Id', key.id), - DiagnosticsProperty('Updated at', key.updatedAt), DiagnosticsProperty('Size', key.size), ], + onDispose: cancel, ); } - // Streams in each stage of the image as we ask for it Stream _codec(LocalFullImageProvider key, ImageDecoderCallback decode) async* { - try { - // First, yield the thumbnail image from LocalThumbProvider - final thumbProvider = LocalThumbProvider(id: key.id, updatedAt: key.updatedAt); - try { - final thumbCodec = await thumbProvider._codec( - thumbProvider, - thumbProvider.cacheManager ?? ThumbnailImageCacheManager(), - decode, - ); - final thumbImageInfo = await thumbCodec.getImageInfo(); - yield thumbImageInfo; - } catch (_) {} + yield* initialImageStream(); - // Then proceed with the main image loading stream - final mainStream = switch (key.type) { - AssetType.image => _decodeProgressive(key, decode), - AssetType.video => _getThumbnailCodec(key, decode), - _ => throw StateError('Unsupported asset type ${key.type}'), - }; - - await for (final imageInfo in mainStream) { - yield imageInfo; - } - } catch (error, stack) { - Logger('ImmichLocalImageProvider').severe('Error loading local image ${key.id}', error, stack); - throw const ImageLoadingException('Could not load image from local storage'); - } - } - - Stream _getThumbnailCodec(LocalFullImageProvider key, ImageDecoderCallback decode) async* { - final thumbBytes = await _assetMediaRepository.getThumbnail(key.id, size: key.size); - if (thumbBytes == null) { - throw StateError("Failed to load preview for ${key.id}"); - } - final buffer = await ImmutableBuffer.fromUint8List(thumbBytes); - final codec = await decode(buffer); - yield await codec.getImageInfo(); - } - - Stream _decodeProgressive(LocalFullImageProvider key, ImageDecoderCallback decode) async* { - final file = await _storageRepository.getFileForAsset(key.id); - if (file == null) { - throw StateError("Opening file for asset ${key.id} failed"); - } - - final fileSize = await file.length(); - final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio; - final isLargeFile = fileSize > 20 * 1024 * 1024; // 20MB - final isHEIC = file.path.toLowerCase().contains(RegExp(r'\.(heic|heif)$')); - final isProgressive = isLargeFile || (isHEIC && !Platform.isIOS); - - if (isProgressive) { - try { - final progressiveMultiplier = devicePixelRatio >= 3.0 ? 1.3 : 1.2; - final size = Size( - (key.size.width * progressiveMultiplier).clamp(256, 1024), - (key.size.height * progressiveMultiplier).clamp(256, 1024), - ); - final mediumThumb = await _assetMediaRepository.getThumbnail(key.id, size: size); - if (mediumThumb != null) { - final mediumBuffer = await ImmutableBuffer.fromUint8List(mediumThumb); - final codec = await decode(mediumBuffer); - yield await codec.getImageInfo(); - } - } catch (_) {} - } - - // Load original only when the file is smaller or if the user wants to load original images - // Or load a slightly larger image for progressive loading - if (isProgressive && !(AppSetting.get(Setting.loadOriginal))) { - final progressiveMultiplier = devicePixelRatio >= 3.0 ? 2.0 : 1.6; - final size = Size( - (key.size.width * progressiveMultiplier).clamp(512, 2048), - (key.size.height * progressiveMultiplier).clamp(512, 2048), - ); - final highThumb = await _assetMediaRepository.getThumbnail(key.id, size: size); - if (highThumb != null) { - final highBuffer = await ImmutableBuffer.fromUint8List(highThumb); - final codec = await decode(highBuffer); - yield await codec.getImageInfo(); - } + if (isCancelled) { + evict(); return; } - final buffer = await ImmutableBuffer.fromFilePath(file.path); - final codec = await decode(buffer); - yield await codec.getImageInfo(); + final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio; + final request = LocalImageRequest( + localId: key.id, + size: Size(size.width * devicePixelRatio, size.height * devicePixelRatio), + assetType: key.assetType, + ); + + yield* loadRequest(request, decode); } @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; + return id == other.id && size == other.size; } return false; } @override - int get hashCode => id.hashCode ^ size.hashCode ^ type.hashCode; + int get hashCode => id.hashCode ^ size.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 index 851ed59220..6d549d4fda 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 @@ -9,7 +9,7 @@ import 'package:flutter/painting.dart'; /// An ImageStreamCompleter with support for loading multiple images. class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter { - ImageInfo? _initialImage; + void Function()? _onDispose; /// The constructor to create an OneFramePlaceholderImageStreamCompleter. The [images] /// should be the primary images to display (typically asynchronously as they load). @@ -19,10 +19,14 @@ class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter { Stream images, { ImageInfo? initialImage, InformationCollector? informationCollector, + void Function()? onDispose, }) { - _initialImage = initialImage; + if (initialImage != null) { + setImage(initialImage); + } + _onDispose = onDispose; images.listen( - _onImage, + setImage, onError: (Object error, StackTrace stack) { reportError( context: ErrorDescription('resolving a single-frame image stream'), @@ -35,33 +39,13 @@ class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter { ); } - void _onImage(ImageInfo image) { - setImage(image); - _initialImage?.dispose(); - _initialImage = null; - } - - @override - void addListener(ImageStreamListener listener) { - 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 onDisposed() { - _initialImage?.dispose(); - _initialImage = null; + final onDispose = _onDispose; + if (onDispose != null) { + _onDispose = null; + onDispose(); + } super.onDisposed(); } } diff --git a/mobile/lib/presentation/widgets/images/remote_image_provider.dart b/mobile/lib/presentation/widgets/images/remote_image_provider.dart index 71c5ad446e..a10fd40ce8 100644 --- a/mobile/lib/presentation/widgets/images/remote_image_provider.dart +++ b/mobile/lib/presentation/widgets/images/remote_image_provider.dart @@ -1,23 +1,22 @@ import 'dart:async'; -import 'dart:ui'; 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/infrastructure/loaders/image_request.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/services/api.service.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; -class RemoteThumbProvider extends ImageProvider { +class RemoteThumbProvider extends CancellableImageProvider + with CancellableImageProviderMixin { + static final cacheManager = RemoteThumbnailCacheManager(); final String assetId; - final CacheManager? cacheManager; - const RemoteThumbProvider({required this.assetId, this.cacheManager}); + RemoteThumbProvider({required this.assetId}); @override Future obtainKey(ImageConfiguration configuration) { @@ -26,33 +25,22 @@ 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), - scale: 1.0, - chunkEvents: chunkController.stream, + return OneFramePlaceholderImageStreamCompleter( + _codec(key, decode), informationCollector: () => [ DiagnosticsProperty('Image provider', this), DiagnosticsProperty('Asset Id', key.assetId), ], - ); + )..addOnLastListenerRemovedCallback(cancel); } - Future _codec( - RemoteThumbProvider key, - CacheManager cache, - ImageDecoderCallback decode, - StreamController chunkController, - ) async { - final preview = getThumbnailUrlForRemoteId(key.assetId); - - return ImageLoader.loadImageFromCache( - preview, - cache: cache, - decode: decode, - chunkEvents: chunkController, - ).whenComplete(chunkController.close); + Stream _codec(RemoteThumbProvider key, ImageDecoderCallback decode) { + final request = RemoteImageRequest( + uri: getThumbnailUrlForRemoteId(key.assetId), + headers: ApiService.getRequestHeaders(), + cacheManager: cacheManager, + ); + return loadRequest(request, decode); } @override @@ -69,11 +57,12 @@ class RemoteThumbProvider extends ImageProvider { int get hashCode => assetId.hashCode; } -class RemoteFullImageProvider extends ImageProvider { +class RemoteFullImageProvider extends CancellableImageProvider + with CancellableImageProviderMixin { + static final cacheManager = RemoteThumbnailCacheManager(); final String assetId; - final CacheManager? cacheManager; - const RemoteFullImageProvider({required this.assetId, this.cacheManager}); + RemoteFullImageProvider({required this.assetId}); @override Future obtainKey(ImageConfiguration configuration) { @@ -82,28 +71,49 @@ class RemoteFullImageProvider extends ImageProvider { @override ImageStreamCompleter loadImage(RemoteFullImageProvider key, ImageDecoderCallback decode) { - final cache = cacheManager ?? RemoteImageCacheManager(); return OneFramePlaceholderImageStreamCompleter( - _codec(key, cache, decode), + _codec(key, decode), initialImage: getCachedImage(RemoteThumbProvider(assetId: key.assetId)), + informationCollector: () => [ + DiagnosticsProperty('Image provider', this), + DiagnosticsProperty('Asset Id', key.assetId), + ], + onDispose: cancel, ); } - 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(); + Stream _codec(RemoteFullImageProvider key, ImageDecoderCallback decode) async* { + yield* initialImageStream(); + + if (isCancelled) { + evict(); + return; + } + + final headers = ApiService.getRequestHeaders(); + try { + final request = RemoteImageRequest( + uri: getPreviewUrlForRemoteId(key.assetId), + headers: headers, + cacheManager: cacheManager, + ); + yield* loadRequest(request, decode); + } finally { + request = null; + } + + if (isCancelled) { + evict(); + return; + } if (AppSetting.get(Setting.loadOriginal)) { - final codec = await ImageLoader.loadImageFromCache( - getOriginalUrlForRemoteId(key.assetId), - cache: cache, - decode: decode, - ); - yield await codec.getImageInfo(); + try { + final request = RemoteImageRequest(uri: getOriginalUrlForRemoteId(key.assetId), headers: headers); + yield* loadRequest(request, decode); + } finally { + request = null; + } } } diff --git a/mobile/lib/presentation/widgets/images/thumb_hash_provider.dart b/mobile/lib/presentation/widgets/images/thumb_hash_provider.dart index 8d292523d7..2789e52c39 100644 --- a/mobile/lib/presentation/widgets/images/thumb_hash_provider.dart +++ b/mobile/lib/presentation/widgets/images/thumb_hash_provider.dart @@ -1,14 +1,14 @@ -import 'dart:convert' hide Codec; -import 'dart:ui'; - import 'package:flutter/foundation.dart'; import 'package:flutter/rendering.dart'; -import 'package:thumbhash/thumbhash.dart'; +import 'package:immich_mobile/infrastructure/loaders/image_request.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 ThumbHashProvider extends ImageProvider { +class ThumbHashProvider extends CancellableImageProvider + with CancellableImageProviderMixin { final String thumbHash; - const ThumbHashProvider({required this.thumbHash}); + ThumbHashProvider({required this.thumbHash}); @override Future obtainKey(ImageConfiguration configuration) { @@ -17,12 +17,11 @@ class ThumbHashProvider extends ImageProvider { @override ImageStreamCompleter loadImage(ThumbHashProvider key, ImageDecoderCallback decode) { - return MultiFrameImageStreamCompleter(codec: _loadCodec(key, decode), scale: 1.0); + return OneFramePlaceholderImageStreamCompleter(_loadCodec(key, decode))..addOnLastListenerRemovedCallback(cancel); } - Future _loadCodec(ThumbHashProvider key, ImageDecoderCallback decode) async { - final image = thumbHashToRGBA(base64Decode(key.thumbHash)); - return decode(await ImmutableBuffer.fromUint8List(rgbaToBmp(image))); + Stream _loadCodec(ThumbHashProvider key, ImageDecoderCallback decode) { + return loadRequest(ThumbhashImageRequest(thumbhash: key.thumbHash), decode); } @override diff --git a/mobile/lib/providers/image/cache/remote_image_cache_manager.dart b/mobile/lib/providers/image/cache/remote_image_cache_manager.dart index b9e2880c04..df5f4566c0 100644 --- a/mobile/lib/providers/image/cache/remote_image_cache_manager.dart +++ b/mobile/lib/providers/image/cache/remote_image_cache_manager.dart @@ -1,13 +1,136 @@ import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +// ignore: implementation_imports +import 'package:flutter_cache_manager/src/cache_store.dart'; +import 'package:logging/logging.dart'; +import 'package:uuid/uuid.dart'; -/// The cache manager for full size images [ImmichRemoteImageProvider] -class RemoteImageCacheManager extends CacheManager { +abstract class RemoteCacheManager extends CacheManager { + static final _log = Logger('RemoteCacheManager'); + + RemoteCacheManager.custom(super.config, CacheStore store) + // Unfortunately, CacheStore is not a public API + // ignore: invalid_use_of_visible_for_testing_member + : super.custom(cacheStore: store); + + Future putStreamedFile( + String url, + Stream> source, { + String? key, + String? eTag, + Duration maxAge = const Duration(days: 30), + String fileExtension = 'file', + }); + + // Unlike `putFileStream`, this method handles request cancellation, + // does not make a (slow) DB call checking if the file is already cached, + // does not synchronously check if a file exists, + // and deletes the file on cancellation without making these checks again. + Future putStreamedFileToStore( + CacheStore store, + String url, + Stream> source, { + String? key, + String? eTag, + Duration maxAge = const Duration(days: 30), + String fileExtension = 'file', + }) async { + final path = '${const Uuid().v1()}.$fileExtension'; + final file = await store.fileSystem.createFile(path); + final sink = file.openWrite(); + try { + await source.pipe(sink); + } catch (e) { + await sink.close(); + try { + await file.delete(); + } catch (e) { + _log.severe('Failed to delete incomplete cache file: $e'); + } + return; + } + + final cacheObject = CacheObject( + url, + key: key, + relativePath: path, + validTill: DateTime.now().add(maxAge), + eTag: eTag, + ); + try { + await store.putFile(cacheObject); + } catch (e) { + try { + await file.delete(); + } catch (e) { + _log.severe('Failed to delete untracked cache file: $e'); + } + } + } +} + +class RemoteImageCacheManager extends RemoteCacheManager { static const key = 'remoteImageCacheKey'; static final RemoteImageCacheManager _instance = RemoteImageCacheManager._(); + static final _config = Config(key, maxNrOfCacheObjects: 500, stalePeriod: const Duration(days: 30)); + static final _store = CacheStore(_config); factory RemoteImageCacheManager() { return _instance; } - RemoteImageCacheManager._() : super(Config(key, maxNrOfCacheObjects: 500, stalePeriod: const Duration(days: 30))); + RemoteImageCacheManager._() : super.custom(_config, _store); + + @override + Future putStreamedFile( + String url, + Stream> source, { + String? key, + String? eTag, + Duration maxAge = const Duration(days: 30), + String fileExtension = 'file', + }) { + return putStreamedFileToStore( + _store, + url, + source, + key: key, + eTag: eTag, + maxAge: maxAge, + fileExtension: fileExtension, + ); + } +} + +/// The cache manager for full size images [ImmichRemoteImageProvider] +class RemoteThumbnailCacheManager extends RemoteCacheManager { + static const key = 'remoteThumbnailCacheKey'; + static final RemoteThumbnailCacheManager _instance = RemoteThumbnailCacheManager._(); + static final _config = Config(key, maxNrOfCacheObjects: 5000, stalePeriod: const Duration(days: 30)); + static final _store = CacheStore(_config); + + factory RemoteThumbnailCacheManager() { + return _instance; + } + + RemoteThumbnailCacheManager._() : super.custom(_config, _store); + + @override + Future putStreamedFile( + String url, + Stream> source, { + String? key, + String? eTag, + Duration maxAge = const Duration(days: 30), + String fileExtension = 'file', + }) { + return putStreamedFileToStore( + _store, + url, + source, + key: key, + eTag: eTag, + maxAge: maxAge, + fileExtension: fileExtension, + ); + } } diff --git a/mobile/lib/providers/infrastructure/platform.provider.dart b/mobile/lib/providers/infrastructure/platform.provider.dart index 477046d0bf..6469624c09 100644 --- a/mobile/lib/providers/infrastructure/platform.provider.dart +++ b/mobile/lib/providers/infrastructure/platform.provider.dart @@ -1,4 +1,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/platform/native_sync_api.g.dart'; +import 'package:immich_mobile/platform/thumbnail_api.g.dart'; final nativeSyncApiProvider = Provider((_) => NativeSyncApi()); + +final thumbnailApi = ThumbnailApi(); diff --git a/mobile/lib/utils/cache/custom_image_cache.dart b/mobile/lib/utils/cache/custom_image_cache.dart index 194c55f9df..a3905baf9b 100644 --- a/mobile/lib/utils/cache/custom_image_cache.dart +++ b/mobile/lib/utils/cache/custom_image_cache.dart @@ -1,6 +1,7 @@ import 'package:flutter/painting.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/images/thumb_hash_provider.dart'; import 'package:immich_mobile/providers/image/immich_local_image_provider.dart'; import 'package:immich_mobile/providers/image/immich_local_thumbnail_provider.dart'; import 'package:immich_mobile/providers/image/immich_remote_image_provider.dart'; @@ -9,6 +10,7 @@ import 'package:immich_mobile/providers/image/immich_remote_thumbnail_provider.d /// [ImageCache] that uses two caches for small and large images /// so that a single large image does not evict all small images final class CustomImageCache implements ImageCache { + final _thumbhash = ImageCache()..maximumSize = 0; final _small = ImageCache(); final _large = ImageCache()..maximumSize = 5; // Maximum 5 images @@ -39,13 +41,16 @@ final class CustomImageCache implements ImageCache { /// Gets the cache for the given key /// [_large] is used for [ImmichLocalImageProvider] and [ImmichRemoteImageProvider] /// [_small] is used for [ImmichLocalThumbnailProvider] and [ImmichRemoteThumbnailProvider] - ImageCache _cacheForKey(Object key) => - (key is ImmichLocalImageProvider || - key is ImmichRemoteImageProvider || - key is LocalFullImageProvider || - key is RemoteFullImageProvider) - ? _large - : _small; + ImageCache _cacheForKey(Object key) { + return switch (key) { + ImmichLocalImageProvider() || + ImmichRemoteImageProvider() || + LocalFullImageProvider() || + RemoteFullImageProvider() => _large, + ThumbHashProvider() => _thumbhash, + _ => _small, + }; + } @override bool containsKey(Object key) { diff --git a/mobile/lib/widgets/photo_view/src/core/photo_view_core.dart b/mobile/lib/widgets/photo_view/src/core/photo_view_core.dart index cc84039d24..b19c7dfdb6 100644 --- a/mobile/lib/widgets/photo_view/src/core/photo_view_core.dart +++ b/mobile/lib/widgets/photo_view/src/core/photo_view_core.dart @@ -421,6 +421,7 @@ class PhotoViewCoreState extends State filterQuality: widget.filterQuality, width: scaleBoundaries.childSize.width * scale, fit: BoxFit.cover, + isAntiAlias: widget.filterQuality == FilterQuality.high, ); } }