feat(mobile): image caching & viewer improvements (#4095)

This commit is contained in:
Fynn Petersen-Frey
2023-09-18 05:57:05 +02:00
committed by GitHub
parent 63b6a71ebd
commit 1c02e1dadf
10 changed files with 283 additions and 212 deletions

View File

@@ -0,0 +1,69 @@
import 'package:flutter/painting.dart';
import 'original_image_provider.dart';
/// [ImageCache] that uses two caches for small and large images
/// so that a single large image does not evict all small iamges
final class CustomImageCache implements ImageCache {
final _small = ImageCache();
final _large = ImageCache();
@override
int get maximumSize => _small.maximumSize + _large.maximumSize;
@override
int get maximumSizeBytes => _small.maximumSizeBytes + _large.maximumSizeBytes;
@override
set maximumSize(int value) => _small.maximumSize = value;
@override
set maximumSizeBytes(int value) => _small.maximumSize = value;
@override
void clear() {
_small.clear();
_large.clear();
}
@override
void clearLiveImages() {
_small.clearLiveImages();
_large.clearLiveImages();
}
@override
bool containsKey(Object key) =>
(key is OriginalImageProvider ? _large : _small).containsKey(key);
@override
int get currentSize => _small.currentSize + _large.currentSize;
@override
int get currentSizeBytes => _small.currentSizeBytes + _large.currentSizeBytes;
@override
bool evict(Object key, {bool includeLive = true}) =>
(key is OriginalImageProvider ? _large : _small)
.evict(key, includeLive: includeLive);
@override
int get liveImageCount => _small.liveImageCount + _large.liveImageCount;
@override
int get pendingImageCount =>
_small.pendingImageCount + _large.pendingImageCount;
@override
ImageStreamCompleter? putIfAbsent(
Object key,
ImageStreamCompleter Function() loader, {
ImageErrorListener? onError,
}) =>
(key is OriginalImageProvider ? _large : _small)
.putIfAbsent(key, loader, onError: onError);
@override
ImageCacheStatus statusForKey(Object key) =>
(key is OriginalImageProvider ? _large : _small).statusForKey(key);
}

View File

@@ -0,0 +1,73 @@
import 'dart:async';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:immich_mobile/shared/models/asset.dart';
/// Loads the original image for local assets
@immutable
final class OriginalImageProvider extends ImageProvider<OriginalImageProvider> {
final Asset asset;
const OriginalImageProvider(this.asset);
@override
Future<OriginalImageProvider> obtainKey(ImageConfiguration configuration) =>
SynchronousFuture<OriginalImageProvider>(this);
@override
ImageStreamCompleter loadImage(
OriginalImageProvider key,
ImageDecoderCallback decode,
) =>
MultiFrameImageStreamCompleter(
codec: _loadAsync(key, decode),
scale: 1.0,
informationCollector: () sync* {
yield ErrorDescription(asset.fileName);
},
);
Future<ui.Codec> _loadAsync(
OriginalImageProvider key,
ImageDecoderCallback decode,
) async {
final ui.ImmutableBuffer buffer;
if (asset.isImage) {
final File? file = await asset.local?.originFile;
if (file == null) {
throw StateError("Opening file for asset ${asset.fileName} failed");
}
try {
buffer = await ui.ImmutableBuffer.fromFilePath(file.path);
} catch (error) {
throw StateError("Loading asset ${asset.fileName} failed");
}
} else {
final thumbBytes = await asset.local?.thumbnailData;
if (thumbBytes == null) {
throw StateError("Loading thumb for video ${asset.fileName} failed");
}
buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes);
}
try {
final codec = await decode(buffer);
debugPrint("Decoded image ${asset.fileName}");
return codec;
} catch (error) {
throw StateError("Decoding asset ${asset.fileName} failed");
}
}
@override
bool operator ==(Object other) {
if (other is! OriginalImageProvider) return false;
if (identical(this, other)) return true;
return asset == other.asset;
}
@override
int get hashCode => asset.hashCode;
}

View File

@@ -0,0 +1,8 @@
import 'package:flutter/widgets.dart';
import 'custom_image_cache.dart';
final class ImmichWidgetsBinding extends WidgetsFlutterBinding {
@override
ImageCache createImageCache() => CustomImageCache();
}

View File

