image provider improvements

This commit is contained in:
mertalev 2025-08-13 00:10:55 -04:00
parent 0d60199514
commit f931060670
No known key found for this signature in database
GPG key ID: DF6ABC77AAD98C95
26 changed files with 1203 additions and 190 deletions

View file

@ -5,13 +5,32 @@ 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';
import 'package:immich_mobile/infrastructure/repositories/asset_media.repository.dart';
abstract class CancellableImageProvider {
void cancel();
}
mixin class CancellableImageProviderMixin implements CancellableImageProvider {
ImageRequest? request;
@override
void cancel() {
final request = this.request;
if (request == null) {
return;
}
this.request = null;
return request.cancel();
}
}
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, size: size, type: asset.type, updatedAt: asset.updatedAt);
provider = LocalFullImageProvider(id: id, size: size);
} else {
final String assetId;
if (asset is LocalAsset && asset.hasRemote) {
@ -36,7 +55,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, size: size);
return LocalThumbProvider(id: id, size: size);
}
final String assetId;

View file

@ -1,37 +1,18 @@
import 'dart:async';
import 'dart:io';
import 'dart:ui';
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';
import 'package:logging/logging.dart';
class LocalThumbProvider extends ImageProvider<LocalThumbProvider> {
final AssetMediaRepository _assetMediaRepository = const AssetMediaRepository();
final CacheManager? cacheManager;
class LocalThumbProvider extends ImageProvider<LocalThumbProvider> with CancellableImageProviderMixin {
final String id;
final DateTime updatedAt;
final Size size;
const LocalThumbProvider({
required this.id,
required this.updatedAt,
this.size = kThumbnailResolution,
this.cacheManager,
});
LocalThumbProvider({required this.id, this.size = kThumbnailResolution});
@override
Future<LocalThumbProvider> obtainKey(ImageConfiguration configuration) {
@ -40,63 +21,45 @@ class LocalThumbProvider extends ImageProvider<LocalThumbProvider> {
@override
ImageStreamCompleter loadImage(LocalThumbProvider key, ImageDecoderCallback decode) {
final cache = cacheManager ?? ThumbnailImageCacheManager();
return MultiFrameImageStreamCompleter(
codec: _codec(key, cache, decode),
scale: 1.0,
return OneFramePlaceholderImageStreamCompleter(
_codec(key, decode),
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<String>('Id', key.id),
DiagnosticsProperty<DateTime>('Updated at', key.updatedAt),
DiagnosticsProperty<Size>('Size', key.size),
],
);
}
Future<Codec> _codec(LocalThumbProvider key, CacheManager cache, ImageDecoderCallback decode) async {
final cacheKey = '${key.id}-${key.updatedAt}-${key.size.width}x${key.size.height}';
final fileFromCache = await cache.getFileFromCache(cacheKey);
if (fileFromCache != null) {
try {
final buffer = await ImmutableBuffer.fromFilePath(fileFromCache.file.path);
return decode(buffer);
} catch (_) {}
Stream<ImageInfo> _codec(LocalThumbProvider key, ImageDecoderCallback decode) async* {
final request = this.request = LocalImageRequest(localId: key.id, size: size);
try {
final image = await request.load(decode);
if (image != null) {
yield image;
}
} finally {
this.request = null;
}
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.id} failed");
}
final buffer = await ImmutableBuffer.fromUint8List(thumbnailBytes);
unawaited(cache.putFile(cacheKey, thumbnailBytes));
return decode(buffer);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is LocalThumbProvider) {
return id == other.id && updatedAt == other.updatedAt;
return id == other.id && size == other.size;
}
return false;
}
@override
int get hashCode => id.hashCode ^ updatedAt.hashCode;
int get hashCode => id.hashCode ^ size.hashCode;
}
class LocalFullImageProvider extends ImageProvider<LocalFullImageProvider> {
final AssetMediaRepository _assetMediaRepository = const AssetMediaRepository();
final StorageRepository _storageRepository = const StorageRepository();
class LocalFullImageProvider extends ImageProvider<LocalFullImageProvider> with CancellableImageProviderMixin {
final String id;
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.size, required this.type, required this.updatedAt});
LocalFullImageProvider({required this.id, required this.size});
@override
Future<LocalFullImageProvider> obtainKey(ImageConfiguration configuration) {
@ -107,98 +70,41 @@ class LocalFullImageProvider extends ImageProvider<LocalFullImageProvider> {
ImageStreamCompleter loadImage(LocalFullImageProvider key, ImageDecoderCallback decode) {
return OneFramePlaceholderImageStreamCompleter(
_codec(key, decode),
initialImage: getCachedImage(LocalThumbProvider(id: key.id, updatedAt: key.updatedAt)),
initialImage: getCachedImage(LocalThumbProvider(id: key.id)),
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this),
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<ImageInfo> _codec(LocalFullImageProvider key, ImageDecoderCallback decode) {
try {
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.id}', error, stack);
throw const ImageLoadingException('Could not load image from local storage');
}
}
Stream<ImageInfo> _getThumbnailCodec(LocalFullImageProvider key, ImageDecoderCallback decode) async* {
final thumbBytes = await _assetMediaRepository.getThumbnail(key.id, size: key.size);
if (thumbBytes == null) {
throw StateError("Failed to load preview for ${key.id}");
}
final buffer = await ImmutableBuffer.fromUint8List(thumbBytes);
final codec = await decode(buffer);
yield await codec.getImageInfo();
}
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.id} failed");
}
final fileSize = await file.length();
Stream<ImageInfo> _codec(LocalFullImageProvider key, ImageDecoderCallback decode) async* {
final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio;
final isLargeFile = fileSize > 20 * 1024 * 1024; // 20MB
final isHEIC = file.path.toLowerCase().contains(RegExp(r'\.(heic|heif)$'));
final isProgressive = isLargeFile || (isHEIC && !Platform.isIOS);
final request = this.request = LocalImageRequest(
localId: key.id,
size: Size(size.width * devicePixelRatio, size.height * devicePixelRatio),
);
if (isProgressive) {
try {
final progressiveMultiplier = devicePixelRatio >= 3.0 ? 1.3 : 1.2;
final size = Size(
(key.size.width * progressiveMultiplier).clamp(256, 1024),
(key.size.height * progressiveMultiplier).clamp(256, 1024),
);
final mediumThumb = await _assetMediaRepository.getThumbnail(key.id, size: size);
if (mediumThumb != null) {
final mediumBuffer = await ImmutableBuffer.fromUint8List(mediumThumb);
final codec = await decode(mediumBuffer);
yield await codec.getImageInfo();
}
} catch (_) {}
}
// Load original only when the file is smaller or if the user wants to load original images
// Or load a slightly larger image for progressive loading
if (isProgressive && !(AppSetting.get(Setting.loadOriginal))) {
final progressiveMultiplier = devicePixelRatio >= 3.0 ? 2.0 : 1.6;
final size = Size(
(key.size.width * progressiveMultiplier).clamp(512, 2048),
(key.size.height * progressiveMultiplier).clamp(512, 2048),
);
final highThumb = await _assetMediaRepository.getThumbnail(key.id, size: size);
if (highThumb != null) {
final highBuffer = await ImmutableBuffer.fromUint8List(highThumb);
final codec = await decode(highBuffer);
yield await codec.getImageInfo();
try {
final image = await request.load(decode);
if (image != null) {
yield image;
}
return;
} finally {
this.request = null;
}
final buffer = await ImmutableBuffer.fromFilePath(file.path);
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;
return id == other.id && size == other.size;
}
return false;
}
@override
int get hashCode => id.hashCode ^ size.hashCode ^ type.hashCode;
int get hashCode => id.hashCode ^ size.hashCode;
}

View file

@ -1,23 +1,21 @@
import 'dart:async';
import 'dart:ui';
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/infrastructure/repositories/asset_media.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/providers/image/cache/image_loader.dart';
import 'package:immich_mobile/providers/image/cache/remote_image_cache_manager.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
class RemoteThumbProvider extends ImageProvider<RemoteThumbProvider> {
class RemoteThumbProvider extends ImageProvider<RemoteThumbProvider> with CancellableImageProviderMixin {
final String assetId;
final CacheManager? cacheManager;
const RemoteThumbProvider({required this.assetId, this.cacheManager});
RemoteThumbProvider({required this.assetId, this.cacheManager});
@override
Future<RemoteThumbProvider> obtainKey(ImageConfiguration configuration) {
@ -26,12 +24,8 @@ class RemoteThumbProvider extends ImageProvider<RemoteThumbProvider> {
@override
ImageStreamCompleter loadImage(RemoteThumbProvider key, ImageDecoderCallback decode) {
final cache = cacheManager ?? RemoteImageCacheManager();
final chunkController = StreamController<ImageChunkEvent>();
return MultiFrameImageStreamCompleter(
codec: _codec(key, cache, decode, chunkController),
scale: 1.0,
chunkEvents: chunkController.stream,
return OneFramePlaceholderImageStreamCompleter(
_codec(key, decode),
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Asset Id', key.assetId),
@ -39,20 +33,17 @@ class RemoteThumbProvider extends ImageProvider<RemoteThumbProvider> {
);
}
Future<Codec> _codec(
RemoteThumbProvider key,
CacheManager cache,
ImageDecoderCallback decode,
StreamController<ImageChunkEvent> chunkController,
) async {
Stream<ImageInfo> _codec(RemoteThumbProvider key, ImageDecoderCallback decode) async* {
final preview = getThumbnailUrlForRemoteId(key.assetId);
return ImageLoader.loadImageFromCache(
preview,
cache: cache,
decode: decode,
chunkEvents: chunkController,
).whenComplete(chunkController.close);
final request = this.request = RemoteImageRequest(uri: preview, headers: ApiService.getRequestHeaders());
try {
final image = await request.load(decode);
if (image != null) {
yield image;
}
} finally {
this.request = null;
}
}
@override
@ -69,11 +60,11 @@ class RemoteThumbProvider extends ImageProvider<RemoteThumbProvider> {
int get hashCode => assetId.hashCode;
}
class RemoteFullImageProvider extends ImageProvider<RemoteFullImageProvider> {
class RemoteFullImageProvider extends ImageProvider<RemoteFullImageProvider> with CancellableImageProviderMixin {
final String assetId;
final CacheManager? cacheManager;
const RemoteFullImageProvider({required this.assetId, this.cacheManager});
RemoteFullImageProvider({required this.assetId, this.cacheManager});
@override
Future<RemoteFullImageProvider> obtainKey(ImageConfiguration configuration) {
@ -82,28 +73,44 @@ class RemoteFullImageProvider extends ImageProvider<RemoteFullImageProvider> {
@override
ImageStreamCompleter loadImage(RemoteFullImageProvider key, ImageDecoderCallback decode) {
final cache = cacheManager ?? RemoteImageCacheManager();
return OneFramePlaceholderImageStreamCompleter(
_codec(key, cache, decode),
initialImage: getCachedImage(RemoteThumbProvider(assetId: key.assetId)),
_codec(key, decode),
initialImage: getCachedImage(RemoteThumbProvider(assetId: assetId)),
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Asset Id', key.assetId),
],
);
}
Stream<ImageInfo> _codec(RemoteFullImageProvider key, CacheManager cache, ImageDecoderCallback decode) async* {
final codec = await ImageLoader.loadImageFromCache(
getPreviewUrlForRemoteId(key.assetId),
cache: cache,
decode: decode,
);
yield await codec.getImageInfo();
Stream<ImageInfo> _codec(RemoteFullImageProvider key, ImageDecoderCallback decode) async* {
try {
final request = this.request = RemoteImageRequest(
uri: getPreviewUrlForRemoteId(key.assetId),
headers: ApiService.getRequestHeaders(),
);
final image = await request.load(decode);
if (image == null) {
return;
}
yield image;
} finally {
request = null;
}
if (AppSetting.get(Setting.loadOriginal)) {
final codec = await ImageLoader.loadImageFromCache(
getOriginalUrlForRemoteId(key.assetId),
cache: cache,
decode: decode,
);
yield await codec.getImageInfo();
try {
final request = this.request = RemoteImageRequest(
uri: getOriginalUrlForRemoteId(key.assetId),
headers: ApiService.getRequestHeaders(),
);
final image = await request.load(decode);
if (image != null) {
yield image;
}
} finally {
request = null;
}
}
}

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 = Size.square(384);
const double kTimelineSpacing = 2.0;
const int kTimelineColumnCount = 3;