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/current_asset.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/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:isar/isar.dart'; import 'package:openapi/api.dart' show ThumbnailFormat; @RoutePage() // 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 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; // Listen provider to prevent autoDispose when navigating to other routes from within the gallery page ref.listen(currentAssetProvider, (_, __) {}); useEffect( () { // Delay state update to after the execution of build method Future.microtask( () => ref.read(currentAssetProvider.notifier).set(asset()), ); return null; }, [asset()], ); 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]); void precacheNextImage(int index) { void onError(Object exception, StackTrace? stackTrace) { // swallow error silently debugPrint('Error precaching next image: $exception, $stackTrace'); } if (index < totalAssets && index >= 0) { final asset = loadAsset(index); precacheImage( ImmichImage.imageProvider(asset: asset), context, onError: onError, ); } } void showInfo() { showModalBottomSheet( shape: const RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(15.0)), ), barrierColor: Colors.transparent, isScrollControlled: true, showDragHandle: true, enableDrag: true, context: context, useSafeArea: true, 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 { // Cannot delete readOnly / external assets. They are handled through library offline jobs if (asset().isReadOnly) { ImmichToast.show( durationInSecond: 1, context: context, msg: 'asset_action_delete_err_read_only'.tr(), gravity: ToastGravity.BOTTOM, ); return; } 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.popRoute(); } 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.popRoute(); } else if (d.dy < -sensitivity && ratio < -ratioThreshold) { showInfo(); } } shareAsset() { if (asset().isOffline) { ImmichToast.show( durationInSecond: 1, context: context, msg: 'asset_action_share_err_offline'.tr(), gravity: ToastGravity.BOTTOM, ); return; } ref.read(imageViewerStateProvider.notifier).shareAsset(asset(), context); } handleArchive(Asset asset) { ref.read(assetProvider.notifier).toggleArchive([asset]); if (isParent) { context.popRoute(); return; } removeAssetFromStack(); } handleUpload(Asset asset) { showDialog( context: context, builder: (BuildContext _) { return UploadDialog( onUpload: () { ref .read(manualUploadProvider.notifier) .uploadAssets(context, [asset]); }, ); }, ); } handleDownload() { if (asset().isLocal) { return; } if (asset().isOffline) { ImmichToast.show( durationInSecond: 1, context: context, msg: 'asset_action_share_err_offline'.tr(), gravity: ToastGravity.BOTTOM, ); return; } ref.read(imageViewerStateProvider.notifier).downloadAsset( asset(), context, ); } handleActivities() { if (album != null && album.shared && album.remoteId != null) { context.pushRoute(const ActivitiesRoute()); } } 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.read(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: { "x-immich-user-token": 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.popRoute(); }, 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.popRoute(); } 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.popRoute(); }, title: const Text( "viewer_unstack", style: TextStyle(fontWeight: FontWeight.bold), ).tr(), ), ], ), ), ); }, ); } // TODO: Migrate to a custom bottom bar and handle long press to delete 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) (_) => handleDownload(), ]; 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 PopScope( canPop: false, onPopInvoked: (_) { // Change immersive mode back to normal "edgeToEdge" mode SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); context.pop(); }, child: Scaffold( backgroundColor: Colors.black, body: Stack( children: [ PhotoViewGallery.builder( scaleStateChangedCallback: (state) { isZoomed.value = state != PhotoViewScaleState.initial; ref.read(showControlsProvider.notifier).show = !isZoomed.value; }, loadingBuilder: (context, event, index) => ImmichImage.thumbnail( asset(), fit: BoxFit.contain, ), 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(); }, builder: (context, index) { final a = index == currentIndex.value ? asset() : loadAsset(index); final ImageProvider provider = ImmichImage.imageProvider(asset: 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 ? '${currentAsset.remoteId}-$heroOffset' : currentAsset.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 ? '${currentAsset.remoteId}-$heroOffset' : currentAsset.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; } }