mirror of
https://github.com/immich-app/immich.git
synced 2025-01-06 03:46:47 +01:00
feat(mobile): image caching & viewer improvements (#4095)
This commit is contained in:
parent
63b6a71ebd
commit
1c02e1dadf
10 changed files with 283 additions and 212 deletions
|
@ -13,6 +13,7 @@ import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
|
||||||
import 'package:immich_mobile/modules/backup/models/duplicated_asset.model.dart';
|
import 'package:immich_mobile/modules/backup/models/duplicated_asset.model.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/routing/tab_navigation_observer.dart';
|
import 'package:immich_mobile/routing/tab_navigation_observer.dart';
|
||||||
|
import 'package:immich_mobile/shared/cache/widgets_binding.dart';
|
||||||
import 'package:immich_mobile/shared/models/album.dart';
|
import 'package:immich_mobile/shared/models/album.dart';
|
||||||
import 'package:immich_mobile/shared/models/android_device_asset.dart';
|
import 'package:immich_mobile/shared/models/android_device_asset.dart';
|
||||||
import 'package:immich_mobile/shared/models/asset.dart';
|
import 'package:immich_mobile/shared/models/asset.dart';
|
||||||
|
@ -37,7 +38,7 @@ import 'package:logging/logging.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
ImmichWidgetsBinding();
|
||||||
|
|
||||||
final db = await loadDb();
|
final db = await loadDb();
|
||||||
await initApp();
|
await initApp();
|
||||||
|
|
|
@ -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/asset_viewer/views/video_viewer_page.dart';
|
||||||
import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.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/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/shared/models/store.dart';
|
||||||
import 'package:immich_mobile/modules/home/ui/delete_dialog.dart';
|
import 'package:immich_mobile/modules/home/ui/delete_dialog.dart';
|
||||||
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.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/providers/asset.provider.dart';
|
||||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
import 'package:openapi/api.dart' show ThumbnailFormat;
|
||||||
import 'package:openapi/api.dart' as api;
|
|
||||||
|
|
||||||
// ignore: must_be_immutable
|
// ignore: must_be_immutable
|
||||||
class GalleryViewerPage extends HookConsumerWidget {
|
class GalleryViewerPage extends HookConsumerWidget {
|
||||||
|
@ -51,6 +51,9 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||||
|
|
||||||
final PageController controller;
|
final PageController controller;
|
||||||
|
|
||||||
|
static const jpeg = ThumbnailFormat.JPEG;
|
||||||
|
static const webp = ThumbnailFormat.WEBP;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final settings = ref.watch(appSettingsServiceProvider);
|
final settings = ref.watch(appSettingsServiceProvider);
|
||||||
|
@ -59,9 +62,9 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||||
final isZoomed = useState<bool>(false);
|
final isZoomed = useState<bool>(false);
|
||||||
final isPlayingMotionVideo = useState(false);
|
final isPlayingMotionVideo = useState(false);
|
||||||
final isPlayingVideo = useState(false);
|
final isPlayingVideo = useState(false);
|
||||||
final progressValue = useState(0.0);
|
|
||||||
Offset? localPosition;
|
Offset? localPosition;
|
||||||
final authToken = 'Bearer ${Store.get(StoreKey.accessToken)}';
|
final authToken = 'Bearer ${Store.get(StoreKey.accessToken)}';
|
||||||
|
final header = {"Authorization": authToken};
|
||||||
final currentIndex = useState(initialIndex);
|
final currentIndex = useState(initialIndex);
|
||||||
final currentAsset = loadAsset(currentIndex.value);
|
final currentAsset = loadAsset(currentIndex.value);
|
||||||
|
|
||||||
|
@ -83,93 +86,52 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||||
.watch(assetProvider.notifier)
|
.watch(assetProvider.notifier)
|
||||||
.toggleFavorite([asset], !asset.isFavorite);
|
.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
|
/// Original (large) image of a remote asset. Required asset.isRemote
|
||||||
ImageProvider originalImageProvider(Asset asset) {
|
ImageProvider remoteOriginalProvider(Asset asset) =>
|
||||||
return CachedNetworkImageProvider(
|
CachedNetworkImageProvider(
|
||||||
getImageUrl(asset),
|
getImageUrl(asset),
|
||||||
cacheKey: getImageCacheKey(asset),
|
cacheKey: getImageCacheKey(asset),
|
||||||
headers: {"Authorization": authToken},
|
headers: header,
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
/// 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(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Original (large) image of a local asset. Required asset.isLocal
|
/// Original (large) image of a local asset. Required asset.isLocal
|
||||||
ImageProvider localImageProvider(Asset asset) {
|
ImageProvider localOriginalProvider(Asset asset) =>
|
||||||
return AssetEntityImageProvider(
|
OriginalImageProvider(asset);
|
||||||
isOriginal: true,
|
|
||||||
asset.local!,
|
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 precacheNextImage(int index) {
|
||||||
|
void onError(Object exception, StackTrace? stackTrace) {
|
||||||
|
// swallow error silently
|
||||||
|
}
|
||||||
if (index < totalAssets && index >= 0) {
|
if (index < totalAssets && index >= 0) {
|
||||||
final asset = loadAsset(index);
|
final asset = loadAsset(index);
|
||||||
|
for (final imageProvider in allImageProviders(asset)) {
|
||||||
if (!asset.isRemote ||
|
precacheImage(imageProvider, context, onError: onError);
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -346,7 +308,6 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||||
activeColor: Colors.white,
|
activeColor: Colors.white,
|
||||||
inactiveColor: Colors.white.withOpacity(0.75),
|
inactiveColor: Colors.white.withOpacity(0.75),
|
||||||
onChanged: (position) {
|
onChanged: (position) {
|
||||||
progressValue.value = position;
|
|
||||||
ref.read(videoPlayerControlsProvider.notifier).position = 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(
|
return Scaffold(
|
||||||
backgroundColor: Colors.black,
|
backgroundColor: Colors.black,
|
||||||
body: WillPopScope(
|
body: WillPopScope(
|
||||||
|
@ -531,79 +471,51 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||||
itemCount: totalAssets,
|
itemCount: totalAssets,
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
onPageChanged: (value) {
|
onPageChanged: (value) {
|
||||||
// Precache image
|
final next = currentIndex.value < value ? value + 1 : value - 1;
|
||||||
if (currentIndex.value < value) {
|
precacheNextImage(next);
|
||||||
// Moving forwards, so precache the next asset
|
|
||||||
precacheNextImage(value + 1);
|
|
||||||
} else {
|
|
||||||
// Moving backwards, so precache previous asset
|
|
||||||
precacheNextImage(value - 1);
|
|
||||||
}
|
|
||||||
currentIndex.value = value;
|
currentIndex.value = value;
|
||||||
progressValue.value = 0.0;
|
|
||||||
|
|
||||||
HapticFeedback.selectionClick();
|
HapticFeedback.selectionClick();
|
||||||
},
|
},
|
||||||
loadingBuilder: isLoadPreview.value
|
loadingBuilder: (context, event, index) {
|
||||||
? (context, event) {
|
final a = loadAsset(index);
|
||||||
final a = asset();
|
if (ImmichImage.useLocal(a)) {
|
||||||
if (!a.isLocal ||
|
return Image(
|
||||||
(a.isRemote &&
|
image: ImmichImage.localThumbnailProvider(a),
|
||||||
Store.get(StoreKey.preferRemoteImage, false))) {
|
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(
|
// Use the WEBP Thumbnail as a placeholder for the JPEG thumbnail to achieve
|
||||||
imageUrl: getThumbnailUrl(
|
// Three-Stage Loading (WEBP -> JPEG -> Original)
|
||||||
a,
|
final webPThumbnail = CachedNetworkImage(
|
||||||
type: api.ThumbnailFormat.WEBP,
|
imageUrl: getThumbnailUrl(a, type: webp),
|
||||||
),
|
cacheKey: getThumbnailCacheKey(a, type: webp),
|
||||||
cacheKey: getThumbnailCacheKey(
|
httpHeaders: header,
|
||||||
a,
|
progressIndicatorBuilder: (_, __, ___) => const Center(
|
||||||
type: api.ThumbnailFormat.WEBP,
|
child: ImmichLoadingIndicator(),
|
||||||
),
|
),
|
||||||
httpHeaders: {'Authorization': authToken},
|
fadeInDuration: const Duration(milliseconds: 0),
|
||||||
progressIndicatorBuilder: (_, __, ___) =>
|
fit: BoxFit.contain,
|
||||||
const Center(
|
errorWidget: (context, url, error) =>
|
||||||
child: ImmichLoadingIndicator(),
|
const Icon(Icons.image_not_supported_outlined),
|
||||||
),
|
);
|
||||||
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
|
||||||
// loading the preview in the loadingBuilder only
|
// makes sense if the original is loaded in the builder
|
||||||
// makes sense if the original is loaded in the builder
|
return isLoadPreview.value && isLoadOriginal.value
|
||||||
return CachedNetworkImage(
|
? CachedNetworkImage(
|
||||||
imageUrl: getThumbnailUrl(
|
imageUrl: getThumbnailUrl(a, type: jpeg),
|
||||||
a,
|
cacheKey: getThumbnailCacheKey(a, type: jpeg),
|
||||||
type: api.ThumbnailFormat.JPEG,
|
httpHeaders: header,
|
||||||
),
|
fit: BoxFit.contain,
|
||||||
cacheKey: getThumbnailCacheKey(
|
fadeInDuration: const Duration(milliseconds: 0),
|
||||||
a,
|
placeholder: (_, __) => webPThumbnail,
|
||||||
type: api.ThumbnailFormat.JPEG,
|
errorWidget: (_, __, ___) => webPThumbnail,
|
||||||
),
|
)
|
||||||
httpHeaders: {'Authorization': authToken},
|
: webPThumbnail;
|
||||||
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,
|
|
||||||
builder: (context, index) {
|
builder: (context, index) {
|
||||||
final asset = loadAsset(index);
|
final asset = loadAsset(index);
|
||||||
final ImageProvider provider = imageProvider(asset);
|
final ImageProvider provider = finalImageProvider(asset);
|
||||||
|
|
||||||
if (asset.isImage && !isPlayingMotionVideo.value) {
|
if (asset.isImage && !isPlayingMotionVideo.value) {
|
||||||
return PhotoViewGalleryPageOptions(
|
return PhotoViewGalleryPageOptions(
|
||||||
|
|
69
mobile/lib/shared/cache/custom_image_cache.dart
vendored
Normal file
69
mobile/lib/shared/cache/custom_image_cache.dart
vendored
Normal 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);
|
||||||
|
}
|
73
mobile/lib/shared/cache/original_image_provider.dart
vendored
Normal file
73
mobile/lib/shared/cache/original_image_provider.dart
vendored
Normal 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;
|
||||||
|
}
|
8
mobile/lib/shared/cache/widgets_binding.dart
vendored
Normal file
8
mobile/lib/shared/cache/widgets_binding.dart
vendored
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
import 'custom_image_cache.dart';
|
||||||
|
|
||||||
|
final class ImmichWidgetsBinding extends WidgetsFlutterBinding {
|
||||||
|
@override
|
||||||
|
ImageCache createImageCache() => CustomImageCache();
|
||||||
|
}
|
|
@ -178,6 +178,7 @@ class Asset {
|
||||||
@override
|
@override
|
||||||
bool operator ==(other) {
|
bool operator ==(other) {
|
||||||
if (other is! Asset) return false;
|
if (other is! Asset) return false;
|
||||||
|
if (identical(this, other)) return true;
|
||||||
return id == other.id &&
|
return id == other.id &&
|
||||||
checksum == other.checksum &&
|
checksum == other.checksum &&
|
||||||
remoteId == other.remoteId &&
|
remoteId == other.remoteId &&
|
||||||
|
|
|
@ -45,14 +45,9 @@ class ImmichImage extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final Asset asset = this.asset!;
|
final Asset asset = this.asset!;
|
||||||
if (!asset.isRemote ||
|
if (useLocal(asset)) {
|
||||||
(asset.isLocal && !Store.get(StoreKey.preferRemoteImage, false))) {
|
|
||||||
return Image(
|
return Image(
|
||||||
image: AssetEntityImageProvider(
|
image: localThumbnailProvider(asset),
|
||||||
asset.local!,
|
|
||||||
isOriginal: false,
|
|
||||||
thumbnailSize: const ThumbnailSize.square(250), // like server thumbs
|
|
||||||
),
|
|
||||||
width: width,
|
width: width,
|
||||||
height: height,
|
height: height,
|
||||||
fit: fit,
|
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
|
/// Precaches this asset for instant load the next time it is shown
|
||||||
static Future<void> precacheAsset(
|
static Future<void> precacheAsset(
|
||||||
Asset asset,
|
Asset asset,
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
type = api.ThumbnailFormat.WEBP,
|
type = api.ThumbnailFormat.WEBP,
|
||||||
}) {
|
}) {
|
||||||
final authToken = 'Bearer ${Store.get(StoreKey.accessToken)}';
|
if (useLocal(asset)) {
|
||||||
|
// Precache the local image
|
||||||
if (type == api.ThumbnailFormat.WEBP) {
|
return precacheImage(localThumbnailProvider(asset), context);
|
||||||
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);
|
|
||||||
} else {
|
} else {
|
||||||
|
final authToken = 'Bearer ${Store.get(StoreKey.accessToken)}';
|
||||||
// Precache the remote image since we are not using local images
|
// Precache the remote image since we are not using local images
|
||||||
final url = getThumbnailUrl(asset, type: api.ThumbnailFormat.JPEG);
|
return precacheImage(
|
||||||
final cacheKey =
|
remoteThumbnailProvider(asset, type, {"Authorization": authToken}),
|
||||||
getThumbnailCacheKey(asset, type: api.ThumbnailFormat.JPEG);
|
context,
|
||||||
final provider = CachedNetworkImageProvider(
|
|
||||||
url,
|
|
||||||
cacheKey: cacheKey,
|
|
||||||
headers: {"Authorization": authToken},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return precacheImage(provider, context);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool useLocal(Asset asset) =>
|
||||||
|
!asset.isRemote ||
|
||||||
|
asset.isLocal && !Store.get(StoreKey.preferRemoteImage, false);
|
||||||
}
|
}
|
||||||
|
|
|
@ -235,6 +235,7 @@ class PhotoView extends StatefulWidget {
|
||||||
const PhotoView({
|
const PhotoView({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.imageProvider,
|
required this.imageProvider,
|
||||||
|
required this.index,
|
||||||
this.loadingBuilder,
|
this.loadingBuilder,
|
||||||
this.backgroundDecoration,
|
this.backgroundDecoration,
|
||||||
this.wantKeepAlive = false,
|
this.wantKeepAlive = false,
|
||||||
|
@ -304,6 +305,7 @@ class PhotoView extends StatefulWidget {
|
||||||
imageProvider = null,
|
imageProvider = null,
|
||||||
gaplessPlayback = false,
|
gaplessPlayback = false,
|
||||||
loadingBuilder = null,
|
loadingBuilder = null,
|
||||||
|
index = 0,
|
||||||
super(key: key);
|
super(key: key);
|
||||||
|
|
||||||
/// Given a [imageProvider] it resolves into an zoomable image widget using. It
|
/// 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.
|
/// Useful when you want to drag a widget without restrictions.
|
||||||
final bool? enablePanAlways;
|
final bool? enablePanAlways;
|
||||||
|
|
||||||
|
final int index;
|
||||||
|
|
||||||
bool get _isCustomChild {
|
bool get _isCustomChild {
|
||||||
return child != null;
|
return child != null;
|
||||||
}
|
}
|
||||||
|
@ -571,6 +575,7 @@ class _PhotoViewState extends State<PhotoView>
|
||||||
disableGestures: widget.disableGestures,
|
disableGestures: widget.disableGestures,
|
||||||
errorBuilder: widget.errorBuilder,
|
errorBuilder: widget.errorBuilder,
|
||||||
enablePanAlways: widget.enablePanAlways,
|
enablePanAlways: widget.enablePanAlways,
|
||||||
|
index: widget.index,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
@ -625,7 +630,7 @@ typedef PhotoViewImageDragStartCallback = Function(
|
||||||
PhotoViewControllerValue controllerValue,
|
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(
|
typedef PhotoViewImageDragUpdateCallback = Function(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
DragUpdateDetails details,
|
DragUpdateDetails details,
|
||||||
|
@ -650,4 +655,5 @@ typedef PhotoViewImageScaleEndCallback = Function(
|
||||||
typedef LoadingBuilder = Widget Function(
|
typedef LoadingBuilder = Widget Function(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
ImageChunkEvent? event,
|
ImageChunkEvent? event,
|
||||||
|
int index,
|
||||||
);
|
);
|
||||||
|
|
|
@ -281,6 +281,7 @@ class _PhotoViewGalleryState extends State<PhotoViewGallery> {
|
||||||
)
|
)
|
||||||
: PhotoView(
|
: PhotoView(
|
||||||
key: ObjectKey(index),
|
key: ObjectKey(index),
|
||||||
|
index: index,
|
||||||
imageProvider: pageOption.imageProvider,
|
imageProvider: pageOption.imageProvider,
|
||||||
loadingBuilder: widget.loadingBuilder,
|
loadingBuilder: widget.loadingBuilder,
|
||||||
backgroundDecoration: widget.backgroundDecoration,
|
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) {
|
if (widget._isBuilder) {
|
||||||
return widget.builder!(context, index);
|
return widget.builder!(context, index);
|
||||||
}
|
}
|
||||||
|
|
|
@ -35,6 +35,7 @@ class ImageWrapper extends StatefulWidget {
|
||||||
required this.disableGestures,
|
required this.disableGestures,
|
||||||
required this.errorBuilder,
|
required this.errorBuilder,
|
||||||
required this.enablePanAlways,
|
required this.enablePanAlways,
|
||||||
|
required this.index,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
final ImageProvider imageProvider;
|
final ImageProvider imageProvider;
|
||||||
|
@ -64,6 +65,7 @@ class ImageWrapper extends StatefulWidget {
|
||||||
final FilterQuality? filterQuality;
|
final FilterQuality? filterQuality;
|
||||||
final bool? disableGestures;
|
final bool? disableGestures;
|
||||||
final bool? enablePanAlways;
|
final bool? enablePanAlways;
|
||||||
|
final int index;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
createState() => _ImageWrapperState();
|
createState() => _ImageWrapperState();
|
||||||
|
@ -128,6 +130,7 @@ class _ImageWrapperState extends State<ImageWrapper> {
|
||||||
_lastException = null;
|
_lastException = null;
|
||||||
_lastStack = null;
|
_lastStack = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
synchronousCall ? setupCB() : setState(setupCB);
|
synchronousCall ? setupCB() : setState(setupCB);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -212,7 +215,7 @@ class _ImageWrapperState extends State<ImageWrapper> {
|
||||||
|
|
||||||
Widget _buildLoading(BuildContext context) {
|
Widget _buildLoading(BuildContext context) {
|
||||||
if (widget.loadingBuilder != null) {
|
if (widget.loadingBuilder != null) {
|
||||||
return widget.loadingBuilder!(context, _loadingProgress);
|
return widget.loadingBuilder!(context, _loadingProgress, widget.index);
|
||||||
}
|
}
|
||||||
|
|
||||||
return PhotoViewDefaultLoading(
|
return PhotoViewDefaultLoading(
|
||||||
|
|
Loading…
Reference in a new issue