diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 2506e9b47a..6b5a9b9ec1 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -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(); diff --git a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart index b52b672832..ad6c2c6e6e 100644 --- a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart +++ b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart @@ -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(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 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( diff --git a/mobile/lib/shared/cache/custom_image_cache.dart b/mobile/lib/shared/cache/custom_image_cache.dart new file mode 100644 index 0000000000..650ab81c6b --- /dev/null +++ b/mobile/lib/shared/cache/custom_image_cache.dart @@ -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); +} diff --git a/mobile/lib/shared/cache/original_image_provider.dart b/mobile/lib/shared/cache/original_image_provider.dart new file mode 100644 index 0000000000..e06d815a49 --- /dev/null +++ b/mobile/lib/shared/cache/original_image_provider.dart @@ -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 { + final Asset asset; + + const OriginalImageProvider(this.asset); + + @override + Future obtainKey(ImageConfiguration configuration) => + SynchronousFuture(this); + + @override + ImageStreamCompleter loadImage( + OriginalImageProvider key, + ImageDecoderCallback decode, + ) => + MultiFrameImageStreamCompleter( + codec: _loadAsync(key, decode), + scale: 1.0, + informationCollector: () sync* { + yield ErrorDescription(asset.fileName); + }, + ); + + Future _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; +} diff --git a/mobile/lib/shared/cache/widgets_binding.dart b/mobile/lib/shared/cache/widgets_binding.dart new file mode 100644 index 0000000000..2749a54d97 --- /dev/null +++ b/mobile/lib/shared/cache/widgets_binding.dart @@ -0,0 +1,8 @@ +import 'package:flutter/widgets.dart'; + +import 'custom_image_cache.dart'; + +final class ImmichWidgetsBinding extends WidgetsFlutterBinding { + @override + ImageCache createImageCache() => CustomImageCache(); +} diff --git a/mobile/lib/shared/models/asset.dart b/mobile/lib/shared/models/asset.dart index 40d329e3f8..e8f969a111 100644 --- a/mobile/lib/shared/models/asset.dart +++ b/mobile/lib/shared/models/asset.dart @@ -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 && diff --git a/mobile/lib/shared/ui/immich_image.dart b/mobile/lib/shared/ui/immich_image.dart index 8653bdd716..8b505f5561 100644 --- a/mobile/lib/shared/ui/immich_image.dart +++ b/mobile/lib/shared/ui/immich_image.dart @@ -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 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 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); } diff --git a/mobile/lib/shared/ui/photo_view/photo_view.dart b/mobile/lib/shared/ui/photo_view/photo_view.dart index 9a5a87aac1..7e28732506 100644 --- a/mobile/lib/shared/ui/photo_view/photo_view.dart +++ b/mobile/lib/shared/ui/photo_view/photo_view.dart @@ -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 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, ); diff --git a/mobile/lib/shared/ui/photo_view/photo_view_gallery.dart b/mobile/lib/shared/ui/photo_view/photo_view_gallery.dart index 9012131ca3..27ac5dc06e 100644 --- a/mobile/lib/shared/ui/photo_view/photo_view_gallery.dart +++ b/mobile/lib/shared/ui/photo_view/photo_view_gallery.dart @@ -281,6 +281,7 @@ class _PhotoViewGalleryState extends State { ) : PhotoView( key: ObjectKey(index), + index: index, imageProvider: pageOption.imageProvider, loadingBuilder: widget.loadingBuilder, backgroundDecoration: widget.backgroundDecoration, @@ -315,7 +316,10 @@ class _PhotoViewGalleryState extends State { ); } - PhotoViewGalleryPageOptions _buildPageOption(BuildContext context, int index) { + PhotoViewGalleryPageOptions _buildPageOption( + BuildContext context, + int index, + ) { if (widget._isBuilder) { return widget.builder!(context, index); } diff --git a/mobile/lib/shared/ui/photo_view/src/photo_view_wrappers.dart b/mobile/lib/shared/ui/photo_view/src/photo_view_wrappers.dart index da80f18962..aefcd4097b 100644 --- a/mobile/lib/shared/ui/photo_view/src/photo_view_wrappers.dart +++ b/mobile/lib/shared/ui/photo_view/src/photo_view_wrappers.dart @@ -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 { _lastException = null; _lastStack = null; } + synchronousCall ? setupCB() : setState(setupCB); } @@ -212,7 +215,7 @@ class _ImageWrapperState extends State { Widget _buildLoading(BuildContext context) { if (widget.loadingBuilder != null) { - return widget.loadingBuilder!(context, _loadingProgress); + return widget.loadingBuilder!(context, _loadingProgress, widget.index); } return PhotoViewDefaultLoading(