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:hooks_riverpod/hooks_riverpod.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/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/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/ui/immich_image.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:photo_manager/photo_manager.dart'; import 'package:openapi/api.dart' as api; // ignore: must_be_immutable class GalleryViewerPage extends HookConsumerWidget { final Asset Function(int index) loadAsset; final int totalAssets; final int initialIndex; final int heroOffset; GalleryViewerPage({ super.key, required this.initialIndex, required this.loadAsset, required this.totalAssets, this.heroOffset = 0, }) : controller = PageController(initialPage: initialIndex); final PageController controller; @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); final progressValue = useState(0.0); 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; useEffect( () { isLoadPreview.value = settings.getSetting(AppSettingsEnum.loadPreview); isLoadOriginal.value = settings.getSetting(AppSettingsEnum.loadOriginal); isPlayingMotionVideo.value = false; return null; }, [], ); void toggleFavorite(Asset asset) => ref .watch(assetProvider.notifier) .toggleFavorite([asset], !asset.isFavorite); /// Thumbnail image of a remote asset. Required asset.isRemote ImageProvider remoteThumbnailImageProvider( Asset asset, api.ThumbnailFormat type, ) { return CachedNetworkImageProvider( getThumbnailUrl( asset, type: type, ), cacheKey: getThumbnailCacheKey( asset, type: type, ), headers: {"Authorization": authToken}, ); } /// Original (large) image of a remote asset. Required asset.isRemote ImageProvider originalImageProvider(Asset asset) { return CachedNetworkImageProvider( getImageUrl(asset), cacheKey: getImageCacheKey(asset), headers: {"Authorization": authToken}, ); } /// Thumbnail image of a local asset. Required asset.isLocal ImageProvider localThumbnailImageProvider(Asset asset) { return AssetEntityImageProvider( asset.local!, isOriginal: false, thumbnailSize: ThumbnailSize( MediaQuery.of(context).size.width.floor(), MediaQuery.of(context).size.height.floor(), ), ); } /// Original (large) image of a local asset. Required asset.isLocal ImageProvider localImageProvider(Asset asset) { return AssetEntityImageProvider( isOriginal: true, asset.local!, ); } void precacheNextImage(int index) { if (index < totalAssets && index >= 0) { final asset = loadAsset(index); if (!asset.isRemote || asset.isLocal && !Store.get(StoreKey.preferRemoteImage, false)) { // Preload the local asset precacheImage(localImageProvider(asset), context); } else { onError(Object exception, StackTrace? stackTrace) { // swallow error silently } // Probably load WEBP either way precacheImage( remoteThumbnailImageProvider( asset, api.ThumbnailFormat.WEBP, ), context, onError: onError, ); if (isLoadPreview.value) { // Precache the JPEG thumbnail precacheImage( remoteThumbnailImageProvider( asset, api.ThumbnailFormat.JPEG, ), context, onError: onError, ); } if (isLoadOriginal.value) { // Preload the original asset precacheImage( originalImageProvider(asset), context, onError: onError, ); } } } } void showInfo() { showModalBottomSheet( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(15.0), ), barrierColor: Colors.transparent, backgroundColor: Colors.transparent, isScrollControlled: true, context: context, builder: (context) { if (ref .watch(appSettingsServiceProvider) .getSetting(AppSettingsEnum.advancedTroubleshooting)) { return AdvancedBottomSheet(assetDetail: asset()); } return Padding( padding: EdgeInsets.only( bottom: MediaQuery.of(context).viewInsets.bottom, ), child: ExifBottomSheet(asset: asset()), ); }, ); } void handleDelete(Asset deleteAsset) { showDialog( context: context, builder: (BuildContext _) { return DeleteDialog( onDelete: () { if (totalAssets == 1) { // Handle only one asset AutoRouter.of(context).pop(); } else { // Go to next page otherwise controller.nextPage( duration: const Duration(milliseconds: 100), curve: Curves.fastLinearToSlowEaseIn, ); } ref.watch(assetProvider.notifier).deleteAssets({deleteAsset}); }, ); }, ); } 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; 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; } if (details.delta.dy > sensitivity) { AutoRouter.of(context).pop(); } else if (details.delta.dy < -sensitivity) { showInfo(); } } shareAsset() { ref.watch(imageViewerStateProvider.notifier).shareAsset(asset(), context); } handleArchive(Asset asset) { ref .watch(assetProvider.notifier) .toggleArchive([asset], !asset.isArchived); AutoRouter.of(context).pop(); } 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( isPlayingMotionVideo: isPlayingMotionVideo.value, asset: asset(), isFavorite: asset().isFavorite, onMoreInfoPressed: showInfo, onFavorite: asset().isRemote ? () => toggleFavorite(asset()) : null, onDownloadPressed: asset().isLocal ? null : () => ref .watch(imageViewerStateProvider.notifier) .downloadAsset( asset(), context, ), onToggleMotionVideo: (() { isPlayingMotionVideo.value = !isPlayingMotionVideo.value; }), onAddToAlbumPressed: () => addToAlbum(asset()), ), ), ), ); } 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) { progressValue.value = 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, ); } buildBottomBar() { return IgnorePointer( ignoring: !ref.watch(showControlsProvider), child: AnimatedOpacity( duration: const Duration(milliseconds: 100), opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0, child: Column( children: [ 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: [ 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(), ), 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(), ), BottomNavigationBarItem( icon: const Icon(Icons.delete_outline), label: 'control_bottom_app_bar_delete'.tr(), tooltip: 'control_bottom_app_bar_delete'.tr(), ), ], onTap: (index) { switch (index) { case 0: shareAsset(); break; case 1: handleArchive(asset()); break; case 2: handleDelete(asset()); break; } }, ), ], ), ), ); } ref.listen(showControlsProvider, (_, show) { if (show) { SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); } else { SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); } }); ImageProvider imageProvider(Asset asset) { if (!asset.isRemote || asset.isLocal && !Store.get(StoreKey.preferRemoteImage, false)) { return localImageProvider(asset); } else { if (isLoadOriginal.value) { return originalImageProvider(asset); } else if (isLoadPreview.value) { return remoteThumbnailImageProvider( asset, api.ThumbnailFormat.JPEG, ); } else { return remoteThumbnailImageProvider( asset, api.ThumbnailFormat.WEBP, ); } } } 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; }, 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) { // Precache image if (currentIndex.value < value) { // Moving forwards, so precache the next asset precacheNextImage(value + 1); } else { // Moving backwards, so precache previous asset precacheNextImage(value - 1); } currentIndex.value = value; progressValue.value = 0.0; HapticFeedback.selectionClick(); }, loadingBuilder: isLoadPreview.value ? (context, event) { final a = asset(); if (!a.isLocal || (a.isRemote && Store.get(StoreKey.preferRemoteImage, false))) { // 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: api.ThumbnailFormat.WEBP, ), cacheKey: getThumbnailCacheKey( a, type: api.ThumbnailFormat.WEBP, ), httpHeaders: {'Authorization': authToken}, progressIndicatorBuilder: (_, __, ___) => const Center( child: ImmichLoadingIndicator(), ), fadeInDuration: const Duration(milliseconds: 0), fit: BoxFit.contain, errorWidget: (context, url, error) => const Icon(Icons.image_not_supported_outlined), ); if (isLoadOriginal.value) { // loading the preview in the loadingBuilder only // makes sense if the original is loaded in the builder return CachedNetworkImage( imageUrl: getThumbnailUrl( a, type: api.ThumbnailFormat.JPEG, ), cacheKey: getThumbnailCacheKey( a, type: api.ThumbnailFormat.JPEG, ), httpHeaders: {'Authorization': authToken}, fit: BoxFit.contain, fadeInDuration: const Duration(milliseconds: 0), placeholder: (_, __) => webPThumbnail, errorWidget: (_, __, ___) => webPThumbnail, ); } else { return webPThumbnail; } } else { return Image( image: localThumbnailImageProvider(a), fit: BoxFit.contain, ); } } : null, builder: (context, index) { final asset = loadAsset(index); final ImageProvider provider = imageProvider(asset); if (asset.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: asset.id + heroOffset, ), filterQuality: FilterQuality.high, tightMode: true, minScale: PhotoViewComputedScale.contained, errorBuilder: (context, error, stackTrace) => ImmichImage( asset, fit: BoxFit.contain, ), ); } else { return PhotoViewGalleryPageOptions.customChild( onDragStart: (_, details, __) => localPosition = details.localPosition, onDragUpdate: (_, details, __) => handleSwipeUpDown(details), heroAttributes: PhotoViewHeroAttributes( tag: asset.id + heroOffset, ), filterQuality: FilterQuality.high, maxScale: 1.0, minScale: 1.0, basePosition: Alignment.center, child: VideoViewerPage( onPlaying: () => isPlayingVideo.value = true, onPaused: () => isPlayingVideo.value = false, asset: asset, isMotionVideo: isPlayingMotionVideo.value, placeholder: Image( image: provider, fit: BoxFit.fitWidth, height: MediaQuery.of(context).size.height, width: MediaQuery.of(context).size.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; } }