From 9b4a770b9de28733bd2bd4a90760f5c5aa433a01 Mon Sep 17 00:00:00 2001 From: martyfuhry Date: Tue, 13 Feb 2024 16:30:32 -0500 Subject: [PATCH] refactor(mobile): Immich image provider (#7016) * Adds image provider * uses image provider * wip load preview * wip everything but activity asset thumbnail needs some help with a remote id * Immich provider used in gallery * First draft of the immich image provider, working nicely! * Removed OriginalImageProvider * Fixes for thumbnails * 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> * Uses octoimage for fade in and placeholders * fixes thumbnails, removes unused values, adds better thumbnail size * removes thumbhash support for now * Forgot one thumbhash removal * Use big thumbnail for local image on ios * 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 * Moves http client to global private place for reuse * Got rid of usePreview for local image providers since we always show a thumbnail anyway first * linter --------- Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: Alex Co-authored-by: Marty Fuhry --- mobile/ios/Podfile.lock | 2 +- .../activities/widgets/activity_tile.dart | 7 +- .../album/ui/album_thumbnail_card.dart | 2 +- .../ui/shared_album_thumbnail_image.dart | 6 +- .../lib/modules/album/views/sharing_page.dart | 2 +- .../immich_local_image_provider.dart | 106 +++++++ .../immich_remote_image_provider.dart | 145 ++++++++++ .../immich_remote_thumbnail_provider.dart | 104 +++++++ .../asset_viewer/views/gallery_viewer.dart | 95 +------ .../home/ui/asset_grid/thumbnail_image.dart | 4 +- .../lib/modules/memories/ui/memory_card.dart | 5 - .../lib/modules/memories/ui/memory_lane.dart | 2 - .../modules/memories/views/memory_page.dart | 25 +- mobile/lib/routing/router.gr.dart | 30 +- .../lib/shared/cache/custom_image_cache.dart | 11 +- .../shared/cache/original_image_provider.dart | 73 ----- mobile/lib/shared/models/asset.dart | 12 +- mobile/lib/shared/ui/immich_image.dart | 264 +++++++----------- mobile/lib/utils/image_url_builder.dart | 6 +- mobile/pubspec.lock | 2 +- mobile/pubspec.yaml | 1 + 21 files changed, 540 insertions(+), 364 deletions(-) create mode 100644 mobile/lib/modules/asset_viewer/image_providers/immich_local_image_provider.dart create mode 100644 mobile/lib/modules/asset_viewer/image_providers/immich_remote_image_provider.dart create mode 100644 mobile/lib/modules/asset_viewer/image_providers/immich_remote_thumbnail_provider.dart delete mode 100644 mobile/lib/shared/cache/original_image_provider.dart diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 6081988b7a..a9ac5b3381 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/activities/widgets/activity_tile.dart b/mobile/lib/modules/activities/widgets/activity_tile.dart index da5dacd58a..cb434d22de 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 { @@ -106,7 +106,10 @@ class _ActivityAssetThumbnail extends StatelessWidget { decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(4)), image: DecorationImage( - image: ImmichImage.remoteThumbnailProviderForId(assetId), + image: ImmichRemoteImageProvider( + assetId: assetId, + isThumbnail: true, + ), fit: BoxFit.cover, ), ), diff --git a/mobile/lib/modules/album/ui/album_thumbnail_card.dart b/mobile/lib/modules/album/ui/album_thumbnail_card.dart index 0b5854fa50..880312322c 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/album/ui/shared_album_thumbnail_image.dart b/mobile/lib/modules/album/ui/shared_album_thumbnail_image.dart index 57dd787719..f70c706f35 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 dcaf732c8f..2e826e86da 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_local_image_provider.dart b/mobile/lib/modules/asset_viewer/image_providers/immich_local_image_provider.dart new file mode 100644 index 0000000000..4c1e9fc5c8 --- /dev/null +++ b/mobile/lib/modules/asset_viewer/image_providers/immich_local_image_provider.dart @@ -0,0 +1,106 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:cached_network_image/cached_network_image.dart'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/painting.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 +class ImmichLocalImageProvider extends ImageProvider { + final Asset 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. + @override + Future obtainKey(ImageConfiguration configuration) { + return SynchronousFuture(asset); + } + + @override + ImageStreamCompleter loadImage(Asset key, ImageDecoderCallback decode) { + final chunkEvents = StreamController(); + return MultiImageStreamCompleter( + codec: _codec(key, decode, chunkEvents), + scale: 1.0, + chunkEvents: chunkEvents.stream, + informationCollector: () sync* { + yield ErrorDescription(asset.fileName); + }, + ); + } + + // Streams in each stage of the image as we ask for it + Stream _codec( + Asset key, + ImageDecoderCallback decode, + StreamController chunkEvents, + ) async* { + // 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) { + /// 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", + ); + } + 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 { + 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(); + } + } + } + } + + chunkEvents.close(); + } + + @override + bool operator ==(Object other) { + if (other is! ImmichLocalImageProvider) return false; + if (identical(this, other)) return true; + return asset == other.asset; + } + + @override + int get hashCode => asset.hashCode; +} 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 0000000000..9f9af7aded --- /dev/null +++ b/mobile/lib/modules/asset_viewer/image_providers/immich_remote_image_provider.dart @@ -0,0 +1,145 @@ +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'; + +/// 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 + final String assetId; + + // If this is a thumbnail, we stop at loading the + // smallest version of the remote image + final bool isThumbnail; + + 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,$isThumbnail'); + } + + @override + ImageStreamCompleter loadImage(String key, ImageDecoderCallback decode) { + final id = key.split(',').first; + final chunkEvents = StreamController(); + return MultiImageStreamCompleter( + codec: _codec(id, decode, chunkEvents), + scale: 1.0, + chunkEvents: chunkEvents.stream, + ); + } + + /// Whether to show the original file or load a compressed version + bool get _useOriginal => Store.get( + AppSettingsEnum.loadOriginal.storeKey, + AppSettingsEnum.loadOriginal.defaultValue, + ); + + /// Whether to load the preview thumbnail first or not + 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( + String key, + ImageDecoderCallback decode, + StreamController chunkEvents, + ) async* { + // Load a preview to the chunk events + if (_loadPreview || isThumbnail) { + final preview = getThumbnailUrlForRemoteId( + assetId, + type: api.ThumbnailFormat.WEBP, + ); + + yield await _loadFromUri( + Uri.parse(preview), + decode, + chunkEvents, + ); + } + + // Guard thumnbail rendering + if (isThumbnail) { + await chunkEvents.close(); + 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); + 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); + yield codec; + } + await chunkEvents.close(); + } + + // Loads the codec from the URI and sends the events to the [chunkEvents] stream + Future _loadFromUri( + Uri uri, + ImageDecoderCallback decode, + StreamController chunkEvents, + ) async { + final request = await _httpClient.getUrl(uri); + request.headers.add( + 'x-immich-user-token', + Store.get(StoreKey.accessToken), + ); + final response = await request.close(); + // Chunks of the completed image can be shown + final data = await consolidateHttpClientResponseBytes( + response, + onBytesReceived: (cumulative, total) { + chunkEvents.add( + ImageChunkEvent( + cumulativeBytesLoaded: cumulative, + expectedTotalBytes: total, + ), + ); + }, + ); + + // Decode the response + final buffer = await ui.ImmutableBuffer.fromUint8List(data); + return decode(buffer); + } + + @override + bool operator ==(Object other) { + if (other is! ImmichRemoteImageProvider) return false; + if (identical(this, other)) return true; + return assetId == other.assetId; + } + + @override + int get hashCode => assetId.hashCode; +} 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 0000000000..8332d8d3d7 --- /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/modules/asset_viewer/views/gallery_viewer.dart b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart index 1903a7f19c..4c702d4c0a 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 = @@ -135,53 +131,18 @@ 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 + debugPrint('Error precaching next image: $exception, $stackTrace'); } if (index < totalAssets && index >= 0) { final asset = loadAsset(index); - for (final imageProvider in allImageProviders(asset)) { - precacheImage(imageProvider, context, onError: onError); - } + precacheImage( + ImmichImage.imageProvider(asset: asset), + context, + onError: onError, + ); } } @@ -765,6 +726,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 @@ -781,47 +746,11 @@ 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); - final ImageProvider provider = finalImageProvider(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 6454d9ba24..6b0e83e527 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 364a88b47c..b5f6ab46e0 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 1a47d9b661..6b11e668db 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/modules/memories/views/memory_page.dart b/mobile/lib/modules/memories/views/memory_page.dart index 9f6fd3925c..199af835c9 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.precacheAsset( - asset, + precaches.addAll([ + precacheImage( + ImmichImage.imageProvider( + asset: asset, + ), context, - type: api.ThumbnailFormat.WEBP, - size: 2048, ), - ); - precaches.add( - ImmichImage.precacheAsset( - asset, + precacheImage( + ImmichImage.imageProvider( + asset: asset, + isThumbnail: true, + ), context, - type: api.ThumbnailFormat.JPEG, - size: 2048, ), - ); - + ]); await Future.wait(precaches); } diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index fa9db9e695..f6968dafe5 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/cache/custom_image_cache.dart b/mobile/lib/shared/cache/custom_image_cache.dart index 650ab81c6b..79338cbda5 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/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 @@ -34,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; @@ -44,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 @@ -60,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); } diff --git a/mobile/lib/shared/cache/original_image_provider.dart b/mobile/lib/shared/cache/original_image_provider.dart deleted file mode 100644 index e06d815a49..0000000000 --- a/mobile/lib/shared/cache/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/models/asset.dart b/mobile/lib/shared/models/asset.dart index 10761616af..afd49adc6a 100644 --- a/mobile/lib/shared/models/asset.dart +++ b/mobile/lib/shared/models/asset.dart @@ -437,17 +437,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"}, diff --git a/mobile/lib/shared/ui/immich_image.dart b/mobile/lib/shared/ui/immich_image.dart index 18f5147e83..21418d5274 100644 --- a/mobile/lib/shared/ui/immich_image.dart +++ b/mobile/lib/shared/ui/immich_image.dart @@ -1,16 +1,16 @@ -import 'package:cached_network_image/cached_network_image.dart'; +import 'dart:math'; + 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/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:immich_mobile/utils/image_url_builder.dart'; +import 'package:octo_image/octo_image.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 { const ImmichImage( this.asset, { @@ -18,23 +18,89 @@ class ImmichImage extends StatelessWidget { this.height, this.fit = BoxFit.cover, this.useGrayBoxPlaceholder = false, - this.useProgressIndicator = false, - this.type = api.ThumbnailFormat.WEBP, - this.preferredLocalAssetSize = 250, + this.isThumbnail = false, + this.thumbnailSize = 250, super.key, }); + final Asset? asset; final bool useGrayBoxPlaceholder; - final bool useProgressIndicator; final double? width; final double? height; final BoxFit fit; - final api.ThumbnailFormat type; - final int preferredLocalAssetSize; + final bool isThumbnail; + final int thumbnailSize; + /// Factory constructor to use the thumbnail variant + factory ImmichImage.thumbnail( + Asset? asset, { + BoxFit fit = BoxFit.cover, + 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, + fit: fit, + width: width, + height: height, + useGrayBoxPlaceholder: true, + thumbnailSize: thumbnailSize, + ); + } + + // 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'); + } + + if (asset == null) { + return ImmichRemoteImageProvider( + assetId: assetId!, + isThumbnail: isThumbnail, + ); + } + + if (useLocal(asset) && isThumbnail) { + return AssetEntityImageProvider( + asset.local!, + isOriginal: false, + thumbnailSize: ThumbnailSize.square(thumbnailSize), + ); + } else if (useLocal(asset) && !isThumbnail) { + return ImmichLocalImageProvider( + asset: asset, + ); + } else { + return ImmichRemoteImageProvider( + assetId: asset.remoteId!, + isThumbnail: isThumbnail, + ); + } + } + + static bool useLocal(Asset asset) => + !asset.isRemote || + asset.isLocal && !Store.get(StoreKey.preferRemoteImage, false); @override Widget build(BuildContext context) { - if (this.asset == null) { + + if (asset == null) { return Container( decoration: const BoxDecoration( color: Colors.grey, @@ -48,96 +114,39 @@ 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(), - ), - ], + return OctoImage( + fadeInDuration: const Duration(milliseconds: 0), + fadeOutDuration: const Duration(milliseconds: 400), + placeholderBuilder: (context) { + if (useGrayBoxPlaceholder) { + // Use the gray box placeholder + return const SizedBox.expand( + child: DecoratedBox( + decoration: BoxDecoration(color: Colors.grey), + ), ); - }, - 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), + } + // No placeholder + return const SizedBox(); + }, + image: ImmichImage.imageProvider( + asset: asset, + isThumbnail: isThumbnail, + ), 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) { - // Show loading if desired - return Stack( - children: [ - if (useGrayBoxPlaceholder) - const SizedBox.square( - dimension: 250, - child: DecoratedBox( - decoration: BoxDecoration(color: Colors.grey), - ), - ), - if (useProgressIndicator) - Transform.scale( - scale: 2, - child: Center( - child: CircularProgressIndicator.adaptive( - strokeWidth: 1, - value: downloadProgress.progress, - ), - ), - ), - ], - ); - }, - 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, @@ -146,65 +155,4 @@ 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, - BuildContext context, { - 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, - ); - } - } - - static bool useLocal(Asset asset) => - !asset.isRemote || - asset.isLocal && !Store.get(StoreKey.preferRemoteImage, false); } diff --git a/mobile/lib/utils/image_url_builder.dart b/mobile/lib/utils/image_url_builder.dart index 6fef55c2f7..9f783c80d8 100644 --- a/mobile/lib/utils/image_url_builder.dart +++ b/mobile/lib/utils/image_url_builder.dart @@ -56,7 +56,11 @@ 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) { + return '${Store.get(StoreKey.serverEndpoint)}/asset/file/$id?isThumb=false'; } String getImageCacheKey(final Asset asset) { diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index de7bbea5ca..ffa57f826b 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 095566cf46..ddfed62dad 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 + octo_image: ^2.0.0 openapi: path: openapi