import 'dart:async'; import 'dart:io'; import 'dart:math'; import 'dart:ui' as ui; import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/scroll_extensions.dart'; import 'package:immich_mobile/pages/common/download_panel.dart'; import 'package:immich_mobile/pages/common/native_video_viewer.page.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/image/immich_remote_image_provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; import 'package:immich_mobile/widgets/asset_viewer/advanced_bottom_sheet.dart'; import 'package:immich_mobile/widgets/asset_viewer/bottom_gallery_bar.dart'; import 'package:immich_mobile/widgets/asset_viewer/detail_panel/detail_panel.dart'; import 'package:immich_mobile/widgets/asset_viewer/gallery_app_bar.dart'; import 'package:immich_mobile/widgets/common/immich_image.dart'; import 'package:immich_mobile/widgets/common/immich_thumbnail.dart'; import 'package:immich_mobile/widgets/photo_view/photo_view_gallery.dart'; import 'package:immich_mobile/widgets/photo_view/src/photo_view_computed_scale.dart'; import 'package:immich_mobile/widgets/photo_view/src/photo_view_scale_state.dart'; import 'package:immich_mobile/widgets/photo_view/src/utils/photo_view_hero_attributes.dart'; @RoutePage() // ignore: must_be_immutable class GalleryViewerPage extends HookConsumerWidget { final int initialIndex; final int heroOffset; final bool showStack; final RenderList renderList; GalleryViewerPage({ super.key, required this.renderList, this.initialIndex = 0, this.heroOffset = 0, this.showStack = false, }) : controller = PageController(initialPage: initialIndex); final PageController controller; @override Widget build(BuildContext context, WidgetRef ref) { final totalAssets = useState(renderList.totalAssets); final isZoomed = useState(false); final isPlayingMotionVideo = useState(false); final stackIndex = useState(-1); final localPosition = useRef<Offset?>(null); final currentIndex = useValueNotifier(initialIndex); final loadAsset = renderList.loadAsset; final currentAsset = loadAsset(currentIndex.value); final stack = showStack && currentAsset.stackCount > 0 ? ref.watch(assetStackStateProvider(currentAsset)) : <Asset>[]; final stackElements = showStack ? [currentAsset, ...stack] : <Asset>[]; // // Update is playing motion video ref.listen( videoPlaybackValueProvider.select( (playback) => playback.state == VideoPlaybackState.playing, ), (_, isPlaying) { final asset = ref.read(currentAssetProvider); if (asset != null && asset.isMotionPhoto) { isPlayingMotionVideo.value = isPlaying; } }); Future<void> precacheNextImage(int index) async { if (!context.mounted) { return; } void onError(Object exception, StackTrace? stackTrace) { // swallow error silently log.severe('Error precaching next image: $exception, $stackTrace'); } try { if (index < totalAssets.value && index >= 0) { final asset = loadAsset(index); await precacheImage( ImmichImage.imageProvider( asset: asset, width: context.width, height: context.height, ), context, onError: onError, ); } } catch (e) { // swallow error silently log.severe('Error precaching next image: $e'); context.maybePop(); } } useEffect( () { if (ref.read(showControlsProvider)) { SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); } else { SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); } // Delay this a bit so we can finish loading the page Timer(const Duration(milliseconds: 400), () { precacheNextImage(currentIndex.value + 1); }); return null; }, [], ); void showInfo() { final asset = ref.read(currentAssetProvider); if (asset == null) { return; } 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 FractionallySizedBox( heightFactor: 0.75, child: Padding( padding: EdgeInsets.only( bottom: MediaQuery.viewInsetsOf(context).bottom, ), child: ref .watch(appSettingsServiceProvider) .getSetting<bool>(AppSettingsEnum.advancedTroubleshooting) ? AdvancedBottomSheet(assetDetail: asset) : DetailPanel(asset: asset), ), ); }, ); } void handleSwipeUpDown(DragUpdateDetails details) { const int sensitivity = 15; const int dxThreshold = 50; const double ratioThreshold = 3.0; if (isZoomed.value) { return; } // Guard [localPosition] null if (localPosition.value == null) { return; } // Check for delta from initial down point final d = details.localPosition - localPosition.value!; // 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.maybePop(); } else if (d.dy < -sensitivity && ratio < -ratioThreshold) { showInfo(); } } ref.listen(showControlsProvider, (_, show) { if (show) { SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); } else { SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); } }); Widget buildStackedChildren() { if (!showStack) { return const SizedBox(); } return ListView.builder( shrinkWrap: true, scrollDirection: Axis.horizontal, itemCount: stackElements.length, padding: const EdgeInsets.only( left: 5, right: 5, bottom: 30, ), itemBuilder: (context, index) { final assetId = stackElements.elementAt(index).remoteId; if (assetId == null) { return const SizedBox(); } return Padding( key: ValueKey(assetId), padding: const EdgeInsets.only(right: 5), child: GestureDetector( onTap: () => stackIndex.value = index, child: Container( width: 60, height: 60, 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: Image( fit: BoxFit.cover, image: ImmichRemoteImageProvider(assetId: assetId), ), ), ), ), ); }, ); } PhotoViewGalleryPageOptions buildImage(BuildContext context, Asset asset) { return PhotoViewGalleryPageOptions( onDragStart: (_, details, __) { localPosition.value = details.localPosition; }, onDragUpdate: (_, details, __) { handleSwipeUpDown(details); }, onTapDown: (_, __, ___) { ref.read(showControlsProvider.notifier).toggle(); }, onLongPressStart: asset.isMotionPhoto ? (_, __, ___) { if (asset.isMotionPhoto) { isPlayingMotionVideo.value = true; } } : null, imageProvider: ImmichImage.imageProvider(asset: asset), heroAttributes: _getHeroAttributes(asset), filterQuality: FilterQuality.high, tightMode: true, minScale: PhotoViewComputedScale.contained, errorBuilder: (context, error, stackTrace) => ImmichImage( asset, fit: BoxFit.contain, ), ); } PhotoViewGalleryPageOptions buildVideo(BuildContext context, Asset asset) { // This key is to prevent the video player from being re-initialized during the hero animation final key = GlobalKey(); return PhotoViewGalleryPageOptions.customChild( onDragStart: (_, details, __) => localPosition.value = details.localPosition, onDragUpdate: (_, details, __) => handleSwipeUpDown(details), heroAttributes: _getHeroAttributes(asset), filterQuality: FilterQuality.high, initialScale: 1.0, maxScale: 1.0, minScale: 1.0, basePosition: Alignment.center, child: SizedBox( width: context.width, height: context.height, child: NativeVideoViewerPage( key: key, asset: asset, placeholder: Image( key: ValueKey(asset), image: ImmichImage.imageProvider( asset: asset, width: context.width, height: context.height, ), fit: BoxFit.contain, height: context.height, width: context.width, alignment: Alignment.center, ), ), ), ); } PhotoViewGalleryPageOptions buildAsset(BuildContext context, int index) { final newAsset = loadAsset(index); if (newAsset.isImage && !isPlayingMotionVideo.value) { return buildImage(context, newAsset); } return buildVideo(context, newAsset); } log.info('GalleryViewerPage: Building gallery viewer page'); return PopScope( // Change immersive mode back to normal "edgeToEdge" mode onPopInvokedWithResult: (didPop, _) => SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge), child: Scaffold( backgroundColor: Colors.black, body: Stack( children: [ PhotoViewGallery.builder( key: const ValueKey('gallery'), scaleStateChangedCallback: (state) { final asset = ref.read(currentAssetProvider); if (asset == null) { return; } if (asset.isImage && !isPlayingMotionVideo.value) { isZoomed.value = state != PhotoViewScaleState.initial; ref.read(showControlsProvider.notifier).show = !isZoomed.value; } }, gaplessPlayback: true, loadingBuilder: (context, event, index) { final asset = loadAsset(index); return ClipRect( child: Stack( fit: StackFit.expand, children: [ BackdropFilter( filter: ui.ImageFilter.blur( sigmaX: 10, sigmaY: 10, ), ), ImmichThumbnail( key: ValueKey(asset), asset: asset, fit: BoxFit.contain, ), ], ), ); }, pageController: controller, scrollPhysics: isZoomed.value ? const NeverScrollableScrollPhysics() // Don't allow paging while scrolled in : (Platform.isIOS ? const FastScrollPhysics() // Use bouncing physics for iOS : const FastClampingScrollPhysics() // Use heavy physics for Android ), itemCount: totalAssets.value, scrollDirection: Axis.horizontal, onPageChanged: (value) { final next = currentIndex.value < value ? value + 1 : value - 1; ref.read(hapticFeedbackProvider.notifier).selectionClick(); final newAsset = loadAsset(value); currentIndex.value = value; stackIndex.value = -1; isPlayingMotionVideo.value = false; ref.read(currentAssetProvider.notifier).set(newAsset); if (newAsset.isVideo || newAsset.isMotionPhoto) { ref.read(videoPlaybackValueProvider.notifier).reset(); } // Wait for page change animation to finish, then precache the next image Timer(const Duration(milliseconds: 400), () { precacheNextImage(next); }); }, builder: buildAsset, ), Positioned( top: 0, left: 0, right: 0, child: GalleryAppBar( key: const ValueKey('app-bar'), showInfo: showInfo, isPlayingMotionVideo: isPlayingMotionVideo, ), ), Positioned( bottom: 0, left: 0, right: 0, child: Column( children: [ Visibility( visible: stack.isNotEmpty, child: SizedBox( height: 80, child: buildStackedChildren(), ), ), BottomGalleryBar( key: const ValueKey('bottom-bar'), renderList: renderList, totalAssets: totalAssets, controller: controller, showStack: showStack, stackIndex: stackIndex.value, assetIndex: currentIndex, ), ], ), ), const DownloadPanel(), ], ), ), ); } @pragma('vm:prefer-inline') PhotoViewHeroAttributes _getHeroAttributes(Asset asset) { return PhotoViewHeroAttributes( tag: asset.isInDb ? asset.id + heroOffset : '${asset.remoteId}-$heroOffset', transitionOnUserGestures: true, ); } }