fix(mobile): use cached thumbnail in full size image provider (#20637)

This commit is contained in:
Mert 2025-08-05 10:20:25 -04:00 committed by GitHub
parent 9680f1290d
commit 9e6fee4064
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 166 additions and 76 deletions

View File

@ -13,6 +13,9 @@ extension ContextHelper on BuildContext {
// Returns the current height from MediaQuery
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)
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
unawaited(
Future.wait([
precacheImage(
getThumbnailImageProvider(asset: asset, size: screenSize),
context,
onError: (_, __) {},
),
precacheImage(getThumbnailImageProvider(asset: asset), context, onError: (_, __) {}),
precacheImage(getFullImageProvider(asset, size: screenSize), context, onError: (_, __) {}),
]),
);
@ -482,7 +478,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
width: double.infinity,
height: double.infinity,
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) {
final size = Size(ctx.width, ctx.height);
final size = ctx.sizeData;
return PhotoViewGalleryPageOptions(
key: ValueKey(asset.heroTag),
imageProvider: getFullImageProvider(asset, size: size),
@ -529,10 +525,10 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
onTapDown: _onTapDown,
onLongPressStart: asset.isMotionPhoto ? _onLongPress : null,
errorBuilder: (_, __, ___) => Container(
width: ctx.width,
height: ctx.height,
width: size.width,
height: size.height,
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,
image: Image(
key: ValueKey(asset),
image: getFullImageProvider(asset, size: Size(ctx.width, ctx.height)),
image: getFullImageProvider(asset, size: ctx.sizeData),
fit: BoxFit.contain,
height: ctx.height,
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/presentation/widgets/images/local_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)}) {
// Create new provider and cache it
final ImageProvider provider;
if (_shouldUseLocalAsset(asset)) {
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 {
final String assetId;
if (asset is LocalAsset && asset.hasRemote) {
@ -26,7 +27,7 @@ ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080
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');
if (remoteId != null) {
@ -35,7 +36,7 @@ ImageProvider getThumbnailImageProvider({BaseAsset? asset, String? remoteId, Siz
if (_shouldUseLocalAsset(asset!)) {
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;
@ -52,3 +53,25 @@ 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
);
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:ui';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.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/setting.model.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/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/providers/image/cache/thumbnail_image_cache_manager.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 DateTime updatedAt;
final String name;
final Size size;
const LocalThumbProvider({
required this.id,
required this.updatedAt,
required this.name,
this.size = const Size.square(kTimelineFixedTileExtent),
this.size = kThumbnailResolution,
this.cacheManager,
});
@ -45,10 +45,8 @@ class LocalThumbProvider extends ImageProvider<LocalThumbProvider> {
codec: _codec(key, cache, decode),
scale: 1.0,
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Id', key.id),
DiagnosticsProperty<DateTime>('Updated at', key.updatedAt),
DiagnosticsProperty<String>('Name', key.name),
DiagnosticsProperty<Size>('Size', key.size),
],
);
@ -68,7 +66,7 @@ class LocalThumbProvider extends ImageProvider<LocalThumbProvider> {
final thumbnailBytes = await _assetMediaRepository.getThumbnail(key.id, size: key.size);
if (thumbnailBytes == null) {
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);
@ -94,11 +92,11 @@ class LocalFullImageProvider extends ImageProvider<LocalFullImageProvider> {
final StorageRepository _storageRepository = const StorageRepository();
final String id;
final String name;
final Size size;
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
Future<LocalFullImageProvider> obtainKey(ImageConfiguration configuration) {
@ -107,52 +105,45 @@ class LocalFullImageProvider extends ImageProvider<LocalFullImageProvider> {
@override
ImageStreamCompleter loadImage(LocalFullImageProvider key, ImageDecoderCallback decode) {
return MultiImageStreamCompleter(
codec: _codec(key, decode),
scale: 1.0,
informationCollector: () sync* {
yield ErrorDescription(name);
},
return OneFramePlaceholderImageStreamCompleter(
_codec(key, decode),
initialImage: getCachedImage(LocalThumbProvider(id: key.id, updatedAt: key.updatedAt)),
informationCollector: () => <DiagnosticsNode>[
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
Stream<Codec> _codec(LocalFullImageProvider key, ImageDecoderCallback decode) async* {
Stream<ImageInfo> _codec(LocalFullImageProvider key, ImageDecoderCallback decode) {
try {
switch (key.type) {
case AssetType.image:
yield* _decodeProgressive(key, decode);
break;
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}');
}
return switch (key.type) {
AssetType.image => _decodeProgressive(key, decode),
AssetType.video => _getThumbnailCodec(key, decode),
_ => throw StateError('Unsupported asset type ${key.type}'),
};
} 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');
}
}
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);
if (thumbBytes == null) {
return null;
throw StateError("Failed to load preview for ${key.id}");
}
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);
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();
@ -171,7 +162,8 @@ class LocalFullImageProvider extends ImageProvider<LocalFullImageProvider> {
final mediumThumb = await _assetMediaRepository.getThumbnail(key.id, size: size);
if (mediumThumb != null) {
final mediumBuffer = await ImmutableBuffer.fromUint8List(mediumThumb);
yield await decode(mediumBuffer);
final codec = await decode(mediumBuffer);
yield await codec.getImageInfo();
}
} catch (_) {}
}
@ -187,24 +179,26 @@ class LocalFullImageProvider extends ImageProvider<LocalFullImageProvider> {
final highThumb = await _assetMediaRepository.getThumbnail(key.id, size: size);
if (highThumb != null) {
final highBuffer = await ImmutableBuffer.fromUint8List(highThumb);
yield await decode(highBuffer);
final codec = await decode(highBuffer);
yield await codec.getImageInfo();
}
return;
}
final buffer = await ImmutableBuffer.fromFilePath(file.path);
yield await decode(buffer);
final codec = await decode(buffer);
yield await codec.getImageInfo();
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
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;
}
@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,67 @@
// 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 [images]
/// should be the primary images to display (typically asynchronously as they load).
/// The [initialImage] is an optional image that will be emitted synchronously
/// until the first stream image is completed, useful as a thumbnail or placeholder.
OneFramePlaceholderImageStreamCompleter(
Stream<ImageInfo> images, {
ImageInfo? initialImage,
InformationCollector? informationCollector,
}) {
_initialImage = initialImage;
images.listen(
_onImage,
onError: (Object error, StackTrace stack) {
reportError(
context: ErrorDescription('resolving a single-frame image stream'),
exception: error,
stack: stack,
informationCollector: informationCollector,
silent: true,
);
},
);
}
void _onImage(ImageInfo image) {
setImage(image);
_initialImage?.dispose();
_initialImage = null;
}
@override
void addListener(ImageStreamListener listener) {
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 onDisposed() {
_initialImage?.dispose();
_initialImage = null;
super.onDisposed();
}
}

View File

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

View File

@ -19,7 +19,7 @@ class Thumbnail extends StatelessWidget {
@override
Widget build(BuildContext context) {
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(
image: provider,

View File

@ -1,5 +1,8 @@
import 'dart:ui';
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 int kTimelineColumnCount = 3;

View File

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