feat(mobile): Remote thumbnails and images use an on-disk image cache (#7929)

* Fixes remote full / thumbnail provider

* Adds image cache manager to both remote image providers

format

format

Fix typo in equals

remove unused import

renames image loader

* Adds height and width to the image cache for thumbs

format

* Uses a separate remote and thumbnail cache

format

* Fixes key name

* Changes uri to string, fixes comment

* Chunk events are optional and remote thumbnails don't report chunk events

* better exception handling

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
martyfuhry 2024-03-14 16:29:09 -04:00 committed by GitHub
parent 5a589babcb
commit 582cdcab82
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 168 additions and 117 deletions

View File

@ -3,7 +3,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/datetime_extensions.dart'; import 'package:immich_mobile/extensions/datetime_extensions.dart';
import 'package:immich_mobile/modules/activities/models/activity.model.dart'; import 'package:immich_mobile/modules/activities/models/activity.model.dart';
import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_remote_image_provider.dart'; import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_remote_thumbnail_provider.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/current_asset.provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/current_asset.provider.dart';
import 'package:immich_mobile/shared/ui/user_circle_avatar.dart'; import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
@ -106,9 +106,8 @@ class _ActivityAssetThumbnail extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(4)), borderRadius: const BorderRadius.all(Radius.circular(4)),
image: DecorationImage( image: DecorationImage(
image: ImmichRemoteImageProvider( image: ImmichRemoteThumbnailProvider(
assetId: assetId, assetId: assetId,
isThumbnail: true,
), ),
fit: BoxFit.cover, fit: BoxFit.cover,
), ),

View File

@ -0,0 +1,58 @@
import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:immich_mobile/modules/asset_viewer/image_providers/exceptions/image_loading_exception.dart';
import 'package:immich_mobile/shared/models/store.dart';
/// Loads the codec from the URI and sends the events to the [chunkEvents] stream
///
/// Credit to [flutter_cached_network_image](https://github.com/Baseflow/flutter_cached_network_image/blob/develop/cached_network_image/lib/src/image_provider/_image_loader.dart)
/// for this wonderful implementation of their image loader
class ImageLoader {
static Future<ui.Codec> loadImageFromCache(
String uri, {
required ImageCacheManager cache,
required ImageDecoderCallback decode,
StreamController<ImageChunkEvent>? chunkEvents,
int? height,
int? width,
}) async {
final headers = {
'x-immich-user-token': Store.get(StoreKey.accessToken),
};
final stream = cache.getImageFile(
uri,
withProgress: true,
headers: headers,
maxHeight: height,
maxWidth: width,
);
await for (final result in stream) {
if (result is DownloadProgress) {
// We are downloading the file, so update the [chunkEvents]
chunkEvents?.add(
ImageChunkEvent(
cumulativeBytesLoaded: result.downloaded,
expectedTotalBytes: result.totalSize,
),
);
}
if (result is FileInfo) {
// We have the file
final file = result.file;
final bytes = await file.readAsBytes();
final buffer = await ui.ImmutableBuffer.fromUint8List(bytes);
final decoded = await decode(buffer);
return decoded;
}
}
// If we get here, the image failed to load from the cache stream
throw ImageLoadingException('Could not load image from stream');
}
}

View File

@ -0,0 +1,20 @@
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
/// The cache manager for full size images [ImmichRemoteImageProvider]
class RemoteImageCacheManager extends CacheManager with ImageCacheManager {
static const key = 'remoteImageCacheKey';
static final RemoteImageCacheManager _instance = RemoteImageCacheManager._();
factory RemoteImageCacheManager() {
return _instance;
}
RemoteImageCacheManager._()
: super(
Config(
key,
maxNrOfCacheObjects: 500,
stalePeriod: const Duration(days: 30),
),
);
}

View File

