diff --git a/mobile/lib/modules/album/ui/album_thumbnail_card.dart b/mobile/lib/modules/album/ui/album_thumbnail_card.dart index 880312322c..96cb9b8f7e 100644 --- a/mobile/lib/modules/album/ui/album_thumbnail_card.dart +++ b/mobile/lib/modules/album/ui/album_thumbnail_card.dart @@ -4,6 +4,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/shared/models/album.dart'; import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/shared/ui/immich_image.dart'; +import 'package:immich_mobile/shared/ui/immich_thumbnail.dart'; class AlbumThumbnailCard extends StatelessWidget { final Function()? onTap; @@ -45,7 +46,7 @@ class AlbumThumbnailCard extends StatelessWidget { ); } - buildAlbumThumbnail() => ImmichImage.thumbnail( + buildAlbumThumbnail() => ImmichThumbnail(asset: 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 f70c706f35..5a27def4c9 100644 --- a/mobile/lib/modules/album/ui/shared_album_thumbnail_image.dart +++ b/mobile/lib/modules/album/ui/shared_album_thumbnail_image.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/shared/models/asset.dart'; -import 'package:immich_mobile/shared/ui/immich_image.dart'; +import 'package:immich_mobile/shared/ui/immich_thumbnail.dart'; class SharedAlbumThumbnailImage extends HookConsumerWidget { final Asset asset; @@ -16,8 +16,8 @@ class SharedAlbumThumbnailImage extends HookConsumerWidget { }, child: Stack( children: [ - ImmichImage.thumbnail( - asset, + ImmichThumbnail( + asset: 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 2e826e86da..b234c719c4 100644 --- a/mobile/lib/modules/album/views/sharing_page.dart +++ b/mobile/lib/modules/album/views/sharing_page.dart @@ -13,6 +13,7 @@ import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/shared/providers/user.provider.dart'; import 'package:immich_mobile/shared/ui/immich_app_bar.dart'; import 'package:immich_mobile/shared/ui/immich_image.dart'; +import 'package:immich_mobile/shared/ui/immich_thumbnail.dart'; @RoutePage() class SharingPage extends HookConsumerWidget { @@ -72,8 +73,8 @@ class SharingPage extends HookConsumerWidget { contentPadding: const EdgeInsets.symmetric(horizontal: 12), leading: ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(8)), - child: ImmichImage.thumbnail( - album.thumbnail.value, + child: ImmichThumbnail( + asset: 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 index 4c1e9fc5c8..3094c69076 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 @@ -11,7 +11,7 @@ import 'package:photo_manager/photo_manager.dart'; /// The local image provider for an asset /// Only viable -class ImmichLocalImageProvider extends ImageProvider { +class ImmichLocalImageProvider extends ImageProvider { final Asset asset; ImmichLocalImageProvider({ @@ -21,15 +21,18 @@ class ImmichLocalImageProvider extends ImageProvider { /// 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); + Future obtainKey(ImageConfiguration configuration) { + return SynchronousFuture(this); } @override - ImageStreamCompleter loadImage(Asset key, ImageDecoderCallback decode) { + ImageStreamCompleter loadImage( + ImmichLocalImageProvider key, + ImageDecoderCallback decode, + ) { final chunkEvents = StreamController(); return MultiImageStreamCompleter( - codec: _codec(key, decode, chunkEvents), + codec: _codec(key.asset, decode, chunkEvents), scale: 1.0, chunkEvents: chunkEvents.stream, informationCollector: () sync* { @@ -82,11 +85,6 @@ class ImmichLocalImageProvider extends ImageProvider { yield codec; } catch (error) { throw StateError("Loading asset ${asset.fileName} failed"); - } finally { - if (Platform.isIOS) { - // Clean up this file - await file.delete(); - } } } } diff --git a/mobile/lib/modules/asset_viewer/image_providers/immich_local_thumbnail_provider.dart b/mobile/lib/modules/asset_viewer/image_providers/immich_local_thumbnail_provider.dart new file mode 100644 index 0000000000..6a9ba9a508 --- /dev/null +++ b/mobile/lib/modules/asset_viewer/image_providers/immich_local_thumbnail_provider.dart @@ -0,0 +1,86 @@ +import 'dart:async'; +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 ImmichLocalThumbnailProvider extends ImageProvider { + final Asset asset; + final int height; + final int width; + + ImmichLocalThumbnailProvider({ + required this.asset, + this.height = 256, + this.width = 256, + }) : 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(32), + quality: 75, + ); + 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"); + } + + final normalThumbBytes = await asset.local + ?.thumbnailDataWithSize(ThumbnailSize(width, height)); + if (normalThumbBytes == null) { + throw StateError( + "Loading thumb for local photo ${asset.fileName} failed", + ); + } + final buffer = await ui.ImmutableBuffer.fromUint8List(normalThumbBytes); + final codec = await decode(buffer); + yield codec; + + chunkEvents.close(); + } + + @override + bool operator ==(Object other) { + if (other is! ImmichLocalThumbnailProvider) 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 index 9f9af7aded..0552d3fecc 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 @@ -16,7 +16,8 @@ import 'package:immich_mobile/utils/image_url_builder.dart'; final _httpClient = HttpClient()..autoUncompress = false; /// The remote image provider -class ImmichRemoteImageProvider extends ImageProvider { +class ImmichRemoteImageProvider + extends ImageProvider { /// The [Asset.remoteId] of the asset to fetch final String assetId; @@ -32,16 +33,20 @@ class ImmichRemoteImageProvider extends ImageProvider { /// 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'); + Future obtainKey( + ImageConfiguration configuration, + ) { + return SynchronousFuture(this); } @override - ImageStreamCompleter loadImage(String key, ImageDecoderCallback decode) { - final id = key.split(',').first; + ImageStreamCompleter loadImage( + ImmichRemoteImageProvider key, + ImageDecoderCallback decode, + ) { final chunkEvents = StreamController(); return MultiImageStreamCompleter( - codec: _codec(id, decode, chunkEvents), + codec: _codec(key, decode, chunkEvents), scale: 1.0, chunkEvents: chunkEvents.stream, ); @@ -61,14 +66,14 @@ class ImmichRemoteImageProvider extends ImageProvider { // Streams in each stage of the image as we ask for it Stream _codec( - String key, + ImmichRemoteImageProvider key, ImageDecoderCallback decode, StreamController chunkEvents, ) async* { // Load a preview to the chunk events - if (_loadPreview || isThumbnail) { + if (_loadPreview || key.isThumbnail) { final preview = getThumbnailUrlForRemoteId( - assetId, + key.assetId, type: api.ThumbnailFormat.WEBP, ); @@ -80,14 +85,14 @@ class ImmichRemoteImageProvider extends ImageProvider { } // Guard thumnbail rendering - if (isThumbnail) { + if (key.isThumbnail) { await chunkEvents.close(); return; } // Load the higher resolution version of the image final url = getThumbnailUrlForRemoteId( - assetId, + key.assetId, type: api.ThumbnailFormat.JPEG, ); final codec = await _loadFromUri(Uri.parse(url), decode, chunkEvents); @@ -96,7 +101,7 @@ class ImmichRemoteImageProvider extends ImageProvider { // Load the final remote image if (_useOriginal) { // Load the original image - final url = getImageUrlFromId(assetId); + final url = getImageUrlFromId(key.assetId); final codec = await _loadFromUri(Uri.parse(url), decode, chunkEvents); yield codec; } @@ -137,7 +142,7 @@ class ImmichRemoteImageProvider extends ImageProvider { bool operator ==(Object other) { if (other is! ImmichRemoteImageProvider) return false; if (identical(this, other)) return true; - return assetId == other.assetId; + return assetId == other.assetId && isThumbnail == other.isThumbnail; } @override 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 index 8332d8d3d7..59783f2e4f 100644 --- 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 @@ -13,7 +13,7 @@ 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 { +class ImmichRemoteThumbnailProvider extends ImageProvider { /// The [Asset.remoteId] of the asset to fetch final String assetId; @@ -27,12 +27,12 @@ class ImmichRemoteThumbnailProvider extends ImageProvider { /// 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); + Future obtainKey(ImageConfiguration configuration) { + return SynchronousFuture(this); } @override - ImageStreamCompleter loadImage(String key, ImageDecoderCallback decode) { + ImageStreamCompleter loadImage(ImmichRemoteThumbnailProvider key, ImageDecoderCallback decode) { final chunkEvents = StreamController(); return MultiImageStreamCompleter( codec: _codec(key, decode, chunkEvents), @@ -43,13 +43,13 @@ class ImmichRemoteThumbnailProvider extends ImageProvider { // Streams in each stage of the image as we ask for it Stream _codec( - String key, + ImmichRemoteThumbnailProvider key, ImageDecoderCallback decode, StreamController chunkEvents, ) async* { // Load a preview to the chunk events final preview = getThumbnailUrlForRemoteId( - assetId, + key.assetId, type: api.ThumbnailFormat.WEBP, ); diff --git a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart index 59dfef8164..4235277beb 100644 --- a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart +++ b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart @@ -1,8 +1,8 @@ import 'dart:io'; import 'dart:math'; +import 'dart:ui' as ui; import 'package:easy_localization/easy_localization.dart'; import 'package:auto_route/auto_route.dart'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; @@ -10,6 +10,7 @@ import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; 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/image_providers/immich_remote_image_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/show_controls.provider.dart'; @@ -33,6 +34,7 @@ import 'package:immich_mobile/modules/settings/services/app_settings.service.dar import 'package:immich_mobile/shared/providers/server_info.provider.dart'; import 'package:immich_mobile/shared/providers/user.provider.dart'; import 'package:immich_mobile/shared/ui/immich_image.dart'; +import 'package:immich_mobile/shared/ui/immich_thumbnail.dart'; import 'package:immich_mobile/shared/ui/immich_toast.dart'; import 'package:immich_mobile/shared/ui/photo_view/photo_view_gallery.dart'; import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_computed_scale.dart'; @@ -481,15 +483,9 @@ class GalleryViewerPage extends HookConsumerWidget { ), child: ClipRRect( borderRadius: BorderRadius.circular(4), - child: CachedNetworkImage( + child: Image( fit: BoxFit.cover, - imageUrl: - '${Store.get(StoreKey.serverEndpoint)}/asset/thumbnail/$assetId', - httpHeaders: { - "x-immich-user-token": Store.get(StoreKey.accessToken), - }, - errorWidget: (context, url, error) => - const Icon(Icons.image_not_supported_outlined), + image: ImmichRemoteImageProvider(assetId: assetId!), ), ), ), @@ -728,9 +724,15 @@ 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, + loadingBuilder: (context, event, index) => BackdropFilter( + filter: ui.ImageFilter.blur( + sigmaX: 0.2, + sigmaY: 0.2, + ), + child: ImmichThumbnail( + asset: asset(), + fit: BoxFit.contain, + ), ), pageController: controller, scrollPhysics: isZoomed.value 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 73b31617f1..392cae7afb 100644 --- a/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart +++ b/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart @@ -5,6 +5,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/ui/immich_image.dart'; +import 'package:immich_mobile/shared/ui/immich_thumbnail.dart'; import 'package:immich_mobile/utils/storage_indicator.dart'; import 'package:isar/isar.dart'; @@ -134,10 +135,10 @@ class ThumbnailImage extends StatelessWidget { tag: isFromDto ? '${asset.remoteId}-$heroOffset' : asset.id + heroOffset, - child: ImmichImage.thumbnail( - asset, - height: 300, - width: 300, + child: ImmichThumbnail( + asset: asset, + height: 250, + width: 250, ), ), ); diff --git a/mobile/lib/modules/memories/ui/memory_card.dart b/mobile/lib/modules/memories/ui/memory_card.dart index 7c998e8f52..af3cfc457e 100644 --- a/mobile/lib/modules/memories/ui/memory_card.dart +++ b/mobile/lib/modules/memories/ui/memory_card.dart @@ -6,6 +6,7 @@ import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart' 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/shared/ui/immich_thumbnail.dart'; class MemoryCard extends StatelessWidget { final Asset asset; @@ -42,9 +43,8 @@ class MemoryCard extends StatelessWidget { child: Container( decoration: BoxDecoration( image: DecorationImage( - image: ImmichImage.imageProvider( + image: ImmichThumbnail.imageProvider( asset: asset, - isThumbnail: true, ), fit: BoxFit.cover, ), diff --git a/mobile/lib/modules/memories/views/memory_page.dart b/mobile/lib/modules/memories/views/memory_page.dart index 199af835c9..4312a7ad2e 100644 --- a/mobile/lib/modules/memories/views/memory_page.dart +++ b/mobile/lib/modules/memories/views/memory_page.dart @@ -10,6 +10,7 @@ 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:immich_mobile/shared/ui/immich_thumbnail.dart'; @RoutePage() class MemoryPage extends HookConsumerWidget { @@ -120,9 +121,8 @@ class MemoryPage extends HookConsumerWidget { context, ), precacheImage( - ImmichImage.imageProvider( + ImmichThumbnail.imageProvider( asset: asset, - isThumbnail: true, ), context, ), diff --git a/mobile/lib/shared/ui/immich_image.dart b/mobile/lib/shared/ui/immich_image.dart index 280f7de170..5effbfb00c 100644 --- a/mobile/lib/shared/ui/immich_image.dart +++ b/mobile/lib/shared/ui/immich_image.dart @@ -19,8 +19,6 @@ class ImmichImage extends StatelessWidget { this.height, this.fit = BoxFit.cover, this.placeholder = const ThumbnailPlaceholder(), - this.isThumbnail = false, - this.thumbnailSize = 250, super.key, }); @@ -29,32 +27,6 @@ class ImmichImage extends StatelessWidget { final double? width; final double? height; final BoxFit fit; - 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, - placeholder: ThumbnailPlaceholder( - height: thumbnailSize.toDouble(), - width: thumbnailSize.toDouble(), - ), - thumbnailSize: thumbnailSize, - ); - } // Helper function to return the image provider for the asset // either by using the asset ID or the asset itself @@ -66,34 +38,29 @@ class ImmichImage extends StatelessWidget { 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) { + print('using remote for $assetId'); return ImmichRemoteImageProvider( assetId: assetId!, - isThumbnail: isThumbnail, + isThumbnail: false, ); } - if (useLocal(asset) && isThumbnail) { - return AssetEntityImageProvider( - asset.local!, - isOriginal: false, - thumbnailSize: ThumbnailSize.square(thumbnailSize), - ); - } else if (useLocal(asset) && !isThumbnail) { + if (useLocal(asset)) { + print('using local for ${asset.localId}'); return ImmichLocalImageProvider( asset: asset, ); } else { + print('using remote for ${asset.localId}'); return ImmichRemoteImageProvider( assetId: asset.remoteId!, - isThumbnail: isThumbnail, + isThumbnail: false, ); } } @@ -105,15 +72,11 @@ class ImmichImage extends StatelessWidget { Widget build(BuildContext context) { if (asset == null) { return Container( - decoration: const BoxDecoration( - color: Colors.grey, - ), - child: SizedBox( - width: width, - height: height, - child: const Center( - child: Icon(Icons.no_photography), - ), + color: Colors.grey, + width: width, + height: height, + child: const Center( + child: Icon(Icons.no_photography), ), ); } @@ -131,7 +94,6 @@ class ImmichImage extends StatelessWidget { }, image: ImmichImage.imageProvider( asset: asset, - isThumbnail: isThumbnail, ), width: width, height: height, diff --git a/mobile/lib/shared/ui/immich_thumbnail.dart b/mobile/lib/shared/ui/immich_thumbnail.dart new file mode 100644 index 0000000000..33e730fe19 --- /dev/null +++ b/mobile/lib/shared/ui/immich_thumbnail.dart @@ -0,0 +1,118 @@ +import 'dart:math'; + +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/image_providers/immich_local_image_provider.dart'; +import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_local_thumbnail_provider.dart'; +import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_remote_image_provider.dart'; +import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_placeholder.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; +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'; + +class ImmichThumbnail extends StatelessWidget { + const ImmichThumbnail({ + this.asset, + this.width = 250, + this.height = 250, + this.fit = BoxFit.cover, + this.placeholder, + super.key, + }); + + final Asset? asset; + final Widget? placeholder; + final double width; + final double height; + final BoxFit fit; + + // 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, + int thumbnailSize = 256, + }) { + if (asset == null && assetId == null) { + throw Exception('Must supply either asset or assetId'); + } + + if (asset == null) { + return ImmichRemoteImageProvider( + assetId: assetId!, + isThumbnail: true, + ); + } + + if (useLocal(asset)) { + return ImmichLocalThumbnailProvider( + asset: asset, + height: thumbnailSize, + width: thumbnailSize, + ); + } else { + return ImmichRemoteImageProvider( + assetId: asset.remoteId!, + isThumbnail: true, + ); + } + } + + static bool useLocal(Asset asset) => !asset.isRemote || asset.isLocal; + + @override + Widget build(BuildContext context) { + if (asset == null) { + return Container( + color: Colors.grey, + width: width, + height: height, + child: const Center( + child: Icon(Icons.no_photography), + ), + ); + } + + return OctoImage( + fadeInDuration: const Duration(milliseconds: 0), + fadeOutDuration: const Duration(milliseconds: 100), + placeholderBuilder: (context) { + return placeholder ?? + ThumbnailPlaceholder( + height: height, + width: width, + ); + }, + image: ImmichThumbnail.imageProvider( + asset: asset, + ), + 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!", + ); + } else { + debugPrint( + "Error getting thumb for assetId=${asset?.localId}: $error", + ); + } + return Icon( + Icons.image_not_supported_outlined, + color: context.primaryColor, + ); + }, + ); + } +}