1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-24 10:37:28 +02:00

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/datetime_extensions.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/shared/ui/user_circle_avatar.dart';
@ -106,9 +106,8 @@ class _ActivityAssetThumbnail extends StatelessWidget {
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(4)),
image: DecorationImage(
image: ImmichRemoteImageProvider(
image: ImmichRemoteThumbnailProvider(
assetId: assetId,
isThumbnail: true,
),
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:io';
import 'dart:ui' as ui;
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: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/utils/image_url_builder.dart';
/// Our Image Provider HTTP client to make the request
final _httpClient = HttpClient()
..autoUncompress = false
..maxConnectionsPerHost = 10;
/// The remote image provider
/// The remote image provider for full size remote images
class ImmichRemoteImageProvider
extends ImageProvider<ImmichRemoteImageProvider> {
/// The [Asset.remoteId] of the asset to fetch
final String assetId;
// If this is a thumbnail, we stop at loading the
// smallest version of the remote image
final bool isThumbnail;
/// The image cache manager
final ImageCacheManager? cacheManager;
ImmichRemoteImageProvider({
required this.assetId,
this.isThumbnail = false,
this.cacheManager,
});
/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key
@ -46,9 +42,10 @@ class ImmichRemoteImageProvider
ImmichRemoteImageProvider key,
ImageDecoderCallback decode,
) {
final cache = cacheManager ?? RemoteImageCacheManager();
final chunkEvents = StreamController<ImageChunkEvent>();
return MultiImageStreamCompleter(
codec: _codec(key, decode, chunkEvents),
codec: _codec(key, cache, decode, chunkEvents),
scale: 1.0,
chunkEvents: chunkEvents.stream,
);
@ -69,82 +66,61 @@ class ImmichRemoteImageProvider
// Streams in each stage of the image as we ask for it
Stream<ui.Codec> _codec(
ImmichRemoteImageProvider key,
ImageCacheManager cache,
ImageDecoderCallback decode,
StreamController<ImageChunkEvent> chunkEvents,
) async* {
// Load a preview to the chunk events
if (_loadPreview || key.isThumbnail) {
if (_loadPreview) {
final preview = getThumbnailUrlForRemoteId(
key.assetId,
type: api.ThumbnailFormat.WEBP,
);
yield await _loadFromUri(
Uri.parse(preview),
decode,
chunkEvents,
yield await ImageLoader.loadImageFromCache(
preview,
cache: cache,
decode: decode,
chunkEvents: chunkEvents,
);
}
// Guard thumnbail rendering
if (key.isThumbnail) {
await chunkEvents.close();
return;
}
// Load the higher resolution version of the image
final url = getThumbnailUrlForRemoteId(
key.assetId,
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;
// Load the final remote image
if (_useOriginal) {
// Load the original image
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;
}
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
bool operator ==(Object other) {
if (other is! ImmichRemoteImageProvider) return false;
if (identical(this, other)) return true;
return assetId == other.assetId && isThumbnail == other.isThumbnail;
if (other is ImmichRemoteImageProvider) {
return assetId == other.assetId;
}
return false;
}
@override

View File

@ -1,30 +1,34 @@
import 'dart:async';
import 'dart:io';
import 'dart:ui' as ui;
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:flutter/foundation.dart';
import 'package:flutter/painting.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';
/// Our HTTP client to make the request
final _httpClient = HttpClient()
..autoUncompress = false
..maxConnectionsPerHost = 100;
/// The remote image provider
class ImmichRemoteThumbnailProvider
extends ImageProvider<ImmichRemoteThumbnailProvider> {
/// The [Asset.remoteId] of the asset to fetch
final String assetId;
final int? height;
final int? width;
/// The image cache manager
final ImageCacheManager? cacheManager;
ImmichRemoteThumbnailProvider({
required this.assetId,
this.height,
this.width,
this.cacheManager,
});
/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key
@ -41,19 +45,18 @@ class ImmichRemoteThumbnailProvider
ImmichRemoteThumbnailProvider key,
ImageDecoderCallback decode,
) {
final chunkEvents = StreamController<ImageChunkEvent>();
final cache = cacheManager ?? ThumbnailImageCacheManager();
return MultiImageStreamCompleter(
codec: _codec(key, decode, chunkEvents),
codec: _codec(key, cache, decode),
scale: 1.0,
chunkEvents: chunkEvents.stream,
);
}
// Streams in each stage of the image as we ask for it
Stream<ui.Codec> _codec(
ImmichRemoteThumbnailProvider key,
ImageCacheManager cache,
ImageDecoderCallback decode,
StreamController<ImageChunkEvent> chunkEvents,
) async* {
// Load a preview to the chunk events
final preview = getThumbnailUrlForRemoteId(
@ -61,50 +64,21 @@ class ImmichRemoteThumbnailProvider
type: api.ThumbnailFormat.WEBP,
);
yield await _loadFromUri(
Uri.parse(preview),
decode,
chunkEvents,
yield await ImageLoader.loadImageFromCache(
preview,
cache: cache,
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
bool operator ==(Object other) {
if (other is! ImmichRemoteImageProvider) return false;
if (identical(this, other)) return true;
return assetId == other.assetId;
if (other is ImmichRemoteThumbnailProvider) {
return assetId == other.assetId;
}
return false;
}
@override

View File

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

View File

@ -3,7 +3,7 @@ import 'dart:typed_data';
import 'package:flutter/material.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_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/ui/hooks/blurhash_hook.dart';
import 'package:immich_mobile/shared/ui/immich_image.dart';
@ -38,9 +38,8 @@ class ImmichThumbnail extends HookWidget {
}
if (asset == null) {
return ImmichRemoteImageProvider(
return ImmichRemoteThumbnailProvider(
assetId: assetId!,
isThumbnail: true,
);
}
@ -51,9 +50,10 @@ class ImmichThumbnail extends HookWidget {
width: thumbnailSize,
);
} else {
return ImmichRemoteImageProvider(
return ImmichRemoteThumbnailProvider(
assetId: asset.remoteId!,
isThumbnail: true,
height: thumbnailSize,
width: thumbnailSize,
);
}
}