mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +00:00
feat(mobile): lazy loading of assets (#2413)
This commit is contained in:
parent
93863b0629
commit
0dde76bbbc
54 changed files with 1494 additions and 2328 deletions
|
|
@ -12,9 +12,7 @@ import 'package:immich_mobile/modules/asset_viewer/ui/advanced_bottom_sheet.dart
|
|||
import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
|
||||
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/favorite/providers/favorite_provider.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/shared/services/asset.service.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/services/app_settings.service.dart';
|
||||
|
|
@ -32,16 +30,16 @@ import 'package:openapi/api.dart' as api;
|
|||
|
||||
// ignore: must_be_immutable
|
||||
class GalleryViewerPage extends HookConsumerWidget {
|
||||
final List<Asset> assetList;
|
||||
final Asset asset;
|
||||
final Asset Function(int index) loadAsset;
|
||||
final int totalAssets;
|
||||
final int initialIndex;
|
||||
|
||||
GalleryViewerPage({
|
||||
super.key,
|
||||
required this.assetList,
|
||||
required this.asset,
|
||||
}) : controller = PageController(initialPage: assetList.indexOf(asset));
|
||||
|
||||
Asset? assetDetail;
|
||||
required this.initialIndex,
|
||||
required this.loadAsset,
|
||||
required this.totalAssets,
|
||||
}) : controller = PageController(initialPage: initialIndex);
|
||||
|
||||
final PageController controller;
|
||||
|
||||
|
|
@ -52,11 +50,15 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
final isLoadOriginal = useState(AppSettingsEnum.loadOriginal.defaultValue);
|
||||
final isZoomed = useState<bool>(false);
|
||||
final showAppBar = useState<bool>(true);
|
||||
final indexOfAsset = useState(assetList.indexOf(asset));
|
||||
final isPlayingMotionVideo = useState(false);
|
||||
final isPlayingVideo = useState(false);
|
||||
late Offset localPosition;
|
||||
final authToken = 'Bearer ${Store.get(StoreKey.accessToken)}';
|
||||
final currentIndex = useState(initialIndex);
|
||||
final currentAsset = loadAsset(currentIndex.value);
|
||||
final watchedAsset = ref.watch(assetDetailProvider(currentAsset));
|
||||
|
||||
Asset asset() => watchedAsset.value ?? currentAsset;
|
||||
|
||||
showAppBar.addListener(() {
|
||||
// Change to and from immersive mode, hiding navigation and app bar
|
||||
|
|
@ -79,16 +81,9 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
[],
|
||||
);
|
||||
|
||||
void toggleFavorite(Asset asset) {
|
||||
ref.watch(favoriteProvider.notifier).toggleFavorite(asset);
|
||||
}
|
||||
|
||||
void getAssetExif() async {
|
||||
assetDetail = assetList[indexOfAsset.value];
|
||||
assetDetail = await ref
|
||||
.watch(assetServiceProvider)
|
||||
.loadExif(assetList[indexOfAsset.value]);
|
||||
}
|
||||
void toggleFavorite(Asset asset) => ref
|
||||
.watch(assetProvider.notifier)
|
||||
.toggleFavorite([asset], !asset.isFavorite);
|
||||
|
||||
/// Thumbnail image of a remote asset. Required asset.isRemote
|
||||
ImageProvider remoteThumbnailImageProvider(
|
||||
|
|
@ -138,8 +133,8 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
}
|
||||
|
||||
void precacheNextImage(int index) {
|
||||
if (index < assetList.length && index >= 0) {
|
||||
final asset = assetList[index];
|
||||
if (index < totalAssets && index >= 0) {
|
||||
final asset = loadAsset(index);
|
||||
|
||||
if (asset.isLocal) {
|
||||
// Preload the local asset
|
||||
|
|
@ -193,13 +188,13 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
if (ref
|
||||
.watch(appSettingsServiceProvider)
|
||||
.getSetting<bool>(AppSettingsEnum.advancedTroubleshooting)) {
|
||||
return AdvancedBottomSheet(assetDetail: assetDetail!);
|
||||
return AdvancedBottomSheet(assetDetail: asset());
|
||||
}
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: ExifBottomSheet(asset: assetDetail!),
|
||||
child: ExifBottomSheet(asset: asset()),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
@ -211,7 +206,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
builder: (BuildContext _) {
|
||||
return DeleteDialog(
|
||||
onDelete: () {
|
||||
if (assetList.length == 1) {
|
||||
if (totalAssets == 1) {
|
||||
// Handle only one asset
|
||||
AutoRouter.of(context).pop();
|
||||
} else {
|
||||
|
|
@ -221,7 +216,6 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
curve: Curves.fastLinearToSlowEaseIn,
|
||||
);
|
||||
}
|
||||
assetList.remove(deleteAsset);
|
||||
ref.watch(assetProvider.notifier).deleteAssets({deleteAsset});
|
||||
},
|
||||
);
|
||||
|
|
@ -267,9 +261,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
}
|
||||
|
||||
shareAsset() {
|
||||
ref
|
||||
.watch(imageViewerStateProvider.notifier)
|
||||
.shareAsset(assetList[indexOfAsset.value], context);
|
||||
ref.watch(imageViewerStateProvider.notifier).shareAsset(asset(), context);
|
||||
}
|
||||
|
||||
handleArchive(Asset asset) {
|
||||
|
|
@ -291,30 +283,21 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
color: Colors.black.withOpacity(0.4),
|
||||
child: TopControlAppBar(
|
||||
isPlayingMotionVideo: isPlayingMotionVideo.value,
|
||||
asset: assetList[indexOfAsset.value],
|
||||
isFavorite: ref.watch(favoriteProvider).contains(
|
||||
assetList[indexOfAsset.value].id,
|
||||
),
|
||||
onMoreInfoPressed: () {
|
||||
showInfo();
|
||||
},
|
||||
onFavorite: () {
|
||||
toggleFavorite(assetList[indexOfAsset.value]);
|
||||
},
|
||||
onDownloadPressed: assetList[indexOfAsset.value].storage ==
|
||||
AssetState.local
|
||||
asset: asset(),
|
||||
isFavorite: asset().isFavorite,
|
||||
onMoreInfoPressed: showInfo,
|
||||
onFavorite: asset().isRemote ? () => toggleFavorite(asset()) : null,
|
||||
onDownloadPressed: asset().storage == AssetState.local
|
||||
? null
|
||||
: () {
|
||||
: () =>
|
||||
ref.watch(imageViewerStateProvider.notifier).downloadAsset(
|
||||
assetList[indexOfAsset.value],
|
||||
asset(),
|
||||
context,
|
||||
);
|
||||
},
|
||||
),
|
||||
onToggleMotionVideo: (() {
|
||||
isPlayingMotionVideo.value = !isPlayingMotionVideo.value;
|
||||
}),
|
||||
onAddToAlbumPressed: () =>
|
||||
addToAlbum(assetList[indexOfAsset.value]),
|
||||
onAddToAlbumPressed: () => addToAlbum(asset()),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
@ -324,8 +307,6 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
final show = (showAppBar.value || // onTap has the final say
|
||||
(showAppBar.value && !isZoomed.value)) &&
|
||||
!isPlayingVideo.value;
|
||||
final currentAsset = assetList[indexOfAsset.value];
|
||||
|
||||
return AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 100),
|
||||
opacity: show ? 1.0 : 0.0,
|
||||
|
|
@ -343,7 +324,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
label: 'control_bottom_app_bar_share'.tr(),
|
||||
tooltip: 'control_bottom_app_bar_share'.tr(),
|
||||
),
|
||||
currentAsset.isArchived
|
||||
asset().isArchived
|
||||
? BottomNavigationBarItem(
|
||||
icon: const Icon(Icons.unarchive_rounded),
|
||||
label: 'control_bottom_app_bar_unarchive'.tr(),
|
||||
|
|
@ -366,10 +347,10 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
shareAsset();
|
||||
break;
|
||||
case 1:
|
||||
handleArchive(assetList[indexOfAsset.value]);
|
||||
handleArchive(asset());
|
||||
break;
|
||||
case 2:
|
||||
handleDelete(assetList[indexOfAsset.value]);
|
||||
handleDelete(asset());
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
|
@ -399,33 +380,33 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
? const ScrollPhysics() // Use bouncing physics for iOS
|
||||
: const ClampingScrollPhysics() // Use heavy physics for Android
|
||||
),
|
||||
itemCount: assetList.length,
|
||||
itemCount: totalAssets,
|
||||
scrollDirection: Axis.horizontal,
|
||||
onPageChanged: (value) {
|
||||
// Precache image
|
||||
if (indexOfAsset.value < value) {
|
||||
if (currentIndex.value < value) {
|
||||
// Moving forwards, so precache the next asset
|
||||
precacheNextImage(value + 1);
|
||||
} else {
|
||||
// Moving backwards, so precache previous asset
|
||||
precacheNextImage(value - 1);
|
||||
}
|
||||
indexOfAsset.value = value;
|
||||
currentIndex.value = value;
|
||||
HapticFeedback.selectionClick();
|
||||
},
|
||||
loadingBuilder: isLoadPreview.value
|
||||
? (context, event) {
|
||||
final asset = assetList[indexOfAsset.value];
|
||||
if (!asset.isLocal) {
|
||||
final a = asset();
|
||||
if (!a.isLocal) {
|
||||
// Use the WEBP Thumbnail as a placeholder for the JPEG thumbnail to achieve
|
||||
// Three-Stage Loading (WEBP -> JPEG -> Original)
|
||||
final webPThumbnail = CachedNetworkImage(
|
||||
imageUrl: getThumbnailUrl(
|
||||
asset,
|
||||
a,
|
||||
type: api.ThumbnailFormat.WEBP,
|
||||
),
|
||||
cacheKey: getThumbnailCacheKey(
|
||||
asset,
|
||||
a,
|
||||
type: api.ThumbnailFormat.WEBP,
|
||||
),
|
||||
httpHeaders: {'Authorization': authToken},
|
||||
|
|
@ -444,11 +425,11 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
// makes sense if the original is loaded in the builder
|
||||
return CachedNetworkImage(
|
||||
imageUrl: getThumbnailUrl(
|
||||
asset,
|
||||
a,
|
||||
type: api.ThumbnailFormat.JPEG,
|
||||
),
|
||||
cacheKey: getThumbnailCacheKey(
|
||||
asset,
|
||||
a,
|
||||
type: api.ThumbnailFormat.JPEG,
|
||||
),
|
||||
httpHeaders: {'Authorization': authToken},
|
||||
|
|
@ -462,30 +443,30 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
}
|
||||
} else {
|
||||
return Image(
|
||||
image: localThumbnailImageProvider(asset),
|
||||
image: localThumbnailImageProvider(a),
|
||||
fit: BoxFit.contain,
|
||||
);
|
||||
}
|
||||
}
|
||||
: null,
|
||||
builder: (context, index) {
|
||||
getAssetExif();
|
||||
if (assetList[index].isImage && !isPlayingMotionVideo.value) {
|
||||
final asset = loadAsset(index);
|
||||
if (asset.isImage && !isPlayingMotionVideo.value) {
|
||||
// Show photo
|
||||
final ImageProvider provider;
|
||||
if (assetList[index].isLocal) {
|
||||
provider = localImageProvider(assetList[index]);
|
||||
if (asset.isLocal) {
|
||||
provider = localImageProvider(asset);
|
||||
} else {
|
||||
if (isLoadOriginal.value) {
|
||||
provider = originalImageProvider(assetList[index]);
|
||||
provider = originalImageProvider(asset);
|
||||
} else if (isLoadPreview.value) {
|
||||
provider = remoteThumbnailImageProvider(
|
||||
assetList[index],
|
||||
asset,
|
||||
api.ThumbnailFormat.JPEG,
|
||||
);
|
||||
} else {
|
||||
provider = remoteThumbnailImageProvider(
|
||||
assetList[index],
|
||||
asset,
|
||||
api.ThumbnailFormat.WEBP,
|
||||
);
|
||||
}
|
||||
|
|
@ -499,13 +480,13 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
showAppBar.value = !showAppBar.value,
|
||||
imageProvider: provider,
|
||||
heroAttributes: PhotoViewHeroAttributes(
|
||||
tag: assetList[index].id,
|
||||
tag: asset.id,
|
||||
),
|
||||
filterQuality: FilterQuality.high,
|
||||
tightMode: true,
|
||||
minScale: PhotoViewComputedScale.contained,
|
||||
errorBuilder: (context, error, stackTrace) => ImmichImage(
|
||||
assetList[indexOfAsset.value],
|
||||
asset,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
);
|
||||
|
|
@ -516,7 +497,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
onDragUpdate: (_, details, __) =>
|
||||
handleSwipeUpDown(details),
|
||||
heroAttributes: PhotoViewHeroAttributes(
|
||||
tag: assetList[index].id,
|
||||
tag: asset.id,
|
||||
),
|
||||
filterQuality: FilterQuality.high,
|
||||
maxScale: 1.0,
|
||||
|
|
@ -526,7 +507,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
child: VideoViewerPage(
|
||||
onPlaying: () => isPlayingVideo.value = true,
|
||||
onPaused: () => isPlayingVideo.value = false,
|
||||
asset: assetList[index],
|
||||
asset: asset,
|
||||
isMotionVideo: isPlayingMotionVideo.value,
|
||||
onVideoEnded: () {
|
||||
if (isPlayingMotionVideo.value) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue