refactor(mobile): Immich image provider (#7016)

* Adds image provider

* uses image provider

* wip load preview

* wip everything but activity asset thumbnail needs some help with a remote id

* Immich provider used in gallery

* First draft of the immich image provider, working nicely!

* Removed OriginalImageProvider

* Fixes for thumbnails

* feat(mobile): thumbhash support (#7028)

* feat(mobile): thumbhash support

* perf(mobile): store bmp thumbhash bytes in Isar

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>

* Uses octoimage for fade in and placeholders

* fixes thumbnails, removes unused values, adds better thumbnail size

* removes thumbhash support for now

* Forgot one thumbhash removal

* Use big thumbnail for local image on ios

* fix(mobile): Multipart image loading for iOS double swipe (#7064)

* uses local thumb first

* Multipart thumbnail

* Clean up file delete

* await file delete

* Fynn's comments, made thumbnail smaller and doesn't crash on erroring out on thumbnail

* lint

---------

Co-authored-by: Marty Fuhry <marty@fuhry.farm>
Co-authored-by: Alex <alex.tran1502@gmail.com>

* Moves http client to global private place for reuse

* Got rid of usePreview for local image providers since we always show a thumbnail anyway first

* linter

---------

Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com>
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
Co-authored-by: Marty Fuhry <marty@fuhry.farm>
This commit is contained in:
martyfuhry
2024-02-13 16:30:32 -05:00
committed by GitHub
parent 4b3f8d1946
commit 9b4a770b9d
21 changed files with 540 additions and 364 deletions
+5 -6
View File
@@ -1,6 +1,5 @@
import 'package:flutter/painting.dart';
import 'original_image_provider.dart';
import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_local_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
@@ -34,7 +33,7 @@ final class CustomImageCache implements ImageCache {
@override
bool containsKey(Object key) =>
(key is OriginalImageProvider ? _large : _small).containsKey(key);
(key is ImmichLocalImageProvider ? _large : _small).containsKey(key);
@override
int get currentSize => _small.currentSize + _large.currentSize;
@@ -44,7 +43,7 @@ final class CustomImageCache implements ImageCache {
@override
bool evict(Object key, {bool includeLive = true}) =>
(key is OriginalImageProvider ? _large : _small)
(key is ImmichLocalImageProvider ? _large : _small)
.evict(key, includeLive: includeLive);
@override
@@ -60,10 +59,10 @@ final class CustomImageCache implements ImageCache {
ImageStreamCompleter Function() loader, {
ImageErrorListener? onError,
}) =>
(key is OriginalImageProvider ? _large : _small)
(key is ImmichLocalImageProvider ? _large : _small)
.putIfAbsent(key, loader, onError: onError);
@override
ImageCacheStatus statusForKey(Object key) =>
(key is OriginalImageProvider ? _large : _small).statusForKey(key);
(key is ImmichLocalImageProvider ? _large : _small).statusForKey(key);
}
-73
View File
@@ -1,73 +0,0 @@
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;
}
+6 -6
View File
@@ -437,17 +437,17 @@ class Asset {
"remoteId": "${remoteId ?? "N/A"}",
"localId": "${localId ?? "N/A"}",
"checksum": "$checksum",
"ownerId": $ownerId,
"ownerId": $ownerId,
"livePhotoVideoId": "${livePhotoVideoId ?? "N/A"}",
"stackCount": "$stackCount",
"stackParentId": "${stackParentId ?? "N/A"}",
"fileCreatedAt": "$fileCreatedAt",
"fileModifiedAt": "$fileModifiedAt",
"updatedAt": "$updatedAt",
"durationInSeconds": $durationInSeconds,
"fileModifiedAt": "$fileModifiedAt",
"updatedAt": "$updatedAt",
"durationInSeconds": $durationInSeconds,
"type": "$type",
"fileName": "$fileName",
"isFavorite": $isFavorite,
"fileName": "$fileName",
"isFavorite": $isFavorite,
"isRemote": $isRemote,
"storage": "$storage",
"width": ${width ?? "N/A"},
+106 -158
View File
@@ -1,16 +1,16 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_local_image_provider.dart';
import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_remote_image_provider.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:octo_image/octo_image.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:openapi/api.dart' as api;
import 'package:photo_manager_image_provider/photo_manager_image_provider.dart';
/// Renders an Asset using local data if available, else remote data
class ImmichImage extends StatelessWidget {
const ImmichImage(
this.asset, {
@@ -18,23 +18,89 @@ class ImmichImage extends StatelessWidget {
this.height,
this.fit = BoxFit.cover,
this.useGrayBoxPlaceholder = false,
this.useProgressIndicator = false,
this.type = api.ThumbnailFormat.WEBP,
this.preferredLocalAssetSize = 250,
this.isThumbnail = false,
this.thumbnailSize = 250,
super.key,
});
final Asset? asset;
final bool useGrayBoxPlaceholder;
final bool useProgressIndicator;
final double? width;
final double? height;
final BoxFit fit;
final api.ThumbnailFormat type;
final int preferredLocalAssetSize;
final bool isThumbnail;
final int thumbnailSize;
/// Factory constructor to use the thumbnail variant
factory ImmichImage.thumbnail(
Asset? asset, {
BoxFit fit = BoxFit.cover,
double? width,
double? height,
}) {
// Use the width and height to derive thumbnail size
final thumbnailSize = max(width ?? 250, height ?? 250).toInt();
return ImmichImage(
asset,
isThumbnail: true,
fit: fit,
width: width,
height: height,
useGrayBoxPlaceholder: true,
thumbnailSize: thumbnailSize,
);
}
// Helper function to return the image provider for the asset
// either by using the asset ID or the asset itself
/// [asset] is the Asset to request, or else use [assetId] to get a remote
/// image provider
/// Use [isThumbnail] and [thumbnailSize] if you'd like to request a thumbnail
/// The size of the square thumbnail to request. Ignored if isThumbnail
/// is not true
static ImageProvider imageProvider({
Asset? asset,
String? assetId,
bool isThumbnail = false,
int thumbnailSize = 250,
}) {
if (asset == null && assetId == null) {
throw Exception('Must supply either asset or assetId');
}
if (asset == null) {
return ImmichRemoteImageProvider(
assetId: assetId!,
isThumbnail: isThumbnail,
);
}
if (useLocal(asset) && isThumbnail) {
return AssetEntityImageProvider(
asset.local!,
isOriginal: false,
thumbnailSize: ThumbnailSize.square(thumbnailSize),
);
} else if (useLocal(asset) && !isThumbnail) {
return ImmichLocalImageProvider(
asset: asset,
);
} else {
return ImmichRemoteImageProvider(
assetId: asset.remoteId!,
isThumbnail: isThumbnail,
);
}
}
static bool useLocal(Asset asset) =>
!asset.isRemote ||
asset.isLocal && !Store.get(StoreKey.preferRemoteImage, false);
@override
Widget build(BuildContext context) {
if (this.asset == null) {
if (asset == null) {
return Container(
decoration: const BoxDecoration(
color: Colors.grey,
@@ -48,96 +114,39 @@ class ImmichImage extends StatelessWidget {
),
);
}
final Asset asset = this.asset!;
if (useLocal(asset)) {
return Image(
image: localImageProvider(asset, size: preferredLocalAssetSize),
width: width,
height: height,
fit: fit,
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded || frame != null) {
return child;
}
// Show loading if desired
return Stack(
children: [
if (useGrayBoxPlaceholder)
const SizedBox.square(
dimension: 250,
child: DecoratedBox(
decoration: BoxDecoration(color: Colors.grey),
),
),
if (useProgressIndicator)
const Center(
child: CircularProgressIndicator(),
),
],
return OctoImage(
fadeInDuration: const Duration(milliseconds: 0),
fadeOutDuration: const Duration(milliseconds: 400),
placeholderBuilder: (context) {
if (useGrayBoxPlaceholder) {
// Use the gray box placeholder
return const SizedBox.expand(
child: DecoratedBox(
decoration: BoxDecoration(color: Colors.grey),
),
);
},
errorBuilder: (context, error, stackTrace) {
if (error is PlatformException &&
error.code == "The asset not found!") {
debugPrint(
"Asset ${asset.localId} does not exist anymore on device!",
);
} else {
debugPrint(
"Error getting thumb for assetId=${asset.localId}: $error",
);
}
return Icon(
Icons.image_not_supported_outlined,
color: context.primaryColor,
);
},
);
}
final String? accessToken = Store.get(StoreKey.accessToken);
final String thumbnailRequestUrl = getThumbnailUrl(asset, type: type);
return CachedNetworkImage(
imageUrl: thumbnailRequestUrl,
httpHeaders: {"x-immich-user-token": accessToken ?? ""},
cacheKey: getThumbnailCacheKey(asset, type: type),
}
// No placeholder
return const SizedBox();
},
image: ImmichImage.imageProvider(
asset: asset,
isThumbnail: isThumbnail,
),
width: width,
height: height,
// keeping memCacheWidth, memCacheHeight, maxWidthDiskCache and
// maxHeightDiskCache = null allows to simply store the webp thumbnail
// from the server and use it for all rendered thumbnail sizes
fit: fit,
fadeInDuration: const Duration(milliseconds: 250),
progressIndicatorBuilder: (context, url, downloadProgress) {
// Show loading if desired
return Stack(
children: [
if (useGrayBoxPlaceholder)
const SizedBox.square(
dimension: 250,
child: DecoratedBox(
decoration: BoxDecoration(color: Colors.grey),
),
),
if (useProgressIndicator)
Transform.scale(
scale: 2,
child: Center(
child: CircularProgressIndicator.adaptive(
strokeWidth: 1,
value: downloadProgress.progress,
),
),
),
],
);
},
errorWidget: (context, url, error) {
if (error is HttpExceptionWithStatus &&
error.statusCode >= 400 &&
error.statusCode < 500) {
debugPrint("Evicting thumbnail '$url' from cache: $error");
CachedNetworkImage.evictFromCache(url);
errorBuilder: (context, error, stackTrace) {
if (error is PlatformException &&
error.code == "The asset not found!") {
debugPrint(
"Asset ${asset?.localId} does not exist anymore on device!",
);
} else {
debugPrint(
"Error getting thumb for assetId=${asset?.localId}: $error",
);
}
return Icon(
Icons.image_not_supported_outlined,
@@ -146,65 +155,4 @@ class ImmichImage extends StatelessWidget {
},
);
}
static AssetEntityImageProvider localImageProvider(
Asset asset, {
int size = 250,
}) =>
AssetEntityImageProvider(
asset.local!,
isOriginal: false,
thumbnailSize: ThumbnailSize.square(size),
);
static CachedNetworkImageProvider remoteThumbnailProvider(
Asset asset,
api.ThumbnailFormat type,
Map<String, String> authHeader,
) =>
CachedNetworkImageProvider(
getThumbnailUrl(asset, type: type),
cacheKey: getThumbnailCacheKey(asset, type: type),
headers: authHeader,
);
/// TODO: refactor image providers to separate class
static CachedNetworkImageProvider remoteThumbnailProviderForId(
String assetId, {
api.ThumbnailFormat type = api.ThumbnailFormat.WEBP,
}) =>
CachedNetworkImageProvider(
getThumbnailUrlForRemoteId(assetId, type: type),
cacheKey: getThumbnailCacheKeyForRemoteId(assetId, type: type),
headers: {
"x-immich-user-token": Store.get(StoreKey.accessToken),
},
);
/// Precaches this asset for instant load the next time it is shown
static Future<void> precacheAsset(
Asset asset,
BuildContext context, {
type = api.ThumbnailFormat.WEBP,
size = 250,
}) {
if (useLocal(asset)) {
// Precache the local image
return precacheImage(
localImageProvider(asset, size: size),
context,
);
} else {
final accessToken = Store.get(StoreKey.accessToken);
// Precache the remote image since we are not using local images
return precacheImage(
remoteThumbnailProvider(asset, type, {"x-immich-user-token": accessToken}),
context,
);
}
}
static bool useLocal(Asset asset) =>
!asset.isRemote ||
asset.isLocal && !Store.get(StoreKey.preferRemoteImage, false);
}