From 582cdcab822aa66ea44beef1b83eda75a9c0e2f9 Mon Sep 17 00:00:00 2001 From: martyfuhry Date: Thu, 14 Mar 2024 16:29:09 -0400 Subject: [PATCH] feat(mobile): Remote thumbnails and images use an on-disk image cache (#7929) * Fixes remote full / thumbnail provider * Adds image cache manager to both remote image providers format format Fix typo in equals remove unused import renames image loader * Adds height and width to the image cache for thumbs format * Uses a separate remote and thumbnail cache format * Fixes key name * Changes uri to string, fixes comment * Chunk events are optional and remote thumbnails don't report chunk events * better exception handling --------- Co-authored-by: Alex Tran --- .../activities/widgets/activity_tile.dart | 5 +- .../image_providers/cache/image_loader.dart | 58 ++++++++++++ .../cache/remote_image_cache_manager.dart | 20 +++++ .../cache/thumbnail_image_cache_manager.dart | 21 +++++ .../exceptions/image_loading_exception.dart | 5 ++ .../immich_remote_image_provider.dart | 90 +++++++------------ .../immich_remote_thumbnail_provider.dart | 74 +++++---------- mobile/lib/shared/ui/immich_image.dart | 2 - mobile/lib/shared/ui/immich_thumbnail.dart | 10 +-- 9 files changed, 168 insertions(+), 117 deletions(-) create mode 100644 mobile/lib/modules/asset_viewer/image_providers/cache/image_loader.dart create mode 100644 mobile/lib/modules/asset_viewer/image_providers/cache/remote_image_cache_manager.dart create mode 100644 mobile/lib/modules/asset_viewer/image_providers/cache/thumbnail_image_cache_manager.dart create mode 100644 mobile/lib/modules/asset_viewer/image_providers/exceptions/image_loading_exception.dart diff --git a/mobile/lib/modules/activities/widgets/activity_tile.dart b/mobile/lib/modules/activities/widgets/activity_tile.dart index cb434d22de..77dcb98922 100644 --- a/mobile/lib/modules/activities/widgets/activity_tile.dart +++ b/mobile/lib/modules/activities/widgets/activity_tile.dart @@ -3,7 +3,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/datetime_extensions.dart'; import 'package:immich_mobile/modules/activities/models/activity.model.dart'; -import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_remote_image_provider.dart'; +import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_remote_thumbnail_provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/current_asset.provider.dart'; import 'package:immich_mobile/shared/ui/user_circle_avatar.dart'; @@ -106,9 +106,8 @@ class _ActivityAssetThumbnail extends StatelessWidget { decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(4)), image: DecorationImage( - image: ImmichRemoteImageProvider( + image: ImmichRemoteThumbnailProvider( assetId: assetId, - isThumbnail: true, ), fit: BoxFit.cover, ), diff --git a/mobile/lib/modules/asset_viewer/image_providers/cache/image_loader.dart b/mobile/lib/modules/asset_viewer/image_providers/cache/image_loader.dart new file mode 100644 index 0000000000..e5204acde5 --- /dev/null +++ b/mobile/lib/modules/asset_viewer/image_providers/cache/image_loader.dart @@ -0,0 +1,58 @@ +import 'dart:async'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:immich_mobile/modules/asset_viewer/image_providers/exceptions/image_loading_exception.dart'; +import 'package:immich_mobile/shared/models/store.dart'; + +/// Loads the codec from the URI and sends the events to the [chunkEvents] stream +/// +/// Credit to [flutter_cached_network_image](https://github.com/Baseflow/flutter_cached_network_image/blob/develop/cached_network_image/lib/src/image_provider/_image_loader.dart) +/// for this wonderful implementation of their image loader +class ImageLoader { + static Future loadImageFromCache( + String uri, { + required ImageCacheManager cache, + required ImageDecoderCallback decode, + StreamController? chunkEvents, + int? height, + int? width, + }) async { + final headers = { + 'x-immich-user-token': Store.get(StoreKey.accessToken), + }; + + final stream = cache.getImageFile( + uri, + withProgress: true, + headers: headers, + maxHeight: height, + maxWidth: width, + ); + + await for (final result in stream) { + if (result is DownloadProgress) { + // We are downloading the file, so update the [chunkEvents] + chunkEvents?.add( + ImageChunkEvent( + cumulativeBytesLoaded: result.downloaded, + expectedTotalBytes: result.totalSize, + ), + ); + } + + if (result is FileInfo) { + // We have the file + final file = result.file; + final bytes = await file.readAsBytes(); + final buffer = await ui.ImmutableBuffer.fromUint8List(bytes); + final decoded = await decode(buffer); + return decoded; + } + } + + // If we get here, the image failed to load from the cache stream + throw ImageLoadingException('Could not load image from stream'); + } +} diff --git a/mobile/lib/modules/asset_viewer/image_providers/cache/remote_image_cache_manager.dart b/mobile/lib/modules/asset_viewer/image_providers/cache/remote_image_cache_manager.dart new file mode 100644 index 0000000000..b0f242a99f --- /dev/null +++ b/mobile/lib/modules/asset_viewer/image_providers/cache/remote_image_cache_manager.dart @@ -0,0 +1,20 @@ +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; + +/// The cache manager for full size images [ImmichRemoteImageProvider] +class RemoteImageCacheManager extends CacheManager with ImageCacheManager { + static const key = 'remoteImageCacheKey'; + static final RemoteImageCacheManager _instance = RemoteImageCacheManager._(); + + factory RemoteImageCacheManager() { + return _instance; + } + + RemoteImageCacheManager._() + : super( + Config( + key, + maxNrOfCacheObjects: 500, + stalePeriod: const Duration(days: 30), + ), + ); +} diff --git a/mobile/lib/modules/asset_viewer/image_providers/cache/thumbnail_image_cache_manager.dart b/mobile/lib/modules/asset_viewer/image_providers/cache/thumbnail_image_cache_manager.dart new file mode 100644 index 0000000000..8bd320b723 --- /dev/null +++ b/mobile/lib/modules/asset_viewer/image_providers/cache/thumbnail_image_cache_manager.dart @@ -0,0 +1,21 @@ +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; + +/// The cache manager for thumbnail images [ImmichRemoteThumbnailProvider] +class ThumbnailImageCacheManager extends CacheManager with ImageCacheManager { + static const key = 'thumbnailImageCacheKey'; + static final ThumbnailImageCacheManager _instance = + ThumbnailImageCacheManager._(); + + factory ThumbnailImageCacheManager() { + return _instance; + } + + ThumbnailImageCacheManager._() + : super( + Config( + key, + maxNrOfCacheObjects: 5000, + stalePeriod: const Duration(days: 30), + ), + ); +} diff --git a/mobile/lib/modules/asset_viewer/image_providers/exceptions/image_loading_exception.dart b/mobile/lib/modules/asset_viewer/image_providers/exceptions/image_loading_exception.dart new file mode 100644 index 0000000000..5e5ff72359 --- /dev/null +++ b/mobile/lib/modules/asset_viewer/image_providers/exceptions/image_loading_exception.dart @@ -0,0 +1,5 @@ +/// An exception for the [ImageLoader] and the Immich image providers +class ImageLoadingException implements Exception { + final String message; + ImageLoadingException(this.message); +} diff --git a/mobile/lib/modules/asset_viewer/image_providers/immich_remote_image_provider.dart b/mobile/lib/modules/asset_viewer/image_providers/immich_remote_image_provider.dart index d9fbd80485..095f15b82e 100644 --- a/mobile/lib/modules/asset_viewer/image_providers/immich_remote_image_provider.dart +++ b/mobile/lib/modules/asset_viewer/image_providers/immich_remote_image_provider.dart @@ -1,8 +1,10 @@ import 'dart:async'; -import 'dart:io'; import 'dart:ui' as ui; import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:immich_mobile/modules/asset_viewer/image_providers/cache/image_loader.dart'; +import 'package:immich_mobile/modules/asset_viewer/image_providers/cache/remote_image_cache_manager.dart'; import 'package:openapi/api.dart' as api; import 'package:flutter/foundation.dart'; @@ -12,24 +14,18 @@ import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; -/// Our Image Provider HTTP client to make the request -final _httpClient = HttpClient() - ..autoUncompress = false - ..maxConnectionsPerHost = 10; - -/// The remote image provider +/// The remote image provider for full size remote images class ImmichRemoteImageProvider extends ImageProvider { /// The [Asset.remoteId] of the asset to fetch final String assetId; - // If this is a thumbnail, we stop at loading the - // smallest version of the remote image - final bool isThumbnail; + /// The image cache manager + final ImageCacheManager? cacheManager; ImmichRemoteImageProvider({ required this.assetId, - this.isThumbnail = false, + this.cacheManager, }); /// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key @@ -46,9 +42,10 @@ class ImmichRemoteImageProvider ImmichRemoteImageProvider key, ImageDecoderCallback decode, ) { + final cache = cacheManager ?? RemoteImageCacheManager(); final chunkEvents = StreamController(); return MultiImageStreamCompleter( - codec: _codec(key, decode, chunkEvents), + codec: _codec(key, cache, decode, chunkEvents), scale: 1.0, chunkEvents: chunkEvents.stream, ); @@ -69,82 +66,61 @@ class ImmichRemoteImageProvider // Streams in each stage of the image as we ask for it Stream _codec( ImmichRemoteImageProvider key, + ImageCacheManager cache, ImageDecoderCallback decode, StreamController chunkEvents, ) async* { // Load a preview to the chunk events - if (_loadPreview || key.isThumbnail) { + if (_loadPreview) { final preview = getThumbnailUrlForRemoteId( key.assetId, type: api.ThumbnailFormat.WEBP, ); - yield await _loadFromUri( - Uri.parse(preview), - decode, - chunkEvents, + yield await ImageLoader.loadImageFromCache( + preview, + cache: cache, + decode: decode, + chunkEvents: chunkEvents, ); } - // Guard thumnbail rendering - if (key.isThumbnail) { - await chunkEvents.close(); - return; - } - // Load the higher resolution version of the image final url = getThumbnailUrlForRemoteId( key.assetId, type: api.ThumbnailFormat.JPEG, ); - final codec = await _loadFromUri(Uri.parse(url), decode, chunkEvents); + final codec = await ImageLoader.loadImageFromCache( + url, + cache: cache, + decode: decode, + chunkEvents: chunkEvents, + ); yield codec; // Load the final remote image if (_useOriginal) { // Load the original image final url = getImageUrlFromId(key.assetId); - final codec = await _loadFromUri(Uri.parse(url), decode, chunkEvents); + final codec = await ImageLoader.loadImageFromCache( + url, + cache: cache, + decode: decode, + chunkEvents: chunkEvents, + ); yield codec; } await chunkEvents.close(); } - // Loads the codec from the URI and sends the events to the [chunkEvents] stream - Future _loadFromUri( - Uri uri, - ImageDecoderCallback decode, - StreamController chunkEvents, - ) async { - final request = await _httpClient.getUrl(uri); - request.headers.add( - 'x-immich-user-token', - Store.get(StoreKey.accessToken), - ); - final response = await request.close(); - // Chunks of the completed image can be shown - final data = await consolidateHttpClientResponseBytes( - response, - onBytesReceived: (cumulative, total) { - chunkEvents.add( - ImageChunkEvent( - cumulativeBytesLoaded: cumulative, - expectedTotalBytes: total, - ), - ); - }, - ); - - // Decode the response - final buffer = await ui.ImmutableBuffer.fromUint8List(data); - return decode(buffer); - } - @override bool operator ==(Object other) { - if (other is! ImmichRemoteImageProvider) return false; if (identical(this, other)) return true; - return assetId == other.assetId && isThumbnail == other.isThumbnail; + if (other is ImmichRemoteImageProvider) { + return assetId == other.assetId; + } + + return false; } @override diff --git a/mobile/lib/modules/asset_viewer/image_providers/immich_remote_thumbnail_provider.dart b/mobile/lib/modules/asset_viewer/image_providers/immich_remote_thumbnail_provider.dart index 92b85b3472..84fdbd7403 100644 --- a/mobile/lib/modules/asset_viewer/image_providers/immich_remote_thumbnail_provider.dart +++ b/mobile/lib/modules/asset_viewer/image_providers/immich_remote_thumbnail_provider.dart @@ -1,30 +1,34 @@ import 'dart:async'; -import 'dart:io'; import 'dart:ui' as ui; import 'package:cached_network_image/cached_network_image.dart'; -import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_remote_image_provider.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:immich_mobile/modules/asset_viewer/image_providers/cache/image_loader.dart'; +import 'package:immich_mobile/modules/asset_viewer/image_providers/cache/thumbnail_image_cache_manager.dart'; import 'package:openapi/api.dart' as api; import 'package:flutter/foundation.dart'; import 'package:flutter/painting.dart'; import 'package:immich_mobile/shared/models/asset.dart'; -import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; -/// Our HTTP client to make the request -final _httpClient = HttpClient() - ..autoUncompress = false - ..maxConnectionsPerHost = 100; - /// The remote image provider class ImmichRemoteThumbnailProvider extends ImageProvider { /// The [Asset.remoteId] of the asset to fetch final String assetId; + final int? height; + final int? width; + + /// The image cache manager + final ImageCacheManager? cacheManager; + ImmichRemoteThumbnailProvider({ required this.assetId, + this.height, + this.width, + this.cacheManager, }); /// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key @@ -41,19 +45,18 @@ class ImmichRemoteThumbnailProvider ImmichRemoteThumbnailProvider key, ImageDecoderCallback decode, ) { - final chunkEvents = StreamController(); + final cache = cacheManager ?? ThumbnailImageCacheManager(); return MultiImageStreamCompleter( - codec: _codec(key, decode, chunkEvents), + codec: _codec(key, cache, decode), scale: 1.0, - chunkEvents: chunkEvents.stream, ); } // Streams in each stage of the image as we ask for it Stream _codec( ImmichRemoteThumbnailProvider key, + ImageCacheManager cache, ImageDecoderCallback decode, - StreamController chunkEvents, ) async* { // Load a preview to the chunk events final preview = getThumbnailUrlForRemoteId( @@ -61,50 +64,21 @@ class ImmichRemoteThumbnailProvider type: api.ThumbnailFormat.WEBP, ); - yield await _loadFromUri( - Uri.parse(preview), - decode, - chunkEvents, + yield await ImageLoader.loadImageFromCache( + preview, + cache: cache, + decode: decode, ); - - await chunkEvents.close(); - } - - // Loads the codec from the URI and sends the events to the [chunkEvents] stream - Future _loadFromUri( - Uri uri, - ImageDecoderCallback decode, - StreamController chunkEvents, - ) async { - final request = await _httpClient.getUrl(uri); - request.headers.add( - 'x-immich-user-token', - Store.get(StoreKey.accessToken), - ); - final response = await request.close(); - // Chunks of the completed image can be shown - final data = await consolidateHttpClientResponseBytes( - response, - onBytesReceived: (cumulative, total) { - chunkEvents.add( - ImageChunkEvent( - cumulativeBytesLoaded: cumulative, - expectedTotalBytes: total, - ), - ); - }, - ); - - // Decode the response - final buffer = await ui.ImmutableBuffer.fromUint8List(data); - return decode(buffer); } @override bool operator ==(Object other) { - if (other is! ImmichRemoteImageProvider) return false; if (identical(this, other)) return true; - return assetId == other.assetId; + if (other is ImmichRemoteThumbnailProvider) { + return assetId == other.assetId; + } + + return false; } @override diff --git a/mobile/lib/shared/ui/immich_image.dart b/mobile/lib/shared/ui/immich_image.dart index f06f1726a1..2e25a67b02 100644 --- a/mobile/lib/shared/ui/immich_image.dart +++ b/mobile/lib/shared/ui/immich_image.dart @@ -42,7 +42,6 @@ class ImmichImage extends StatelessWidget { if (asset == null) { return ImmichRemoteImageProvider( assetId: assetId!, - isThumbnail: false, ); } @@ -53,7 +52,6 @@ class ImmichImage extends StatelessWidget { } else { return ImmichRemoteImageProvider( assetId: asset.remoteId!, - isThumbnail: false, ); } } diff --git a/mobile/lib/shared/ui/immich_thumbnail.dart b/mobile/lib/shared/ui/immich_thumbnail.dart index 77827348db..3bfd164251 100644 --- a/mobile/lib/shared/ui/immich_thumbnail.dart +++ b/mobile/lib/shared/ui/immich_thumbnail.dart @@ -3,7 +3,7 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_local_thumbnail_provider.dart'; -import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_remote_image_provider.dart'; +import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_remote_thumbnail_provider.dart'; import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/ui/hooks/blurhash_hook.dart'; import 'package:immich_mobile/shared/ui/immich_image.dart'; @@ -38,9 +38,8 @@ class ImmichThumbnail extends HookWidget { } if (asset == null) { - return ImmichRemoteImageProvider( + return ImmichRemoteThumbnailProvider( assetId: assetId!, - isThumbnail: true, ); } @@ -51,9 +50,10 @@ class ImmichThumbnail extends HookWidget { width: thumbnailSize, ); } else { - return ImmichRemoteImageProvider( + return ImmichRemoteThumbnailProvider( assetId: asset.remoteId!, - isThumbnail: true, + height: thumbnailSize, + width: thumbnailSize, ); } }