You've already forked immich
							
							
				mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-31 00:18:28 +02:00 
			
		
		
		
	feat(mobile): image caching & viewer improvements (#4095)
This commit is contained in:
		
				
					committed by
					
						 GitHub
						GitHub
					
				
			
			
				
	
			
			
			
						parent
						
							63b6a71ebd
						
					
				
				
					commit
					1c02e1dadf
				
			| @@ -13,6 +13,7 @@ import 'package:immich_mobile/modules/backup/models/backup_album.model.dart'; | ||||
| import 'package:immich_mobile/modules/backup/models/duplicated_asset.model.dart'; | ||||
| import 'package:immich_mobile/routing/router.dart'; | ||||
| import 'package:immich_mobile/routing/tab_navigation_observer.dart'; | ||||
| import 'package:immich_mobile/shared/cache/widgets_binding.dart'; | ||||
| import 'package:immich_mobile/shared/models/album.dart'; | ||||
| import 'package:immich_mobile/shared/models/android_device_asset.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
| @@ -37,7 +38,7 @@ import 'package:logging/logging.dart'; | ||||
| import 'package:path_provider/path_provider.dart'; | ||||
|  | ||||
| void main() async { | ||||
|   WidgetsFlutterBinding.ensureInitialized(); | ||||
|   ImmichWidgetsBinding(); | ||||
|  | ||||
|   final db = await loadDb(); | ||||
|   await initApp(); | ||||
|   | ||||
| @@ -18,6 +18,7 @@ import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart'; | ||||
| import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart'; | ||||
| import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart'; | ||||
| import 'package:immich_mobile/modules/home/ui/upload_dialog.dart'; | ||||
| import 'package:immich_mobile/shared/cache/original_image_provider.dart'; | ||||
| import 'package:immich_mobile/shared/models/store.dart'; | ||||
| import 'package:immich_mobile/modules/home/ui/delete_dialog.dart'; | ||||
| import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart'; | ||||
| @@ -31,8 +32,7 @@ import 'package:immich_mobile/shared/models/asset.dart'; | ||||
| import 'package:immich_mobile/shared/providers/asset.provider.dart'; | ||||
| import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; | ||||
| import 'package:immich_mobile/utils/image_url_builder.dart'; | ||||
| import 'package:photo_manager/photo_manager.dart'; | ||||
| import 'package:openapi/api.dart' as api; | ||||
| import 'package:openapi/api.dart' show ThumbnailFormat; | ||||
|  | ||||
| // ignore: must_be_immutable | ||||
| class GalleryViewerPage extends HookConsumerWidget { | ||||
| @@ -51,6 +51,9 @@ class GalleryViewerPage extends HookConsumerWidget { | ||||
|  | ||||
|   final PageController controller; | ||||
|  | ||||
|   static const jpeg = ThumbnailFormat.JPEG; | ||||
|   static const webp = ThumbnailFormat.WEBP; | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final settings = ref.watch(appSettingsServiceProvider); | ||||
| @@ -59,9 +62,9 @@ class GalleryViewerPage extends HookConsumerWidget { | ||||
|     final isZoomed = useState<bool>(false); | ||||
|     final isPlayingMotionVideo = useState(false); | ||||
|     final isPlayingVideo = useState(false); | ||||
|     final progressValue = useState(0.0); | ||||
|     Offset? localPosition; | ||||
|     final authToken = 'Bearer ${Store.get(StoreKey.accessToken)}'; | ||||
|     final header = {"Authorization": authToken}; | ||||
|     final currentIndex = useState(initialIndex); | ||||
|     final currentAsset = loadAsset(currentIndex.value); | ||||
|  | ||||
| @@ -83,93 +86,52 @@ class GalleryViewerPage extends HookConsumerWidget { | ||||
|         .watch(assetProvider.notifier) | ||||
|         .toggleFavorite([asset], !asset.isFavorite); | ||||
|  | ||||
|     /// Thumbnail image of a remote asset. Required asset.isRemote | ||||
|     ImageProvider remoteThumbnailImageProvider( | ||||
|       Asset asset, | ||||
|       api.ThumbnailFormat type, | ||||
|     ) { | ||||
|       return CachedNetworkImageProvider( | ||||
|         getThumbnailUrl( | ||||
|           asset, | ||||
|           type: type, | ||||
|         ), | ||||
|         cacheKey: getThumbnailCacheKey( | ||||
|           asset, | ||||
|           type: type, | ||||
|         ), | ||||
|         headers: {"Authorization": authToken}, | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     /// Original (large) image of a remote asset. Required asset.isRemote | ||||
|     ImageProvider originalImageProvider(Asset asset) { | ||||
|       return CachedNetworkImageProvider( | ||||
|         getImageUrl(asset), | ||||
|         cacheKey: getImageCacheKey(asset), | ||||
|         headers: {"Authorization": authToken}, | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     /// Thumbnail image of a local asset. Required asset.isLocal | ||||
|     ImageProvider localThumbnailImageProvider(Asset asset) { | ||||
|       return AssetEntityImageProvider( | ||||
|         asset.local!, | ||||
|         isOriginal: false, | ||||
|         thumbnailSize: ThumbnailSize( | ||||
|           MediaQuery.of(context).size.width.floor(), | ||||
|           MediaQuery.of(context).size.height.floor(), | ||||
|         ), | ||||
|       ); | ||||
|     } | ||||
|     ImageProvider remoteOriginalProvider(Asset asset) => | ||||
|         CachedNetworkImageProvider( | ||||
|           getImageUrl(asset), | ||||
|           cacheKey: getImageCacheKey(asset), | ||||
|           headers: header, | ||||
|         ); | ||||
|  | ||||
|     /// Original (large) image of a local asset. Required asset.isLocal | ||||
|     ImageProvider localImageProvider(Asset asset) { | ||||
|       return AssetEntityImageProvider( | ||||
|         isOriginal: true, | ||||
|         asset.local!, | ||||
|       ); | ||||
|     ImageProvider localOriginalProvider(Asset asset) => | ||||
|         OriginalImageProvider(asset); | ||||
|  | ||||
|     ImageProvider finalImageProvider(Asset asset) { | ||||
|       if (ImmichImage.useLocal(asset)) { | ||||
|         return localOriginalProvider(asset); | ||||
|       } else if (isLoadOriginal.value) { | ||||
|         return remoteOriginalProvider(asset); | ||||
|       } else if (isLoadPreview.value) { | ||||
|         return ImmichImage.remoteThumbnailProvider(asset, jpeg, header); | ||||
|       } | ||||
|       return ImmichImage.remoteThumbnailProvider(asset, webp, header); | ||||
|     } | ||||
|  | ||||
|     Iterable<ImageProvider> allImageProviders(Asset asset) sync* { | ||||
|       if (ImmichImage.useLocal(asset)) { | ||||
|         yield ImmichImage.localThumbnailProvider(asset); | ||||
|         yield localOriginalProvider(asset); | ||||
|       } else { | ||||
|         yield ImmichImage.remoteThumbnailProvider(asset, webp, header); | ||||
|         if (isLoadPreview.value) { | ||||
|           yield ImmichImage.remoteThumbnailProvider(asset, jpeg, header); | ||||
|         } | ||||
|         if (isLoadOriginal.value) { | ||||
|           yield remoteOriginalProvider(asset); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     void precacheNextImage(int index) { | ||||
|       void onError(Object exception, StackTrace? stackTrace) { | ||||
|         // swallow error silently | ||||
|       } | ||||
|       if (index < totalAssets && index >= 0) { | ||||
|         final asset = loadAsset(index); | ||||
|  | ||||
|         if (!asset.isRemote || | ||||
|             asset.isLocal && !Store.get(StoreKey.preferRemoteImage, false)) { | ||||
|           // Preload the local asset | ||||
|           precacheImage(localImageProvider(asset), context); | ||||
|         } else { | ||||
|           onError(Object exception, StackTrace? stackTrace) { | ||||
|             // swallow error silently | ||||
|           } | ||||
|           // Probably load WEBP either way | ||||
|           precacheImage( | ||||
|             remoteThumbnailImageProvider( | ||||
|               asset, | ||||
|               api.ThumbnailFormat.WEBP, | ||||
|             ), | ||||
|             context, | ||||
|             onError: onError, | ||||
|           ); | ||||
|           if (isLoadPreview.value) { | ||||
|             // Precache the JPEG thumbnail | ||||
|             precacheImage( | ||||
|               remoteThumbnailImageProvider( | ||||
|                 asset, | ||||
|                 api.ThumbnailFormat.JPEG, | ||||
|               ), | ||||
|               context, | ||||
|               onError: onError, | ||||
|             ); | ||||
|           } | ||||
|           if (isLoadOriginal.value) { | ||||
|             // Preload the original asset | ||||
|             precacheImage( | ||||
|               originalImageProvider(asset), | ||||
|               context, | ||||
|               onError: onError, | ||||
|             ); | ||||
|           } | ||||
|         for (final imageProvider in allImageProviders(asset)) { | ||||
|           precacheImage(imageProvider, context, onError: onError); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
| @@ -346,7 +308,6 @@ class GalleryViewerPage extends HookConsumerWidget { | ||||
|           activeColor: Colors.white, | ||||
|           inactiveColor: Colors.white.withOpacity(0.75), | ||||
|           onChanged: (position) { | ||||
|             progressValue.value = position; | ||||
|             ref.read(videoPlayerControlsProvider.notifier).position = position; | ||||
|           }, | ||||
|         ), | ||||
| @@ -485,27 +446,6 @@ class GalleryViewerPage extends HookConsumerWidget { | ||||
|       } | ||||
|     }); | ||||
|  | ||||
|     ImageProvider imageProvider(Asset asset) { | ||||
|       if (!asset.isRemote || | ||||
|           asset.isLocal && !Store.get(StoreKey.preferRemoteImage, false)) { | ||||
|         return localImageProvider(asset); | ||||
|       } else { | ||||
|         if (isLoadOriginal.value) { | ||||
|           return originalImageProvider(asset); | ||||
|         } else if (isLoadPreview.value) { | ||||
|           return remoteThumbnailImageProvider( | ||||
|             asset, | ||||
|             api.ThumbnailFormat.JPEG, | ||||
|           ); | ||||
|         } else { | ||||
|           return remoteThumbnailImageProvider( | ||||
|             asset, | ||||
|             api.ThumbnailFormat.WEBP, | ||||
|           ); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     return Scaffold( | ||||
|       backgroundColor: Colors.black, | ||||
|       body: WillPopScope( | ||||
| @@ -531,79 +471,51 @@ class GalleryViewerPage extends HookConsumerWidget { | ||||
|               itemCount: totalAssets, | ||||
|               scrollDirection: Axis.horizontal, | ||||
|               onPageChanged: (value) { | ||||
|                 // Precache image | ||||
|                 if (currentIndex.value < value) { | ||||
|                   // Moving forwards, so precache the next asset | ||||
|                   precacheNextImage(value + 1); | ||||
|                 } else { | ||||
|                   // Moving backwards, so precache previous asset | ||||
|                   precacheNextImage(value - 1); | ||||
|                 } | ||||
|                 final next = currentIndex.value < value ? value + 1 : value - 1; | ||||
|                 precacheNextImage(next); | ||||
|                 currentIndex.value = value; | ||||
|                 progressValue.value = 0.0; | ||||
|  | ||||
|                 HapticFeedback.selectionClick(); | ||||
|               }, | ||||
|               loadingBuilder: isLoadPreview.value | ||||
|                   ? (context, event) { | ||||
|                       final a = asset(); | ||||
|                       if (!a.isLocal || | ||||
|                           (a.isRemote && | ||||
|                               Store.get(StoreKey.preferRemoteImage, false))) { | ||||
|                         // Use the WEBP Thumbnail as a placeholder for the JPEG thumbnail to achieve | ||||
|                         // Three-Stage Loading (WEBP -> JPEG -> Original) | ||||
|                         final webPThumbnail = CachedNetworkImage( | ||||
|                           imageUrl: getThumbnailUrl( | ||||
|                             a, | ||||
|                             type: api.ThumbnailFormat.WEBP, | ||||
|                           ), | ||||
|                           cacheKey: getThumbnailCacheKey( | ||||
|                             a, | ||||
|                             type: api.ThumbnailFormat.WEBP, | ||||
|                           ), | ||||
|                           httpHeaders: {'Authorization': authToken}, | ||||
|                           progressIndicatorBuilder: (_, __, ___) => | ||||
|                               const Center( | ||||
|                             child: ImmichLoadingIndicator(), | ||||
|                           ), | ||||
|                           fadeInDuration: const Duration(milliseconds: 0), | ||||
|                           fit: BoxFit.contain, | ||||
|                           errorWidget: (context, url, error) => | ||||
|                               const Icon(Icons.image_not_supported_outlined), | ||||
|                         ); | ||||
|               loadingBuilder: (context, event, index) { | ||||
|                 final a = loadAsset(index); | ||||
|                 if (ImmichImage.useLocal(a)) { | ||||
|                   return Image( | ||||
|                     image: ImmichImage.localThumbnailProvider(a), | ||||
|                     fit: BoxFit.contain, | ||||
|                   ); | ||||
|                 } | ||||
|                 // Use the WEBP Thumbnail as a placeholder for the JPEG thumbnail to achieve | ||||
|                 // Three-Stage Loading (WEBP -> JPEG -> Original) | ||||
|                 final webPThumbnail = CachedNetworkImage( | ||||
|                   imageUrl: getThumbnailUrl(a, type: webp), | ||||
|                   cacheKey: getThumbnailCacheKey(a, type: webp), | ||||
|                   httpHeaders: header, | ||||
|                   progressIndicatorBuilder: (_, __, ___) => const Center( | ||||
|                     child: ImmichLoadingIndicator(), | ||||
|                   ), | ||||
|                   fadeInDuration: const Duration(milliseconds: 0), | ||||
|                   fit: BoxFit.contain, | ||||
|                   errorWidget: (context, url, error) => | ||||
|                       const Icon(Icons.image_not_supported_outlined), | ||||
|                 ); | ||||
|  | ||||
|                         if (isLoadOriginal.value) { | ||||
|                           // loading the preview in the loadingBuilder only | ||||
|                           // makes sense if the original is loaded in the builder | ||||
|                           return CachedNetworkImage( | ||||
|                             imageUrl: getThumbnailUrl( | ||||
|                               a, | ||||
|                               type: api.ThumbnailFormat.JPEG, | ||||
|                             ), | ||||
|                             cacheKey: getThumbnailCacheKey( | ||||
|                               a, | ||||
|                               type: api.ThumbnailFormat.JPEG, | ||||
|                             ), | ||||
|                             httpHeaders: {'Authorization': authToken}, | ||||
|                             fit: BoxFit.contain, | ||||
|                             fadeInDuration: const Duration(milliseconds: 0), | ||||
|                             placeholder: (_, __) => webPThumbnail, | ||||
|                             errorWidget: (_, __, ___) => webPThumbnail, | ||||
|                           ); | ||||
|                         } else { | ||||
|                           return webPThumbnail; | ||||
|                         } | ||||
|                       } else { | ||||
|                         return Image( | ||||
|                           image: localThumbnailImageProvider(a), | ||||
|                           fit: BoxFit.contain, | ||||
|                         ); | ||||
|                       } | ||||
|                     } | ||||
|                   : null, | ||||
|                 // loading the preview in the loadingBuilder only | ||||
|                 // makes sense if the original is loaded in the builder | ||||
|                 return isLoadPreview.value && isLoadOriginal.value | ||||
|                     ? CachedNetworkImage( | ||||
|                         imageUrl: getThumbnailUrl(a, type: jpeg), | ||||
|                         cacheKey: getThumbnailCacheKey(a, type: jpeg), | ||||
|                         httpHeaders: header, | ||||
|                         fit: BoxFit.contain, | ||||
|                         fadeInDuration: const Duration(milliseconds: 0), | ||||
|                         placeholder: (_, __) => webPThumbnail, | ||||
|                         errorWidget: (_, __, ___) => webPThumbnail, | ||||
|                       ) | ||||
|                     : webPThumbnail; | ||||
|               }, | ||||
|               builder: (context, index) { | ||||
|                 final asset = loadAsset(index); | ||||
|                 final ImageProvider provider = imageProvider(asset); | ||||
|                 final ImageProvider provider = finalImageProvider(asset); | ||||
|  | ||||
|                 if (asset.isImage && !isPlayingMotionVideo.value) { | ||||
|                   return PhotoViewGalleryPageOptions( | ||||
|   | ||||
							
								
								
									
										69
									
								
								mobile/lib/shared/cache/custom_image_cache.dart
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								mobile/lib/shared/cache/custom_image_cache.dart
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,69 @@ | ||||
| import 'package:flutter/painting.dart'; | ||||
|  | ||||
| import 'original_image_provider.dart'; | ||||
|  | ||||
| /// [ImageCache] that uses two caches for small and large images | ||||
| /// so that a single large image does not evict all small iamges | ||||
| final class CustomImageCache implements ImageCache { | ||||
|   final _small = ImageCache(); | ||||
|   final _large = ImageCache(); | ||||
|  | ||||
|   @override | ||||
|   int get maximumSize => _small.maximumSize + _large.maximumSize; | ||||
|  | ||||
|   @override | ||||
|   int get maximumSizeBytes => _small.maximumSizeBytes + _large.maximumSizeBytes; | ||||
|  | ||||
|   @override | ||||
|   set maximumSize(int value) => _small.maximumSize = value; | ||||
|  | ||||
|   @override | ||||
|   set maximumSizeBytes(int value) => _small.maximumSize = value; | ||||
|  | ||||
|   @override | ||||
|   void clear() { | ||||
|     _small.clear(); | ||||
|     _large.clear(); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void clearLiveImages() { | ||||
|     _small.clearLiveImages(); | ||||
|     _large.clearLiveImages(); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   bool containsKey(Object key) => | ||||
|       (key is OriginalImageProvider ? _large : _small).containsKey(key); | ||||
|  | ||||
|   @override | ||||
|   int get currentSize => _small.currentSize + _large.currentSize; | ||||
|  | ||||
|   @override | ||||
|   int get currentSizeBytes => _small.currentSizeBytes + _large.currentSizeBytes; | ||||
|  | ||||
|   @override | ||||
|   bool evict(Object key, {bool includeLive = true}) => | ||||
|       (key is OriginalImageProvider ? _large : _small) | ||||
|           .evict(key, includeLive: includeLive); | ||||
|  | ||||
|   @override | ||||
|   int get liveImageCount => _small.liveImageCount + _large.liveImageCount; | ||||
|  | ||||
|   @override | ||||
|   int get pendingImageCount => | ||||
|       _small.pendingImageCount + _large.pendingImageCount; | ||||
|  | ||||
|   @override | ||||
|   ImageStreamCompleter? putIfAbsent( | ||||
|     Object key, | ||||
|     ImageStreamCompleter Function() loader, { | ||||
|     ImageErrorListener? onError, | ||||
|   }) => | ||||
|       (key is OriginalImageProvider ? _large : _small) | ||||
|           .putIfAbsent(key, loader, onError: onError); | ||||
|  | ||||
|   @override | ||||
|   ImageCacheStatus statusForKey(Object key) => | ||||
|       (key is OriginalImageProvider ? _large : _small).statusForKey(key); | ||||
| } | ||||
							
								
								
									
										73
									
								
								mobile/lib/shared/cache/original_image_provider.dart
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								mobile/lib/shared/cache/original_image_provider.dart
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,73 @@ | ||||
| import 'dart:async'; | ||||
| import 'dart:io'; | ||||
| import 'dart:ui' as ui; | ||||
|  | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:flutter/widgets.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
|  | ||||
| /// Loads the original image for local assets | ||||
| @immutable | ||||
| final class OriginalImageProvider extends ImageProvider<OriginalImageProvider> { | ||||
|   final Asset asset; | ||||
|  | ||||
|   const OriginalImageProvider(this.asset); | ||||
|  | ||||
|   @override | ||||
|   Future<OriginalImageProvider> obtainKey(ImageConfiguration configuration) => | ||||
|       SynchronousFuture<OriginalImageProvider>(this); | ||||
|  | ||||
|   @override | ||||
|   ImageStreamCompleter loadImage( | ||||
|     OriginalImageProvider key, | ||||
|     ImageDecoderCallback decode, | ||||
|   ) => | ||||
|       MultiFrameImageStreamCompleter( | ||||
|         codec: _loadAsync(key, decode), | ||||
|         scale: 1.0, | ||||
|         informationCollector: () sync* { | ||||
|           yield ErrorDescription(asset.fileName); | ||||
|         }, | ||||
|       ); | ||||
|  | ||||
|   Future<ui.Codec> _loadAsync( | ||||
|     OriginalImageProvider key, | ||||
|     ImageDecoderCallback decode, | ||||
|   ) async { | ||||
|     final ui.ImmutableBuffer buffer; | ||||
|     if (asset.isImage) { | ||||
|       final File? file = await asset.local?.originFile; | ||||
|       if (file == null) { | ||||
|         throw StateError("Opening file for asset ${asset.fileName} failed"); | ||||
|       } | ||||
|       try { | ||||
|         buffer = await ui.ImmutableBuffer.fromFilePath(file.path); | ||||
|       } catch (error) { | ||||
|         throw StateError("Loading asset ${asset.fileName} failed"); | ||||
|       } | ||||
|     } else { | ||||
|       final thumbBytes = await asset.local?.thumbnailData; | ||||
|       if (thumbBytes == null) { | ||||
|         throw StateError("Loading thumb for video ${asset.fileName} failed"); | ||||
|       } | ||||
|       buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes); | ||||
|     } | ||||
|     try { | ||||
|       final codec = await decode(buffer); | ||||
|       debugPrint("Decoded image ${asset.fileName}"); | ||||
|       return codec; | ||||
|     } catch (error) { | ||||
|       throw StateError("Decoding asset ${asset.fileName} failed"); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   bool operator ==(Object other) { | ||||
|     if (other is! OriginalImageProvider) return false; | ||||
|     if (identical(this, other)) return true; | ||||
|     return asset == other.asset; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   int get hashCode => asset.hashCode; | ||||
| } | ||||
							
								
								
									
										8
									
								
								mobile/lib/shared/cache/widgets_binding.dart
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								mobile/lib/shared/cache/widgets_binding.dart
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | ||||
| import 'package:flutter/widgets.dart'; | ||||
|  | ||||
| import 'custom_image_cache.dart'; | ||||
|  | ||||
| final class ImmichWidgetsBinding extends WidgetsFlutterBinding { | ||||
|   @override | ||||
|   ImageCache createImageCache() => CustomImageCache(); | ||||
| } | ||||
| @@ -178,6 +178,7 @@ class Asset { | ||||
|   @override | ||||
|   bool operator ==(other) { | ||||
|     if (other is! Asset) return false; | ||||
|     if (identical(this, other)) return true; | ||||
|     return id == other.id && | ||||
|         checksum == other.checksum && | ||||
|         remoteId == other.remoteId && | ||||
|   | ||||
| @@ -45,14 +45,9 @@ class ImmichImage extends StatelessWidget { | ||||
|       ); | ||||
|     } | ||||
|     final Asset asset = this.asset!; | ||||
|     if (!asset.isRemote || | ||||
|         (asset.isLocal && !Store.get(StoreKey.preferRemoteImage, false))) { | ||||
|     if (useLocal(asset)) { | ||||
|       return Image( | ||||
|         image: AssetEntityImageProvider( | ||||
|           asset.local!, | ||||
|           isOriginal: false, | ||||
|           thumbnailSize: const ThumbnailSize.square(250), // like server thumbs | ||||
|         ), | ||||
|         image: localThumbnailProvider(asset), | ||||
|         width: width, | ||||
|         height: height, | ||||
|         fit: fit, | ||||
| @@ -148,45 +143,44 @@ class ImmichImage extends StatelessWidget { | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   static AssetEntityImageProvider localThumbnailProvider(Asset asset) => | ||||
|       AssetEntityImageProvider( | ||||
|         asset.local!, | ||||
|         isOriginal: false, | ||||
|         thumbnailSize: const ThumbnailSize.square(250), | ||||
|       ); | ||||
|  | ||||
|   static CachedNetworkImageProvider remoteThumbnailProvider( | ||||
|     Asset asset, | ||||
|     api.ThumbnailFormat type, | ||||
|     Map<String, String> authHeader, | ||||
|   ) => | ||||
|       CachedNetworkImageProvider( | ||||
|         getThumbnailUrl(asset, type: type), | ||||
|         cacheKey: getThumbnailCacheKey(asset, type: type), | ||||
|         headers: authHeader, | ||||
|       ); | ||||
|  | ||||
|   /// Precaches this asset for instant load the next time it is shown | ||||
|   static Future<void> precacheAsset( | ||||
|     Asset asset, | ||||
|     BuildContext context, { | ||||
|     type = api.ThumbnailFormat.WEBP, | ||||
|   }) { | ||||
|     final authToken = 'Bearer ${Store.get(StoreKey.accessToken)}'; | ||||
|  | ||||
|     if (type == api.ThumbnailFormat.WEBP) { | ||||
|       final thumbnailUrl = getThumbnailUrl(asset); | ||||
|       final thumbnailCacheKey = getThumbnailCacheKey(asset); | ||||
|       final thumbnailProvider = CachedNetworkImageProvider( | ||||
|         thumbnailUrl, | ||||
|         cacheKey: thumbnailCacheKey, | ||||
|         headers: {"Authorization": authToken}, | ||||
|       ); | ||||
|       return precacheImage(thumbnailProvider, context); | ||||
|     } | ||||
|     // Precache the local image | ||||
|     if (!asset.isRemote && | ||||
|         (asset.isLocal || !Store.get(StoreKey.preferRemoteImage, false))) { | ||||
|       final provider = AssetEntityImageProvider( | ||||
|         asset.local!, | ||||
|         isOriginal: false, | ||||
|         thumbnailSize: const ThumbnailSize.square(250), // like server thumbs | ||||
|       ); | ||||
|       return precacheImage(provider, context); | ||||
|     if (useLocal(asset)) { | ||||
|       // Precache the local image | ||||
|       return precacheImage(localThumbnailProvider(asset), context); | ||||
|     } else { | ||||
|       final authToken = 'Bearer ${Store.get(StoreKey.accessToken)}'; | ||||
|       // Precache the remote image since we are not using local images | ||||
|       final url = getThumbnailUrl(asset, type: api.ThumbnailFormat.JPEG); | ||||
|       final cacheKey = | ||||
|           getThumbnailCacheKey(asset, type: api.ThumbnailFormat.JPEG); | ||||
|       final provider = CachedNetworkImageProvider( | ||||
|         url, | ||||
|         cacheKey: cacheKey, | ||||
|         headers: {"Authorization": authToken}, | ||||
|       return precacheImage( | ||||
|         remoteThumbnailProvider(asset, type, {"Authorization": authToken}), | ||||
|         context, | ||||
|       ); | ||||
|  | ||||
|       return precacheImage(provider, context); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   static bool useLocal(Asset asset) => | ||||
|       !asset.isRemote || | ||||
|       asset.isLocal && !Store.get(StoreKey.preferRemoteImage, false); | ||||
| } | ||||
|   | ||||
| @@ -235,6 +235,7 @@ class PhotoView extends StatefulWidget { | ||||
|   const PhotoView({ | ||||
|     Key? key, | ||||
|     required this.imageProvider, | ||||
|     required this.index, | ||||
|     this.loadingBuilder, | ||||
|     this.backgroundDecoration, | ||||
|     this.wantKeepAlive = false, | ||||
| @@ -304,6 +305,7 @@ class PhotoView extends StatefulWidget { | ||||
|         imageProvider = null, | ||||
|         gaplessPlayback = false, | ||||
|         loadingBuilder = null, | ||||
|         index = 0, | ||||
|         super(key: key); | ||||
|  | ||||
|   /// Given a [imageProvider] it resolves into an zoomable image widget using. It | ||||
| @@ -419,6 +421,8 @@ class PhotoView extends StatefulWidget { | ||||
|   /// Useful when you want to drag a widget without restrictions. | ||||
|   final bool? enablePanAlways; | ||||
|  | ||||
|   final int index; | ||||
|  | ||||
|   bool get _isCustomChild { | ||||
|     return child != null; | ||||
|   } | ||||
| @@ -571,6 +575,7 @@ class _PhotoViewState extends State<PhotoView> | ||||
|                 disableGestures: widget.disableGestures, | ||||
|                 errorBuilder: widget.errorBuilder, | ||||
|                 enablePanAlways: widget.enablePanAlways, | ||||
|                 index: widget.index, | ||||
|               ); | ||||
|       }, | ||||
|     ); | ||||
| @@ -625,7 +630,7 @@ typedef PhotoViewImageDragStartCallback = Function( | ||||
|   PhotoViewControllerValue controllerValue, | ||||
| ); | ||||
|  | ||||
| /// A type definition for a callback when the user drags  | ||||
| /// A type definition for a callback when the user drags | ||||
| typedef PhotoViewImageDragUpdateCallback = Function( | ||||
|   BuildContext context, | ||||
|   DragUpdateDetails details, | ||||
| @@ -650,4 +655,5 @@ typedef PhotoViewImageScaleEndCallback = Function( | ||||
| typedef LoadingBuilder = Widget Function( | ||||
|   BuildContext context, | ||||
|   ImageChunkEvent? event, | ||||
|   int index, | ||||
| ); | ||||
|   | ||||
| @@ -281,6 +281,7 @@ class _PhotoViewGalleryState extends State<PhotoViewGallery> { | ||||
|           ) | ||||
|         : PhotoView( | ||||
|             key: ObjectKey(index), | ||||
|             index: index, | ||||
|             imageProvider: pageOption.imageProvider, | ||||
|             loadingBuilder: widget.loadingBuilder, | ||||
|             backgroundDecoration: widget.backgroundDecoration, | ||||
| @@ -315,7 +316,10 @@ class _PhotoViewGalleryState extends State<PhotoViewGallery> { | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   PhotoViewGalleryPageOptions _buildPageOption(BuildContext context, int index) { | ||||
|   PhotoViewGalleryPageOptions _buildPageOption( | ||||
|     BuildContext context, | ||||
|     int index, | ||||
|   ) { | ||||
|     if (widget._isBuilder) { | ||||
|       return widget.builder!(context, index); | ||||
|     } | ||||
|   | ||||
| @@ -35,6 +35,7 @@ class ImageWrapper extends StatefulWidget { | ||||
|     required this.disableGestures, | ||||
|     required this.errorBuilder, | ||||
|     required this.enablePanAlways, | ||||
|     required this.index, | ||||
|   }) : super(key: key); | ||||
|  | ||||
|   final ImageProvider imageProvider; | ||||
| @@ -64,6 +65,7 @@ class ImageWrapper extends StatefulWidget { | ||||
|   final FilterQuality? filterQuality; | ||||
|   final bool? disableGestures; | ||||
|   final bool? enablePanAlways; | ||||
|   final int index; | ||||
|  | ||||
|   @override | ||||
|   createState() => _ImageWrapperState(); | ||||
| @@ -128,6 +130,7 @@ class _ImageWrapperState extends State<ImageWrapper> { | ||||
|         _lastException = null; | ||||
|         _lastStack = null; | ||||
|       } | ||||
|  | ||||
|       synchronousCall ? setupCB() : setState(setupCB); | ||||
|     } | ||||
|  | ||||
| @@ -212,7 +215,7 @@ class _ImageWrapperState extends State<ImageWrapper> { | ||||
|  | ||||
|   Widget _buildLoading(BuildContext context) { | ||||
|     if (widget.loadingBuilder != null) { | ||||
|       return widget.loadingBuilder!(context, _loadingProgress); | ||||
|       return widget.loadingBuilder!(context, _loadingProgress, widget.index); | ||||
|     } | ||||
|  | ||||
|     return PhotoViewDefaultLoading( | ||||
|   | ||||
		Reference in New Issue
	
	Block a user