diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/ThumbnailsImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/ThumbnailsImpl.kt index 59f19e69d7..9426ba43dc 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/ThumbnailsImpl.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/ThumbnailsImpl.kt @@ -221,8 +221,8 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi { return 1 shl max( 0, floor( min( - log2(fullWidth / (2.0 * reqWidth)), - log2(fullHeight / (2.0 * reqHeight)), + log2(fullWidth / reqWidth.toDouble()), + log2(fullHeight / reqHeight.toDouble()), ) ).toInt() ) diff --git a/mobile/lib/constants/constants.dart b/mobile/lib/constants/constants.dart index 616a306d94..0cfc0c57e3 100644 --- a/mobile/lib/constants/constants.dart +++ b/mobile/lib/constants/constants.dart @@ -26,7 +26,7 @@ const String kDownloadGroupLivePhoto = 'group_livephoto'; // Timeline constants const int kTimelineNoneSegmentSize = 120; -const int kTimelineAssetLoadBatchSize = 256; +const int kTimelineAssetLoadBatchSize = 1024; const int kTimelineAssetLoadOppositeSize = 64; // Widget keys diff --git a/mobile/lib/infrastructure/loaders/image_request.dart b/mobile/lib/infrastructure/loaders/image_request.dart index 4614b61930..d839b8bdf6 100644 --- a/mobile/lib/infrastructure/loaders/image_request.dart +++ b/mobile/lib/infrastructure/loaders/image_request.dart @@ -5,7 +5,6 @@ import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter/foundation.dart'; import 'package:ffi/ffi.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/providers/image/cache/remote_image_cache_manager.dart'; @@ -41,41 +40,47 @@ abstract class ImageRequest { Future _fromPlatformImage(Map info) async { final address = info['pointer']; if (address == null) { - if (!kReleaseMode) { - debugPrint('Platform image request for $requestId was cancelled'); - } return null; } final pointer = Pointer.fromAddress(address); + if (_isCancelled) { + malloc.free(pointer); + return null; + } + + final int actualWidth; + final int actualHeight; + final int actualSize; + final ui.ImmutableBuffer buffer; try { - if (_isCancelled) { - return null; - } - - final actualWidth = info['width']!; - final actualHeight = info['height']!; - final actualSize = actualWidth * actualHeight * 4; - - final buffer = await ImmutableBuffer.fromUint8List(pointer.asTypedList(actualSize)); - if (_isCancelled) { - return null; - } - - final descriptor = ui.ImageDescriptor.raw( - buffer, - width: actualWidth, - height: actualHeight, - pixelFormat: ui.PixelFormat.rgba8888, - ); - final codec = await descriptor.instantiateCodec(); - if (_isCancelled) { - return null; - } - - return await codec.getNextFrame(); + actualWidth = info['width']!; + actualHeight = info['height']!; + actualSize = actualWidth * actualHeight * 4; + buffer = await ImmutableBuffer.fromUint8List(pointer.asTypedList(actualSize)); } finally { malloc.free(pointer); } + + if (_isCancelled) { + buffer.dispose(); + return null; + } + + final descriptor = ui.ImageDescriptor.raw( + buffer, + width: actualWidth, + height: actualHeight, + pixelFormat: ui.PixelFormat.rgba8888, + ); + final codec = await descriptor.instantiateCodec(); + if (_isCancelled) { + buffer.dispose(); + descriptor.dispose(); + codec.dispose(); + return null; + } + + return await codec.getNextFrame(); } } diff --git a/mobile/lib/infrastructure/loaders/remote_image_request.dart b/mobile/lib/infrastructure/loaders/remote_image_request.dart index ffb7312850..fe62469461 100644 --- a/mobile/lib/infrastructure/loaders/remote_image_request.dart +++ b/mobile/lib/infrastructure/loaders/remote_image_request.dart @@ -2,7 +2,7 @@ part of 'image_request.dart'; class RemoteImageRequest extends ImageRequest { static final log = Logger('RemoteImageRequest'); - static final client = HttpClient()..maxConnectionsPerHost = 32; + static final client = HttpClient()..maxConnectionsPerHost = 16; final RemoteCacheManager? cacheManager; final String uri; final Map headers; diff --git a/mobile/lib/pages/backup/drift_backup_asset_detail.page.dart b/mobile/lib/pages/backup/drift_backup_asset_detail.page.dart index f361261e34..c6ebd34ae1 100644 --- a/mobile/lib/pages/backup/drift_backup_asset_detail.page.dart +++ b/mobile/lib/pages/backup/drift_backup_asset_detail.page.dart @@ -66,7 +66,7 @@ class DriftBackupAssetDetailPage extends ConsumerWidget { ), leading: ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(12)), - child: Thumbnail(asset: asset, size: const Size(64, 64), fit: BoxFit.cover), + child: Thumbnail.fromAsset(asset: asset, size: const Size(64, 64), fit: BoxFit.cover), ), trailing: const Padding(padding: EdgeInsets.only(right: 24, left: 8), child: Icon(Icons.image_search)), onTap: () async { diff --git a/mobile/lib/pages/backup/drift_upload_detail.page.dart b/mobile/lib/pages/backup/drift_upload_detail.page.dart index 36dbe4e128..bececddc7f 100644 --- a/mobile/lib/pages/backup/drift_upload_detail.page.dart +++ b/mobile/lib/pages/backup/drift_upload_detail.page.dart @@ -224,7 +224,7 @@ class FileDetailDialog extends ConsumerWidget { borderRadius: const BorderRadius.all(Radius.circular(12)), ), child: asset != null - ? Thumbnail(asset: asset, size: const Size(512, 512), fit: BoxFit.cover) + ? Thumbnail.fromAsset(asset: asset, size: const Size(128, 128), fit: BoxFit.cover) : null, ), ), diff --git a/mobile/lib/presentation/pages/drift_create_album.page.dart b/mobile/lib/presentation/pages/drift_create_album.page.dart index bac23b45c7..c70c4a0bd7 100644 --- a/mobile/lib/presentation/pages/drift_create_album.page.dart +++ b/mobile/lib/presentation/pages/drift_create_album.page.dart @@ -119,7 +119,7 @@ class _DriftCreateAlbumPageState extends ConsumerState { final asset = selectedAssets.elementAt(index); return GestureDetector( onTap: onBackgroundTapped, - child: Thumbnail(asset: asset), + child: Thumbnail.fromAsset(asset: asset), ); }, childCount: selectedAssets.length), ), diff --git a/mobile/lib/presentation/pages/drift_place.page.dart b/mobile/lib/presentation/pages/drift_place.page.dart index f540cbd46e..c5c0b76988 100644 --- a/mobile/lib/presentation/pages/drift_place.page.dart +++ b/mobile/lib/presentation/pages/drift_place.page.dart @@ -163,7 +163,11 @@ class _PlaceTile extends StatelessWidget { title: Text(place.$1, style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500)), leading: ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(20)), - child: Thumbnail(size: const Size(80, 80), fit: BoxFit.cover, remoteId: place.$2), + child: SizedBox( + width: 80, + height: 80, + child: Thumbnail.remote(remoteId: place.$2, fit: BoxFit.cover), + ), ), ); } diff --git a/mobile/lib/presentation/widgets/album/album_selector.widget.dart b/mobile/lib/presentation/widgets/album/album_selector.widget.dart index aacede789e..e49f2b6804 100644 --- a/mobile/lib/presentation/widgets/album/album_selector.widget.dart +++ b/mobile/lib/presentation/widgets/album/album_selector.widget.dart @@ -458,7 +458,7 @@ class _AlbumList extends ConsumerWidget { leading: album.thumbnailAssetId != null ? ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(15)), - child: SizedBox(width: 80, height: 80, child: Thumbnail(remoteId: album.thumbnailAssetId)), + child: SizedBox(width: 80, height: 80, child: Thumbnail.remote(remoteId: album.thumbnailAssetId!)), ) : SizedBox( width: 80, @@ -577,7 +577,7 @@ class _GridAlbumCard extends ConsumerWidget { child: SizedBox( width: double.infinity, child: album.thumbnailAssetId != null - ? Thumbnail(remoteId: album.thumbnailAssetId) + ? Thumbnail.remote(remoteId: album.thumbnailAssetId!) : Container( color: context.colorScheme.surfaceContainerHighest, child: const Icon(Icons.photo_album_rounded, size: 40, color: Colors.grey), diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart index f1e8c99b2e..6c78cfac3e 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -536,7 +536,7 @@ class _AssetViewerState extends ConsumerState { width: size.width, height: size.height, color: backgroundColor, - child: Thumbnail(asset: asset, fit: BoxFit.contain), + child: Thumbnail.fromAsset(asset: asset, fit: BoxFit.contain), ), ); } diff --git a/mobile/lib/presentation/widgets/images/image_provider.dart b/mobile/lib/presentation/widgets/images/image_provider.dart index 419b077c06..d0428e5013 100644 --- a/mobile/lib/presentation/widgets/images/image_provider.dart +++ b/mobile/lib/presentation/widgets/images/image_provider.dart @@ -150,26 +150,3 @@ ImageProvider getThumbnailImageProvider({BaseAsset? asset, String? remoteId, Siz bool _shouldUseLocalAsset(BaseAsset asset) => asset.hasLocal && (!asset.hasRemote || !AppSetting.get(Setting.preferRemoteImage)); - -ImageInfo? getCachedImage(ImageProvider key) { - ImageInfo? thumbnail; - final ImageStreamCompleter? stream = PaintingBinding.instance.imageCache.putIfAbsent( - key, - () => throw Exception(), // don't bother loading if it isn't cached - onError: (_, __) {}, - ); - - if (stream != null) { - void listener(ImageInfo info, bool synchronousCall) { - thumbnail = info; - } - - try { - stream.addListener(ImageStreamListener(listener)); - } finally { - stream.removeListener(ImageStreamListener(listener)); - } - } - - return thumbnail; -} diff --git a/mobile/lib/presentation/widgets/images/local_album_thumbnail.widget.dart b/mobile/lib/presentation/widgets/images/local_album_thumbnail.widget.dart index 8b9ede4c6d..b519da33c3 100644 --- a/mobile/lib/presentation/widgets/images/local_album_thumbnail.widget.dart +++ b/mobile/lib/presentation/widgets/images/local_album_thumbnail.widget.dart @@ -26,7 +26,7 @@ class LocalAlbumThumbnail extends ConsumerWidget { return ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(16)), - child: Thumbnail(asset: data), + child: Thumbnail.fromAsset(asset: data), ); }, error: (error, stack) { diff --git a/mobile/lib/presentation/widgets/images/local_image_provider.dart b/mobile/lib/presentation/widgets/images/local_image_provider.dart index e4eb899ecb..8bdbe3c16a 100644 --- a/mobile/lib/presentation/widgets/images/local_image_provider.dart +++ b/mobile/lib/presentation/widgets/images/local_image_provider.dart @@ -30,24 +30,25 @@ class LocalThumbProvider extends CancellableImageProvider DiagnosticsProperty('Id', key.id), DiagnosticsProperty('Size', key.size), ], - )..addOnLastListenerRemovedCallback(cancel); + onDispose: cancel, + ); } Stream _codec(LocalThumbProvider key, ImageDecoderCallback decode) { - return loadRequest(LocalImageRequest(localId: key.id, size: size, assetType: key.assetType), decode); + return loadRequest(LocalImageRequest(localId: key.id, size: key.size, assetType: key.assetType), decode); } @override bool operator ==(Object other) { if (identical(this, other)) return true; if (other is LocalThumbProvider) { - return id == other.id && size == other.size; + return id == other.id; } return false; } @override - int get hashCode => id.hashCode ^ size.hashCode; + int get hashCode => id.hashCode; } class LocalFullImageProvider extends CancellableImageProvider @@ -67,7 +68,7 @@ class LocalFullImageProvider extends CancellableImageProvider [ DiagnosticsProperty('Image provider', this), DiagnosticsProperty('Id', key.id), diff --git a/mobile/lib/presentation/widgets/images/remote_image_provider.dart b/mobile/lib/presentation/widgets/images/remote_image_provider.dart index a10fd40ce8..fcd8cde266 100644 --- a/mobile/lib/presentation/widgets/images/remote_image_provider.dart +++ b/mobile/lib/presentation/widgets/images/remote_image_provider.dart @@ -31,7 +31,8 @@ class RemoteThumbProvider extends CancellableImageProvider DiagnosticsProperty('Image provider', this), DiagnosticsProperty('Asset Id', key.assetId), ], - )..addOnLastListenerRemovedCallback(cancel); + onDispose: cancel, + ); } Stream _codec(RemoteThumbProvider key, ImageDecoderCallback decode) { @@ -73,7 +74,7 @@ class RemoteFullImageProvider extends CancellableImageProvider [ DiagnosticsProperty('Image provider', this), DiagnosticsProperty('Asset Id', key.assetId), diff --git a/mobile/lib/presentation/widgets/images/thumbnail.widget.dart b/mobile/lib/presentation/widgets/images/thumbnail.widget.dart index 9965c1bfd5..347d7efd3e 100644 --- a/mobile/lib/presentation/widgets/images/thumbnail.widget.dart +++ b/mobile/lib/presentation/widgets/images/thumbnail.widget.dart @@ -1,61 +1,367 @@ +import 'dart:ui' as ui; + import 'package:flutter/material.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart'; +import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/thumb_hash_provider.dart'; -import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart'; -import 'package:immich_mobile/widgets/common/fade_in_placeholder_image.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; import 'package:logging/logging.dart'; -import 'package:octo_image/octo_image.dart'; -class Thumbnail extends StatelessWidget { - const Thumbnail({this.asset, this.remoteId, this.size = const Size.square(256), this.fit = BoxFit.cover, super.key}) - : assert(asset != null || remoteId != null, 'Either asset or remoteId must be provided'); +final log = Logger('ThumbnailWidget'); - final BaseAsset? asset; - final String? remoteId; - final Size size; +enum ThumbhashMode { enabled, disabled, only } + +class Thumbnail extends StatefulWidget { + final ImageProvider? imageProvider; + final ImageProvider? thumbhashProvider; final BoxFit fit; + const Thumbnail({this.imageProvider, this.fit = BoxFit.cover, this.thumbhashProvider, super.key}); + + Thumbnail.remote({required String remoteId, this.fit = BoxFit.cover, Size size = kThumbnailResolution, super.key}) + : imageProvider = RemoteThumbProvider(assetId: remoteId), + thumbhashProvider = null; + + Thumbnail.fromAsset({ + required BaseAsset? asset, + this.fit = BoxFit.cover, + + /// The logical UI size of the thumbnail. This is only used to determine the ideal image resolution and does not affect the widget size. + Size size = kThumbnailResolution, + super.key, + }) : thumbhashProvider = switch (asset) { + RemoteAsset() when asset.thumbHash != null && asset.localId == null => ThumbHashProvider( + thumbHash: asset.thumbHash!, + ), + _ => null, + }, + imageProvider = switch (asset) { + RemoteAsset() => + asset.localId == null + ? RemoteThumbProvider(assetId: asset.id) + : LocalThumbProvider(id: asset.localId!, size: size, assetType: asset.type), + LocalAsset() => LocalThumbProvider(id: asset.id, size: size, assetType: asset.type), + _ => null, + }; + + @override + State createState() => _ThumbnailState(); +} + +class _ThumbnailState extends State with SingleTickerProviderStateMixin { + ui.Image? _providerImage; + ui.Image? _previousImage; + + late AnimationController _fadeController; + late Animation _fadeAnimation; + + ImageStream? _imageStream; + ImageStreamListener? _imageStreamListener; + ImageStream? _thumbhashStream; + ImageStreamListener? _thumbhashStreamListener; + + static final _gradientCache = {}; + + @override + void initState() { + super.initState(); + _fadeController = AnimationController(duration: const Duration(milliseconds: 100), vsync: this); + _fadeAnimation = CurvedAnimation(parent: _fadeController, curve: Curves.easeOut); + _fadeController.addStatusListener(_onAnimationStatusChanged); + _loadImage(); + } + + void _onAnimationStatusChanged(AnimationStatus status) { + if (status == AnimationStatus.completed) { + _previousImage?.dispose(); + _previousImage = null; + } + } + + void _loadFromThumbhashProvider() { + _stopListeningToThumbhashStream(); + final thumbhashProvider = widget.thumbhashProvider; + if (thumbhashProvider == null || _providerImage != null) return; + + final thumbhashStream = _thumbhashStream = thumbhashProvider.resolve(ImageConfiguration.empty); + final thumbhashStreamListener = _thumbhashStreamListener = ImageStreamListener( + (ImageInfo imageInfo, bool synchronousCall) { + _stopListeningToThumbhashStream(); + if (!mounted || _providerImage != null) { + imageInfo.dispose(); + return; + } + + setState(() { + _providerImage = imageInfo.image; + }); + }, + onError: (exception, stackTrace) { + log.severe('Error loading thumbhash', exception, stackTrace); + _stopListeningToThumbhashStream(); + }, + ); + thumbhashStream.addListener(thumbhashStreamListener); + } + + void _loadFromImageProvider() { + _stopListeningToImageStream(); + final imageProvider = widget.imageProvider; + if (imageProvider == null) return; + + final imageStream = _imageStream = imageProvider.resolve(ImageConfiguration.empty); + final imageStreamListener = _imageStreamListener = ImageStreamListener( + (ImageInfo imageInfo, bool synchronousCall) { + _stopListeningToStream(); + if (!mounted) { + imageInfo.dispose(); + return; + } + + if (_providerImage == imageInfo.image) { + return; + } + + if (synchronousCall && _providerImage == null) { + _fadeController.value = 1.0; + } else if (_fadeController.isAnimating) { + _fadeController.forward(); + } else { + _fadeController.forward(from: 0.0); + } + + setState(() { + _previousImage?.dispose(); + if (_providerImage != null) { + _previousImage = _providerImage; + } else { + _previousImage = null; + } + _providerImage = imageInfo.image; + }); + }, + onError: (exception, stackTrace) { + log.severe('Error loading image: $exception', exception, stackTrace); + _stopListeningToImageStream(); + }, + ); + imageStream.addListener(imageStreamListener); + } + + void _stopListeningToImageStream() { + if (_imageStreamListener != null && _imageStream != null) { + _imageStream!.removeListener(_imageStreamListener!); + } + _imageStream = null; + _imageStreamListener = null; + } + + void _stopListeningToThumbhashStream() { + if (_thumbhashStreamListener != null && _thumbhashStream != null) { + _thumbhashStream!.removeListener(_thumbhashStreamListener!); + } + _thumbhashStream = null; + _thumbhashStreamListener = null; + } + + void _stopListeningToStream() { + _stopListeningToImageStream(); + _stopListeningToThumbhashStream(); + } + + @override + void didUpdateWidget(Thumbnail oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.imageProvider != oldWidget.imageProvider) { + if (_fadeController.isAnimating) { + _fadeController.stop(); + _previousImage?.dispose(); + _previousImage = null; + } + _loadFromImageProvider(); + } + + if (_providerImage == null && oldWidget.thumbhashProvider != widget.thumbhashProvider) { + _loadFromThumbhashProvider(); + } + } + + @override + void reassemble() { + super.reassemble(); + _loadImage(); + } + + void _loadImage() { + _loadFromImageProvider(); + _loadFromThumbhashProvider(); + } + @override Widget build(BuildContext context) { - final thumbHash = asset is RemoteAsset ? (asset as RemoteAsset).thumbHash : null; - final provider = getThumbnailImageProvider(asset: asset, remoteId: remoteId); - - return OctoImage.fromSet( - image: provider, - octoSet: OctoSet( - placeholderBuilder: _blurHashPlaceholderBuilder(thumbHash, fit: fit), - errorBuilder: _blurHashErrorBuilder(thumbHash, provider: provider, fit: fit, asset: asset), - ), - fadeOutDuration: const Duration(milliseconds: 100), - fadeInDuration: Duration.zero, - width: size.width, - height: size.height, - fit: fit, - placeholderFadeInDuration: Duration.zero, + final colorScheme = context.colorScheme; + final gradient = _gradientCache[colorScheme] ??= LinearGradient( + colors: [colorScheme.surfaceContainer, colorScheme.surfaceContainer.darken(amount: .1)], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, ); + + return AnimatedBuilder( + animation: _fadeAnimation, + builder: (context, child) { + return _ThumbnailLeaf( + image: _providerImage, + previousImage: _previousImage, + fadeValue: _fadeAnimation.value, + fit: widget.fit, + placeholderGradient: gradient, + ); + }, + ); + } + + @override + void dispose() { + _fadeController.removeStatusListener(_onAnimationStatusChanged); + _fadeController.dispose(); + _stopListeningToStream(); + _providerImage?.dispose(); + _previousImage?.dispose(); + super.dispose(); } } -OctoPlaceholderBuilder _blurHashPlaceholderBuilder(String? thumbHash, {BoxFit? fit}) { - return (context) => thumbHash == null - ? const ThumbnailPlaceholder() - : FadeInPlaceholderImage( - placeholder: const ThumbnailPlaceholder(), - image: ThumbHashProvider(thumbHash: thumbHash), - fit: fit ?? BoxFit.cover, - ); +class _ThumbnailLeaf extends LeafRenderObjectWidget { + final ui.Image? image; + final ui.Image? previousImage; + final double fadeValue; + final BoxFit fit; + final Gradient placeholderGradient; + + const _ThumbnailLeaf({ + required this.image, + required this.previousImage, + required this.fadeValue, + required this.fit, + required this.placeholderGradient, + }); + + @override + RenderObject createRenderObject(BuildContext context) { + return _ThumbnailRenderBox( + image: image, + previousImage: previousImage, + fadeValue: fadeValue, + fit: fit, + placeholderGradient: placeholderGradient, + ); + } + + @override + void updateRenderObject(BuildContext context, _ThumbnailRenderBox renderObject) { + renderObject + ..image = image + ..previousImage = previousImage + ..fadeValue = fadeValue + ..fit = fit + ..placeholderGradient = placeholderGradient; + } } -OctoErrorBuilder _blurHashErrorBuilder(String? blurhash, {BaseAsset? asset, ImageProvider? provider, BoxFit? fit}) => - (context, e, s) { - Logger("ImThumbnail").warning("Error loading thumbnail for ${asset?.name}", e, s); - provider?.evict(); - return Stack( - alignment: Alignment.center, - children: [ - _blurHashPlaceholderBuilder(blurhash, fit: fit)(context), - const Opacity(opacity: 0.75, child: Icon(Icons.error_outline_rounded)), - ], +class _ThumbnailRenderBox extends RenderBox { + ui.Image? _image; + ui.Image? _previousImage; + double _fadeValue; + BoxFit _fit; + Gradient _placeholderGradient; + + @override + bool isRepaintBoundary = true; + + _ThumbnailRenderBox({ + required ui.Image? image, + required ui.Image? previousImage, + required double fadeValue, + required BoxFit fit, + required Gradient placeholderGradient, + }) : _image = image, + _previousImage = previousImage, + _fadeValue = fadeValue, + _fit = fit, + _placeholderGradient = placeholderGradient; + + @override + void paint(PaintingContext context, Offset offset) { + final rect = offset & size; + final canvas = context.canvas; + + if (_previousImage != null && _fadeValue < 1.0) { + paintImage( + canvas: canvas, + rect: rect, + image: _previousImage!, + fit: _fit, + filterQuality: FilterQuality.low, + opacity: 1.0 - _fadeValue, ); - }; + } else if (_image == null || _fadeValue < 1.0) { + final paint = Paint()..shader = _placeholderGradient.createShader(rect); + canvas.drawRect(rect, paint); + } + + if (_image != null) { + paintImage( + canvas: canvas, + rect: rect, + image: _image!, + fit: _fit, + filterQuality: FilterQuality.low, + opacity: _fadeValue, + ); + } + } + + @override + void performLayout() { + size = constraints.biggest; + } + + set image(ui.Image? value) { + if (_image != value) { + _image = value; + markNeedsPaint(); + } + } + + set previousImage(ui.Image? value) { + if (_previousImage != value) { + _previousImage = value; + markNeedsPaint(); + } + } + + set fadeValue(double value) { + if (_fadeValue != value) { + _fadeValue = value; + markNeedsPaint(); + } + } + + set fit(BoxFit value) { + if (_fit != value) { + _fit = value; + markNeedsPaint(); + } + } + + set placeholderGradient(Gradient value) { + if (_placeholderGradient != value) { + _placeholderGradient = value; + markNeedsPaint(); + } + } +} diff --git a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart index 37743c5e86..cfcb7a8985 100644 --- a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart +++ b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart @@ -7,13 +7,14 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/duration_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; class ThumbnailTile extends ConsumerWidget { const ThumbnailTile( this.asset, { - this.size = const Size.square(256), + this.size = kThumbnailResolution, this.fit = BoxFit.cover, this.showStorageIndicator, this.lockSelection = false, @@ -21,7 +22,7 @@ class ThumbnailTile extends ConsumerWidget { super.key, }); - final BaseAsset asset; + final BaseAsset? asset; final Size size; final BoxFit fit; final bool? showStorageIndicator; @@ -30,6 +31,7 @@ class ThumbnailTile extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final asset = this.asset; final heroIndex = heroOffset ?? TabsRouterScope.of(context)?.controller.activeIndex ?? 0; final assetContainerColor = context.isDarkTheme @@ -52,7 +54,7 @@ class ThumbnailTile extends ConsumerWidget { ) : const BoxDecoration(); - final hasStack = asset is RemoteAsset && (asset as RemoteAsset).stackId != null; + final hasStack = asset is RemoteAsset && asset.stackId != null; final bool storageIndicator = showStorageIndicator ?? ref.watch(settingsProvider.select((s) => s.get(Setting.showStorageIndicator))); @@ -71,8 +73,8 @@ class ThumbnailTile extends ConsumerWidget { children: [ Positioned.fill( child: Hero( - tag: '${asset.heroTag}_$heroIndex', - child: Thumbnail(asset: asset, fit: fit, size: size), + tag: '${asset?.heroTag ?? ''}_$heroIndex', + child: Thumbnail.fromAsset(asset: asset, size: size), ), ), if (hasStack) @@ -83,7 +85,7 @@ class ThumbnailTile extends ConsumerWidget { child: const _TileOverlayIcon(Icons.burst_mode_rounded), ), ), - if (asset.isVideo) + if (asset != null && asset.isVideo) Align( alignment: Alignment.topRight, child: Padding( @@ -91,7 +93,7 @@ class ThumbnailTile extends ConsumerWidget { child: _VideoIndicator(asset.duration), ), ), - if (storageIndicator) + if (storageIndicator && asset != null) switch (asset.storage) { AssetState.local => const Align( alignment: Alignment.bottomRight, @@ -115,7 +117,7 @@ class ThumbnailTile extends ConsumerWidget { ), ), }, - if (asset.isFavorite) + if (asset != null && asset.isFavorite) const Align( alignment: Alignment.bottomLeft, child: Padding( diff --git a/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart b/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart index e2bc59b4c7..ec49bbec96 100644 --- a/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart +++ b/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart @@ -61,7 +61,7 @@ class DriftMemoryCard extends ConsumerWidget { child: SizedBox( width: 205, height: 200, - child: Thumbnail(remoteId: memory.assets[0].id, fit: BoxFit.cover), + child: Thumbnail.remote(remoteId: memory.assets[0].id, fit: BoxFit.cover), ), ), Positioned( diff --git a/mobile/lib/presentation/widgets/timeline/constants.dart b/mobile/lib/presentation/widgets/timeline/constants.dart index e3bb5fe273..da78b5b02c 100644 --- a/mobile/lib/presentation/widgets/timeline/constants.dart +++ b/mobile/lib/presentation/widgets/timeline/constants.dart @@ -2,7 +2,7 @@ import 'dart:ui'; const double kTimelineHeaderExtent = 80.0; const Size kTimelineFixedTileExtent = Size.square(256); -const Size kThumbnailResolution = kTimelineFixedTileExtent; +const Size kThumbnailResolution = kTimelineFixedTileExtent; // TODO: make the resolution vary based on actual tile size const double kTimelineSpacing = 2.0; const int kTimelineColumnCount = 3;