From 28413fedcca0fa7bdcc6fca3c5e8c32146e55132 Mon Sep 17 00:00:00 2001 From: Marty Fuhry Date: Fri, 9 Feb 2024 21:13:20 -0500 Subject: [PATCH 01/18] Adds image provider --- .../providers/immich_image_provider.dart | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 mobile/lib/modules/asset_viewer/providers/immich_image_provider.dart diff --git a/mobile/lib/modules/asset_viewer/providers/immich_image_provider.dart b/mobile/lib/modules/asset_viewer/providers/immich_image_provider.dart new file mode 100644 index 0000000000000..43435e7023f13 --- /dev/null +++ b/mobile/lib/modules/asset_viewer/providers/immich_image_provider.dart @@ -0,0 +1,139 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:openapi/api.dart' as api; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/painting.dart'; +import 'package:immich_mobile/modules/settings/services/app_settings.service.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'; + +class ImmichImageProvider extends ImageProvider { + final Asset asset; + + ImmichImageProvider({required this.asset}); + + /// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key + /// that describes the precise image to load. + @override + Future obtainKey(ImageConfiguration configuration) { + return SynchronousFuture(asset); + } + + @override + ImageStreamCompleter loadImage(Asset key, ImageDecoderCallback decode) { + final chunkEvents = StreamController(); + return MultiFrameImageStreamCompleter( + codec: _codec(key, decode, chunkEvents), + scale: 1.0, + chunkEvents: chunkEvents.stream, + ); + } + + bool get _useLocal => + !asset.isRemote || + asset.isLocal && !Store.get(StoreKey.preferRemoteImage, false); + + bool get _useOriginal => AppSettingsEnum.loadOriginal.defaultValue; + bool get _loadPreview => AppSettingsEnum.loadPreview.defaultValue; + + Future _codec( + Asset key, + ImageDecoderCallback decode, + StreamController chunkEvents, + ) async { + if (_useLocal) { + return _loadLocalCodec(key, decode, chunkEvents); + } + + // Load a preview to the chunk events + //if (_loadPreview) { + //unawaited(_loadPreview(key, decode, chunkEvents)); + //} + + // Load the final remote image + if (_useOriginal) { + // Load the original image + final url = getImageUrl(asset); + return _loadFromUri(Uri.parse(url), decode, chunkEvents); + } else { + // Load a webp version of the image + final url = getThumbnailUrl(asset, type: api.ThumbnailFormat.WEBP); + return _loadFromUri(Uri.parse(url), decode, chunkEvents); + } + } + + final _httpClient = HttpClient()..autoUncompress = false; + + 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 + await consolidateHttpClientResponseBytes( + response, + onBytesReceived: (cumulative, total) { + chunkEvents.add(ImageChunkEvent( + cumulativeBytesLoaded: cumulative, expectedTotalBytes: total)); + }, + ); + + // Close the stream and decode it + final events = await chunkEvents.close(); + final buffer = await ui.ImmutableBuffer.fromUint8List(events); + return decode(buffer); + } + + /// The local codec for local images + Future _loadLocalCodec( + Asset key, + ImageDecoderCallback decode, + StreamController chunkEvents, + ) 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! ImmichImageProvider) return false; + if (identical(this, other)) return true; + return asset == other.asset; + } + + @override + int get hashCode => asset.hashCode; +} From 758b5cd6c23889ebd06525457b89450ba4650f3f Mon Sep 17 00:00:00 2001 From: Marty Fuhry Date: Fri, 9 Feb 2024 21:13:24 -0500 Subject: [PATCH 02/18] uses image provider --- mobile/lib/modules/asset_viewer/views/gallery_viewer.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart index 1903a7f19c01f..c175a84be9c17 100644 --- a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart +++ b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart @@ -12,6 +12,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/modules/album/providers/current_album.provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/asset_stack.provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/current_asset.provider.dart'; +import 'package:immich_mobile/modules/asset_viewer/providers/immich_image_provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/video_player_controls_provider.dart'; import 'package:immich_mobile/modules/album/ui/add_to_album_bottom_sheet.dart'; @@ -821,7 +822,7 @@ class GalleryViewerPage extends HookConsumerWidget { builder: (context, index) { final a = index == currentIndex.value ? asset() : loadAsset(index); - final ImageProvider provider = finalImageProvider(a); + final ImageProvider provider = ImmichImageProvider(asset: a); if (a.isImage && !isPlayingMotionVideo.value) { return PhotoViewGalleryPageOptions( From c1452a359c77fc1006a7e3d7973d7c2c6ecb122a Mon Sep 17 00:00:00 2001 From: Marty Fuhry Date: Sat, 10 Feb 2024 06:19:16 -0500 Subject: [PATCH 03/18] wip load preview --- .../providers/immich_image_provider.dart | 27 ++++++++------ .../asset_viewer/views/gallery_viewer.dart | 37 ------------------- 2 files changed, 16 insertions(+), 48 deletions(-) diff --git a/mobile/lib/modules/asset_viewer/providers/immich_image_provider.dart b/mobile/lib/modules/asset_viewer/providers/immich_image_provider.dart index 43435e7023f13..58b59e57ff132 100644 --- a/mobile/lib/modules/asset_viewer/providers/immich_image_provider.dart +++ b/mobile/lib/modules/asset_viewer/providers/immich_image_provider.dart @@ -13,6 +13,7 @@ import 'package:immich_mobile/utils/image_url_builder.dart'; class ImmichImageProvider extends ImageProvider { final Asset asset; + final _httpClient = HttpClient()..autoUncompress = false; ImmichImageProvider({required this.asset}); @@ -50,9 +51,10 @@ class ImmichImageProvider extends ImageProvider { } // Load a preview to the chunk events - //if (_loadPreview) { - //unawaited(_loadPreview(key, decode, chunkEvents)); - //} + if (_loadPreview) { + final preview = getThumbnailUrl(asset, type: api.ThumbnailFormat.WEBP); + unawaited(_loadFromUri(Uri.parse(preview), decode, chunkEvents)); + } // Load the final remote image if (_useOriginal) { @@ -61,13 +63,12 @@ class ImmichImageProvider extends ImageProvider { return _loadFromUri(Uri.parse(url), decode, chunkEvents); } else { // Load a webp version of the image - final url = getThumbnailUrl(asset, type: api.ThumbnailFormat.WEBP); + final url = getThumbnailUrl(asset, type: api.ThumbnailFormat.JPEG); return _loadFromUri(Uri.parse(url), decode, chunkEvents); } } - final _httpClient = HttpClient()..autoUncompress = false; - + // Loads the codec from the URI and sends the events to the [chunkEvents] stream Future _loadFromUri( Uri uri, ImageDecoderCallback decode, @@ -80,17 +81,21 @@ class ImmichImageProvider extends ImageProvider { ); final response = await request.close(); // Chunks of the completed image can be shown - await consolidateHttpClientResponseBytes( + final data = await consolidateHttpClientResponseBytes( response, onBytesReceived: (cumulative, total) { - chunkEvents.add(ImageChunkEvent( - cumulativeBytesLoaded: cumulative, expectedTotalBytes: total)); + chunkEvents.add( + ImageChunkEvent( + cumulativeBytesLoaded: cumulative, + expectedTotalBytes: total, + ), + ); }, ); // Close the stream and decode it - final events = await chunkEvents.close(); - final buffer = await ui.ImmutableBuffer.fromUint8List(events); + await chunkEvents.close(); + final buffer = await ui.ImmutableBuffer.fromUint8List(data); return decode(buffer); } diff --git a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart index c175a84be9c17..485de8d983f7b 100644 --- a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart +++ b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart @@ -782,43 +782,6 @@ class GalleryViewerPage extends HookConsumerWidget { stackIndex.value = -1; HapticFeedback.selectionClick(); }, - loadingBuilder: (context, event, index) { - final a = loadAsset(index); - if (ImmichImage.useLocal(a)) { - return Image( - image: ImmichImage.localImageProvider(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), - ); - - // 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 a = index == currentIndex.value ? asset() : loadAsset(index); From 0e672a5b242bbca657d9058cded2dea8a5380a46 Mon Sep 17 00:00:00 2001 From: Marty Fuhry Date: Sat, 10 Feb 2024 08:49:57 -0500 Subject: [PATCH 04/18] wip everything but activity asset thumbnail needs some help with a remote id --- .../activities/widgets/activity_tile.dart | 3 + .../providers/immich_image_provider.dart | 38 +++-- .../asset_viewer/views/gallery_viewer.dart | 46 +----- mobile/lib/shared/ui/immich_image.dart | 153 +++--------------- 4 files changed, 61 insertions(+), 179 deletions(-) diff --git a/mobile/lib/modules/activities/widgets/activity_tile.dart b/mobile/lib/modules/activities/widgets/activity_tile.dart index da5dacd58a17f..a96b83cc59d0d 100644 --- a/mobile/lib/modules/activities/widgets/activity_tile.dart +++ b/mobile/lib/modules/activities/widgets/activity_tile.dart @@ -103,6 +103,8 @@ class _ActivityAssetThumbnail extends StatelessWidget { return Container( width: 40, height: 30, + // TODO: fix this + /* decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(4)), image: DecorationImage( @@ -110,6 +112,7 @@ class _ActivityAssetThumbnail extends StatelessWidget { fit: BoxFit.cover, ), ), + */ child: const SizedBox.shrink(), ); } diff --git a/mobile/lib/modules/asset_viewer/providers/immich_image_provider.dart b/mobile/lib/modules/asset_viewer/providers/immich_image_provider.dart index 58b59e57ff132..995df9a231f58 100644 --- a/mobile/lib/modules/asset_viewer/providers/immich_image_provider.dart +++ b/mobile/lib/modules/asset_viewer/providers/immich_image_provider.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'dart:ui' as ui; +import 'package:cached_network_image/cached_network_image.dart'; import 'package:openapi/api.dart' as api; import 'package:flutter/foundation.dart'; @@ -27,13 +28,21 @@ class ImmichImageProvider extends ImageProvider { @override ImageStreamCompleter loadImage(Asset key, ImageDecoderCallback decode) { final chunkEvents = StreamController(); - return MultiFrameImageStreamCompleter( + return MultiImageStreamCompleter( codec: _codec(key, decode, chunkEvents), scale: 1.0, chunkEvents: chunkEvents.stream, ); } + ImageStream? _stream; + + @override + ImageStream createStream(ImageConfiguration configuration) { + _stream = ImageStream(); + return _stream!; + } + bool get _useLocal => !asset.isRemote || asset.isLocal && !Store.get(StoreKey.preferRemoteImage, false); @@ -41,30 +50,42 @@ class ImmichImageProvider extends ImageProvider { bool get _useOriginal => AppSettingsEnum.loadOriginal.defaultValue; bool get _loadPreview => AppSettingsEnum.loadPreview.defaultValue; - Future _codec( + // Streams in each stage of the image as we ask for it + Stream _codec( Asset key, ImageDecoderCallback decode, StreamController chunkEvents, - ) async { + ) async* { if (_useLocal) { - return _loadLocalCodec(key, decode, chunkEvents); + if (_loadPreview) { + // Use local preview + } + yield await _loadLocalCodec(key, decode, chunkEvents); } // Load a preview to the chunk events if (_loadPreview) { final preview = getThumbnailUrl(asset, type: api.ThumbnailFormat.WEBP); - unawaited(_loadFromUri(Uri.parse(preview), decode, chunkEvents)); + yield await _loadFromUri( + Uri.parse(preview), + decode, + chunkEvents, + ); } // Load the final remote image if (_useOriginal) { // Load the original image final url = getImageUrl(asset); - return _loadFromUri(Uri.parse(url), decode, chunkEvents); + final codec = await _loadFromUri(Uri.parse(url), decode, chunkEvents); + await chunkEvents.close(); + yield codec; } else { // Load a webp version of the image final url = getThumbnailUrl(asset, type: api.ThumbnailFormat.JPEG); - return _loadFromUri(Uri.parse(url), decode, chunkEvents); + final codec = await _loadFromUri(Uri.parse(url), decode, chunkEvents); + await chunkEvents.close(); + yield codec; } } @@ -93,8 +114,7 @@ class ImmichImageProvider extends ImageProvider { }, ); - // Close the stream and decode it - await chunkEvents.close(); + // Decode the response final buffer = await ui.ImmutableBuffer.fromUint8List(data); return decode(buffer); } diff --git a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart index 485de8d983f7b..f97204687f5c5 100644 --- a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart +++ b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart @@ -136,53 +136,17 @@ class GalleryViewerPage extends HookConsumerWidget { void toggleFavorite(Asset asset) => ref.read(assetProvider.notifier).toggleFavorite([asset]); - /// Original (large) image of a remote asset. Required asset.isRemote - ImageProvider remoteOriginalProvider(Asset asset) => - CachedNetworkImageProvider( - getImageUrl(asset), - cacheKey: getImageCacheKey(asset), - headers: header, - ); - - /// Original (large) image of a local asset. Required asset.isLocal - 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.localImageProvider(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); - for (final imageProvider in allImageProviders(asset)) { - precacheImage(imageProvider, context, onError: onError); - } + precacheImage( + ImmichImageProvider(asset: asset), + context, + onError: onError, + ); } } diff --git a/mobile/lib/shared/ui/immich_image.dart b/mobile/lib/shared/ui/immich_image.dart index 18f5147e832bd..4fdb92276da1a 100644 --- a/mobile/lib/shared/ui/immich_image.dart +++ b/mobile/lib/shared/ui/immich_image.dart @@ -1,14 +1,9 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/modules/asset_viewer/providers/immich_image_provider.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'; -import 'package:photo_manager/photo_manager.dart'; import 'package:openapi/api.dart' as api; -import 'package:photo_manager_image_provider/photo_manager_image_provider.dart'; /// Renders an Asset using local data if available, else remote data class ImmichImage extends StatelessWidget { @@ -49,66 +44,16 @@ class ImmichImage extends StatelessWidget { ); } final Asset asset = this.asset!; - if (useLocal(asset)) { - return Image( - image: localImageProvider(asset, size: preferredLocalAssetSize), - width: width, - height: height, - fit: fit, - frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { - if (wasSynchronouslyLoaded || frame != null) { - return child; - } - - // Show loading if desired - return Stack( - children: [ - if (useGrayBoxPlaceholder) - const SizedBox.square( - dimension: 250, - child: DecoratedBox( - decoration: BoxDecoration(color: Colors.grey), - ), - ), - if (useProgressIndicator) - const Center( - child: CircularProgressIndicator(), - ), - ], - ); - }, - errorBuilder: (context, error, stackTrace) { - if (error is PlatformException && - error.code == "The asset not found!") { - debugPrint( - "Asset ${asset.localId} does not exist anymore on device!", - ); - } else { - debugPrint( - "Error getting thumb for assetId=${asset.localId}: $error", - ); - } - return Icon( - Icons.image_not_supported_outlined, - color: context.primaryColor, - ); - }, - ); - } - final String? accessToken = Store.get(StoreKey.accessToken); - final String thumbnailRequestUrl = getThumbnailUrl(asset, type: type); - return CachedNetworkImage( - imageUrl: thumbnailRequestUrl, - httpHeaders: {"x-immich-user-token": accessToken ?? ""}, - cacheKey: getThumbnailCacheKey(asset, type: type), + return Image( + image: ImmichImageProvider(asset: asset), width: width, height: height, - // keeping memCacheWidth, memCacheHeight, maxWidthDiskCache and - // maxHeightDiskCache = null allows to simply store the webp thumbnail - // from the server and use it for all rendered thumbnail sizes fit: fit, - fadeInDuration: const Duration(milliseconds: 250), - progressIndicatorBuilder: (context, url, downloadProgress) { + frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded || frame != null) { + return child; + } + // Show loading if desired return Stack( children: [ @@ -120,24 +65,22 @@ class ImmichImage extends StatelessWidget { ), ), if (useProgressIndicator) - Transform.scale( - scale: 2, - child: Center( - child: CircularProgressIndicator.adaptive( - strokeWidth: 1, - value: downloadProgress.progress, - ), - ), + const Center( + child: CircularProgressIndicator(), ), ], ); }, - errorWidget: (context, url, error) { - if (error is HttpExceptionWithStatus && - error.statusCode >= 400 && - error.statusCode < 500) { - debugPrint("Evicting thumbnail '$url' from cache: $error"); - CachedNetworkImage.evictFromCache(url); + errorBuilder: (context, error, stackTrace) { + if (error is PlatformException && + error.code == "The asset not found!") { + debugPrint( + "Asset ${asset.localId} does not exist anymore on device!", + ); + } else { + debugPrint( + "Error getting thumb for assetId=${asset.localId}: $error", + ); } return Icon( Icons.image_not_supported_outlined, @@ -147,40 +90,6 @@ class ImmichImage extends StatelessWidget { ); } - static AssetEntityImageProvider localImageProvider( - Asset asset, { - int size = 250, - }) => - AssetEntityImageProvider( - asset.local!, - isOriginal: false, - thumbnailSize: ThumbnailSize.square(size), - ); - - static CachedNetworkImageProvider remoteThumbnailProvider( - Asset asset, - api.ThumbnailFormat type, - Map authHeader, - ) => - CachedNetworkImageProvider( - getThumbnailUrl(asset, type: type), - cacheKey: getThumbnailCacheKey(asset, type: type), - headers: authHeader, - ); - - /// TODO: refactor image providers to separate class - static CachedNetworkImageProvider remoteThumbnailProviderForId( - String assetId, { - api.ThumbnailFormat type = api.ThumbnailFormat.WEBP, - }) => - CachedNetworkImageProvider( - getThumbnailUrlForRemoteId(assetId, type: type), - cacheKey: getThumbnailCacheKeyForRemoteId(assetId, type: type), - headers: { - "x-immich-user-token": Store.get(StoreKey.accessToken), - }, - ); - /// Precaches this asset for instant load the next time it is shown static Future precacheAsset( Asset asset, @@ -188,23 +97,9 @@ class ImmichImage extends StatelessWidget { type = api.ThumbnailFormat.WEBP, size = 250, }) { - if (useLocal(asset)) { - // Precache the local image - return precacheImage( - localImageProvider(asset, size: size), - context, - ); - } else { - final accessToken = Store.get(StoreKey.accessToken); - // Precache the remote image since we are not using local images - return precacheImage( - remoteThumbnailProvider(asset, type, {"x-immich-user-token": accessToken}), - context, - ); - } + return precacheImage( + ImmichImageProvider(asset: asset), + context, + ); } - - static bool useLocal(Asset asset) => - !asset.isRemote || - asset.isLocal && !Store.get(StoreKey.preferRemoteImage, false); } From 230c7dae0ff4cff2de8f859656728411a79ca8f6 Mon Sep 17 00:00:00 2001 From: Marty Fuhry Date: Sat, 10 Feb 2024 10:09:10 -0500 Subject: [PATCH 05/18] Immich provider used in gallery --- .../activities/widgets/activity_tile.dart | 7 +- .../immich_local_image_provider.dart} | 79 +---------- .../immich_remote_image_provider.dart | 126 ++++++++++++++++++ .../asset_viewer/views/gallery_viewer.dart | 7 +- .../modules/memories/views/memory_page.dart | 4 +- mobile/lib/shared/ui/immich_image.dart | 52 ++++++-- mobile/lib/utils/image_url_builder.dart | 4 + 7 files changed, 185 insertions(+), 94 deletions(-) rename mobile/lib/modules/asset_viewer/{providers/immich_image_provider.dart => image_providers/immich_local_image_provider.dart} (55%) create mode 100644 mobile/lib/modules/asset_viewer/image_providers/immich_remote_image_provider.dart diff --git a/mobile/lib/modules/activities/widgets/activity_tile.dart b/mobile/lib/modules/activities/widgets/activity_tile.dart index a96b83cc59d0d..324d3cac8f725 100644 --- a/mobile/lib/modules/activities/widgets/activity_tile.dart +++ b/mobile/lib/modules/activities/widgets/activity_tile.dart @@ -3,8 +3,8 @@ 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/providers/current_asset.provider.dart'; -import 'package:immich_mobile/shared/ui/immich_image.dart'; import 'package:immich_mobile/shared/ui/user_circle_avatar.dart'; class ActivityTile extends HookConsumerWidget { @@ -103,16 +103,13 @@ class _ActivityAssetThumbnail extends StatelessWidget { return Container( width: 40, height: 30, - // TODO: fix this - /* decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(4)), image: DecorationImage( - image: ImmichImage.remoteThumbnailProviderForId(assetId), + image: ImmichRemoteImageProvider(assetId: assetId), fit: BoxFit.cover, ), ), - */ child: const SizedBox.shrink(), ); } diff --git a/mobile/lib/modules/asset_viewer/providers/immich_image_provider.dart b/mobile/lib/modules/asset_viewer/image_providers/immich_local_image_provider.dart similarity index 55% rename from mobile/lib/modules/asset_viewer/providers/immich_image_provider.dart rename to mobile/lib/modules/asset_viewer/image_providers/immich_local_image_provider.dart index 995df9a231f58..c0ec739a3fd97 100644 --- a/mobile/lib/modules/asset_viewer/providers/immich_image_provider.dart +++ b/mobile/lib/modules/asset_viewer/image_providers/immich_local_image_provider.dart @@ -12,11 +12,11 @@ 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'; -class ImmichImageProvider extends ImageProvider { +class ImmichLocalImageProvider extends ImageProvider { final Asset asset; final _httpClient = HttpClient()..autoUncompress = false; - ImmichImageProvider({required this.asset}); + ImmichLocalImageProvider({required this.asset}); /// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key /// that describes the precise image to load. @@ -35,18 +35,6 @@ class ImmichImageProvider extends ImageProvider { ); } - ImageStream? _stream; - - @override - ImageStream createStream(ImageConfiguration configuration) { - _stream = ImageStream(); - return _stream!; - } - - bool get _useLocal => - !asset.isRemote || - asset.isLocal && !Store.get(StoreKey.preferRemoteImage, false); - bool get _useOriginal => AppSettingsEnum.loadOriginal.defaultValue; bool get _loadPreview => AppSettingsEnum.loadPreview.defaultValue; @@ -56,67 +44,10 @@ class ImmichImageProvider extends ImageProvider { ImageDecoderCallback decode, StreamController chunkEvents, ) async* { - if (_useLocal) { - if (_loadPreview) { - // Use local preview - } - yield await _loadLocalCodec(key, decode, chunkEvents); - } - - // Load a preview to the chunk events if (_loadPreview) { - final preview = getThumbnailUrl(asset, type: api.ThumbnailFormat.WEBP); - yield await _loadFromUri( - Uri.parse(preview), - decode, - chunkEvents, - ); + // TODO: Use local preview } - - // Load the final remote image - if (_useOriginal) { - // Load the original image - final url = getImageUrl(asset); - final codec = await _loadFromUri(Uri.parse(url), decode, chunkEvents); - await chunkEvents.close(); - yield codec; - } else { - // Load a webp version of the image - final url = getThumbnailUrl(asset, type: api.ThumbnailFormat.JPEG); - final codec = await _loadFromUri(Uri.parse(url), decode, chunkEvents); - await chunkEvents.close(); - yield codec; - } - } - - // 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); + yield await _loadLocalCodec(key, decode, chunkEvents); } /// The local codec for local images @@ -154,7 +85,7 @@ class ImmichImageProvider extends ImageProvider { @override bool operator ==(Object other) { - if (other is! ImmichImageProvider) return false; + if (other is! ImmichLocalImageProvider) return false; if (identical(this, other)) return true; return asset == other.asset; } 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 new file mode 100644 index 0000000000000..74bedfc9cdb4d --- /dev/null +++ b/mobile/lib/modules/asset_viewer/image_providers/immich_remote_image_provider.dart @@ -0,0 +1,126 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:openapi/api.dart' as api; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/painting.dart'; +import 'package:immich_mobile/modules/settings/services/app_settings.service.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'; + +/// The remote image provider +class ImmichRemoteImageProvider extends ImageProvider { + /// The [Asset.remoteId] of the asset to fetch + final String assetId; + + /// Our HTTP client to make the request + final _httpClient = HttpClient()..autoUncompress = false; + + ImmichRemoteImageProvider({required this.assetId}); + + /// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key + /// that describes the precise image to load. + @override + Future obtainKey(ImageConfiguration configuration) { + return SynchronousFuture(assetId); + } + + @override + ImageStreamCompleter loadImage(String key, ImageDecoderCallback decode) { + final chunkEvents = StreamController(); + return MultiImageStreamCompleter( + codec: _codec(key, decode, chunkEvents), + scale: 1.0, + chunkEvents: chunkEvents.stream, + ); + } + + /// Whether to show the original file or load a compressed version + bool get _useOriginal => AppSettingsEnum.loadOriginal.defaultValue; + + /// Whether to load the preview thumbnail first or not + bool get _loadPreview => AppSettingsEnum.loadPreview.defaultValue; + + // Streams in each stage of the image as we ask for it + Stream _codec( + String key, + ImageDecoderCallback decode, + StreamController chunkEvents, + ) async* { + // Load a preview to the chunk events + if (_loadPreview) { + final preview = getThumbnailUrlForRemoteId( + assetId, + type: api.ThumbnailFormat.WEBP, + ); + + yield await _loadFromUri( + Uri.parse(preview), + decode, + chunkEvents, + ); + } + + // Load a webp version of the image + final url = getThumbnailUrlForRemoteId( + assetId, + type: api.ThumbnailFormat.JPEG, + ); + final codec = await _loadFromUri(Uri.parse(url), decode, chunkEvents); + await chunkEvents.close(); + yield codec; + + // Load the final remote image + if (_useOriginal) { + // Load the original image + final url = getImageUrlFromId(assetId); + final codec = await _loadFromUri(Uri.parse(url), decode, chunkEvents); + await chunkEvents.close(); + yield codec; + } + } + + // 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; + } + + @override + int get hashCode => assetId.hashCode; +} diff --git a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart index f97204687f5c5..43c251c5eca56 100644 --- a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart +++ b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart @@ -12,7 +12,6 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/modules/album/providers/current_album.provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/asset_stack.provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/current_asset.provider.dart'; -import 'package:immich_mobile/modules/asset_viewer/providers/immich_image_provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/video_player_controls_provider.dart'; import 'package:immich_mobile/modules/album/ui/add_to_album_bottom_sheet.dart'; @@ -142,8 +141,8 @@ class GalleryViewerPage extends HookConsumerWidget { } if (index < totalAssets && index >= 0) { final asset = loadAsset(index); - precacheImage( - ImmichImageProvider(asset: asset), + ImmichImage.precacheAssetImageProvider( + asset, context, onError: onError, ); @@ -749,7 +748,7 @@ class GalleryViewerPage extends HookConsumerWidget { builder: (context, index) { final a = index == currentIndex.value ? asset() : loadAsset(index); - final ImageProvider provider = ImmichImageProvider(asset: a); + final ImageProvider provider = ImmichImage.imageProvider(asset: a); if (a.isImage && !isPlayingMotionVideo.value) { return PhotoViewGalleryPageOptions( diff --git a/mobile/lib/modules/memories/views/memory_page.dart b/mobile/lib/modules/memories/views/memory_page.dart index 63f4e7df04fc1..846d6886a59d4 100644 --- a/mobile/lib/modules/memories/views/memory_page.dart +++ b/mobile/lib/modules/memories/views/memory_page.dart @@ -114,7 +114,7 @@ class MemoryPage extends HookConsumerWidget { final precaches = >[]; precaches.add( - ImmichImage.precacheAsset( + ImmichImage.precacheAssetImageProvider( asset, context, type: api.ThumbnailFormat.WEBP, @@ -122,7 +122,7 @@ class MemoryPage extends HookConsumerWidget { ), ); precaches.add( - ImmichImage.precacheAsset( + ImmichImage.precacheAssetImageProvider( asset, context, type: api.ThumbnailFormat.JPEG, diff --git a/mobile/lib/shared/ui/immich_image.dart b/mobile/lib/shared/ui/immich_image.dart index 4fdb92276da1a..faccba1d2f398 100644 --- a/mobile/lib/shared/ui/immich_image.dart +++ b/mobile/lib/shared/ui/immich_image.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/modules/asset_viewer/providers/immich_image_provider.dart'; +import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_local_image_provider.dart'; +import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_remote_image_provider.dart'; import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/shared/models/store.dart'; import 'package:openapi/api.dart' as api; /// Renders an Asset using local data if available, else remote data @@ -45,7 +47,7 @@ class ImmichImage extends StatelessWidget { } final Asset asset = this.asset!; return Image( - image: ImmichImageProvider(asset: asset), + image: ImmichImage.imageProvider(asset: asset), width: width, height: height, fit: fit, @@ -90,16 +92,48 @@ class ImmichImage extends StatelessWidget { ); } + static bool useLocal(Asset asset) => + !asset.isRemote || + asset.isLocal && !Store.get(StoreKey.preferRemoteImage, false); + + /// A helper function to use the correct image loader based on + /// whether the asset should be local or remote /// Precaches this asset for instant load the next time it is shown - static Future precacheAsset( + static Future precacheAssetImageProvider( Asset asset, BuildContext context, { - type = api.ThumbnailFormat.WEBP, - size = 250, + api.ThumbnailFormat type = api.ThumbnailFormat.WEBP, + int size = 250, + ImageErrorListener? onError, }) { - return precacheImage( - ImmichImageProvider(asset: asset), - context, - ); + if (useLocal(asset)) { + return precacheImage( + ImmichLocalImageProvider(asset: asset), + context, + onError: onError, + ); + } else { + return precacheImage( + ImmichRemoteImageProvider(assetId: asset.remoteId!), + context, + onError: onError, + ); + } + } + + // Helper function to return the image provider for the asset + // either by using the asset ID or the asset itself + static ImageProvider imageProvider({Asset? asset, String? assetId}) { + if (asset == null && assetId == null) { + throw Exception('Must supply either asset or assetId'); + } + + if (asset == null) { + return ImmichRemoteImageProvider(assetId: assetId!); + } else if (useLocal(asset)) { + return ImmichLocalImageProvider(asset: asset); + } else { + return ImmichRemoteImageProvider(assetId: asset.remoteId!); + } } } diff --git a/mobile/lib/utils/image_url_builder.dart b/mobile/lib/utils/image_url_builder.dart index 6fef55c2f748f..cb1d4e25bbc90 100644 --- a/mobile/lib/utils/image_url_builder.dart +++ b/mobile/lib/utils/image_url_builder.dart @@ -59,6 +59,10 @@ String getImageUrl(final Asset asset) { return '${Store.get(StoreKey.serverEndpoint)}/asset/file/${asset.remoteId}?isThumb=false'; } +String getImageUrlFromId(final String id) { + return '${Store.get(StoreKey.serverEndpoint)}/asset/file/$id?isThumb=false'; +} + String getImageCacheKey(final Asset asset) { // Assets from response DTOs do not have an isar id, querying which would give us the default autoIncrement id final isFromDto = asset.id == Isar.autoIncrement; From 02075bc52ef5555d00753c5de5a2fc3bd25ddf3e Mon Sep 17 00:00:00 2001 From: Marty Fuhry Date: Sat, 10 Feb 2024 11:06:32 -0500 Subject: [PATCH 06/18] First draft of the immich image provider, working nicely! --- .../immich_local_image_provider.dart | 14 ++--- .../immich_remote_image_provider.dart | 26 ++++++--- .../original_image_provider.dart | 0 .../asset_viewer/views/gallery_viewer.dart | 11 ++-- .../home/ui/asset_grid/thumbnail_image.dart | 4 +- .../lib/modules/memories/ui/memory_card.dart | 5 -- .../lib/modules/memories/ui/memory_lane.dart | 2 - .../lib/shared/cache/custom_image_cache.dart | 3 +- mobile/lib/shared/ui/immich_image.dart | 57 +++++++++++++++---- 9 files changed, 81 insertions(+), 41 deletions(-) rename mobile/lib/{shared/cache => modules/asset_viewer/image_providers}/original_image_provider.dart (100%) diff --git a/mobile/lib/modules/asset_viewer/image_providers/immich_local_image_provider.dart b/mobile/lib/modules/asset_viewer/image_providers/immich_local_image_provider.dart index c0ec739a3fd97..90f9b50dfa5bf 100644 --- a/mobile/lib/modules/asset_viewer/image_providers/immich_local_image_provider.dart +++ b/mobile/lib/modules/asset_viewer/image_providers/immich_local_image_provider.dart @@ -3,20 +3,20 @@ import 'dart:io'; import 'dart:ui' as ui; import 'package:cached_network_image/cached_network_image.dart'; -import 'package:openapi/api.dart' as api; import 'package:flutter/foundation.dart'; import 'package:flutter/painting.dart'; import 'package:immich_mobile/modules/settings/services/app_settings.service.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'; +/// The local image provider for an asset +/// Only viable class ImmichLocalImageProvider extends ImageProvider { final Asset asset; - final _httpClient = HttpClient()..autoUncompress = false; - ImmichLocalImageProvider({required this.asset}); + ImmichLocalImageProvider({ + required this.asset, + }) : assert(asset.local != null, 'Only usable when asset.local is set'); /// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key /// that describes the precise image to load. @@ -47,11 +47,11 @@ class ImmichLocalImageProvider extends ImageProvider { if (_loadPreview) { // TODO: Use local preview } - yield await _loadLocalCodec(key, decode, chunkEvents); + yield await _loadOriginalCodec(key, decode, chunkEvents); } /// The local codec for local images - Future _loadLocalCodec( + Future _loadOriginalCodec( Asset key, ImageDecoderCallback decode, StreamController chunkEvents, 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 74bedfc9cdb4d..744e73b110335 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 @@ -17,23 +17,31 @@ 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; + /// Our HTTP client to make the request final _httpClient = HttpClient()..autoUncompress = false; - ImmichRemoteImageProvider({required this.assetId}); + ImmichRemoteImageProvider({ + required this.assetId, + this.isThumbnail = false, + }); /// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key /// that describes the precise image to load. @override Future obtainKey(ImageConfiguration configuration) { - return SynchronousFuture(assetId); + return SynchronousFuture('$assetId,$isThumbnail'); } @override ImageStreamCompleter loadImage(String key, ImageDecoderCallback decode) { + final id = key.split(',').first; final chunkEvents = StreamController(); return MultiImageStreamCompleter( - codec: _codec(key, decode, chunkEvents), + codec: _codec(id, decode, chunkEvents), scale: 1.0, chunkEvents: chunkEvents.stream, ); @@ -52,7 +60,7 @@ class ImmichRemoteImageProvider extends ImageProvider { StreamController chunkEvents, ) async* { // Load a preview to the chunk events - if (_loadPreview) { + if (_loadPreview || isThumbnail) { final preview = getThumbnailUrlForRemoteId( assetId, type: api.ThumbnailFormat.WEBP, @@ -65,13 +73,17 @@ class ImmichRemoteImageProvider extends ImageProvider { ); } - // Load a webp version of the image + // Guard thumnbail rendering + if (isThumbnail) { + return; + } + + // Load the higher resolution version of the image final url = getThumbnailUrlForRemoteId( assetId, type: api.ThumbnailFormat.JPEG, ); final codec = await _loadFromUri(Uri.parse(url), decode, chunkEvents); - await chunkEvents.close(); yield codec; // Load the final remote image @@ -79,9 +91,9 @@ class ImmichRemoteImageProvider extends ImageProvider { // Load the original image final url = getImageUrlFromId(assetId); final codec = await _loadFromUri(Uri.parse(url), decode, chunkEvents); - await chunkEvents.close(); yield codec; } + await chunkEvents.close(); } // Loads the codec from the URI and sends the events to the [chunkEvents] stream diff --git a/mobile/lib/shared/cache/original_image_provider.dart b/mobile/lib/modules/asset_viewer/image_providers/original_image_provider.dart similarity index 100% rename from mobile/lib/shared/cache/original_image_provider.dart rename to mobile/lib/modules/asset_viewer/image_providers/original_image_provider.dart diff --git a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart index 43c251c5eca56..0b65f5e702dc2 100644 --- a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart +++ b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart @@ -25,7 +25,6 @@ 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/modules/partner/providers/partner.provider.dart'; -import 'package:immich_mobile/shared/cache/original_image_provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/modules/home/ui/delete_dialog.dart'; @@ -41,8 +40,6 @@ import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_scale_state.da import 'package:immich_mobile/shared/ui/photo_view/src/utils/photo_view_hero_attributes.dart'; 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:isar/isar.dart'; import 'package:openapi/api.dart' show ThumbnailFormat; @@ -78,7 +75,6 @@ class GalleryViewerPage extends HookConsumerWidget { final isPlayingMotionVideo = useState(false); final isPlayingVideo = useState(false); Offset? localPosition; - final header = {"x-immich-user-token": Store.get(StoreKey.accessToken)}; final currentIndex = useState(initialIndex); final currentAsset = loadAsset(currentIndex.value); final isTrashEnabled = @@ -729,6 +725,10 @@ class GalleryViewerPage extends HookConsumerWidget { isZoomed.value = state != PhotoViewScaleState.initial; ref.read(showControlsProvider.notifier).show = !isZoomed.value; }, + loadingBuilder: (context, event, index) => ImmichImage.thumbnail( + asset(), + fit: BoxFit.contain, + ), pageController: controller, scrollPhysics: isZoomed.value ? const NeverScrollableScrollPhysics() // Don't allow paging while scrolled in @@ -748,7 +748,8 @@ class GalleryViewerPage extends HookConsumerWidget { builder: (context, index) { final a = index == currentIndex.value ? asset() : loadAsset(index); - final ImageProvider provider = ImmichImage.imageProvider(asset: a); + final ImageProvider provider = + ImmichImage.imageProvider(asset: a); if (a.isImage && !isPlayingMotionVideo.value) { return PhotoViewGalleryPageOptions( diff --git a/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart b/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart index 6454d9ba248c1..6b0e83e527602 100644 --- a/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart +++ b/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart @@ -136,10 +136,8 @@ class ThumbnailImage extends StatelessWidget { tag: isFromDto ? '${asset.remoteId}-$heroOffset' : asset.id + heroOffset, - child: ImmichImage( + child: ImmichImage.thumbnail( asset, - useGrayBoxPlaceholder: useGrayBoxPlaceholder, - fit: BoxFit.cover, ), ), ); diff --git a/mobile/lib/modules/memories/ui/memory_card.dart b/mobile/lib/modules/memories/ui/memory_card.dart index 364a88b47c2b7..b5f6ab46e02ec 100644 --- a/mobile/lib/modules/memories/ui/memory_card.dart +++ b/mobile/lib/modules/memories/ui/memory_card.dart @@ -8,7 +8,6 @@ import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/shared/ui/immich_image.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; -import 'package:openapi/api.dart'; class MemoryCard extends StatelessWidget { final Asset asset; @@ -84,8 +83,6 @@ class MemoryCard extends StatelessWidget { fit: fit, height: double.infinity, width: double.infinity, - type: ThumbnailFormat.JPEG, - preferredLocalAssetSize: 2048, ), ); } else { @@ -97,8 +94,6 @@ class MemoryCard extends StatelessWidget { placeholder: ImmichImage( asset, fit: fit, - type: ThumbnailFormat.JPEG, - preferredLocalAssetSize: 2048, ), hideControlsTimer: const Duration(seconds: 2), onVideoEnded: onVideoEnded, diff --git a/mobile/lib/modules/memories/ui/memory_lane.dart b/mobile/lib/modules/memories/ui/memory_lane.dart index 1a47d9b661a52..6b11e668dbb66 100644 --- a/mobile/lib/modules/memories/ui/memory_lane.dart +++ b/mobile/lib/modules/memories/ui/memory_lane.dart @@ -5,7 +5,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/memories/providers/memory.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/shared/ui/immich_image.dart'; -import 'package:openapi/api.dart'; class MemoryLane extends HookConsumerWidget { const MemoryLane({super.key}); @@ -62,7 +61,6 @@ class MemoryLane extends HookConsumerWidget { width: 130, height: 200, useGrayBoxPlaceholder: true, - type: ThumbnailFormat.JPEG, ), ), ), diff --git a/mobile/lib/shared/cache/custom_image_cache.dart b/mobile/lib/shared/cache/custom_image_cache.dart index 650ab81c6b2eb..8e0097cee455b 100644 --- a/mobile/lib/shared/cache/custom_image_cache.dart +++ b/mobile/lib/shared/cache/custom_image_cache.dart @@ -1,6 +1,5 @@ import 'package:flutter/painting.dart'; - -import 'original_image_provider.dart'; +import 'package:immich_mobile/modules/asset_viewer/image_providers/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 diff --git a/mobile/lib/shared/ui/immich_image.dart b/mobile/lib/shared/ui/immich_image.dart index faccba1d2f398..34c55ddff5e21 100644 --- a/mobile/lib/shared/ui/immich_image.dart +++ b/mobile/lib/shared/ui/immich_image.dart @@ -6,6 +6,8 @@ import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_remote import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/store.dart'; import 'package:openapi/api.dart' as api; +import 'package:photo_manager/photo_manager.dart'; +import 'package:photo_manager_image_provider/photo_manager_image_provider.dart'; /// Renders an Asset using local data if available, else remote data class ImmichImage extends StatelessWidget { @@ -16,8 +18,7 @@ class ImmichImage extends StatelessWidget { this.fit = BoxFit.cover, this.useGrayBoxPlaceholder = false, this.useProgressIndicator = false, - this.type = api.ThumbnailFormat.WEBP, - this.preferredLocalAssetSize = 250, + this.isThumbnail = false, super.key, }); final Asset? asset; @@ -26,8 +27,19 @@ class ImmichImage extends StatelessWidget { final double? width; final double? height; final BoxFit fit; - final api.ThumbnailFormat type; - final int preferredLocalAssetSize; + final bool isThumbnail; + + /// Factory constructor to use the thumbnail variant + factory ImmichImage.thumbnail( + Asset asset, { + BoxFit fit = BoxFit.cover, + }) { + return ImmichImage( + asset, + isThumbnail: true, + fit: fit, + ); + } @override Widget build(BuildContext context) { @@ -45,9 +57,14 @@ class ImmichImage extends StatelessWidget { ), ); } + final Asset asset = this.asset!; + return Image( - image: ImmichImage.imageProvider(asset: asset), + image: ImmichImage.imageProvider( + asset: asset, + isThumbnail: isThumbnail, + ), width: width, height: height, fit: fit, @@ -123,17 +140,37 @@ class ImmichImage extends StatelessWidget { // Helper function to return the image provider for the asset // either by using the asset ID or the asset itself - static ImageProvider imageProvider({Asset? asset, String? assetId}) { + static ImageProvider imageProvider({ + Asset? asset, + String? assetId, + bool isThumbnail = false, + }) { if (asset == null && assetId == null) { throw Exception('Must supply either asset or assetId'); } if (asset == null) { - return ImmichRemoteImageProvider(assetId: assetId!); - } else if (useLocal(asset)) { - return ImmichLocalImageProvider(asset: asset); + return ImmichRemoteImageProvider( + assetId: assetId!, + isThumbnail: isThumbnail, + ); + } + + if (useLocal(asset) && isThumbnail) { + return AssetEntityImageProvider( + asset.local!, + isOriginal: false, + thumbnailSize: const ThumbnailSize.square(250), + ); + } else if (useLocal(asset) && !isThumbnail) { + return ImmichLocalImageProvider( + asset: asset, + ); } else { - return ImmichRemoteImageProvider(assetId: asset.remoteId!); + return ImmichRemoteImageProvider( + assetId: asset.remoteId!, + isThumbnail: isThumbnail, + ); } } } From 61e32f4bf777c40b2ce176c38745c8768e40d5a6 Mon Sep 17 00:00:00 2001 From: Marty Fuhry Date: Sat, 10 Feb 2024 15:07:40 -0500 Subject: [PATCH 07/18] Removed OriginalImageProvider --- .../immich_local_image_provider.dart | 5 +- .../original_image_provider.dart | 73 ------------------- .../lib/shared/cache/custom_image_cache.dart | 10 +-- 3 files changed, 9 insertions(+), 79 deletions(-) delete mode 100644 mobile/lib/modules/asset_viewer/image_providers/original_image_provider.dart diff --git a/mobile/lib/modules/asset_viewer/image_providers/immich_local_image_provider.dart b/mobile/lib/modules/asset_viewer/image_providers/immich_local_image_provider.dart index 90f9b50dfa5bf..026dac8df235e 100644 --- a/mobile/lib/modules/asset_viewer/image_providers/immich_local_image_provider.dart +++ b/mobile/lib/modules/asset_viewer/image_providers/immich_local_image_provider.dart @@ -32,10 +32,13 @@ class ImmichLocalImageProvider extends ImageProvider { codec: _codec(key, decode, chunkEvents), scale: 1.0, chunkEvents: chunkEvents.stream, + informationCollector: () sync* { + yield ErrorDescription(asset.fileName); + }, ); } - bool get _useOriginal => AppSettingsEnum.loadOriginal.defaultValue; + //bool get _useOriginal => AppSettingsEnum.loadOriginal.defaultValue; bool get _loadPreview => AppSettingsEnum.loadPreview.defaultValue; // Streams in each stage of the image as we ask for it diff --git a/mobile/lib/modules/asset_viewer/image_providers/original_image_provider.dart b/mobile/lib/modules/asset_viewer/image_providers/original_image_provider.dart deleted file mode 100644 index e06d815a490b3..0000000000000 --- a/mobile/lib/modules/asset_viewer/image_providers/original_image_provider.dart +++ /dev/null @@ -1,73 +0,0 @@ -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/custom_image_cache.dart b/mobile/lib/shared/cache/custom_image_cache.dart index 8e0097cee455b..79338cbda5f43 100644 --- a/mobile/lib/shared/cache/custom_image_cache.dart +++ b/mobile/lib/shared/cache/custom_image_cache.dart @@ -1,5 +1,5 @@ import 'package:flutter/painting.dart'; -import 'package:immich_mobile/modules/asset_viewer/image_providers/original_image_provider.dart'; +import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_local_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 @@ -33,7 +33,7 @@ final class CustomImageCache implements ImageCache { @override bool containsKey(Object key) => - (key is OriginalImageProvider ? _large : _small).containsKey(key); + (key is ImmichLocalImageProvider ? _large : _small).containsKey(key); @override int get currentSize => _small.currentSize + _large.currentSize; @@ -43,7 +43,7 @@ final class CustomImageCache implements ImageCache { @override bool evict(Object key, {bool includeLive = true}) => - (key is OriginalImageProvider ? _large : _small) + (key is ImmichLocalImageProvider ? _large : _small) .evict(key, includeLive: includeLive); @override @@ -59,10 +59,10 @@ final class CustomImageCache implements ImageCache { ImageStreamCompleter Function() loader, { ImageErrorListener? onError, }) => - (key is OriginalImageProvider ? _large : _small) + (key is ImmichLocalImageProvider ? _large : _small) .putIfAbsent(key, loader, onError: onError); @override ImageCacheStatus statusForKey(Object key) => - (key is OriginalImageProvider ? _large : _small).statusForKey(key); + (key is ImmichLocalImageProvider ? _large : _small).statusForKey(key); } From e2a25742f4176c7075f99f358136d75e13551f70 Mon Sep 17 00:00:00 2001 From: Marty Fuhry Date: Sun, 11 Feb 2024 16:45:38 -0500 Subject: [PATCH 08/18] Fixes for thumbnails --- .../album/ui/album_thumbnail_card.dart | 2 +- .../asset_viewer/views/gallery_viewer.dart | 5 ++- .../modules/memories/views/memory_page.dart | 25 +++++------ mobile/lib/shared/ui/immich_image.dart | 44 +++++++------------ 4 files changed, 31 insertions(+), 45 deletions(-) diff --git a/mobile/lib/modules/album/ui/album_thumbnail_card.dart b/mobile/lib/modules/album/ui/album_thumbnail_card.dart index 0b5854fa5068f..880312322cb8e 100644 --- a/mobile/lib/modules/album/ui/album_thumbnail_card.dart +++ b/mobile/lib/modules/album/ui/album_thumbnail_card.dart @@ -45,7 +45,7 @@ class AlbumThumbnailCard extends StatelessWidget { ); } - buildAlbumThumbnail() => ImmichImage( + buildAlbumThumbnail() => ImmichImage.thumbnail( album.thumbnail.value, width: cardSize, height: cardSize, diff --git a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart index 0b65f5e702dc2..4c702d4c0a57d 100644 --- a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart +++ b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart @@ -134,11 +134,12 @@ class GalleryViewerPage extends HookConsumerWidget { void precacheNextImage(int index) { void onError(Object exception, StackTrace? stackTrace) { // swallow error silently + debugPrint('Error precaching next image: $exception, $stackTrace'); } if (index < totalAssets && index >= 0) { final asset = loadAsset(index); - ImmichImage.precacheAssetImageProvider( - asset, + precacheImage( + ImmichImage.imageProvider(asset: asset), context, onError: onError, ); diff --git a/mobile/lib/modules/memories/views/memory_page.dart b/mobile/lib/modules/memories/views/memory_page.dart index 846d6886a59d4..07f37cb7346ff 100644 --- a/mobile/lib/modules/memories/views/memory_page.dart +++ b/mobile/lib/modules/memories/views/memory_page.dart @@ -10,7 +10,6 @@ import 'package:immich_mobile/modules/memories/ui/memory_epilogue.dart'; import 'package:immich_mobile/modules/memories/ui/memory_progress_indicator.dart'; import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/ui/immich_image.dart'; -import 'package:openapi/api.dart' as api; @RoutePage() class MemoryPage extends HookConsumerWidget { @@ -113,23 +112,21 @@ class MemoryPage extends HookConsumerWidget { // Gets the thumbnail url and precaches it final precaches = >[]; - precaches.add( - ImmichImage.precacheAssetImageProvider( - asset, + precaches.addAll([ + precacheImage( + ImmichImage.imageProvider( + asset: asset, + ), context, - type: api.ThumbnailFormat.WEBP, - size: 2048, ), - ); - precaches.add( - ImmichImage.precacheAssetImageProvider( - asset, + precacheImage( + ImmichImage.imageProvider( + asset: asset, + isThumbnail: true, + ), context, - type: api.ThumbnailFormat.JPEG, - size: 2048, ), - ); - + ]); await Future.wait(precaches); } diff --git a/mobile/lib/shared/ui/immich_image.dart b/mobile/lib/shared/ui/immich_image.dart index 34c55ddff5e21..5237815266d75 100644 --- a/mobile/lib/shared/ui/immich_image.dart +++ b/mobile/lib/shared/ui/immich_image.dart @@ -5,7 +5,6 @@ import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_local_ import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_remote_image_provider.dart'; import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/store.dart'; -import 'package:openapi/api.dart' as api; import 'package:photo_manager/photo_manager.dart'; import 'package:photo_manager_image_provider/photo_manager_image_provider.dart'; @@ -19,6 +18,7 @@ class ImmichImage extends StatelessWidget { this.useGrayBoxPlaceholder = false, this.useProgressIndicator = false, this.isThumbnail = false, + this.thumbnailSize = 250, super.key, }); final Asset? asset; @@ -28,16 +28,21 @@ class ImmichImage extends StatelessWidget { final double? height; final BoxFit fit; final bool isThumbnail; + final int thumbnailSize; /// Factory constructor to use the thumbnail variant factory ImmichImage.thumbnail( - Asset asset, { + Asset? asset, { BoxFit fit = BoxFit.cover, + double? width, + double? height, }) { return ImmichImage( asset, isThumbnail: true, fit: fit, + width: width, + height: height, ); } @@ -64,6 +69,7 @@ class ImmichImage extends StatelessWidget { image: ImmichImage.imageProvider( asset: asset, isThumbnail: isThumbnail, + thumbnailSize: thumbnailSize, ), width: width, height: height, @@ -113,37 +119,19 @@ class ImmichImage extends StatelessWidget { !asset.isRemote || asset.isLocal && !Store.get(StoreKey.preferRemoteImage, false); - /// A helper function to use the correct image loader based on - /// whether the asset should be local or remote - /// Precaches this asset for instant load the next time it is shown - static Future precacheAssetImageProvider( - Asset asset, - BuildContext context, { - api.ThumbnailFormat type = api.ThumbnailFormat.WEBP, - int size = 250, - ImageErrorListener? onError, - }) { - if (useLocal(asset)) { - return precacheImage( - ImmichLocalImageProvider(asset: asset), - context, - onError: onError, - ); - } else { - return precacheImage( - ImmichRemoteImageProvider(assetId: asset.remoteId!), - context, - onError: onError, - ); - } - } - // Helper function to return the image provider for the asset // either by using the asset ID or the asset itself + /// [asset] is the Asset to request, or else use [assetId] to get a remote + /// image provider + /// Use [isThumbnail] and [thumbnailSize] if you'd like to request a thumbnail + /// The size of the square thumbnail to request. Ignored if isThumbnail + /// is not true + static ImageProvider imageProvider({ Asset? asset, String? assetId, bool isThumbnail = false, + int thumbnailSize = 250, }) { if (asset == null && assetId == null) { throw Exception('Must supply either asset or assetId'); @@ -160,7 +148,7 @@ class ImmichImage extends StatelessWidget { return AssetEntityImageProvider( asset.local!, isOriginal: false, - thumbnailSize: const ThumbnailSize.square(250), + thumbnailSize: ThumbnailSize.square(thumbnailSize), ); } else if (useLocal(asset) && !isThumbnail) { return ImmichLocalImageProvider( From e270ae0017f99ae442f72b2599c1e3dd19617e0e Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Mon, 12 Feb 2024 14:07:42 +0000 Subject: [PATCH 09/18] feat(mobile): thumbhash support (#7028) * feat(mobile): thumbhash support * perf(mobile): store bmp thumbhash bytes in Isar --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- mobile/lib/shared/models/asset.dart | 17 ++- mobile/lib/shared/models/asset.g.dart | 202 +++++++++++++++++++++++-- mobile/lib/shared/ui/immich_image.dart | 170 +++++++++++++-------- mobile/pubspec.lock | 8 + mobile/pubspec.yaml | 1 + 5 files changed, 319 insertions(+), 79 deletions(-) diff --git a/mobile/lib/shared/models/asset.dart b/mobile/lib/shared/models/asset.dart index 0ed69dea75f53..f039c81a59144 100644 --- a/mobile/lib/shared/models/asset.dart +++ b/mobile/lib/shared/models/asset.dart @@ -35,7 +35,8 @@ class Asset { isReadOnly = remote.isReadOnly, isOffline = remote.isOffline, stackParentId = remote.stackParentId, - stackCount = remote.stackCount; + stackCount = remote.stackCount, + thumbhash = _decodeThumbhash(remote.thumbhash); Asset.local(AssetEntity local, List hash) : localId = local.id, @@ -88,6 +89,7 @@ class Asset { this.stackCount = 0, this.isReadOnly = false, this.isOffline = false, + this.thumbhash, }); @ignore @@ -116,6 +118,8 @@ class Asset { /// because Isar cannot sort lists of byte arrays String checksum; + List? thumbhash; + @Index(unique: false, replace: false, type: IndexType.hash) String? remoteId; @@ -271,6 +275,7 @@ class Asset { a.exifInfo?.latitude != exifInfo?.latitude || a.exifInfo?.longitude != exifInfo?.longitude || // no local stack count or different count from remote + a.thumbhash != thumbhash || ((stackCount == null && a.stackCount != null) || (stackCount != null && a.stackCount != null && @@ -332,6 +337,7 @@ class Asset { isReadOnly: a.isReadOnly, isOffline: a.isOffline, exifInfo: a.exifInfo?.copyWith(id: id) ?? exifInfo, + thumbhash: a.thumbhash, ); } else { // add only missing values (and set isLocal to true) @@ -368,6 +374,7 @@ class Asset { ExifInfo? exifInfo, String? stackParentId, int? stackCount, + List? thumbhash, }) => Asset( id: id ?? this.id, @@ -392,6 +399,7 @@ class Asset { exifInfo: exifInfo ?? this.exifInfo, stackParentId: stackParentId ?? this.stackParentId, stackCount: stackCount ?? this.stackCount, + thumbhash: thumbhash ?? this.thumbhash, ); Future put(Isar db) async { @@ -504,3 +512,10 @@ extension AssetsHelper on IsarCollection { return where().anyOf(ids, (q, String e) => q.localIdEqualTo(e)); } } + +List? _decodeThumbhash(String? hash) { + if (hash == null) { + return null; + } + return base64.decode(base64.normalize(hash)).toList(); +} diff --git a/mobile/lib/shared/models/asset.g.dart b/mobile/lib/shared/models/asset.g.dart index d845b5353a9f8..ce086d288f652 100644 --- a/mobile/lib/shared/models/asset.g.dart +++ b/mobile/lib/shared/models/asset.g.dart @@ -102,19 +102,24 @@ const AssetSchema = CollectionSchema( name: r'stackParentId', type: IsarType.string, ), - r'type': PropertySchema( + r'thumbhash': PropertySchema( id: 17, + name: r'thumbhash', + type: IsarType.byteList, + ), + r'type': PropertySchema( + id: 18, name: r'type', type: IsarType.byte, enumMap: _AssettypeEnumValueMap, ), r'updatedAt': PropertySchema( - id: 18, + id: 19, name: r'updatedAt', type: IsarType.dateTime, ), r'width': PropertySchema( - id: 19, + id: 20, name: r'width', type: IsarType.int, ) @@ -210,6 +215,12 @@ int _assetEstimateSize( bytesCount += 3 + value.length * 3; } } + { + final value = object.thumbhash; + if (value != null) { + bytesCount += 3 + value.length; + } + } return bytesCount; } @@ -236,9 +247,10 @@ void _assetSerialize( writer.writeString(offsets[14], object.remoteId); writer.writeLong(offsets[15], object.stackCount); writer.writeString(offsets[16], object.stackParentId); - writer.writeByte(offsets[17], object.type.index); - writer.writeDateTime(offsets[18], object.updatedAt); - writer.writeInt(offsets[19], object.width); + writer.writeByteList(offsets[17], object.thumbhash); + writer.writeByte(offsets[18], object.type.index); + writer.writeDateTime(offsets[19], object.updatedAt); + writer.writeInt(offsets[20], object.width); } Asset _assetDeserialize( @@ -266,10 +278,11 @@ Asset _assetDeserialize( remoteId: reader.readStringOrNull(offsets[14]), stackCount: reader.readLongOrNull(offsets[15]), stackParentId: reader.readStringOrNull(offsets[16]), - type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[17])] ?? + thumbhash: reader.readByteList(offsets[17]), + type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[18])] ?? AssetType.other, - updatedAt: reader.readDateTime(offsets[18]), - width: reader.readIntOrNull(offsets[19]), + updatedAt: reader.readDateTime(offsets[19]), + width: reader.readIntOrNull(offsets[20]), ); return object; } @@ -316,11 +329,13 @@ P _assetDeserializeProp

( case 16: return (reader.readStringOrNull(offset)) as P; case 17: + return (reader.readByteList(offset)) as P; + case 18: return (_AssettypeValueEnumMap[reader.readByteOrNull(offset)] ?? AssetType.other) as P; - case 18: - return (reader.readDateTime(offset)) as P; case 19: + return (reader.readDateTime(offset)) as P; + case 20: return (reader.readIntOrNull(offset)) as P; default: throw IsarError('Unknown property with id $propertyId'); @@ -2078,6 +2093,159 @@ extension AssetQueryFilter on QueryBuilder { }); } + QueryBuilder thumbhashIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'thumbhash', + )); + }); + } + + QueryBuilder thumbhashIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'thumbhash', + )); + }); + } + + QueryBuilder thumbhashElementEqualTo( + int value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'thumbhash', + value: value, + )); + }); + } + + QueryBuilder thumbhashElementGreaterThan( + int value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'thumbhash', + value: value, + )); + }); + } + + QueryBuilder thumbhashElementLessThan( + int value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'thumbhash', + value: value, + )); + }); + } + + QueryBuilder thumbhashElementBetween( + int lower, + int upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'thumbhash', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + )); + }); + } + + QueryBuilder thumbhashLengthEqualTo( + int length) { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'thumbhash', + length, + true, + length, + true, + ); + }); + } + + QueryBuilder thumbhashIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'thumbhash', + 0, + true, + 0, + true, + ); + }); + } + + QueryBuilder thumbhashIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'thumbhash', + 0, + false, + 999999, + true, + ); + }); + } + + QueryBuilder thumbhashLengthLessThan( + int length, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'thumbhash', + 0, + true, + length, + include, + ); + }); + } + + QueryBuilder thumbhashLengthGreaterThan( + int length, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'thumbhash', + length, + include, + 999999, + true, + ); + }); + } + + QueryBuilder thumbhashLengthBetween( + int lower, + int upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'thumbhash', + lower, + includeLower, + upper, + includeUpper, + ); + }); + } + QueryBuilder typeEqualTo( AssetType value) { return QueryBuilder.apply(this, (query) { @@ -2864,6 +3032,12 @@ extension AssetQueryWhereDistinct on QueryBuilder { }); } + QueryBuilder distinctByThumbhash() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'thumbhash'); + }); + } + QueryBuilder distinctByType() { return QueryBuilder.apply(this, (query) { return query.addDistinctBy(r'type'); @@ -2992,6 +3166,12 @@ extension AssetQueryProperty on QueryBuilder { }); } + QueryBuilder?, QQueryOperations> thumbhashProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'thumbhash'); + }); + } + QueryBuilder typeProperty() { return QueryBuilder.apply(this, (query) { return query.addPropertyName(r'type'); diff --git a/mobile/lib/shared/ui/immich_image.dart b/mobile/lib/shared/ui/immich_image.dart index 34c55ddff5e21..4169b9bc67ad4 100644 --- a/mobile/lib/shared/ui/immich_image.dart +++ b/mobile/lib/shared/ui/immich_image.dart @@ -8,9 +8,10 @@ import 'package:immich_mobile/shared/models/store.dart'; import 'package:openapi/api.dart' as api; import 'package:photo_manager/photo_manager.dart'; import 'package:photo_manager_image_provider/photo_manager_image_provider.dart'; +import 'package:thumbhash/thumbhash.dart' as thumbhash; /// Renders an Asset using local data if available, else remote data -class ImmichImage extends StatelessWidget { +class ImmichImage extends StatefulWidget { const ImmichImage( this.asset, { this.width, @@ -42,72 +43,7 @@ class ImmichImage extends StatelessWidget { } @override - Widget build(BuildContext context) { - if (this.asset == null) { - return Container( - decoration: const BoxDecoration( - color: Colors.grey, - ), - child: SizedBox( - width: width, - height: height, - child: const Center( - child: Icon(Icons.no_photography), - ), - ), - ); - } - - final Asset asset = this.asset!; - - return Image( - image: ImmichImage.imageProvider( - asset: asset, - isThumbnail: isThumbnail, - ), - width: width, - height: height, - fit: fit, - frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { - if (wasSynchronouslyLoaded || frame != null) { - return child; - } - - // Show loading if desired - return Stack( - children: [ - if (useGrayBoxPlaceholder) - const SizedBox.square( - dimension: 250, - child: DecoratedBox( - decoration: BoxDecoration(color: Colors.grey), - ), - ), - if (useProgressIndicator) - const Center( - child: CircularProgressIndicator(), - ), - ], - ); - }, - errorBuilder: (context, error, stackTrace) { - if (error is PlatformException && - error.code == "The asset not found!") { - debugPrint( - "Asset ${asset.localId} does not exist anymore on device!", - ); - } else { - debugPrint( - "Error getting thumb for assetId=${asset.localId}: $error", - ); - } - return Icon( - Icons.image_not_supported_outlined, - color: context.primaryColor, - ); - }, - ); - } + State createState() => _ImmichImageState(); static bool useLocal(Asset asset) => !asset.isRemote || @@ -174,3 +110,103 @@ class ImmichImage extends StatelessWidget { } } } + +class _ImmichImageState extends State { + // Creating the Uint8List from the List during each build results in flickers during + // the fade transition. Calculate the hash in the initState and cache it for further builds + Uint8List? thumbHashBytes; + static const _placeholderDimension = 300.0; + + @override + void initState() { + super.initState(); + if (widget.asset?.thumbhash != null) { + final bytes = Uint8List.fromList(widget.asset!.thumbhash!); + final rgbaImage = thumbhash.thumbHashToRGBA(bytes); + thumbHashBytes = thumbhash.rgbaToBmp(rgbaImage); + } + } + + @override + Widget build(BuildContext context) { + if (widget.asset == null) { + return Container( + decoration: const BoxDecoration( + color: Colors.grey, + ), + child: SizedBox( + width: widget.width, + height: widget.height, + child: const Center( + child: Icon(Icons.no_photography), + ), + ), + ); + } + + final Asset asset = widget.asset!; + + return Image( + image: ImmichImage.imageProvider( + asset: asset, + isThumbnail: widget.isThumbnail, + ), + width: widget.width, + height: widget.height, + fit: widget.fit, + loadingBuilder: (_, child, loadingProgress) { + return AnimatedOpacity( + opacity: loadingProgress != null ? 0 : 1, + duration: const Duration(seconds: 1), + curve: Curves.easeOut, + child: child, + ); + }, + frameBuilder: (_, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded || frame != null) { + return child; + } + + return Stack( + alignment: Alignment.center, + children: [ + if (widget.useGrayBoxPlaceholder) + const SizedBox.square( + dimension: _placeholderDimension, + child: DecoratedBox( + decoration: BoxDecoration(color: Colors.grey), + ), + ), + if (thumbHashBytes != null) + Image.memory( + thumbHashBytes!, + width: _placeholderDimension, + height: _placeholderDimension, + fit: BoxFit.cover, + ), + if (widget.useProgressIndicator) + const Center( + child: CircularProgressIndicator(), + ), + ], + ); + }, + errorBuilder: (context, error, stackTrace) { + if (error is PlatformException && + error.code == "The asset not found!") { + debugPrint( + "Asset ${asset.localId} does not exist anymore on device!", + ); + } else { + debugPrint( + "Error getting thumb for assetId=${asset.localId}: $error", + ); + } + return Icon( + Icons.image_not_supported_outlined, + color: context.primaryColor, + ); + }, + ); + } +} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index de7bbea5ca2e9..947fb9de8d506 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1467,6 +1467,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.1" + thumbhash: + dependency: "direct main" + description: + name: thumbhash + sha256: "5f6d31c5279ca0b5caa81ec10aae8dcaab098d82cb699ea66ada4ed09c794a37" + url: "https://pub.dev" + source: hosted + version: "0.1.0+1" time: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 095566cf46e29..71e48a1562ef3 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -56,6 +56,7 @@ dependencies: wakelock_plus: ^1.1.4 flutter_local_notifications: ^16.3.2 timezone: ^0.9.2 + thumbhash: 0.1.0+1 openapi: path: openapi From 4140a66cabe6e93fac6f9c8aed2139a4b8c9b0ea Mon Sep 17 00:00:00 2001 From: Marty Fuhry Date: Mon, 12 Feb 2024 10:48:48 -0500 Subject: [PATCH 10/18] Uses octoimage for fade in and placeholders --- .../immich_remote_image_provider.dart | 1 + .../immich_remote_thumbnail_provider.dart | 104 ++++++++++++ mobile/lib/shared/models/asset.dart | 14 +- mobile/lib/shared/ui/immich_image.dart | 151 ++++-------------- mobile/lib/utils/image_url_builder.dart | 2 +- mobile/pubspec.lock | 2 +- mobile/pubspec.yaml | 1 + 7 files changed, 150 insertions(+), 125 deletions(-) create mode 100644 mobile/lib/modules/asset_viewer/image_providers/immich_remote_thumbnail_provider.dart 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 744e73b110335..f6e45f958426d 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 @@ -75,6 +75,7 @@ class ImmichRemoteImageProvider extends ImageProvider { // Guard thumnbail rendering if (isThumbnail) { + await chunkEvents.close(); return; } 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 new file mode 100644 index 0000000000000..8332d8d3d7e3c --- /dev/null +++ b/mobile/lib/modules/asset_viewer/image_providers/immich_remote_thumbnail_provider.dart @@ -0,0 +1,104 @@ +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: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'; + +/// The remote image provider +class ImmichRemoteThumbnailProvider extends ImageProvider { + /// The [Asset.remoteId] of the asset to fetch + final String assetId; + + /// Our HTTP client to make the request + final _httpClient = HttpClient()..autoUncompress = false; + + ImmichRemoteThumbnailProvider({ + required this.assetId, + }); + + /// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key + /// that describes the precise image to load. + @override + Future obtainKey(ImageConfiguration configuration) { + return SynchronousFuture(assetId); + } + + @override + ImageStreamCompleter loadImage(String key, ImageDecoderCallback decode) { + final chunkEvents = StreamController(); + return MultiImageStreamCompleter( + codec: _codec(key, decode, chunkEvents), + scale: 1.0, + chunkEvents: chunkEvents.stream, + ); + } + + // Streams in each stage of the image as we ask for it + Stream _codec( + String key, + ImageDecoderCallback decode, + StreamController chunkEvents, + ) async* { + // Load a preview to the chunk events + final preview = getThumbnailUrlForRemoteId( + assetId, + type: api.ThumbnailFormat.WEBP, + ); + + yield await _loadFromUri( + Uri.parse(preview), + decode, + chunkEvents, + ); + + 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; + } + + @override + int get hashCode => assetId.hashCode; +} diff --git a/mobile/lib/shared/models/asset.dart b/mobile/lib/shared/models/asset.dart index f039c81a59144..1536b8b0e0358 100644 --- a/mobile/lib/shared/models/asset.dart +++ b/mobile/lib/shared/models/asset.dart @@ -439,17 +439,17 @@ class Asset { "remoteId": "${remoteId ?? "N/A"}", "localId": "${localId ?? "N/A"}", "checksum": "$checksum", - "ownerId": $ownerId, + "ownerId": $ownerId, "livePhotoVideoId": "${livePhotoVideoId ?? "N/A"}", "stackCount": "$stackCount", "stackParentId": "${stackParentId ?? "N/A"}", "fileCreatedAt": "$fileCreatedAt", - "fileModifiedAt": "$fileModifiedAt", - "updatedAt": "$updatedAt", - "durationInSeconds": $durationInSeconds, + "fileModifiedAt": "$fileModifiedAt", + "updatedAt": "$updatedAt", + "durationInSeconds": $durationInSeconds, "type": "$type", - "fileName": "$fileName", - "isFavorite": $isFavorite, + "fileName": "$fileName", + "isFavorite": $isFavorite, "isRemote": $isRemote, "storage": "$storage", "width": ${width ?? "N/A"}, @@ -517,5 +517,5 @@ List? _decodeThumbhash(String? hash) { if (hash == null) { return null; } - return base64.decode(base64.normalize(hash)).toList(); + return base64.decode(hash); } diff --git a/mobile/lib/shared/ui/immich_image.dart b/mobile/lib/shared/ui/immich_image.dart index be5d0569cb513..6635ad031bc93 100644 --- a/mobile/lib/shared/ui/immich_image.dart +++ b/mobile/lib/shared/ui/immich_image.dart @@ -5,11 +5,12 @@ import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_local_ import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_remote_image_provider.dart'; import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/store.dart'; +import 'package:immich_mobile/shared/ui/transparent_image.dart'; +import 'package:octo_image/octo_image.dart'; import 'package:photo_manager/photo_manager.dart'; import 'package:photo_manager_image_provider/photo_manager_image_provider.dart'; import 'package:thumbhash/thumbhash.dart' as thumbhash; -/// Renders an Asset using local data if available, else remote data class ImmichImage extends StatefulWidget { const ImmichImage( this.asset, { @@ -22,6 +23,7 @@ class ImmichImage extends StatefulWidget { this.thumbnailSize = 250, super.key, }); + final Asset? asset; final bool useGrayBoxPlaceholder; final bool useProgressIndicator; @@ -44,82 +46,10 @@ class ImmichImage extends StatefulWidget { fit: fit, width: width, height: height, + useGrayBoxPlaceholder: true, ); } - @override - Widget build(BuildContext context) { - if (this.asset == null) { - return Container( - decoration: const BoxDecoration( - color: Colors.grey, - ), - child: SizedBox( - width: width, - height: height, - child: const Center( - child: Icon(Icons.no_photography), - ), - ), - ); - } - - final Asset asset = this.asset!; - - return Image( - image: ImmichImage.imageProvider( - asset: asset, - isThumbnail: isThumbnail, - thumbnailSize: thumbnailSize, - ), - width: width, - height: height, - fit: fit, - frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { - if (wasSynchronouslyLoaded || frame != null) { - return child; - } - - // Show loading if desired - return Stack( - children: [ - if (useGrayBoxPlaceholder) - const SizedBox.square( - dimension: 250, - child: DecoratedBox( - decoration: BoxDecoration(color: Colors.grey), - ), - ), - if (useProgressIndicator) - const Center( - child: CircularProgressIndicator(), - ), - ], - ); - }, - errorBuilder: (context, error, stackTrace) { - if (error is PlatformException && - error.code == "The asset not found!") { - debugPrint( - "Asset ${asset.localId} does not exist anymore on device!", - ); - } else { - debugPrint( - "Error getting thumb for assetId=${asset.localId}: $error", - ); - } - return Icon( - Icons.image_not_supported_outlined, - color: context.primaryColor, - ); - }, - ); - } - - static bool useLocal(Asset asset) => - !asset.isRemote || - asset.isLocal && !Store.get(StoreKey.preferRemoteImage, false); - // Helper function to return the image provider for the asset // either by using the asset ID or the asset itself /// [asset] is the Asset to request, or else use [assetId] to get a remote @@ -127,7 +57,6 @@ class ImmichImage extends StatefulWidget { /// Use [isThumbnail] and [thumbnailSize] if you'd like to request a thumbnail /// The size of the square thumbnail to request. Ignored if isThumbnail /// is not true - static ImageProvider imageProvider({ Asset? asset, String? assetId, @@ -162,19 +91,26 @@ class ImmichImage extends StatefulWidget { ); } } + + static bool useLocal(Asset asset) => + !asset.isRemote || + asset.isLocal && !Store.get(StoreKey.preferRemoteImage, false); + + @override + createState() => _ImmichImageState(); } +/// Renders an Asset using local data if available, else remote data class _ImmichImageState extends State { // Creating the Uint8List from the List during each build results in flickers during // the fade transition. Calculate the hash in the initState and cache it for further builds Uint8List? thumbHashBytes; - static const _placeholderDimension = 300.0; @override void initState() { super.initState(); - if (widget.asset?.thumbhash != null) { - final bytes = Uint8List.fromList(widget.asset!.thumbhash!); + if (widget.isThumbnail && widget.asset?.thumbhash != null) { + final bytes = widget.asset!.thumbhash! as Uint8List; final rgbaImage = thumbhash.thumbHashToRGBA(bytes); thumbHashBytes = thumbhash.rgbaToBmp(rgbaImage); } @@ -199,7 +135,27 @@ class _ImmichImageState extends State { final Asset asset = widget.asset!; - return Image( + return OctoImage( + fadeInDuration: const Duration(milliseconds: 200), + fadeOutDuration: const Duration(milliseconds: 200), + placeholderBuilder: (context) { + if (thumbHashBytes != null && widget.isThumbnail) { + // Use the blurhash placeholder + return Image.memory( + thumbHashBytes!, + fit: BoxFit.cover, + ); + } else if (widget.useGrayBoxPlaceholder) { + // Use the gray box placeholder + return const SizedBox.expand( + child: DecoratedBox( + decoration: BoxDecoration(color: Colors.grey), + ), + ); + } + // No placeholder + return const SizedBox(); + }, image: ImmichImage.imageProvider( asset: asset, isThumbnail: widget.isThumbnail, @@ -207,43 +163,6 @@ class _ImmichImageState extends State { width: widget.width, height: widget.height, fit: widget.fit, - loadingBuilder: (_, child, loadingProgress) { - return AnimatedOpacity( - opacity: loadingProgress != null ? 0 : 1, - duration: const Duration(seconds: 1), - curve: Curves.easeOut, - child: child, - ); - }, - frameBuilder: (_, child, frame, wasSynchronouslyLoaded) { - if (wasSynchronouslyLoaded || frame != null) { - return child; - } - - return Stack( - alignment: Alignment.center, - children: [ - if (widget.useGrayBoxPlaceholder) - const SizedBox.square( - dimension: _placeholderDimension, - child: DecoratedBox( - decoration: BoxDecoration(color: Colors.grey), - ), - ), - if (thumbHashBytes != null) - Image.memory( - thumbHashBytes!, - width: _placeholderDimension, - height: _placeholderDimension, - fit: BoxFit.cover, - ), - if (widget.useProgressIndicator) - const Center( - child: CircularProgressIndicator(), - ), - ], - ); - }, errorBuilder: (context, error, stackTrace) { if (error is PlatformException && error.code == "The asset not found!") { diff --git a/mobile/lib/utils/image_url_builder.dart b/mobile/lib/utils/image_url_builder.dart index cb1d4e25bbc90..9f783c80d8647 100644 --- a/mobile/lib/utils/image_url_builder.dart +++ b/mobile/lib/utils/image_url_builder.dart @@ -56,7 +56,7 @@ String getAlbumThumbNailCacheKey( } String getImageUrl(final Asset asset) { - return '${Store.get(StoreKey.serverEndpoint)}/asset/file/${asset.remoteId}?isThumb=false'; + return getImageUrlFromId(asset.remoteId!); } String getImageUrlFromId(final String id) { diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 947fb9de8d506..7608a3ab6cf33 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -960,7 +960,7 @@ packages: source: hosted version: "0.5.0" octo_image: - dependency: transitive + dependency: "direct main" description: name: octo_image sha256: "45b40f99622f11901238e18d48f5f12ea36426d8eced9f4cbf58479c7aa2430d" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 71e48a1562ef3..8aa0cb9ce22b9 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -57,6 +57,7 @@ dependencies: flutter_local_notifications: ^16.3.2 timezone: ^0.9.2 thumbhash: 0.1.0+1 + octo_image: ^2.0.0 openapi: path: openapi From 2f8cb30c344864bed6407bb0dd3a0685e599cdb1 Mon Sep 17 00:00:00 2001 From: Marty Fuhry Date: Mon, 12 Feb 2024 13:21:57 -0500 Subject: [PATCH 11/18] fixes thumbnails, removes unused values, adds better thumbnail size --- .../modules/activities/widgets/activity_tile.dart | 5 ++++- .../album/ui/shared_album_thumbnail_image.dart | 6 +++++- mobile/lib/modules/album/views/sharing_page.dart | 2 +- .../immich_remote_image_provider.dart | 10 ++++++++-- mobile/lib/shared/ui/immich_image.dart | 13 ++++++++----- 5 files changed, 26 insertions(+), 10 deletions(-) diff --git a/mobile/lib/modules/activities/widgets/activity_tile.dart b/mobile/lib/modules/activities/widgets/activity_tile.dart index 324d3cac8f725..cb434d22dea8a 100644 --- a/mobile/lib/modules/activities/widgets/activity_tile.dart +++ b/mobile/lib/modules/activities/widgets/activity_tile.dart @@ -106,7 +106,10 @@ class _ActivityAssetThumbnail extends StatelessWidget { decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(4)), image: DecorationImage( - image: ImmichRemoteImageProvider(assetId: assetId), + image: ImmichRemoteImageProvider( + assetId: assetId, + isThumbnail: true, + ), fit: BoxFit.cover, ), ), diff --git a/mobile/lib/modules/album/ui/shared_album_thumbnail_image.dart b/mobile/lib/modules/album/ui/shared_album_thumbnail_image.dart index 57dd787719bdd..f70c706f35f1a 100644 --- a/mobile/lib/modules/album/ui/shared_album_thumbnail_image.dart +++ b/mobile/lib/modules/album/ui/shared_album_thumbnail_image.dart @@ -16,7 +16,11 @@ class SharedAlbumThumbnailImage extends HookConsumerWidget { }, child: Stack( children: [ - ImmichImage(asset, width: 500, height: 500), + ImmichImage.thumbnail( + asset, + width: 500, + height: 500, + ), ], ), ); diff --git a/mobile/lib/modules/album/views/sharing_page.dart b/mobile/lib/modules/album/views/sharing_page.dart index dcaf732c8f20b..2e826e86dac49 100644 --- a/mobile/lib/modules/album/views/sharing_page.dart +++ b/mobile/lib/modules/album/views/sharing_page.dart @@ -72,7 +72,7 @@ class SharingPage extends HookConsumerWidget { contentPadding: const EdgeInsets.symmetric(horizontal: 12), leading: ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(8)), - child: ImmichImage( + child: ImmichImage.thumbnail( album.thumbnail.value, width: 60, height: 60, 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 f6e45f958426d..38a9f8bd54167 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 @@ -48,10 +48,16 @@ class ImmichRemoteImageProvider extends ImageProvider { } /// Whether to show the original file or load a compressed version - bool get _useOriginal => AppSettingsEnum.loadOriginal.defaultValue; + bool get _useOriginal => Store.get( + AppSettingsEnum.loadOriginal.storeKey, + AppSettingsEnum.loadOriginal.defaultValue, + ); /// Whether to load the preview thumbnail first or not - bool get _loadPreview => AppSettingsEnum.loadPreview.defaultValue; + bool get _loadPreview => Store.get( + AppSettingsEnum.loadPreview.storeKey, + AppSettingsEnum.loadPreview.defaultValue, + ); // Streams in each stage of the image as we ask for it Stream _codec( diff --git a/mobile/lib/shared/ui/immich_image.dart b/mobile/lib/shared/ui/immich_image.dart index 6635ad031bc93..9967b8b290083 100644 --- a/mobile/lib/shared/ui/immich_image.dart +++ b/mobile/lib/shared/ui/immich_image.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; @@ -5,7 +7,6 @@ import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_local_ import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_remote_image_provider.dart'; import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/store.dart'; -import 'package:immich_mobile/shared/ui/transparent_image.dart'; import 'package:octo_image/octo_image.dart'; import 'package:photo_manager/photo_manager.dart'; import 'package:photo_manager_image_provider/photo_manager_image_provider.dart'; @@ -18,7 +19,6 @@ class ImmichImage extends StatefulWidget { this.height, this.fit = BoxFit.cover, this.useGrayBoxPlaceholder = false, - this.useProgressIndicator = false, this.isThumbnail = false, this.thumbnailSize = 250, super.key, @@ -26,7 +26,6 @@ class ImmichImage extends StatefulWidget { final Asset? asset; final bool useGrayBoxPlaceholder; - final bool useProgressIndicator; final double? width; final double? height; final BoxFit fit; @@ -40,6 +39,9 @@ class ImmichImage extends StatefulWidget { double? width, double? height, }) { + // Use the width and height to derive thumbnail size + final thumbnailSize = max(width ?? 250, height ?? 250).toInt(); + return ImmichImage( asset, isThumbnail: true, @@ -47,6 +49,7 @@ class ImmichImage extends StatefulWidget { width: width, height: height, useGrayBoxPlaceholder: true, + thumbnailSize: thumbnailSize, ); } @@ -136,8 +139,8 @@ class _ImmichImageState extends State { final Asset asset = widget.asset!; return OctoImage( - fadeInDuration: const Duration(milliseconds: 200), - fadeOutDuration: const Duration(milliseconds: 200), + fadeInDuration: const Duration(milliseconds: 0), + fadeOutDuration: const Duration(milliseconds: 400), placeholderBuilder: (context) { if (thumbHashBytes != null && widget.isThumbnail) { // Use the blurhash placeholder From c544526400929055f40550b276d5dbe7382e9331 Mon Sep 17 00:00:00 2001 From: Marty Fuhry Date: Mon, 12 Feb 2024 13:31:01 -0500 Subject: [PATCH 12/18] removes thumbhash support for now --- mobile/lib/routing/router.gr.dart | 30 +++- mobile/lib/shared/models/asset.dart | 16 +- mobile/lib/shared/models/asset.g.dart | 202 ++----------------------- mobile/lib/shared/ui/immich_image.dart | 53 ++----- mobile/pubspec.lock | 8 - mobile/pubspec.yaml | 1 - 6 files changed, 48 insertions(+), 262 deletions(-) diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index fa9db9e6959ff..f6968dafe5eaa 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -354,6 +354,9 @@ abstract class _$AppRouter extends RootStackRouter { onPlaying: args.onPlaying, onPaused: args.onPaused, placeholder: args.placeholder, + showControls: args.showControls, + hideControlsTimer: args.hideControlsTimer, + showDownloadingIndicator: args.showDownloadingIndicator, ), ); }, @@ -1384,11 +1387,14 @@ class VideoViewerRoute extends PageRouteInfo { VideoViewerRoute({ Key? key, required Asset asset, - required bool isMotionVideo, - required void Function() onVideoEnded, + bool isMotionVideo = false, + void Function()? onVideoEnded, void Function()? onPlaying, void Function()? onPaused, Widget? placeholder, + bool showControls = true, + Duration hideControlsTimer = const Duration(seconds: 5), + bool showDownloadingIndicator = true, List? children, }) : super( VideoViewerRoute.name, @@ -1400,6 +1406,9 @@ class VideoViewerRoute extends PageRouteInfo { onPlaying: onPlaying, onPaused: onPaused, placeholder: placeholder, + showControls: showControls, + hideControlsTimer: hideControlsTimer, + showDownloadingIndicator: showDownloadingIndicator, ), initialChildren: children, ); @@ -1414,11 +1423,14 @@ class VideoViewerRouteArgs { const VideoViewerRouteArgs({ this.key, required this.asset, - required this.isMotionVideo, - required this.onVideoEnded, + this.isMotionVideo = false, + this.onVideoEnded, this.onPlaying, this.onPaused, this.placeholder, + this.showControls = true, + this.hideControlsTimer = const Duration(seconds: 5), + this.showDownloadingIndicator = true, }); final Key? key; @@ -1427,7 +1439,7 @@ class VideoViewerRouteArgs { final bool isMotionVideo; - final void Function() onVideoEnded; + final void Function()? onVideoEnded; final void Function()? onPlaying; @@ -1435,8 +1447,14 @@ class VideoViewerRouteArgs { final Widget? placeholder; + final bool showControls; + + final Duration hideControlsTimer; + + final bool showDownloadingIndicator; + @override String toString() { - return 'VideoViewerRouteArgs{key: $key, asset: $asset, isMotionVideo: $isMotionVideo, onVideoEnded: $onVideoEnded, onPlaying: $onPlaying, onPaused: $onPaused, placeholder: $placeholder}'; + return 'VideoViewerRouteArgs{key: $key, asset: $asset, isMotionVideo: $isMotionVideo, onVideoEnded: $onVideoEnded, onPlaying: $onPlaying, onPaused: $onPaused, placeholder: $placeholder, showControls: $showControls, hideControlsTimer: $hideControlsTimer, showDownloadingIndicator: $showDownloadingIndicator}'; } } diff --git a/mobile/lib/shared/models/asset.dart b/mobile/lib/shared/models/asset.dart index 1536b8b0e0358..9cc0cf59a9961 100644 --- a/mobile/lib/shared/models/asset.dart +++ b/mobile/lib/shared/models/asset.dart @@ -35,8 +35,7 @@ class Asset { isReadOnly = remote.isReadOnly, isOffline = remote.isOffline, stackParentId = remote.stackParentId, - stackCount = remote.stackCount, - thumbhash = _decodeThumbhash(remote.thumbhash); + stackCount = remote.stackCount; Asset.local(AssetEntity local, List hash) : localId = local.id, @@ -89,7 +88,6 @@ class Asset { this.stackCount = 0, this.isReadOnly = false, this.isOffline = false, - this.thumbhash, }); @ignore @@ -118,8 +116,6 @@ class Asset { /// because Isar cannot sort lists of byte arrays String checksum; - List? thumbhash; - @Index(unique: false, replace: false, type: IndexType.hash) String? remoteId; @@ -275,7 +271,6 @@ class Asset { a.exifInfo?.latitude != exifInfo?.latitude || a.exifInfo?.longitude != exifInfo?.longitude || // no local stack count or different count from remote - a.thumbhash != thumbhash || ((stackCount == null && a.stackCount != null) || (stackCount != null && a.stackCount != null && @@ -337,7 +332,6 @@ class Asset { isReadOnly: a.isReadOnly, isOffline: a.isOffline, exifInfo: a.exifInfo?.copyWith(id: id) ?? exifInfo, - thumbhash: a.thumbhash, ); } else { // add only missing values (and set isLocal to true) @@ -399,7 +393,6 @@ class Asset { exifInfo: exifInfo ?? this.exifInfo, stackParentId: stackParentId ?? this.stackParentId, stackCount: stackCount ?? this.stackCount, - thumbhash: thumbhash ?? this.thumbhash, ); Future put(Isar db) async { @@ -512,10 +505,3 @@ extension AssetsHelper on IsarCollection { return where().anyOf(ids, (q, String e) => q.localIdEqualTo(e)); } } - -List? _decodeThumbhash(String? hash) { - if (hash == null) { - return null; - } - return base64.decode(hash); -} diff --git a/mobile/lib/shared/models/asset.g.dart b/mobile/lib/shared/models/asset.g.dart index ce086d288f652..d845b5353a9f8 100644 --- a/mobile/lib/shared/models/asset.g.dart +++ b/mobile/lib/shared/models/asset.g.dart @@ -102,24 +102,19 @@ const AssetSchema = CollectionSchema( name: r'stackParentId', type: IsarType.string, ), - r'thumbhash': PropertySchema( - id: 17, - name: r'thumbhash', - type: IsarType.byteList, - ), r'type': PropertySchema( - id: 18, + id: 17, name: r'type', type: IsarType.byte, enumMap: _AssettypeEnumValueMap, ), r'updatedAt': PropertySchema( - id: 19, + id: 18, name: r'updatedAt', type: IsarType.dateTime, ), r'width': PropertySchema( - id: 20, + id: 19, name: r'width', type: IsarType.int, ) @@ -215,12 +210,6 @@ int _assetEstimateSize( bytesCount += 3 + value.length * 3; } } - { - final value = object.thumbhash; - if (value != null) { - bytesCount += 3 + value.length; - } - } return bytesCount; } @@ -247,10 +236,9 @@ void _assetSerialize( writer.writeString(offsets[14], object.remoteId); writer.writeLong(offsets[15], object.stackCount); writer.writeString(offsets[16], object.stackParentId); - writer.writeByteList(offsets[17], object.thumbhash); - writer.writeByte(offsets[18], object.type.index); - writer.writeDateTime(offsets[19], object.updatedAt); - writer.writeInt(offsets[20], object.width); + writer.writeByte(offsets[17], object.type.index); + writer.writeDateTime(offsets[18], object.updatedAt); + writer.writeInt(offsets[19], object.width); } Asset _assetDeserialize( @@ -278,11 +266,10 @@ Asset _assetDeserialize( remoteId: reader.readStringOrNull(offsets[14]), stackCount: reader.readLongOrNull(offsets[15]), stackParentId: reader.readStringOrNull(offsets[16]), - thumbhash: reader.readByteList(offsets[17]), - type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[18])] ?? + type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[17])] ?? AssetType.other, - updatedAt: reader.readDateTime(offsets[19]), - width: reader.readIntOrNull(offsets[20]), + updatedAt: reader.readDateTime(offsets[18]), + width: reader.readIntOrNull(offsets[19]), ); return object; } @@ -329,13 +316,11 @@ P _assetDeserializeProp

( case 16: return (reader.readStringOrNull(offset)) as P; case 17: - return (reader.readByteList(offset)) as P; - case 18: return (_AssettypeValueEnumMap[reader.readByteOrNull(offset)] ?? AssetType.other) as P; - case 19: + case 18: return (reader.readDateTime(offset)) as P; - case 20: + case 19: return (reader.readIntOrNull(offset)) as P; default: throw IsarError('Unknown property with id $propertyId'); @@ -2093,159 +2078,6 @@ extension AssetQueryFilter on QueryBuilder { }); } - QueryBuilder thumbhashIsNull() { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(const FilterCondition.isNull( - property: r'thumbhash', - )); - }); - } - - QueryBuilder thumbhashIsNotNull() { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(const FilterCondition.isNotNull( - property: r'thumbhash', - )); - }); - } - - QueryBuilder thumbhashElementEqualTo( - int value) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.equalTo( - property: r'thumbhash', - value: value, - )); - }); - } - - QueryBuilder thumbhashElementGreaterThan( - int value, { - bool include = false, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.greaterThan( - include: include, - property: r'thumbhash', - value: value, - )); - }); - } - - QueryBuilder thumbhashElementLessThan( - int value, { - bool include = false, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.lessThan( - include: include, - property: r'thumbhash', - value: value, - )); - }); - } - - QueryBuilder thumbhashElementBetween( - int lower, - int upper, { - bool includeLower = true, - bool includeUpper = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.between( - property: r'thumbhash', - lower: lower, - includeLower: includeLower, - upper: upper, - includeUpper: includeUpper, - )); - }); - } - - QueryBuilder thumbhashLengthEqualTo( - int length) { - return QueryBuilder.apply(this, (query) { - return query.listLength( - r'thumbhash', - length, - true, - length, - true, - ); - }); - } - - QueryBuilder thumbhashIsEmpty() { - return QueryBuilder.apply(this, (query) { - return query.listLength( - r'thumbhash', - 0, - true, - 0, - true, - ); - }); - } - - QueryBuilder thumbhashIsNotEmpty() { - return QueryBuilder.apply(this, (query) { - return query.listLength( - r'thumbhash', - 0, - false, - 999999, - true, - ); - }); - } - - QueryBuilder thumbhashLengthLessThan( - int length, { - bool include = false, - }) { - return QueryBuilder.apply(this, (query) { - return query.listLength( - r'thumbhash', - 0, - true, - length, - include, - ); - }); - } - - QueryBuilder thumbhashLengthGreaterThan( - int length, { - bool include = false, - }) { - return QueryBuilder.apply(this, (query) { - return query.listLength( - r'thumbhash', - length, - include, - 999999, - true, - ); - }); - } - - QueryBuilder thumbhashLengthBetween( - int lower, - int upper, { - bool includeLower = true, - bool includeUpper = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.listLength( - r'thumbhash', - lower, - includeLower, - upper, - includeUpper, - ); - }); - } - QueryBuilder typeEqualTo( AssetType value) { return QueryBuilder.apply(this, (query) { @@ -3032,12 +2864,6 @@ extension AssetQueryWhereDistinct on QueryBuilder { }); } - QueryBuilder distinctByThumbhash() { - return QueryBuilder.apply(this, (query) { - return query.addDistinctBy(r'thumbhash'); - }); - } - QueryBuilder distinctByType() { return QueryBuilder.apply(this, (query) { return query.addDistinctBy(r'type'); @@ -3166,12 +2992,6 @@ extension AssetQueryProperty on QueryBuilder { }); } - QueryBuilder?, QQueryOperations> thumbhashProperty() { - return QueryBuilder.apply(this, (query) { - return query.addPropertyName(r'thumbhash'); - }); - } - QueryBuilder typeProperty() { return QueryBuilder.apply(this, (query) { return query.addPropertyName(r'type'); diff --git a/mobile/lib/shared/ui/immich_image.dart b/mobile/lib/shared/ui/immich_image.dart index 9967b8b290083..21418d5274dfc 100644 --- a/mobile/lib/shared/ui/immich_image.dart +++ b/mobile/lib/shared/ui/immich_image.dart @@ -10,9 +10,8 @@ import 'package:immich_mobile/shared/models/store.dart'; import 'package:octo_image/octo_image.dart'; import 'package:photo_manager/photo_manager.dart'; import 'package:photo_manager_image_provider/photo_manager_image_provider.dart'; -import 'package:thumbhash/thumbhash.dart' as thumbhash; -class ImmichImage extends StatefulWidget { +class ImmichImage extends StatelessWidget { const ImmichImage( this.asset, { this.width, @@ -98,37 +97,17 @@ class ImmichImage extends StatefulWidget { static bool useLocal(Asset asset) => !asset.isRemote || asset.isLocal && !Store.get(StoreKey.preferRemoteImage, false); - - @override - createState() => _ImmichImageState(); -} - -/// Renders an Asset using local data if available, else remote data -class _ImmichImageState extends State { - // Creating the Uint8List from the List during each build results in flickers during - // the fade transition. Calculate the hash in the initState and cache it for further builds - Uint8List? thumbHashBytes; - - @override - void initState() { - super.initState(); - if (widget.isThumbnail && widget.asset?.thumbhash != null) { - final bytes = widget.asset!.thumbhash! as Uint8List; - final rgbaImage = thumbhash.thumbHashToRGBA(bytes); - thumbHashBytes = thumbhash.rgbaToBmp(rgbaImage); - } - } - @override Widget build(BuildContext context) { - if (widget.asset == null) { + + if (asset == null) { return Container( decoration: const BoxDecoration( color: Colors.grey, ), child: SizedBox( - width: widget.width, - height: widget.height, + width: width, + height: height, child: const Center( child: Icon(Icons.no_photography), ), @@ -136,19 +115,11 @@ class _ImmichImageState extends State { ); } - final Asset asset = widget.asset!; - return OctoImage( fadeInDuration: const Duration(milliseconds: 0), fadeOutDuration: const Duration(milliseconds: 400), placeholderBuilder: (context) { - if (thumbHashBytes != null && widget.isThumbnail) { - // Use the blurhash placeholder - return Image.memory( - thumbHashBytes!, - fit: BoxFit.cover, - ); - } else if (widget.useGrayBoxPlaceholder) { + if (useGrayBoxPlaceholder) { // Use the gray box placeholder return const SizedBox.expand( child: DecoratedBox( @@ -161,20 +132,20 @@ class _ImmichImageState extends State { }, image: ImmichImage.imageProvider( asset: asset, - isThumbnail: widget.isThumbnail, + isThumbnail: isThumbnail, ), - width: widget.width, - height: widget.height, - fit: widget.fit, + width: width, + height: height, + fit: fit, errorBuilder: (context, error, stackTrace) { if (error is PlatformException && error.code == "The asset not found!") { debugPrint( - "Asset ${asset.localId} does not exist anymore on device!", + "Asset ${asset?.localId} does not exist anymore on device!", ); } else { debugPrint( - "Error getting thumb for assetId=${asset.localId}: $error", + "Error getting thumb for assetId=${asset?.localId}: $error", ); } return Icon( diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 7608a3ab6cf33..ffa57f826b06e 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1467,14 +1467,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.1" - thumbhash: - dependency: "direct main" - description: - name: thumbhash - sha256: "5f6d31c5279ca0b5caa81ec10aae8dcaab098d82cb699ea66ada4ed09c794a37" - url: "https://pub.dev" - source: hosted - version: "0.1.0+1" time: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 8aa0cb9ce22b9..ddfed62dad504 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -56,7 +56,6 @@ dependencies: wakelock_plus: ^1.1.4 flutter_local_notifications: ^16.3.2 timezone: ^0.9.2 - thumbhash: 0.1.0+1 octo_image: ^2.0.0 openapi: From 74ff8f1e56cf45f0ba85bd9d3181934310022ff7 Mon Sep 17 00:00:00 2001 From: Marty Fuhry Date: Mon, 12 Feb 2024 13:48:03 -0500 Subject: [PATCH 13/18] Forgot one thumbhash removal --- mobile/lib/shared/models/asset.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/mobile/lib/shared/models/asset.dart b/mobile/lib/shared/models/asset.dart index 9cc0cf59a9961..8540752c00d0a 100644 --- a/mobile/lib/shared/models/asset.dart +++ b/mobile/lib/shared/models/asset.dart @@ -368,7 +368,6 @@ class Asset { ExifInfo? exifInfo, String? stackParentId, int? stackCount, - List? thumbhash, }) => Asset( id: id ?? this.id, From aea7651c759294270fa93755054c99facbcdedcf Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 12 Feb 2024 15:09:13 -0600 Subject: [PATCH 14/18] Use big thumbnail for local image on ios --- mobile/ios/Podfile.lock | 2 +- .../immich_local_image_provider.dart | 30 ++++++++++++++----- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 6081988b7aaf6..a9ac5b33817e9 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -180,4 +180,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 64c9b5291666c0ca3caabdfe9865c141ac40321d -COCOAPODS: 1.11.3 +COCOAPODS: 1.12.1 diff --git a/mobile/lib/modules/asset_viewer/image_providers/immich_local_image_provider.dart b/mobile/lib/modules/asset_viewer/image_providers/immich_local_image_provider.dart index 026dac8df235e..17f1d0cb36380 100644 --- a/mobile/lib/modules/asset_viewer/image_providers/immich_local_image_provider.dart +++ b/mobile/lib/modules/asset_viewer/image_providers/immich_local_image_provider.dart @@ -8,6 +8,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/painting.dart'; import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:photo_manager/photo_manager.dart'; /// The local image provider for an asset /// Only viable @@ -60,15 +61,28 @@ class ImmichLocalImageProvider extends ImageProvider { StreamController chunkEvents, ) 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"); + /// Using 2K thumbnail for local iOS image to avoid double swiping issue + if (Platform.isIOS) { + final largeImageBytes = await asset.local + ?.thumbnailDataWithSize(const ThumbnailSize(3840, 2160)); + if (largeImageBytes == null) { + throw StateError( + "Loading thumb for local photo ${asset.fileName} failed", + ); + } + buffer = await ui.ImmutableBuffer.fromUint8List(largeImageBytes); + } else { + 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; From ea293dfe06ad820ebae3060a1be116b09c26b97b Mon Sep 17 00:00:00 2001 From: martyfuhry Date: Mon, 12 Feb 2024 17:09:45 -0500 Subject: [PATCH 15/18] fix(mobile): Multipart image loading for iOS double swipe (#7064) * uses local thumb first * Multipart thumbnail * Clean up file delete * await file delete * Fynn's comments, made thumbnail smaller and doesn't crash on erroring out on thumbnail * lint --------- Co-authored-by: Marty Fuhry Co-authored-by: Alex --- .../immich_local_image_provider.dart | 51 +++++++++---------- 1 file changed, 25 insertions(+), 26 deletions(-) diff --git a/mobile/lib/modules/asset_viewer/image_providers/immich_local_image_provider.dart b/mobile/lib/modules/asset_viewer/image_providers/immich_local_image_provider.dart index 17f1d0cb36380..69ecbb29eb890 100644 --- a/mobile/lib/modules/asset_viewer/image_providers/immich_local_image_provider.dart +++ b/mobile/lib/modules/asset_viewer/image_providers/immich_local_image_provider.dart @@ -49,18 +49,19 @@ class ImmichLocalImageProvider extends ImageProvider { StreamController chunkEvents, ) async* { if (_loadPreview) { - // TODO: Use local preview + // Load a small thumbnail + final thumbBytes = await asset.local?.thumbnailDataWithSize( + const ThumbnailSize.square(256), + quality: 80, + ); + if (thumbBytes != null) { + final buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes); + final codec = await decode(buffer); + yield codec; + } else { + debugPrint("Loading thumb for ${asset.fileName} failed"); + } } - yield await _loadOriginalCodec(key, decode, chunkEvents); - } - - /// The local codec for local images - Future _loadOriginalCodec( - Asset key, - ImageDecoderCallback decode, - StreamController chunkEvents, - ) async { - final ui.ImmutableBuffer buffer; if (asset.isImage) { /// Using 2K thumbnail for local iOS image to avoid double swiping issue @@ -72,32 +73,30 @@ class ImmichLocalImageProvider extends ImageProvider { "Loading thumb for local photo ${asset.fileName} failed", ); } - buffer = await ui.ImmutableBuffer.fromUint8List(largeImageBytes); + final buffer = await ui.ImmutableBuffer.fromUint8List(largeImageBytes); + final codec = await decode(buffer); + yield codec; } else { + // Use the original file for Android 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); + final buffer = await ui.ImmutableBuffer.fromFilePath(file.path); + final codec = await decode(buffer); + yield codec; + if (Platform.isIOS) { + // Clean up this file + await file.delete(); + } } 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"); } + + chunkEvents.close(); } @override From a033d751b352a9ffa8c5c66381a53499fe5e78c6 Mon Sep 17 00:00:00 2001 From: Marty Fuhry Date: Mon, 12 Feb 2024 18:20:31 -0500 Subject: [PATCH 16/18] Moves http client to global private place for reuse --- .../image_providers/immich_local_image_provider.dart | 5 +++-- .../image_providers/immich_remote_image_provider.dart | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/mobile/lib/modules/asset_viewer/image_providers/immich_local_image_provider.dart b/mobile/lib/modules/asset_viewer/image_providers/immich_local_image_provider.dart index 69ecbb29eb890..8624d6b206038 100644 --- a/mobile/lib/modules/asset_viewer/image_providers/immich_local_image_provider.dart +++ b/mobile/lib/modules/asset_viewer/image_providers/immich_local_image_provider.dart @@ -86,12 +86,13 @@ class ImmichLocalImageProvider extends ImageProvider { final buffer = await ui.ImmutableBuffer.fromFilePath(file.path); final codec = await decode(buffer); yield codec; + } catch (error) { + throw StateError("Loading asset ${asset.fileName} failed"); + } finally { if (Platform.isIOS) { // Clean up this file await file.delete(); } - } catch (error) { - throw StateError("Loading asset ${asset.fileName} failed"); } } } 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 38a9f8bd54167..9f9af7aded7f0 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 @@ -12,6 +12,9 @@ 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; + /// The remote image provider class ImmichRemoteImageProvider extends ImageProvider { /// The [Asset.remoteId] of the asset to fetch @@ -21,9 +24,6 @@ class ImmichRemoteImageProvider extends ImageProvider { // smallest version of the remote image final bool isThumbnail; - /// Our HTTP client to make the request - final _httpClient = HttpClient()..autoUncompress = false; - ImmichRemoteImageProvider({ required this.assetId, this.isThumbnail = false, From 17b6e0250a2c5722d20bde72f8644f2397c22a47 Mon Sep 17 00:00:00 2001 From: Marty Fuhry Date: Tue, 13 Feb 2024 08:31:10 -0500 Subject: [PATCH 17/18] Got rid of usePreview for local image providers since we always show a thumbnail anyway first --- .../immich_local_image_provider.dart | 27 ++++++++----------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/mobile/lib/modules/asset_viewer/image_providers/immich_local_image_provider.dart b/mobile/lib/modules/asset_viewer/image_providers/immich_local_image_provider.dart index 8624d6b206038..fc4518c613709 100644 --- a/mobile/lib/modules/asset_viewer/image_providers/immich_local_image_provider.dart +++ b/mobile/lib/modules/asset_viewer/image_providers/immich_local_image_provider.dart @@ -39,28 +39,23 @@ class ImmichLocalImageProvider extends ImageProvider { ); } - //bool get _useOriginal => AppSettingsEnum.loadOriginal.defaultValue; - bool get _loadPreview => AppSettingsEnum.loadPreview.defaultValue; - // Streams in each stage of the image as we ask for it Stream _codec( Asset key, ImageDecoderCallback decode, StreamController chunkEvents, ) async* { - if (_loadPreview) { - // Load a small thumbnail - final thumbBytes = await asset.local?.thumbnailDataWithSize( - const ThumbnailSize.square(256), - quality: 80, - ); - if (thumbBytes != null) { - final buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes); - final codec = await decode(buffer); - yield codec; - } else { - debugPrint("Loading thumb for ${asset.fileName} failed"); - } + // Load a small thumbnail + final thumbBytes = await asset.local?.thumbnailDataWithSize( + const ThumbnailSize.square(256), + quality: 80, + ); + if (thumbBytes != null) { + final buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes); + final codec = await decode(buffer); + yield codec; + } else { + debugPrint("Loading thumb for ${asset.fileName} failed"); } if (asset.isImage) { From 21ea5d8d85d90d7a751ae067736a1d2f9edd4288 Mon Sep 17 00:00:00 2001 From: Marty Fuhry Date: Tue, 13 Feb 2024 08:57:07 -0500 Subject: [PATCH 18/18] linter --- .../image_providers/immich_local_image_provider.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/mobile/lib/modules/asset_viewer/image_providers/immich_local_image_provider.dart b/mobile/lib/modules/asset_viewer/image_providers/immich_local_image_provider.dart index fc4518c613709..4c1e9fc5c8f34 100644 --- a/mobile/lib/modules/asset_viewer/image_providers/immich_local_image_provider.dart +++ b/mobile/lib/modules/asset_viewer/image_providers/immich_local_image_provider.dart @@ -6,7 +6,6 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/painting.dart'; -import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; import 'package:immich_mobile/shared/models/asset.dart'; import 'package:photo_manager/photo_manager.dart';