Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 9 commits into from
Mar 14, 2024
5 changes: 2 additions & 3 deletions mobile/lib/modules/activities/widgets/activity_tile.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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,
),
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
martyfuhry marked this conversation as resolved.
Show resolved Hide resolved
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');
}
}
Original file line number Diff line number Diff line change
@@ -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,
fyfrey marked this conversation as resolved.
Show resolved Hide resolved
stalePeriod: const Duration(days: 30),
),
);
}
Original file line number Diff line number Diff line change
@@ -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),
),
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/// An exception for the [ImageLoader] and the Immich image providers
class ImageLoadingException implements Exception {
final String message;
ImageLoadingException(this.message);
}
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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
Expand All @@ -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,
);
Expand All @@ -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
Expand Down
Loading
Loading