feat(mobile): lazy loading of assets (#2413)

This commit is contained in:
Fynn Petersen-Frey
2023-05-17 19:36:02 +02:00
committed by GitHub
parent 93863b0629
commit 0dde76bbbc
54 changed files with 1494 additions and 2328 deletions

View File

@@ -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) {