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
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 283 additions and 212 deletions

View file

@ -18,6 +18,7 @@ import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart';
import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart';
import 'package:immich_mobile/modules/home/ui/upload_dialog.dart';
import 'package:immich_mobile/shared/cache/original_image_provider.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/modules/home/ui/delete_dialog.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
@ -31,8 +32,7 @@ import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:openapi/api.dart' as api;
import 'package:openapi/api.dart' show ThumbnailFormat;
// ignore: must_be_immutable
class GalleryViewerPage extends HookConsumerWidget {
@ -51,6 +51,9 @@ class GalleryViewerPage extends HookConsumerWidget {
final PageController controller;
static const jpeg = ThumbnailFormat.JPEG;
static const webp = ThumbnailFormat.WEBP;
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(appSettingsServiceProvider);
@ -59,9 +62,9 @@ class GalleryViewerPage extends HookConsumerWidget {
final isZoomed = useState<bool>(false);
final isPlayingMotionVideo = useState(false);
final isPlayingVideo = useState(false);
final progressValue = useState(0.0);
Offset? localPosition;
final authToken = 'Bearer ${Store.get(StoreKey.accessToken)}';
final header = {"Authorization": authToken};
final currentIndex = useState(initialIndex);
final currentAsset = loadAsset(currentIndex.value);
@ -83,93 +86,52 @@ class GalleryViewerPage extends HookConsumerWidget {
.watch(assetProvider.notifier)
.toggleFavorite([asset], !asset.isFavorite);
/// Thumbnail image of a remote asset. Required asset.isRemote
ImageProvider remoteThumbnailImageProvider(
Asset asset,
api.ThumbnailFormat type,
) {
return CachedNetworkImageProvider(
getThumbnailUrl(
asset,
type: type,
),
cacheKey: getThumbnailCacheKey(
asset,
type: type,
),
headers: {"Authorization": authToken},
);
}
/// Original (large) image of a remote asset. Required asset.isRemote
ImageProvider originalImageProvider(Asset asset) {
return CachedNetworkImageProvider(
getImageUrl(asset),
cacheKey: getImageCacheKey(asset),
headers: {"Authorization": authToken},
);
}
/// Thumbnail image of a local asset. Required asset.isLocal
ImageProvider localThumbnailImageProvider(Asset asset) {
return AssetEntityImageProvider(
asset.local!,
isOriginal: false,
thumbnailSize: ThumbnailSize(
MediaQuery.of(context).size.width.floor(),
MediaQuery.of(context).size.height.floor(),
),
);
}
ImageProvider remoteOriginalProvider(Asset asset) =>
CachedNetworkImageProvider(
getImageUrl(asset),
cacheKey: getImageCacheKey(asset),
headers: header,
);
/// Original (large) image of a local asset. Required asset.isLocal
ImageProvider localImageProvider(Asset asset) {
return AssetEntityImageProvider(
isOriginal: true,
asset.local!,
);
ImageProvider localOriginalProvider(Asset asset) =>
OriginalImageProvider(asset);
ImageProvider finalImageProvider(Asset asset) {
if (ImmichImage.useLocal(asset)) {
return localOriginalProvider(asset);
} else if (isLoadOriginal.value) {
return remoteOriginalProvider(asset);
} else if (isLoadPreview.value) {
return ImmichImage.remoteThumbnailProvider(asset, jpeg, header);
}
return ImmichImage.remoteThumbnailProvider(asset, webp, header);
}
Iterable<ImageProvider> allImageProviders(Asset asset) sync* {
if (ImmichImage.useLocal(asset)) {
yield ImmichImage.localThumbnailProvider(asset);
yield localOriginalProvider(asset);
} else {
yield ImmichImage.remoteThumbnailProvider(asset, webp, header);
if (isLoadPreview.value) {
yield ImmichImage.remoteThumbnailProvider(asset, jpeg, header);
}
if (isLoadOriginal.value) {
yield remoteOriginalProvider(asset);
}
}
}
void precacheNextImage(int index) {
void onError(Object exception, StackTrace? stackTrace) {
// swallow error silently
}
if (index < totalAssets && index >= 0) {
final asset = loadAsset(index);
if (!asset.isRemote ||
asset.isLocal && !Store.get(StoreKey.preferRemoteImage, false)) {
// Preload the local asset
precacheImage(localImageProvider(asset), context);
} else {
onError(Object exception, StackTrace? stackTrace) {
// swallow error silently
}
// Probably load WEBP either way
precacheImage(
remoteThumbnailImageProvider(
asset,
api.ThumbnailFormat.WEBP,
),
context,
onError: onError,
);
if (isLoadPreview.value) {
// Precache the JPEG thumbnail
precacheImage(
remoteThumbnailImageProvider(
asset,
api.ThumbnailFormat.JPEG,
),
context,
onError: onError,
);
}
if (isLoadOriginal.value) {
// Preload the original asset
precacheImage(
originalImageProvider(asset),
context,
onError: onError,
);
}
for (final imageProvider in allImageProviders(asset)) {
precacheImage(imageProvider, context, onError: onError);
}
}
}
@ -346,7 +308,6 @@ class GalleryViewerPage extends HookConsumerWidget {
activeColor: Colors.white,
inactiveColor: Colors.white.withOpacity(0.75),
onChanged: (position) {
progressValue.value = position;
ref.read(videoPlayerControlsProvider.notifier).position = position;
},
),
@ -485,27 +446,6 @@ class GalleryViewerPage extends HookConsumerWidget {
}
});
ImageProvider imageProvider(Asset asset) {
if (!asset.isRemote ||
asset.isLocal && !Store.get(StoreKey.preferRemoteImage, false)) {
return localImageProvider(asset);
} else {
if (isLoadOriginal.value) {
return originalImageProvider(asset);
} else if (isLoadPreview.value) {
return remoteThumbnailImageProvider(
asset,
api.ThumbnailFormat.JPEG,
);
} else {
return remoteThumbnailImageProvider(
asset,
api.ThumbnailFormat.WEBP,
);
}
}
}
return Scaffold(
backgroundColor: Colors.black,
body: WillPopScope(
@ -531,79 +471,51 @@ class GalleryViewerPage extends HookConsumerWidget {
itemCount: totalAssets,
scrollDirection: Axis.horizontal,
onPageChanged: (value) {
// Precache image
if (currentIndex.value < value) {
// Moving forwards, so precache the next asset
precacheNextImage(value + 1);
} else {
// Moving backwards, so precache previous asset
precacheNextImage(value - 1);
}
final next = currentIndex.value < value ? value + 1 : value - 1;
precacheNextImage(next);
currentIndex.value = value;
progressValue.value = 0.0;
HapticFeedback.selectionClick();
},
loadingBuilder: isLoadPreview.value
? (context, event) {
final a = asset();
if (!a.isLocal ||
(a.isRemote &&
Store.get(StoreKey.preferRemoteImage, false))) {
// Use the WEBP Thumbnail as a placeholder for the JPEG thumbnail to achieve
// Three-Stage Loading (WEBP -> JPEG -> Original)
final webPThumbnail = CachedNetworkImage(
imageUrl: getThumbnailUrl(
a,
type: api.ThumbnailFormat.WEBP,
),
cacheKey: getThumbnailCacheKey(
a,
type: api.ThumbnailFormat.WEBP,
),
httpHeaders: {'Authorization': authToken},
progressIndicatorBuilder: (_, __, ___) =>
const Center(
child: ImmichLoadingIndicator(),
),
fadeInDuration: const Duration(milliseconds: 0),
fit: BoxFit.contain,
errorWidget: (context, url, error) =>
const Icon(Icons.image_not_supported_outlined),
);
loadingBuilder: (context, event, index) {
final a = loadAsset(index);
if (ImmichImage.useLocal(a)) {
return Image(
image: ImmichImage.localThumbnailProvider(a),
fit: BoxFit.contain,
);
}
// Use the WEBP Thumbnail as a placeholder for the JPEG thumbnail to achieve
// Three-Stage Loading (WEBP -> JPEG -> Original)
final webPThumbnail = CachedNetworkImage(
imageUrl: getThumbnailUrl(a, type: webp),
cacheKey: getThumbnailCacheKey(a, type: webp),
httpHeaders: header,
progressIndicatorBuilder: (_, __, ___) => const Center(
child: ImmichLoadingIndicator(),
),
fadeInDuration: const Duration(milliseconds: 0),
fit: BoxFit.contain,
errorWidget: (context, url, error) =>
const Icon(Icons.image_not_supported_outlined),
);
if (isLoadOriginal.value) {
// loading the preview in the loadingBuilder only
// makes sense if the original is loaded in the builder
return CachedNetworkImage(
imageUrl: getThumbnailUrl(
a,
type: api.ThumbnailFormat.JPEG,
),
cacheKey: getThumbnailCacheKey(
a,
type: api.ThumbnailFormat.JPEG,
),
httpHeaders: {'Authorization': authToken},
fit: BoxFit.contain,
fadeInDuration: const Duration(milliseconds: 0),
placeholder: (_, __) => webPThumbnail,
errorWidget: (_, __, ___) => webPThumbnail,
);
} else {
return webPThumbnail;
}
} else {
return Image(
image: localThumbnailImageProvider(a),
fit: BoxFit.contain,
);
}
}
: null,
// loading the preview in the loadingBuilder only
// makes sense if the original is loaded in the builder
return isLoadPreview.value && isLoadOriginal.value
? CachedNetworkImage(
imageUrl: getThumbnailUrl(a, type: jpeg),
cacheKey: getThumbnailCacheKey(a, type: jpeg),
httpHeaders: header,
fit: BoxFit.contain,
fadeInDuration: const Duration(milliseconds: 0),
placeholder: (_, __) => webPThumbnail,
errorWidget: (_, __, ___) => webPThumbnail,
)
: webPThumbnail;
},
builder: (context, index) {
final asset = loadAsset(index);
final ImageProvider provider = imageProvider(asset);
final ImageProvider provider = finalImageProvider(asset);
if (asset.isImage && !isPlayingMotionVideo.value) {
return PhotoViewGalleryPageOptions(