@@ -178,6 +178,7 @@ class Asset {
@override
bool operator ==(other) {
if (other is! Asset) return false;
if (identical(this, other)) return true;
return id == other.id &&
checksum == other.checksum &&
remoteId == other.remoteId &&

View File

@@ -45,14 +45,9 @@ class ImmichImage extends StatelessWidget {
);
}
final Asset asset = this.asset!;
if (!asset.isRemote ||
(asset.isLocal && !Store.get(StoreKey.preferRemoteImage, false))) {
if (useLocal(asset)) {
return Image(
image: AssetEntityImageProvider(
asset.local!,
isOriginal: false,
thumbnailSize: const ThumbnailSize.square(250), // like server thumbs
),
image: localThumbnailProvider(asset),
width: width,
height: height,
fit: fit,
@@ -148,45 +143,44 @@ class ImmichImage extends StatelessWidget {
);
}
static AssetEntityImageProvider localThumbnailProvider(Asset asset) =>
AssetEntityImageProvider(
asset.local!,
isOriginal: false,
thumbnailSize: const ThumbnailSize.square(250),
);
static CachedNetworkImageProvider remoteThumbnailProvider(
Asset asset,
api.ThumbnailFormat type,
Map<String, String> authHeader,
) =>
CachedNetworkImageProvider(
getThumbnailUrl(asset, type: type),
cacheKey: getThumbnailCacheKey(asset, type: type),
headers: authHeader,
);
/// Precaches this asset for instant load the next time it is shown
static Future<void> precacheAsset(
Asset asset,
BuildContext context, {
type = api.ThumbnailFormat.WEBP,
}) {
final authToken = 'Bearer ${Store.get(StoreKey.accessToken)}';
if (type == api.ThumbnailFormat.WEBP) {
final thumbnailUrl = getThumbnailUrl(asset);
final thumbnailCacheKey = getThumbnailCacheKey(asset);
final thumbnailProvider = CachedNetworkImageProvider(
thumbnailUrl,
cacheKey: thumbnailCacheKey,
headers: {"Authorization": authToken},
);
return precacheImage(thumbnailProvider, context);
}
// Precache the local image
if (!asset.isRemote &&
(asset.isLocal || !Store.get(StoreKey.preferRemoteImage, false))) {
final provider = AssetEntityImageProvider(
asset.local!,
isOriginal: false,
thumbnailSize: const ThumbnailSize.square(250), // like server thumbs
);
return precacheImage(provider, context);
if (useLocal(asset)) {
// Precache the local image
return precacheImage(localThumbnailProvider(asset), context);
} else {
final authToken = 'Bearer ${Store.get(StoreKey.accessToken)}';
// Precache the remote image since we are not using local images
final url = getThumbnailUrl(asset, type: api.ThumbnailFormat.JPEG);
final cacheKey =
getThumbnailCacheKey(asset, type: api.ThumbnailFormat.JPEG);
final provider = CachedNetworkImageProvider(
url,
cacheKey: cacheKey,
headers: {"Authorization": authToken},
return precacheImage(
remoteThumbnailProvider(asset, type, {"Authorization": authToken}),
context,
);
return precacheImage(provider, context);
}
}
static bool useLocal(Asset asset) =>
!asset.isRemote ||
asset.isLocal && !Store.get(StoreKey.preferRemoteImage, false);
}

View File

@@ -235,6 +235,7 @@ class PhotoView extends StatefulWidget {
const PhotoView({
Key? key,
required this.imageProvider,
required this.index,
this.loadingBuilder,
this.backgroundDecoration,
this.wantKeepAlive = false,
@@ -304,6 +305,7 @@ class PhotoView extends StatefulWidget {
imageProvider = null,
gaplessPlayback = false,
loadingBuilder = null,
index = 0,
super(key: key);
/// Given a [imageProvider] it resolves into an zoomable image widget using. It
@@ -419,6 +421,8 @@ class PhotoView extends StatefulWidget {
/// Useful when you want to drag a widget without restrictions.
final bool? enablePanAlways;
final int index;
bool get _isCustomChild {
return child != null;
}
@@ -571,6 +575,7 @@ class _PhotoViewState extends State<PhotoView>
disableGestures: widget.disableGestures,
errorBuilder: widget.errorBuilder,
enablePanAlways: widget.enablePanAlways,
index: widget.index,
);
},
);
@@ -625,7 +630,7 @@ typedef PhotoViewImageDragStartCallback = Function(
PhotoViewControllerValue controllerValue,
);
/// A type definition for a callback when the user drags
/// A type definition for a callback when the user drags
typedef PhotoViewImageDragUpdateCallback = Function(
BuildContext context,
DragUpdateDetails details,
@@ -650,4 +655,5 @@ typedef PhotoViewImageScaleEndCallback = Function(
typedef LoadingBuilder = Widget Function(
BuildContext context,
ImageChunkEvent? event,
int index,
);

View File

@@ -281,6 +281,7 @@ class _PhotoViewGalleryState extends State<PhotoViewGallery> {
)
: PhotoView(
key: ObjectKey(index),
index: index,
imageProvider: pageOption.imageProvider,
loadingBuilder: widget.loadingBuilder,
backgroundDecoration: widget.backgroundDecoration,
@@ -315,7 +316,10 @@ class _PhotoViewGalleryState extends State<PhotoViewGallery> {
);
}
PhotoViewGalleryPageOptions _buildPageOption(BuildContext context, int index) {
PhotoViewGalleryPageOptions _buildPageOption(
BuildContext context,
int index,
) {
if (widget._isBuilder) {
return widget.builder!(context, index);
}

View File

@@ -35,6 +35,7 @@ class ImageWrapper extends StatefulWidget {
required this.disableGestures,
required this.errorBuilder,
required this.enablePanAlways,
required this.index,
}) : super(key: key);
final ImageProvider imageProvider;
@@ -64,6 +65,7 @@ class ImageWrapper extends StatefulWidget {
final FilterQuality? filterQuality;
final bool? disableGestures;
final bool? enablePanAlways;
final int index;
@override
createState() => _ImageWrapperState();
@@ -128,6 +130,7 @@ class _ImageWrapperState extends State<ImageWrapper> {
_lastException = null;
_lastStack = null;
}
synchronousCall ? setupCB() : setState(setupCB);
}
@@ -212,7 +215,7 @@ class _ImageWrapperState extends State<ImageWrapper> {
Widget _buildLoading(BuildContext context) {
if (widget.loadingBuilder != null) {
return widget.loadingBuilder!(context, _loadingProgress);
return widget.loadingBuilder!(context, _loadingProgress, widget.index);
}
return PhotoViewDefaultLoading(