use cached thumbnail

This commit is contained in:
mertalev 2025-08-04 13:20:13 -04:00
parent c8f9a72d3e
commit 57970800bb
No known key found for this signature in database
GPG Key ID: DF6ABC77AAD98C95
10 changed files with 216 additions and 76 deletions

View File

@ -13,6 +13,9 @@ extension ContextHelper on BuildContext {
// Returns the current height from MediaQuery // Returns the current height from MediaQuery
double get height => MediaQuery.sizeOf(this).height; double get height => MediaQuery.sizeOf(this).height;
// Returns the current size from MediaQuery
Size get sizeData => MediaQuery.sizeOf(this);
// Returns true if the app is running on a mobile device (!tablets) // Returns true if the app is running on a mobile device (!tablets)
bool get isMobile => width < 550; bool get isMobile => width < 550;

View File

@ -0,0 +1,10 @@
import 'dart:ui';
import 'package:flutter/painting.dart';
extension CodecImageInfoExtension on Codec {
Future<ImageInfo> getImageInfo({double scale = 1.0}) async {
final frame = await getNextFrame();
return ImageInfo(image: frame.image, scale: scale);
}
}

View File

@ -147,11 +147,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
// Precache both thumbnail and full image for smooth transitions // Precache both thumbnail and full image for smooth transitions
unawaited( unawaited(
Future.wait([ Future.wait([
precacheImage( precacheImage(getThumbnailImageProvider(asset: asset), context, onError: (_, __) {}),
getThumbnailImageProvider(asset: asset, size: screenSize),
context,
onError: (_, __) {},
),
precacheImage(getFullImageProvider(asset, size: screenSize), context, onError: (_, __) {}), precacheImage(getFullImageProvider(asset, size: screenSize), context, onError: (_, __) {}),
]), ]),
); );
@ -482,7 +478,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
width: double.infinity, width: double.infinity,
height: double.infinity, height: double.infinity,
color: backgroundColor, color: backgroundColor,
child: Thumbnail(asset: asset, fit: BoxFit.contain, size: Size(ctx.width, ctx.height)), child: Thumbnail(asset: asset, fit: BoxFit.contain),
); );
} }
@ -513,7 +509,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
} }
PhotoViewGalleryPageOptions _imageBuilder(BuildContext ctx, BaseAsset asset) { PhotoViewGalleryPageOptions _imageBuilder(BuildContext ctx, BaseAsset asset) {
final size = Size(ctx.width, ctx.height); final size = ctx.sizeData;
return PhotoViewGalleryPageOptions( return PhotoViewGalleryPageOptions(
key: ValueKey(asset.heroTag), key: ValueKey(asset.heroTag),
imageProvider: getFullImageProvider(asset, size: size), imageProvider: getFullImageProvider(asset, size: size),
@ -529,10 +525,10 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
onTapDown: _onTapDown, onTapDown: _onTapDown,
onLongPressStart: asset.isMotionPhoto ? _onLongPress : null, onLongPressStart: asset.isMotionPhoto ? _onLongPress : null,
errorBuilder: (_, __, ___) => Container( errorBuilder: (_, __, ___) => Container(
width: ctx.width, width: size.width,
height: ctx.height, height: size.height,
color: backgroundColor, color: backgroundColor,
child: Thumbnail(asset: asset, fit: BoxFit.contain, size: size), child: Thumbnail(asset: asset, fit: BoxFit.contain),
), ),
); );
} }
@ -562,7 +558,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
asset: asset, asset: asset,
image: Image( image: Image(
key: ValueKey(asset), key: ValueKey(asset),
image: getFullImageProvider(asset, size: Size(ctx.width, ctx.height)), image: getFullImageProvider(asset, size: ctx.sizeData),
fit: BoxFit.contain, fit: BoxFit.contain,
height: ctx.height, height: ctx.height,
width: ctx.width, width: ctx.width,

View File

@ -4,13 +4,14 @@ import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/domain/services/setting.service.dart'; import 'package:immich_mobile/domain/services/setting.service.dart';
import 'package:immich_mobile/presentation/widgets/images/local_image_provider.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/remote_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080, 1920)}) { ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080, 1920)}) {
// Create new provider and cache it // Create new provider and cache it
final ImageProvider provider; final ImageProvider provider;
if (_shouldUseLocalAsset(asset)) { if (_shouldUseLocalAsset(asset)) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!; final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
provider = LocalFullImageProvider(id: id, name: asset.name, size: size, type: asset.type); provider = LocalFullImageProvider(id: id, size: size, type: asset.type, updatedAt: asset.updatedAt);
} else { } else {
final String assetId; final String assetId;
if (asset is LocalAsset && asset.hasRemote) { if (asset is LocalAsset && asset.hasRemote) {
@ -26,7 +27,7 @@ ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080
return provider; return provider;
} }
ImageProvider getThumbnailImageProvider({BaseAsset? asset, String? remoteId, Size size = const Size.square(256)}) { ImageProvider getThumbnailImageProvider({BaseAsset? asset, String? remoteId, Size size = kThumbnailResolution}) {
assert(asset != null || remoteId != null, 'Either asset or remoteId must be provided'); assert(asset != null || remoteId != null, 'Either asset or remoteId must be provided');
if (remoteId != null) { if (remoteId != null) {
@ -35,7 +36,7 @@ ImageProvider getThumbnailImageProvider({BaseAsset? asset, String? remoteId, Siz
if (_shouldUseLocalAsset(asset!)) { if (_shouldUseLocalAsset(asset!)) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!; final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
return LocalThumbProvider(id: id, updatedAt: asset.updatedAt, name: asset.name, size: size); return LocalThumbProvider(id: id, updatedAt: asset.updatedAt, size: size);
} }
final String assetId; final String assetId;
@ -52,3 +53,25 @@ ImageProvider getThumbnailImageProvider({BaseAsset? asset, String? remoteId, Siz
bool _shouldUseLocalAsset(BaseAsset asset) => bool _shouldUseLocalAsset(BaseAsset asset) =>
asset.hasLocal && (!asset.hasRemote || !AppSetting.get(Setting.preferRemoteImage)); 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
);
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

@ -2,15 +2,17 @@ import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'dart:ui'; import 'dart:ui';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/domain/services/setting.service.dart'; import 'package:immich_mobile/domain/services/setting.service.dart';
import 'package:immich_mobile/extensions/codec_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/asset_media.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/asset_media.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
import 'package:immich_mobile/providers/image/cache/thumbnail_image_cache_manager.dart'; import 'package:immich_mobile/providers/image/cache/thumbnail_image_cache_manager.dart';
import 'package:immich_mobile/providers/image/exceptions/image_loading_exception.dart'; import 'package:immich_mobile/providers/image/exceptions/image_loading_exception.dart';
@ -22,14 +24,12 @@ class LocalThumbProvider extends ImageProvider<LocalThumbProvider> {
final String id; final String id;
final DateTime updatedAt; final DateTime updatedAt;
final String name;
final Size size; final Size size;
const LocalThumbProvider({ const LocalThumbProvider({
required this.id, required this.id,
required this.updatedAt, required this.updatedAt,
required this.name, this.size = kThumbnailResolution,
this.size = const Size.square(kTimelineFixedTileExtent),
this.cacheManager, this.cacheManager,
}); });
@ -45,10 +45,8 @@ class LocalThumbProvider extends ImageProvider<LocalThumbProvider> {
codec: _codec(key, cache, decode), codec: _codec(key, cache, decode),
scale: 1.0, scale: 1.0,
informationCollector: () => <DiagnosticsNode>[ informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Id', key.id), DiagnosticsProperty<String>('Id', key.id),
DiagnosticsProperty<DateTime>('Updated at', key.updatedAt), DiagnosticsProperty<DateTime>('Updated at', key.updatedAt),
DiagnosticsProperty<String>('Name', key.name),
DiagnosticsProperty<Size>('Size', key.size), DiagnosticsProperty<Size>('Size', key.size),
], ],
); );
@ -68,7 +66,7 @@ class LocalThumbProvider extends ImageProvider<LocalThumbProvider> {
final thumbnailBytes = await _assetMediaRepository.getThumbnail(key.id, size: key.size); final thumbnailBytes = await _assetMediaRepository.getThumbnail(key.id, size: key.size);
if (thumbnailBytes == null) { if (thumbnailBytes == null) {
PaintingBinding.instance.imageCache.evict(key); PaintingBinding.instance.imageCache.evict(key);
throw StateError("Loading thumb for local photo ${key.name} failed"); throw StateError("Loading thumb for local photo ${key.id} failed");
} }
final buffer = await ImmutableBuffer.fromUint8List(thumbnailBytes); final buffer = await ImmutableBuffer.fromUint8List(thumbnailBytes);
@ -94,11 +92,11 @@ class LocalFullImageProvider extends ImageProvider<LocalFullImageProvider> {
final StorageRepository _storageRepository = const StorageRepository(); final StorageRepository _storageRepository = const StorageRepository();
final String id; final String id;
final String name;
final Size size; final Size size;
final AssetType type; final AssetType type;
final DateTime updatedAt; // temporary, only exists to fetch cached thumbnail until local disk cache is removed
const LocalFullImageProvider({required this.id, required this.name, required this.size, required this.type}); const LocalFullImageProvider({required this.id, required this.size, required this.type, required this.updatedAt});
@override @override
Future<LocalFullImageProvider> obtainKey(ImageConfiguration configuration) { Future<LocalFullImageProvider> obtainKey(ImageConfiguration configuration) {
@ -107,52 +105,45 @@ class LocalFullImageProvider extends ImageProvider<LocalFullImageProvider> {
@override @override
ImageStreamCompleter loadImage(LocalFullImageProvider key, ImageDecoderCallback decode) { ImageStreamCompleter loadImage(LocalFullImageProvider key, ImageDecoderCallback decode) {
return MultiImageStreamCompleter( return OneFramePlaceholderImageStreamCompleter(
codec: _codec(key, decode), _codec(key, decode),
scale: 1.0, initialImage: getCachedImage(LocalThumbProvider(id: key.id, updatedAt: key.updatedAt)),
informationCollector: () sync* { informationCollector: () => <DiagnosticsNode>[
yield ErrorDescription(name); DiagnosticsProperty<String>('Id', key.id),
}, DiagnosticsProperty<DateTime>('Updated at', key.updatedAt),
DiagnosticsProperty<Size>('Size', key.size),
],
); );
} }
// Streams in each stage of the image as we ask for it // Streams in each stage of the image as we ask for it
Stream<Codec> _codec(LocalFullImageProvider key, ImageDecoderCallback decode) async* { Stream<ImageInfo> _codec(LocalFullImageProvider key, ImageDecoderCallback decode) {
try { try {
switch (key.type) { return switch (key.type) {
case AssetType.image: AssetType.image => _decodeProgressive(key, decode),
yield* _decodeProgressive(key, decode); AssetType.video => _getThumbnailCodec(key, decode),
break; _ => throw StateError('Unsupported asset type ${key.type}'),
case AssetType.video: };
final codec = await _getThumbnailCodec(key, decode);
if (codec == null) {
throw StateError("Failed to load preview for ${key.name}");
}
yield codec;
break;
case AssetType.other:
case AssetType.audio:
throw StateError('Unsupported asset type ${key.type}');
}
} catch (error, stack) { } catch (error, stack) {
Logger('ImmichLocalImageProvider').severe('Error loading local image ${key.name}', error, stack); Logger('ImmichLocalImageProvider').severe('Error loading local image ${key.id}', error, stack);
throw const ImageLoadingException('Could not load image from local storage'); throw const ImageLoadingException('Could not load image from local storage');
} }
} }
Future<Codec?> _getThumbnailCodec(LocalFullImageProvider key, ImageDecoderCallback decode) async { Stream<ImageInfo> _getThumbnailCodec(LocalFullImageProvider key, ImageDecoderCallback decode) async* {
final thumbBytes = await _assetMediaRepository.getThumbnail(key.id, size: key.size); final thumbBytes = await _assetMediaRepository.getThumbnail(key.id, size: key.size);
if (thumbBytes == null) { if (thumbBytes == null) {
return null; throw StateError("Failed to load preview for ${key.id}");
} }
final buffer = await ImmutableBuffer.fromUint8List(thumbBytes); final buffer = await ImmutableBuffer.fromUint8List(thumbBytes);
return decode(buffer); final codec = await decode(buffer);
yield await codec.getImageInfo();
} }
Stream<Codec> _decodeProgressive(LocalFullImageProvider key, ImageDecoderCallback decode) async* { Stream<ImageInfo> _decodeProgressive(LocalFullImageProvider key, ImageDecoderCallback decode) async* {
final file = await _storageRepository.getFileForAsset(key.id); final file = await _storageRepository.getFileForAsset(key.id);
if (file == null) { if (file == null) {
throw StateError("Opening file for asset ${key.name} failed"); throw StateError("Opening file for asset ${key.id} failed");
} }
final fileSize = await file.length(); final fileSize = await file.length();
@ -171,7 +162,8 @@ class LocalFullImageProvider extends ImageProvider<LocalFullImageProvider> {
final mediumThumb = await _assetMediaRepository.getThumbnail(key.id, size: size); final mediumThumb = await _assetMediaRepository.getThumbnail(key.id, size: size);
if (mediumThumb != null) { if (mediumThumb != null) {
final mediumBuffer = await ImmutableBuffer.fromUint8List(mediumThumb); final mediumBuffer = await ImmutableBuffer.fromUint8List(mediumThumb);
yield await decode(mediumBuffer); final codec = await decode(mediumBuffer);
yield await codec.getImageInfo();
} }
} catch (_) {} } catch (_) {}
} }
@ -187,24 +179,26 @@ class LocalFullImageProvider extends ImageProvider<LocalFullImageProvider> {
final highThumb = await _assetMediaRepository.getThumbnail(key.id, size: size); final highThumb = await _assetMediaRepository.getThumbnail(key.id, size: size);
if (highThumb != null) { if (highThumb != null) {
final highBuffer = await ImmutableBuffer.fromUint8List(highThumb); final highBuffer = await ImmutableBuffer.fromUint8List(highThumb);
yield await decode(highBuffer); final codec = await decode(highBuffer);
yield await codec.getImageInfo();
} }
return; return;
} }
final buffer = await ImmutableBuffer.fromFilePath(file.path); final buffer = await ImmutableBuffer.fromFilePath(file.path);
yield await decode(buffer); final codec = await decode(buffer);
yield await codec.getImageInfo();
} }
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
if (identical(this, other)) return true; if (identical(this, other)) return true;
if (other is LocalFullImageProvider) { if (other is LocalFullImageProvider) {
return id == other.id && size == other.size && type == other.type && name == other.name; return id == other.id && size == other.size && type == other.type;
} }
return false; return false;
} }
@override @override
int get hashCode => id.hashCode ^ size.hashCode ^ type.hashCode ^ name.hashCode; int get hashCode => id.hashCode ^ size.hashCode ^ type.hashCode;
} }

View File

@ -0,0 +1,117 @@
// The below code is adapted from cached_network_image package's
// MultiImageStreamCompleter to better suit one-frame image loading.
// In particular, it allows providing an initial image to emit synchronously.
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
/// An ImageStreamCompleter with support for loading multiple images.
class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter {
ImageInfo? _initialImage;
/// The constructor to create an OneFramePlaceholderImageStreamCompleter. The [image]
/// should be the primary image to display. The [initialImage] is an optional
/// image that will be emitted synchronously, useful as a thumbnail or placeholder.
OneFramePlaceholderImageStreamCompleter(
Stream<ImageInfo> image, {
ImageInfo? initialImage,
InformationCollector? informationCollector,
}) {
_initialImage = initialImage;
image.listen(
setImage,
onError: (Object error, StackTrace stack) {
reportError(
context: ErrorDescription('resolving a single-frame image stream'),
exception: error,
stack: stack,
informationCollector: informationCollector,
silent: true,
);
},
);
}
/// We must avoid disposing a completer if it never had a listener, even
/// if all [keepAlive] handles get disposed.
bool __hadAtLeastOneListener = false;
bool __disposed = false;
@override
void addListener(ImageStreamListener listener) {
__hadAtLeastOneListener = true;
final initialImage = _initialImage;
if (initialImage != null) {
try {
listener.onImage(initialImage.clone(), true);
} catch (exception, stack) {
reportError(
context: ErrorDescription('by a synchronously-called image listener'),
exception: exception,
stack: stack,
);
}
}
super.addListener(listener);
}
@override
void removeListener(ImageStreamListener listener) {
super.removeListener(listener);
if (!hasListeners) {
__maybeDispose();
}
}
int __keepAliveHandles = 0;
@override
ImageStreamCompleterHandle keepAlive() {
final delegateHandle = super.keepAlive();
return _OneFramePlaceholderImageStreamCompleterHandle(this, delegateHandle);
}
void __maybeDispose() {
if (!__hadAtLeastOneListener || __disposed || hasListeners || __keepAliveHandles != 0) {
return;
}
__disposed = true;
}
@override
void onDisposed() {
_initialImage?.dispose();
_initialImage = null;
super.onDisposed();
}
}
class _OneFramePlaceholderImageStreamCompleterHandle implements ImageStreamCompleterHandle {
_OneFramePlaceholderImageStreamCompleterHandle(this._completer, this._delegateHandle) {
_completer!.__keepAliveHandles += 1;
}
OneFramePlaceholderImageStreamCompleter? _completer;
final ImageStreamCompleterHandle _delegateHandle;
/// Call this method to signal the [ImageStreamCompleter] that it can now be
/// disposed when its last listener drops.
///
/// This method must only be called once per object.
@override
void dispose() {
assert(_completer != null);
assert(_completer!.__keepAliveHandles > 0);
assert(!_completer!.__disposed);
_delegateHandle.dispose();
_completer!.__keepAliveHandles -= 1;
_completer!.__maybeDispose();
_completer = null;
}
}

View File

@ -1,12 +1,14 @@
import 'dart:async'; import 'dart:async';
import 'dart:ui'; import 'dart:ui';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart'; import 'package:flutter/painting.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/domain/services/setting.service.dart'; import 'package:immich_mobile/domain/services/setting.service.dart';
import 'package:immich_mobile/extensions/codec_extensions.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
import 'package:immich_mobile/providers/image/cache/image_loader.dart'; import 'package:immich_mobile/providers/image/cache/image_loader.dart';
import 'package:immich_mobile/providers/image/cache/remote_image_cache_manager.dart'; import 'package:immich_mobile/providers/image/cache/remote_image_cache_manager.dart';
import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/utils/image_url_builder.dart';
@ -81,36 +83,28 @@ class RemoteFullImageProvider extends ImageProvider<RemoteFullImageProvider> {
@override @override
ImageStreamCompleter loadImage(RemoteFullImageProvider key, ImageDecoderCallback decode) { ImageStreamCompleter loadImage(RemoteFullImageProvider key, ImageDecoderCallback decode) {
final cache = cacheManager ?? RemoteImageCacheManager(); final cache = cacheManager ?? RemoteImageCacheManager();
final chunkEvents = StreamController<ImageChunkEvent>(); return OneFramePlaceholderImageStreamCompleter(
return MultiImageStreamCompleter( _codec(key, cache, decode),
codec: _codec(key, cache, decode, chunkEvents), initialImage: getCachedImage(RemoteThumbProvider(assetId: key.assetId)),
scale: 1.0,
chunkEvents: chunkEvents.stream,
); );
} }
Stream<Codec> _codec( Stream<ImageInfo> _codec(RemoteFullImageProvider key, CacheManager cache, ImageDecoderCallback decode) async* {
RemoteFullImageProvider key, final codec = await ImageLoader.loadImageFromCache(
CacheManager cache,
ImageDecoderCallback decode,
StreamController<ImageChunkEvent> chunkController,
) async* {
yield await ImageLoader.loadImageFromCache(
getPreviewUrlForRemoteId(key.assetId), getPreviewUrlForRemoteId(key.assetId),
cache: cache, cache: cache,
decode: decode, decode: decode,
chunkEvents: chunkController,
); );
yield await codec.getImageInfo();
if (AppSetting.get(Setting.loadOriginal)) { if (AppSetting.get(Setting.loadOriginal)) {
yield await ImageLoader.loadImageFromCache( final codec = await ImageLoader.loadImageFromCache(
getOriginalUrlForRemoteId(key.assetId), getOriginalUrlForRemoteId(key.assetId),
cache: cache, cache: cache,
decode: decode, decode: decode,
chunkEvents: chunkController,
); );
yield await codec.getImageInfo();
} }
await chunkController.close();
} }
@override @override

View File

@ -19,7 +19,7 @@ class Thumbnail extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final thumbHash = asset is RemoteAsset ? (asset as RemoteAsset).thumbHash : null; final thumbHash = asset is RemoteAsset ? (asset as RemoteAsset).thumbHash : null;
final provider = getThumbnailImageProvider(asset: asset, remoteId: remoteId, size: size); final provider = getThumbnailImageProvider(asset: asset, remoteId: remoteId);
return OctoImage.fromSet( return OctoImage.fromSet(
image: provider, image: provider,

View File

@ -1,5 +1,8 @@
import 'dart:ui';
const double kTimelineHeaderExtent = 80.0; const double kTimelineHeaderExtent = 80.0;
const double kTimelineFixedTileExtent = 256; const Size kTimelineFixedTileExtent = Size.square(256);
const Size kThumbnailResolution = kTimelineFixedTileExtent;
const double kTimelineSpacing = 2.0; const double kTimelineSpacing = 2.0;
const int kTimelineColumnCount = 3; const int kTimelineColumnCount = 3;

View File

@ -21,7 +21,7 @@ abstract class SegmentBuilder {
static Widget buildPlaceholder( static Widget buildPlaceholder(
BuildContext context, BuildContext context,
int count, { int count, {
Size size = const Size.square(kTimelineFixedTileExtent), Size size = kTimelineFixedTileExtent,
double spacing = kTimelineSpacing, double spacing = kTimelineSpacing,
}) => RepaintBoundary( }) => RepaintBoundary(
child: FixedTimelineRow( child: FixedTimelineRow(