@ -0,0 +1,21 @@
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
/// The cache manager for thumbnail images [ImmichRemoteThumbnailProvider]
class ThumbnailImageCacheManager extends CacheManager with ImageCacheManager {
static const key = 'thumbnailImageCacheKey';
static final ThumbnailImageCacheManager _instance =
ThumbnailImageCacheManager._();
factory ThumbnailImageCacheManager() {
return _instance;
}
ThumbnailImageCacheManager._()
: super(
Config(
key,
maxNrOfCacheObjects: 5000,
stalePeriod: const Duration(days: 30),
),
);
}

View File

@ -0,0 +1,5 @@
/// An exception for the [ImageLoader] and the Immich image providers
class ImageLoadingException implements Exception {
final String message;
ImageLoadingException(this.message);
}

View File

@ -1,8 +1,10 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:immich_mobile/modules/asset_viewer/image_providers/cache/image_loader.dart';
import 'package:immich_mobile/modules/asset_viewer/image_providers/cache/remote_image_cache_manager.dart';
import 'package:openapi/api.dart' as api; import 'package:openapi/api.dart' as api;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@ -12,24 +14,18 @@ import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/utils/image_url_builder.dart';
/// Our Image Provider HTTP client to make the request /// The remote image provider for full size remote images
final _httpClient = HttpClient()
..autoUncompress = false
..maxConnectionsPerHost = 10;
/// The remote image provider
class ImmichRemoteImageProvider class ImmichRemoteImageProvider
extends ImageProvider<ImmichRemoteImageProvider> { extends ImageProvider<ImmichRemoteImageProvider> {
/// The [Asset.remoteId] of the asset to fetch /// The [Asset.remoteId] of the asset to fetch
final String assetId; final String assetId;
// If this is a thumbnail, we stop at loading the /// The image cache manager
// smallest version of the remote image final ImageCacheManager? cacheManager;
final bool isThumbnail;
ImmichRemoteImageProvider({ ImmichRemoteImageProvider({
required this.assetId, required this.assetId,
this.isThumbnail = false, this.cacheManager,
}); });
/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key /// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key
@ -46,9 +42,10 @@ class ImmichRemoteImageProvider
ImmichRemoteImageProvider key, ImmichRemoteImageProvider key,
ImageDecoderCallback decode, ImageDecoderCallback decode,
) { ) {
final cache = cacheManager ?? RemoteImageCacheManager();
final chunkEvents = StreamController<ImageChunkEvent>(); final chunkEvents = StreamController<ImageChunkEvent>();
return MultiImageStreamCompleter( return MultiImageStreamCompleter(
codec: _codec(key, decode, chunkEvents), codec: _codec(key, cache, decode, chunkEvents),
scale: 1.0, scale: 1.0,
chunkEvents: chunkEvents.stream, chunkEvents: chunkEvents.stream,
); );
@ -69,82 +66,61 @@ class ImmichRemoteImageProvider
// 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<ui.Codec> _codec( Stream<ui.Codec> _codec(
ImmichRemoteImageProvider key, ImmichRemoteImageProvider key,
ImageCacheManager cache,
ImageDecoderCallback decode, ImageDecoderCallback decode,
StreamController<ImageChunkEvent> chunkEvents, StreamController<ImageChunkEvent> chunkEvents,
) async* { ) async* {
// Load a preview to the chunk events // Load a preview to the chunk events
if (_loadPreview || key.isThumbnail) { if (_loadPreview) {
final preview = getThumbnailUrlForRemoteId( final preview = getThumbnailUrlForRemoteId(
key.assetId, key.assetId,
type: api.ThumbnailFormat.WEBP, type: api.ThumbnailFormat.WEBP,
); );
yield await _loadFromUri( yield await ImageLoader.loadImageFromCache(
Uri.parse(preview), preview,
decode, cache: cache,
chunkEvents, decode: decode,
chunkEvents: chunkEvents,
); );
} }
// Guard thumnbail rendering
if (key.isThumbnail) {
await chunkEvents.close();
return;
}
// Load the higher resolution version of the image // Load the higher resolution version of the image
final url = getThumbnailUrlForRemoteId( final url = getThumbnailUrlForRemoteId(
key.assetId, key.assetId,
type: api.ThumbnailFormat.JPEG, type: api.ThumbnailFormat.JPEG,
); );
final codec = await _loadFromUri(Uri.parse(url), decode, chunkEvents); final codec = await ImageLoader.loadImageFromCache(
url,
cache: cache,
decode: decode,
chunkEvents: chunkEvents,
);
yield codec; yield codec;
// Load the final remote image // Load the final remote image
if (_useOriginal) { if (_useOriginal) {
// Load the original image // Load the original image
final url = getImageUrlFromId(key.assetId); final url = getImageUrlFromId(key.assetId);
final codec = await _loadFromUri(Uri.parse(url), decode, chunkEvents); final codec = await ImageLoader.loadImageFromCache(
url,
cache: cache,
decode: decode,
chunkEvents: chunkEvents,
);
yield codec; yield codec;
} }
await chunkEvents.close(); await chunkEvents.close();
} }
// Loads the codec from the URI and sends the events to the [chunkEvents] stream
Future<ui.Codec> _loadFromUri(
Uri uri,
ImageDecoderCallback decode,
StreamController<ImageChunkEvent> chunkEvents,
) async {
final request = await _httpClient.getUrl(uri);
request.headers.add(
'x-immich-user-token',
Store.get(StoreKey.accessToken),
);
final response = await request.close();
// Chunks of the completed image can be shown
final data = await consolidateHttpClientResponseBytes(
response,
onBytesReceived: (cumulative, total) {
chunkEvents.add(
ImageChunkEvent(
cumulativeBytesLoaded: cumulative,
expectedTotalBytes: total,
),
);
},
);
// Decode the response
final buffer = await ui.ImmutableBuffer.fromUint8List(data);
return decode(buffer);
}
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
if (other is! ImmichRemoteImageProvider) return false;
if (identical(this, other)) return true; if (identical(this, other)) return true;
return assetId == other.assetId && isThumbnail == other.isThumbnail; if (other is ImmichRemoteImageProvider) {
return assetId == other.assetId;
}
return false;
} }
@override @override

