import 'dart:io'; import 'dart:math'; import 'package:easy_localization/easy_localization.dart'; import 'package:auto_route/auto_route.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/modules/album/providers/current_album.provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/asset_stack.provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/video_player_controls_provider.dart'; import 'package:immich_mobile/modules/album/ui/add_to_album_bottom_sheet.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/video_player_value_provider.dart'; import 'package:immich_mobile/modules/asset_viewer/services/asset_stack.service.dart'; 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/backup/providers/manual_upload.provider.dart'; import 'package:immich_mobile/modules/home/ui/upload_dialog.dart'; import 'package:immich_mobile/modules/partner/providers/partner.provider.dart'; import 'package:immich_mobile/shared/cache/original_image_provider.dart'; import 'package:immich_mobile/routing/router.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'; import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; import 'package:immich_mobile/shared/providers/server_info.provider.dart'; import 'package:immich_mobile/shared/providers/user.provider.dart'; import 'package:immich_mobile/shared/ui/immich_image.dart'; import 'package:immich_mobile/shared/ui/immich_toast.dart'; import 'package:immich_mobile/shared/ui/photo_view/photo_view_gallery.dart'; import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_computed_scale.dart'; import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_scale_state.dart'; import 'package:immich_mobile/shared/ui/photo_view/src/utils/photo_view_hero_attributes.dart'; 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:isar/isar.dart'; import 'package:openapi/api.dart' show ThumbnailFormat; // ignore: must_be_immutable class GalleryViewerPage extends HookConsumerWidget { final Asset Function(int index) loadAsset; final int totalAssets; final int initialIndex; final int heroOffset; final bool showStack; GalleryViewerPage({ super.key, required this.initialIndex, required this.loadAsset, required this.totalAssets, this.heroOffset = 0, this.showStack = false, }) : controller = PageController(initialPage: initialIndex); 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); final isLoadPreview = useState(AppSettingsEnum.loadPreview.defaultValue); final isLoadOriginal = useState(AppSettingsEnum.loadOriginal.defaultValue); final isZoomed = useState(false); final isPlayingMotionVideo = useState(false); final isPlayingVideo = useState(false); Offset? localPosition; final authToken = 'Bearer ${Store.get(StoreKey.accessToken)}'; final header = {"Authorization": authToken}; final currentIndex = useState(initialIndex); final currentAsset = loadAsset(currentIndex.value); final isTrashEnabled = ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash)); final navStack = AutoRouter.of(context).stackData; final isFromTrash = isTrashEnabled && navStack.length > 2 && navStack.elementAt(navStack.length - 2).name == TrashRoute.name; final stackIndex = useState(-1); final stack = showStack && currentAsset.stackChildrenCount > 0 ? ref.watch(assetStackStateProvider(currentAsset)) : []; final stackElements = showStack ? [currentAsset, ...stack] : []; // Assets from response DTOs do not have an isar id, querying which would give us the default autoIncrement id final isFromDto = currentAsset.id == Isar.autoIncrement; final album = ref.watch(currentAlbumProvider); Asset asset() => stackIndex.value == -1 ? currentAsset : stackElements.elementAt(stackIndex.value); final isOwner = asset().ownerId == ref.watch(currentUserProvider)?.isarId; final isPartner = ref .watch(partnerSharedWithProvider) .map((e) => e.isarId) .contains(asset().ownerId); bool isParent = stackIndex.value == -1 || stackIndex.value == 0; useEffect( () { isLoadPreview.value = settings.getSetting(AppSettingsEnum.loadPreview); isLoadOriginal.value = settings.getSetting(AppSettingsEnum.loadOriginal); isPlayingMotionVideo.value = false; return null; }, [], ); void toggleFavorite(Asset asset) => ref.read(assetProvider.notifier).toggleFavorite([asset]); /// Original (large) image of a remote asset. Required asset.isRemote ImageProvider remoteOriginalProvider(Asset asset) => CachedNetworkImageProvider( getImageUrl(asset), cacheKey: getImageCacheKey(asset), headers: header, ); /// Original (large) image of a local asset. Required asset.isLocal 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 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); for (final imageProvider in allImageProviders(asset)) { precacheImage(imageProvider, context, onError: onError); } } } void showInfo() { showModalBottomSheet( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(15.0), ), barrierColor: Colors.transparent, backgroundColor: Colors.transparent, isScrollControlled: true, useSafeArea: true, context: context, builder: (context) { return Padding( padding: EdgeInsets.only( bottom: MediaQuery.viewInsetsOf(context).bottom, ), child: ref .watch(appSettingsServiceProvider) .getSetting(AppSettingsEnum.advancedTroubleshooting) ? AdvancedBottomSheet(assetDetail: asset()) : ExifBottomSheet(asset: asset()), ); }, ); } void removeAssetFromStack() { if (stackIndex.value > 0 && showStack) { ref .read(assetStackStateProvider(currentAsset).notifier) .removeChild(stackIndex.value - 1); stackIndex.value = stackIndex.value - 1; } } void handleDelete(Asset deleteAsset) async { Future onDelete(bool force) async { final isDeleted = await ref.read(assetProvider.notifier).deleteAssets( {deleteAsset}, force: force, ); if (isDeleted && isParent) { if (totalAssets == 1) { // Handle only one asset context.autoPop(); } else { // Go to next page otherwise controller.nextPage( duration: const Duration(milliseconds: 100), curve: Curves.fastLinearToSlowEaseIn, ); } } return isDeleted; } // Asset is trashed if (isTrashEnabled && !isFromTrash) { final isDeleted = await onDelete(false); if (isDeleted) { // Can only trash assets stored in server. Local assets are always permanently removed for now if (context.mounted && deleteAsset.isRemote && isParent) { ImmichToast.show( durationInSecond: 1, context: context, msg: 'Asset trashed', gravity: ToastGravity.BOTTOM, ); } removeAssetFromStack(); } return; } // Asset is permanently removed showDialog( context: context, builder: (BuildContext _) { return DeleteDialog( onDelete: () async { final isDeleted = await onDelete(true); if (isDeleted) { removeAssetFromStack(); } }, ); }, ); } void addToAlbum(Asset addToAlbumAsset) { showModalBottomSheet( elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(15.0), ), context: context, builder: (BuildContext _) { return AddToAlbumBottomSheet( assets: [addToAlbumAsset], ); }, ); } void handleSwipeUpDown(DragUpdateDetails details) { int sensitivity = 15; int dxThreshold = 50; double ratioThreshold = 3.0; if (isZoomed.value) { return; } // Guard [localPosition] null if (localPosition == null) { return; } // Check for delta from initial down point final d = details.localPosition - localPosition!; // If the magnitude of the dx swipe is large, we probably didn't mean to go down if (d.dx.abs() > dxThreshold) { return; } final ratio = d.dy / max(d.dx.abs(), 1); if (d.dy > sensitivity && ratio > ratioThreshold) { context.autoPop(); } else if (d.dy < -sensitivity && ratio < -ratioThreshold) { showInfo(); } } shareAsset() { ref.watch(imageViewerStateProvider.notifier).shareAsset(asset(), context); } handleArchive(Asset asset) { ref.watch(assetProvider.notifier).toggleArchive([asset]); if (isParent) { context.autoPop(); return; } removeAssetFromStack(); } handleUpload(Asset asset) { showDialog( context: context, builder: (BuildContext _) { return UploadDialog( onUpload: () { ref .read(manualUploadProvider.notifier) .uploadAssets(context, [asset]); }, ); }, ); } handleActivities() { if (album != null && album.shared && album.remoteId != null) { context.autoPush( ActivitiesRoute( albumId: album.remoteId!, assetId: asset().remoteId, withAssetThumbs: false, isOwner: isOwner, ), ); } } buildAppBar() { return IgnorePointer( ignoring: !ref.watch(showControlsProvider), child: AnimatedOpacity( duration: const Duration(milliseconds: 100), opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0, child: Container( color: Colors.black.withOpacity(0.4), child: TopControlAppBar( isOwner: isOwner, isPartner: isPartner, isPlayingMotionVideo: isPlayingMotionVideo.value, asset: asset(), onMoreInfoPressed: showInfo, onFavorite: toggleFavorite, onUploadPressed: asset().isLocal ? () => handleUpload(asset()) : null, onDownloadPressed: asset().isLocal ? null : () => ref .watch(imageViewerStateProvider.notifier) .downloadAsset( asset(), context, ), onToggleMotionVideo: (() { isPlayingMotionVideo.value = !isPlayingMotionVideo.value; }), onAddToAlbumPressed: () => addToAlbum(asset()), onActivitiesPressed: handleActivities, ), ), ), ); } Widget buildProgressBar() { final playerValue = ref.watch(videoPlaybackValueProvider); return Expanded( child: Slider( value: playerValue.duration == Duration.zero ? 0.0 : min( playerValue.position.inMicroseconds / playerValue.duration.inMicroseconds * 100, 100, ), min: 0, max: 100, thumbColor: Colors.white, activeColor: Colors.white, inactiveColor: Colors.white.withOpacity(0.75), onChanged: (position) { ref.read(videoPlayerControlsProvider.notifier).position = position; }, ), ); } Text buildPosition() { final position = ref .watch(videoPlaybackValueProvider.select((value) => value.position)); return Text( _formatDuration(position), style: TextStyle( fontSize: 14.0, color: Colors.white.withOpacity(.75), fontWeight: FontWeight.normal, ), ); } Text buildDuration() { final duration = ref .watch(videoPlaybackValueProvider.select((value) => value.duration)); return Text( _formatDuration(duration), style: TextStyle( fontSize: 14.0, color: Colors.white.withOpacity(.75), fontWeight: FontWeight.normal, ), ); } Widget buildMuteButton() { return IconButton( icon: Icon( ref.watch(videoPlayerControlsProvider.select((value) => value.mute)) ? Icons.volume_off : Icons.volume_up, ), onPressed: () => ref.read(videoPlayerControlsProvider.notifier).toggleMute(), color: Colors.white, ); } Widget buildStackedChildren() { return ListView.builder( shrinkWrap: true, scrollDirection: Axis.horizontal, itemCount: stackElements.length, itemBuilder: (context, index) { final assetId = stackElements.elementAt(index).remoteId; return Padding( padding: const EdgeInsets.only(right: 10), child: GestureDetector( onTap: () => stackIndex.value = index, child: Container( width: 40, decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(6), border: (stackIndex.value == -1 && index == 0) || index == stackIndex.value ? Border.all( color: Colors.white, width: 2, ) : null, ), child: ClipRRect( borderRadius: BorderRadius.circular(4), child: CachedNetworkImage( fit: BoxFit.cover, imageUrl: '${Store.get(StoreKey.serverEndpoint)}/asset/thumbnail/$assetId', httpHeaders: { "Authorization": "Bearer ${Store.get(StoreKey.accessToken)}", }, errorWidget: (context, url, error) => const Icon(Icons.image_not_supported_outlined), ), ), ), ), ); }, ); } void showStackActionItems() { showModalBottomSheet( context: context, enableDrag: false, builder: (BuildContext ctx) { return SafeArea( child: Padding( padding: const EdgeInsets.only(top: 24.0), child: Column( mainAxisSize: MainAxisSize.min, children: [ if (!isParent) ListTile( leading: const Icon( Icons.bookmark_border_outlined, size: 24, ), onTap: () async { await ref .read(assetStackServiceProvider) .updateStackParent( currentAsset, stackElements.elementAt(stackIndex.value), ); ctx.pop(); context.autoPop(); }, title: const Text( "viewer_stack_use_as_main_asset", style: TextStyle(fontWeight: FontWeight.bold), ).tr(), ), ListTile( leading: const Icon( Icons.copy_all_outlined, size: 24, ), onTap: () async { if (isParent) { await ref .read(assetStackServiceProvider) .updateStackParent( currentAsset, stackElements .elementAt(1), // Next asset as parent ); // Remove itself from stack await ref.read(assetStackServiceProvider).updateStack( stackElements.elementAt(1), childrenToRemove: [currentAsset], ); ctx.pop(); context.autoPop(); } else { await ref.read(assetStackServiceProvider).updateStack( currentAsset, childrenToRemove: [ stackElements.elementAt(stackIndex.value), ], ); removeAssetFromStack(); ctx.pop(); } }, title: const Text( "viewer_remove_from_stack", style: TextStyle(fontWeight: FontWeight.bold), ).tr(), ), ListTile( leading: const Icon( Icons.filter_none_outlined, size: 18, ), onTap: () async { await ref.read(assetStackServiceProvider).updateStack( currentAsset, childrenToRemove: stack, ); ctx.pop(); context.autoPop(); }, title: const Text( "viewer_unstack", style: TextStyle(fontWeight: FontWeight.bold), ).tr(), ), ], ), ), ); }, ); } Widget buildBottomBar() { // !!!! itemsList and actionlist should always be in sync final itemsList = [ BottomNavigationBarItem( icon: Icon( Platform.isAndroid ? Icons.share_rounded : Icons.ios_share_rounded, ), label: 'control_bottom_app_bar_share'.tr(), tooltip: 'control_bottom_app_bar_share'.tr(), ), if (isOwner) asset().isArchived ? BottomNavigationBarItem( icon: const Icon(Icons.unarchive_rounded), label: 'control_bottom_app_bar_unarchive'.tr(), tooltip: 'control_bottom_app_bar_unarchive'.tr(), ) : BottomNavigationBarItem( icon: const Icon(Icons.archive_outlined), label: 'control_bottom_app_bar_archive'.tr(), tooltip: 'control_bottom_app_bar_archive'.tr(), ), if (isOwner && stack.isNotEmpty) BottomNavigationBarItem( icon: const Icon(Icons.burst_mode_outlined), label: 'control_bottom_app_bar_stack'.tr(), tooltip: 'control_bottom_app_bar_stack'.tr(), ), if (isOwner) BottomNavigationBarItem( icon: const Icon(Icons.delete_outline), label: 'control_bottom_app_bar_delete'.tr(), tooltip: 'control_bottom_app_bar_delete'.tr(), ), if (!isOwner) BottomNavigationBarItem( icon: const Icon(Icons.download_outlined), label: 'download'.tr(), tooltip: 'download'.tr(), ), ]; List actionslist = [ (_) => shareAsset(), if (isOwner) (_) => handleArchive(asset()), if (isOwner && stack.isNotEmpty) (_) => showStackActionItems(), if (isOwner) (_) => handleDelete(asset()), if (!isOwner) (_) => asset().isLocal ? null : ref.watch(imageViewerStateProvider.notifier).downloadAsset( asset(), context, ), ]; return IgnorePointer( ignoring: !ref.watch(showControlsProvider), child: AnimatedOpacity( duration: const Duration(milliseconds: 100), opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0, child: Column( children: [ if (stack.isNotEmpty) Padding( padding: const EdgeInsets.only( left: 10, bottom: 30, ), child: SizedBox( height: 40, child: buildStackedChildren(), ), ), Visibility( visible: !asset().isImage && !isPlayingMotionVideo.value, child: Container( color: Colors.black.withOpacity(0.4), child: Padding( padding: MediaQuery.of(context).orientation == Orientation.portrait ? const EdgeInsets.symmetric(horizontal: 12.0) : const EdgeInsets.symmetric(horizontal: 64.0), child: Row( children: [ buildPosition(), buildProgressBar(), buildDuration(), buildMuteButton(), ], ), ), ), ), BottomNavigationBar( backgroundColor: Colors.black.withOpacity(0.4), unselectedIconTheme: const IconThemeData(color: Colors.white), selectedIconTheme: const IconThemeData(color: Colors.white), unselectedLabelStyle: const TextStyle(color: Colors.black), selectedLabelStyle: const TextStyle(color: Colors.black), showSelectedLabels: false, showUnselectedLabels: false, items: itemsList, onTap: (index) { if (index < actionslist.length) { actionslist[index].call(index); } }, ), ], ), ), ); } ref.listen(showControlsProvider, (_, show) { if (show) { SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); } else { SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); } }); return Scaffold( backgroundColor: Colors.black, body: WillPopScope( onWillPop: () async { // Change immersive mode back to normal "edgeToEdge" mode await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); return true; }, child: Stack( children: [ PhotoViewGallery.builder( scaleStateChangedCallback: (state) { isZoomed.value = state != PhotoViewScaleState.initial; ref.read(showControlsProvider.notifier).show = !isZoomed.value; }, pageController: controller, scrollPhysics: isZoomed.value ? const NeverScrollableScrollPhysics() // Don't allow paging while scrolled in : (Platform.isIOS ? const ScrollPhysics() // Use bouncing physics for iOS : const ClampingScrollPhysics() // Use heavy physics for Android ), itemCount: totalAssets, scrollDirection: Axis.horizontal, onPageChanged: (value) { final next = currentIndex.value < value ? value + 1 : value - 1; precacheNextImage(next); currentIndex.value = value; stackIndex.value = -1; HapticFeedback.selectionClick(); }, 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), ); // 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 a = index == currentIndex.value ? asset() : loadAsset(index); final ImageProvider provider = finalImageProvider(a); if (a.isImage && !isPlayingMotionVideo.value) { return PhotoViewGalleryPageOptions( onDragStart: (_, details, __) => localPosition = details.localPosition, onDragUpdate: (_, details, __) => handleSwipeUpDown(details), onTapDown: (_, __, ___) { ref.read(showControlsProvider.notifier).toggle(); }, imageProvider: provider, heroAttributes: PhotoViewHeroAttributes( tag: isFromDto ? '${a.remoteId}-$heroOffset' : a.id + heroOffset, transitionOnUserGestures: true, ), filterQuality: FilterQuality.high, tightMode: true, minScale: PhotoViewComputedScale.contained, errorBuilder: (context, error, stackTrace) => ImmichImage( a, fit: BoxFit.contain, ), ); } else { return PhotoViewGalleryPageOptions.customChild( onDragStart: (_, details, __) => localPosition = details.localPosition, onDragUpdate: (_, details, __) => handleSwipeUpDown(details), heroAttributes: PhotoViewHeroAttributes( tag: isFromDto ? '${a.remoteId}-$heroOffset' : a.id + heroOffset, ), filterQuality: FilterQuality.high, maxScale: 1.0, minScale: 1.0, basePosition: Alignment.center, child: VideoViewerPage( onPlaying: () => isPlayingVideo.value = true, onPaused: () => WidgetsBinding.instance.addPostFrameCallback( (_) => isPlayingVideo.value = false, ), asset: a, isMotionVideo: isPlayingMotionVideo.value, placeholder: Image( image: provider, fit: BoxFit.fitWidth, height: context.height, width: context.width, alignment: Alignment.center, ), onVideoEnded: () { if (isPlayingMotionVideo.value) { isPlayingMotionVideo.value = false; } }, ), ); } }, ), Positioned( top: 0, left: 0, right: 0, child: buildAppBar(), ), Positioned( bottom: 0, left: 0, right: 0, child: buildBottomBar(), ), ], ), ), ); } String _formatDuration(Duration position) { final ms = position.inMilliseconds; int seconds = ms ~/ 1000; final int hours = seconds ~/ 3600; seconds = seconds % 3600; final minutes = seconds ~/ 60; seconds = seconds % 60; final hoursString = hours >= 10 ? '$hours' : hours == 0 ? '00' : '0$hours'; final minutesString = minutes >= 10 ? '$minutes' : minutes == 0 ? '00' : '0$minutes'; final secondsString = seconds >= 10 ? '$seconds' : seconds == 0 ? '00' : '0$seconds'; final formattedTime = '${hoursString == '00' ? '' : '$hoursString:'}$minutesString:$secondsString'; return formattedTime; } }