mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-31 10:37:11 -04:00 
			
		
		
		
	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 <alex.tran1502@gmail.com>
This commit is contained in:
		
							parent
							
								
									5a589babcb
								
							
						
					
					
						commit
						582cdcab82
					
				| @ -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, | ||||
|         ), | ||||
|  | ||||
							
								
								
									
										58
									
								
								mobile/lib/modules/asset_viewer/image_providers/cache/image_loader.dart
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								mobile/lib/modules/asset_viewer/image_providers/cache/image_loader.dart
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @ -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<ui.Codec> loadImageFromCache( | ||||
|     String uri, { | ||||
|     required ImageCacheManager cache, | ||||
|     required ImageDecoderCallback decode, | ||||
|     StreamController<ImageChunkEvent>? 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'); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										20
									
								
								mobile/lib/modules/asset_viewer/image_providers/cache/remote_image_cache_manager.dart
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								mobile/lib/modules/asset_viewer/image_providers/cache/remote_image_cache_manager.dart
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @ -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), | ||||
|           ), | ||||
|         ); | ||||
| } | ||||
							
								
								
									
										21
									
								
								mobile/lib/modules/asset_viewer/image_providers/cache/thumbnail_image_cache_manager.dart
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								mobile/lib/modules/asset_viewer/image_providers/cache/thumbnail_image_cache_manager.dart
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @ -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), | ||||
|           ), | ||||
|         ); | ||||
| } | ||||
| @ -0,0 +1,5 @@ | ||||
| /// An exception for the [ImageLoader] and the Immich image providers | ||||
| class ImageLoadingException implements Exception { | ||||
|   final String message; | ||||
|   ImageLoadingException(this.message); | ||||
| } | ||||
| @ -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<ImmichRemoteImageProvider> { | ||||
|   /// 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<ImageChunkEvent>(); | ||||
|     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<ui.Codec> _codec( | ||||
|     ImmichRemoteImageProvider key, | ||||
|     ImageCacheManager cache, | ||||
|     ImageDecoderCallback decode, | ||||
|     StreamController<ImageChunkEvent> 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<ui.Codec> _loadFromUri( | ||||
|     Uri uri, | ||||
|     ImageDecoderCallback decode, | ||||
|     StreamController<ImageChunkEvent> 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 | ||||
|  | ||||
| @ -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<ImmichRemoteThumbnailProvider> { | ||||
|   /// 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<ImageChunkEvent>(); | ||||
|     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<ui.Codec> _codec( | ||||
|     ImmichRemoteThumbnailProvider key, | ||||
|     ImageCacheManager cache, | ||||
|     ImageDecoderCallback decode, | ||||
|     StreamController<ImageChunkEvent> 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<ui.Codec> _loadFromUri( | ||||
|     Uri uri, | ||||
|     ImageDecoderCallback decode, | ||||
|     StreamController<ImageChunkEvent> 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 | ||||
|  | ||||
| @ -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, | ||||
|       ); | ||||
|     } | ||||
|   } | ||||
|  | ||||
| @ -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, | ||||
|       ); | ||||
|     } | ||||
|   } | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user