feat(mobile): optimized thumbnail widget (#21073)

* thumbnail widget

* use animation ticker, improvements

* use static thumbnail resolution for now

* fix android sample size

* free memory sooner

* formatting

* tweaks

* wait for disposal

* remove debug prints

* take two on animation

* fix

* remote constructor

* missed one

* unused imports

* unnecessary import

* formatting
This commit is contained in:
Mert 2025-08-21 14:06:02 -04:00 committed by GitHub
parent ab2849781a
commit fb59fa343d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 421 additions and 125 deletions

View File

@ -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()
)

View File

@ -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

View File

@ -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<ui.FrameInfo?> _fromPlatformImage(Map<String, int> info) async {
final address = info['pointer'];
if (address == null) {
if (!kReleaseMode) {
debugPrint('Platform image request for $requestId was cancelled');
}
return null;
}
final pointer = Pointer<Uint8>.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();
}
}

View File

@ -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<String, String> headers;

View File

@ -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 {

View File

@ -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,
),
),

View File

@ -119,7 +119,7 @@ class _DriftCreateAlbumPageState extends ConsumerState<DriftCreateAlbumPage> {
final asset = selectedAssets.elementAt(index);
return GestureDetector(
onTap: onBackgroundTapped,
child: Thumbnail(asset: asset),
child: Thumbnail.fromAsset(asset: asset),
);
}, childCount: selectedAssets.length),
),

View File

@ -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),
),
),
);
}

View File

@ -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),

View File

@ -536,7 +536,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
width: size.width,
height: size.height,
color: backgroundColor,
child: Thumbnail(asset: asset, fit: BoxFit.contain),
child: Thumbnail.fromAsset(asset: asset, fit: BoxFit.contain),
),
);
}

View File

@ -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;
}

View File

@ -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) {

View File

@ -30,24 +30,25 @@ class LocalThumbProvider extends CancellableImageProvider<LocalThumbProvider>
DiagnosticsProperty<String>('Id', key.id),
DiagnosticsProperty<Size>('Size', key.size),
],
)..addOnLastListenerRemovedCallback(cancel);
onDispose: cancel,
);
}
Stream<ImageInfo> _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<LocalFullImageProvider>
@ -67,7 +68,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
ImageStreamCompleter loadImage(LocalFullImageProvider key, ImageDecoderCallback decode) {
return OneFramePlaceholderImageStreamCompleter(
_codec(key, decode),
initialImage: getCachedImage(LocalThumbProvider(id: key.id, assetType: key.assetType)),
initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType)),
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Id', key.id),

View File

@ -31,7 +31,8 @@ class RemoteThumbProvider extends CancellableImageProvider<RemoteThumbProvider>
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Asset Id', key.assetId),
],
)..addOnLastListenerRemovedCallback(cancel);
onDispose: cancel,
);
}
Stream<ImageInfo> _codec(RemoteThumbProvider key, ImageDecoderCallback decode) {
@ -73,7 +74,7 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
ImageStreamCompleter loadImage(RemoteFullImageProvider key, ImageDecoderCallback decode) {
return OneFramePlaceholderImageStreamCompleter(
_codec(key, decode),
initialImage: getCachedImage(RemoteThumbProvider(assetId: key.assetId)),
initialImage: getInitialImage(RemoteThumbProvider(assetId: key.assetId)),
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Asset Id', key.assetId),

View File

@ -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<Thumbnail> createState() => _ThumbnailState();
}
class _ThumbnailState extends State<Thumbnail> with SingleTickerProviderStateMixin {
ui.Image? _providerImage;
ui.Image? _previousImage;
late AnimationController _fadeController;
late Animation<double> _fadeAnimation;
ImageStream? _imageStream;
ImageStreamListener? _imageStreamListener;
ImageStream? _thumbhashStream;
ImageStreamListener? _thumbhashStreamListener;
static final _gradientCache = <ColorScheme, Gradient>{};
@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();
}
}
}

View File

@ -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(

View File

@ -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(

View File

@ -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;