View File

@ -1,30 +1,34 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_remote_image_provider.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:immich_mobile/modules/asset_viewer/image_providers/cache/image_loader.dart';
import 'package:immich_mobile/modules/asset_viewer/image_providers/cache/thumbnail_image_cache_manager.dart';
import 'package:openapi/api.dart' as api; import 'package:openapi/api.dart' as api;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart'; import 'package:flutter/painting.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/utils/image_url_builder.dart';
/// Our HTTP client to make the request
final _httpClient = HttpClient()
..autoUncompress = false
..maxConnectionsPerHost = 100;
/// The remote image provider /// The remote image provider
class ImmichRemoteThumbnailProvider class ImmichRemoteThumbnailProvider
extends ImageProvider<ImmichRemoteThumbnailProvider> { extends ImageProvider<ImmichRemoteThumbnailProvider> {
/// The [Asset.remoteId] of the asset to fetch /// The [Asset.remoteId] of the asset to fetch
final String assetId; final String assetId;
final int? height;
final int? width;
/// The image cache manager
final ImageCacheManager? cacheManager;
ImmichRemoteThumbnailProvider({ ImmichRemoteThumbnailProvider({
required this.assetId, required this.assetId,
this.height,
this.width,
this.cacheManager,
}); });
/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key /// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key
@ -41,19 +45,18 @@ class ImmichRemoteThumbnailProvider
ImmichRemoteThumbnailProvider key, ImmichRemoteThumbnailProvider key,
ImageDecoderCallback decode, ImageDecoderCallback decode,
) { ) {
final chunkEvents = StreamController<ImageChunkEvent>(); final cache = cacheManager ?? ThumbnailImageCacheManager();
return MultiImageStreamCompleter( return MultiImageStreamCompleter(
codec: _codec(key, decode, chunkEvents), codec: _codec(key, cache, decode),
scale: 1.0, scale: 1.0,
chunkEvents: chunkEvents.stream,
); );
} }
// 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<ui.Codec> _codec( Stream<ui.Codec> _codec(
ImmichRemoteThumbnailProvider key, ImmichRemoteThumbnailProvider key,
ImageCacheManager cache,
ImageDecoderCallback decode, ImageDecoderCallback decode,
StreamController<ImageChunkEvent> chunkEvents,
) async* { ) async* {
// Load a preview to the chunk events // Load a preview to the chunk events
final preview = getThumbnailUrlForRemoteId( final preview = getThumbnailUrlForRemoteId(
@ -61,52 +64,23 @@ class ImmichRemoteThumbnailProvider
type: api.ThumbnailFormat.WEBP, type: api.ThumbnailFormat.WEBP,
); );
yield await _loadFromUri( yield await ImageLoader.loadImageFromCache(
Uri.parse(preview), preview,
decode, cache: cache,
chunkEvents, decode: decode,
); );
await chunkEvents.close();
}
// Loads the codec from the URI and sends the events to the [chunkEvents] stream
Future<ui.Codec> _loadFromUri(
Uri uri,
ImageDecoderCallback decode,
StreamController<ImageChunkEvent> chunkEvents,
) async {
final request = await _httpClient.getUrl(uri);
request.headers.add(
'x-immich-user-token',
Store.get(StoreKey.accessToken),
);
final response = await request.close();
// Chunks of the completed image can be shown
final data = await consolidateHttpClientResponseBytes(
response,
onBytesReceived: (cumulative, total) {
chunkEvents.add(
ImageChunkEvent(
cumulativeBytesLoaded: cumulative,
expectedTotalBytes: total,
),
);
},
);
// Decode the response
final buffer = await ui.ImmutableBuffer.fromUint8List(data);
return decode(buffer);
} }
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
if (other is! ImmichRemoteImageProvider) return false;
if (identical(this, other)) return true; if (identical(this, other)) return true;
if (other is ImmichRemoteThumbnailProvider) {
return assetId == other.assetId; return assetId == other.assetId;
} }
return false;
}
@override @override
int get hashCode => assetId.hashCode; int get hashCode => assetId.hashCode;
} }

View File

@ -42,7 +42,6 @@ class ImmichImage extends StatelessWidget {
if (asset == null) { if (asset == null) {
return ImmichRemoteImageProvider( return ImmichRemoteImageProvider(
assetId: assetId!, assetId: assetId!,
isThumbnail: false,
); );
} }
@ -53,7 +52,6 @@ class ImmichImage extends StatelessWidget {
} else { } else {
return ImmichRemoteImageProvider( return ImmichRemoteImageProvider(
assetId: asset.remoteId!, assetId: asset.remoteId!,
isThumbnail: false,
); );
} }
} }

View File

@ -3,7 +3,7 @@ import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_local_thumbnail_provider.dart'; import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_local_thumbnail_provider.dart';
import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_remote_image_provider.dart'; import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_remote_thumbnail_provider.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/ui/hooks/blurhash_hook.dart'; import 'package:immich_mobile/shared/ui/hooks/blurhash_hook.dart';
import 'package:immich_mobile/shared/ui/immich_image.dart'; import 'package:immich_mobile/shared/ui/immich_image.dart';
@ -38,9 +38,8 @@ class ImmichThumbnail extends HookWidget {
} }
if (asset == null) { if (asset == null) {
return ImmichRemoteImageProvider( return ImmichRemoteThumbnailProvider(
assetId: assetId!, assetId: assetId!,
isThumbnail: true,
); );
} }
@ -51,9 +50,10 @@ class ImmichThumbnail extends HookWidget {
width: thumbnailSize, width: thumbnailSize,
); );
} else { } else {
return ImmichRemoteImageProvider( return ImmichRemoteThumbnailProvider(
assetId: asset.remoteId!, assetId: asset.remoteId!,
isThumbnail: true, height: thumbnailSize,
width: thumbnailSize,
); );
} }
} }