diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart index 97f2e9dfec..e9a0e99a8b 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -67,22 +67,17 @@ class GalleryViewerPage extends HookConsumerWidget { ? 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 == noDbId; - - Asset asset = stackIndex.value == -1 - ? currentAsset - : stackElements.elementAt(stackIndex.value); // // Update is playing motion video - if (asset.isMotionPhoto) { - ref.listen( - videoPlaybackValueProvider.select( - (playback) => playback.state == VideoPlaybackState.playing, - ), (_, isPlaying) { + ref.listen( + videoPlaybackValueProvider.select( + (playback) => playback.state == VideoPlaybackState.playing, + ), (_, isPlaying) { + final asset = ref.read(currentAssetProvider); + if (asset != null && asset.isMotionPhoto) { isPlayingMotionVideo.value = isPlaying; - }); - } + } + }); Future precacheNextImage(int index) async { if (!context.mounted) { @@ -114,26 +109,29 @@ class GalleryViewerPage extends HookConsumerWidget { } } - // Listen provider to prevent autoDispose when navigating to other routes from within the gallery page - ref.listen(currentAssetProvider, (prev, cur) {}); + useEffect( + () { + if (ref.read(showControlsProvider)) { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + } else { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); + } - useEffect(() { - ref.read(currentAssetProvider.notifier).set(asset); - 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); + }); - // Delay this a bit so we can finish loading the page - Timer(const Duration(milliseconds: 400), () { - precacheNextImage(currentIndex.value + 1); - }); - - return null; - }); + return null; + }, + [], + ); void showInfo() { + final asset = ref.read(currentAssetProvider); + if (asset == null) { + return; + } showModalBottomSheet( shape: const RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(15.0)), @@ -205,7 +203,6 @@ class GalleryViewerPage extends HookConsumerWidget { } return ListView.builder( - key: ValueKey(currentAsset), shrinkWrap: true, scrollDirection: Axis.horizontal, itemCount: stackElements.length, @@ -252,12 +249,6 @@ class GalleryViewerPage extends HookConsumerWidget { ); } - Object getHeroTag(Asset asset) { - return isFromDto - ? '${asset.remoteId}-$heroOffset' - : asset.id + heroOffset; - } - PhotoViewGalleryPageOptions buildImage(BuildContext context, Asset asset) { return PhotoViewGalleryPageOptions( onDragStart: (_, details, __) { @@ -277,10 +268,7 @@ class GalleryViewerPage extends HookConsumerWidget { } : null, imageProvider: ImmichImage.imageProvider(asset: asset), - heroAttributes: PhotoViewHeroAttributes( - tag: getHeroTag(asset), - transitionOnUserGestures: true, - ), + heroAttributes: _getHeroAttributes(asset), filterQuality: FilterQuality.high, tightMode: true, minScale: PhotoViewComputedScale.contained, @@ -294,40 +282,33 @@ class GalleryViewerPage extends HookConsumerWidget { 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(); - final tag = getHeroTag(asset); return PhotoViewGalleryPageOptions.customChild( onDragStart: (_, details, __) => localPosition.value = details.localPosition, onDragUpdate: (_, details, __) => handleSwipeUpDown(details), - heroAttributes: PhotoViewHeroAttributes( - tag: tag, - transitionOnUserGestures: true, - ), + heroAttributes: _getHeroAttributes(asset), filterQuality: FilterQuality.high, initialScale: 1.0, maxScale: 1.0, minScale: 1.0, basePosition: Alignment.center, - child: Hero( - tag: tag, - 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, + 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, - alignment: Alignment.center, + height: context.height, ), + fit: BoxFit.contain, + height: context.height, + width: context.width, + alignment: Alignment.center, ), ), ), @@ -335,7 +316,7 @@ class GalleryViewerPage extends HookConsumerWidget { } PhotoViewGalleryPageOptions buildAsset(BuildContext context, int index) { - final newAsset = index == currentIndex.value ? asset : loadAsset(index); + final newAsset = loadAsset(index); if (newAsset.isImage && !isPlayingMotionVideo.value) { return buildImage(context, newAsset); @@ -343,6 +324,8 @@ class GalleryViewerPage extends HookConsumerWidget { return buildVideo(context, newAsset); } + log.info('GalleryViewerPage: Building gallery viewer page'); + return PopScope( // Change immersive mode back to normal "edgeToEdge" mode onPopInvokedWithResult: (didPop, _) => @@ -352,34 +335,41 @@ class GalleryViewerPage extends HookConsumerWidget { body: Stack( children: [ PhotoViewGallery.builder( - key: ValueKey(asset), + 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; } }, - // wantKeepAlive: true, gaplessPlayback: true, - loadingBuilder: (context, event, index) => ClipRect( - child: Stack( - fit: StackFit.expand, - children: [ - BackdropFilter( - filter: ui.ImageFilter.blur( - sigmaX: 10, - sigmaY: 10, + 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, - ), - ], - ), - ), + ImmichThumbnail( + key: ValueKey(asset), + asset: asset, + fit: BoxFit.contain, + ), + ], + ), + ); + }, pageController: controller, scrollPhysics: isZoomed.value ? const NeverScrollableScrollPhysics() // Don't allow paging while scrolled in @@ -394,8 +384,7 @@ class GalleryViewerPage extends HookConsumerWidget { ref.read(hapticFeedbackProvider.notifier).selectionClick(); - final newAsset = - value == currentIndex.value ? asset : loadAsset(value); + final newAsset = loadAsset(value); currentIndex.value = value; stackIndex.value = -1; @@ -418,6 +407,7 @@ class GalleryViewerPage extends HookConsumerWidget { left: 0, right: 0, child: GalleryAppBar( + key: const ValueKey('app-bar'), showInfo: showInfo, isPlayingMotionVideo: isPlayingMotionVideo, ), @@ -436,6 +426,7 @@ class GalleryViewerPage extends HookConsumerWidget { ), ), BottomGalleryBar( + key: const ValueKey('bottom-bar'), renderList: renderList, totalAssets: totalAssets, controller: controller, @@ -452,4 +443,14 @@ class GalleryViewerPage extends HookConsumerWidget { ), ); } + + @pragma('vm:prefer-inline') + PhotoViewHeroAttributes _getHeroAttributes(Asset asset) { + return PhotoViewHeroAttributes( + tag: asset.isInDb + ? asset.id + heroOffset + : '${asset.remoteId}-$heroOffset', + transitionOnUserGestures: true, + ); + } } diff --git a/mobile/lib/pages/common/native_video_viewer.page.dart b/mobile/lib/pages/common/native_video_viewer.page.dart index 290f8d7f2e..9612de8f35 100644 --- a/mobile/lib/pages/common/native_video_viewer.page.dart +++ b/mobile/lib/pages/common/native_video_viewer.page.dart @@ -24,7 +24,6 @@ import 'package:wakelock_plus/wakelock_plus.dart'; class NativeVideoViewerPage extends HookConsumerWidget { final Asset asset; final bool showControls; - final Duration hideControlsTimer; final Widget placeholder; const NativeVideoViewerPage({ @@ -32,7 +31,6 @@ class NativeVideoViewerPage extends HookConsumerWidget { required this.asset, required this.placeholder, this.showControls = true, - this.hideControlsTimer = const Duration(seconds: 5), }); @override @@ -370,6 +368,18 @@ class NativeVideoViewerPage extends HookConsumerWidget { removeListeners(playerController); } + final curAsset = currentAsset.value; + if (curAsset == asset) { + return; + } + + // no need to delay video playback when swiping from an image to a video + if (curAsset != null && !curAsset.isVideo) { + currentAsset.value = value; + onPlaybackReady(); + return; + } + // Delay the video playback to avoid a stutter in the swipe animation Timer(const Duration(milliseconds: 300), () { if (!context.mounted) { @@ -395,38 +405,30 @@ class NativeVideoViewerPage extends HookConsumerWidget { log.severe('Error stopping video: $error'); }); - controller.value = null; WakelockPlus.disable(); }; }, - [videoSource], + [], ); return Stack( children: [ placeholder, // this is always under the video to avoid flickering - Center( - key: ValueKey('player-${asset.hashCode}'), - child: aspectRatio.value != null - ? AspectRatio( - key: ValueKey(asset), - aspectRatio: aspectRatio.value!, - child: isCurrent - ? NativeVideoPlayerView( - key: ValueKey(asset), - onViewReady: initController, - ) - : null, - ) - : null, - ), - if (showControls) + if (aspectRatio.value != null) Center( - key: ValueKey('controls-${asset.hashCode}'), - child: CustomVideoPlayerControls( - hideTimerDuration: hideControlsTimer, + key: ValueKey(asset), + child: AspectRatio( + key: ValueKey(asset), + aspectRatio: aspectRatio.value!, + child: isCurrent + ? NativeVideoPlayerView( + key: ValueKey(asset), + onViewReady: initController, + ) + : null, ), ), + if (showControls) const Center(child: CustomVideoPlayerControls()), ], ); } diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 49da6fcd9e..5e88d8879a 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -1087,7 +1087,6 @@ class NativeVideoViewerRoute extends PageRouteInfo { required Asset asset, required Widget placeholder, bool showControls = true, - Duration hideControlsTimer = const Duration(seconds: 5), List? children, }) : super( NativeVideoViewerRoute.name, @@ -1096,7 +1095,6 @@ class NativeVideoViewerRoute extends PageRouteInfo { asset: asset, placeholder: placeholder, showControls: showControls, - hideControlsTimer: hideControlsTimer, ), initialChildren: children, ); @@ -1112,7 +1110,6 @@ class NativeVideoViewerRoute extends PageRouteInfo { asset: args.asset, placeholder: args.placeholder, showControls: args.showControls, - hideControlsTimer: args.hideControlsTimer, ); }, ); @@ -1124,7 +1121,6 @@ class NativeVideoViewerRouteArgs { required this.asset, required this.placeholder, this.showControls = true, - this.hideControlsTimer = const Duration(seconds: 5), }); final Key? key; @@ -1135,11 +1131,9 @@ class NativeVideoViewerRouteArgs { final bool showControls; - final Duration hideControlsTimer; - @override String toString() { - return 'NativeVideoViewerRouteArgs{key: $key, asset: $asset, placeholder: $placeholder, showControls: $showControls, hideControlsTimer: $hideControlsTimer}'; + return 'NativeVideoViewerRouteArgs{key: $key, asset: $asset, placeholder: $placeholder, showControls: $showControls}'; } } diff --git a/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart b/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart index 38e499b5de..5670aa388f 100644 --- a/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart +++ b/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart @@ -12,7 +12,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/collection_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/scroll_notifier.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_drag_region.dart'; import 'package:immich_mobile/widgets/asset_grid/thumbnail_image.dart'; import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart'; @@ -89,6 +91,7 @@ class ImmichAssetGridViewState extends ConsumerState { ScrollOffsetController(); final ItemPositionsListener _itemPositionsListener = ItemPositionsListener.create(); + late final KeepAliveLink currentAssetLink; /// The timestamp when the haptic feedback was last invoked int _hapticFeedbackTS = 0; @@ -201,6 +204,12 @@ class ImmichAssetGridViewState extends ConsumerState { allAssetsSelected: _allAssetsSelected, showStack: widget.showStack, heroOffset: widget.heroOffset, + onAssetTap: (asset) { + ref.read(currentAssetProvider.notifier).set(asset); + if (asset.isVideo) { + ref.read(showControlsProvider.notifier).show = false; + } + }, ); } @@ -348,6 +357,7 @@ class ImmichAssetGridViewState extends ConsumerState { @override void initState() { super.initState(); + currentAssetLink = ref.read(currentAssetProvider.notifier).ref.keepAlive(); scrollToTopNotifierProvider.addListener(_scrollToTop); scrollToDateNotifierProvider.addListener(_scrollToDate); @@ -369,6 +379,7 @@ class ImmichAssetGridViewState extends ConsumerState { _itemPositionsListener.itemPositions.removeListener(_positionListener); } _itemPositionsListener.itemPositions.removeListener(_hapticsListener); + currentAssetLink.close(); super.dispose(); } @@ -595,12 +606,13 @@ class _Section extends StatelessWidget { final RenderList renderList; final bool selectionActive; final bool dynamicLayout; - final Function(List) selectAssets; - final Function(List) deselectAssets; + final void Function(List) selectAssets; + final void Function(List) deselectAssets; final bool Function(List) allAssetsSelected; final bool showStack; final int heroOffset; final bool showStorageIndicator; + final void Function(Asset) onAssetTap; const _Section({ required this.section, @@ -618,6 +630,7 @@ class _Section extends StatelessWidget { required this.showStack, required this.heroOffset, required this.showStorageIndicator, + required this.onAssetTap, }); @override @@ -683,6 +696,7 @@ class _Section extends StatelessWidget { selectionActive: selectionActive, onSelect: (asset) => selectAssets([asset]), onDeselect: (asset) => deselectAssets([asset]), + onAssetTap: onAssetTap, ), ], ); @@ -724,9 +738,9 @@ class _Title extends StatelessWidget { final String title; final List assets; final bool selectionActive; - final Function(List) selectAssets; - final Function(List) deselectAssets; - final Function(List) allAssetsSelected; + final void Function(List) selectAssets; + final void Function(List) deselectAssets; + final bool Function(List) allAssetsSelected; const _Title({ required this.title, @@ -765,8 +779,9 @@ class _AssetRow extends StatelessWidget { final bool showStorageIndicator; final int heroOffset; final bool showStack; - final Function(Asset)? onSelect; - final Function(Asset)? onDeselect; + final void Function(Asset) onAssetTap; + final void Function(Asset)? onSelect; + final void Function(Asset)? onDeselect; final bool isSelectionActive; const _AssetRow({ @@ -786,6 +801,7 @@ class _AssetRow extends StatelessWidget { required this.showStack, required this.isSelectionActive, required this.selectedAssets, + required this.onAssetTap, this.onSelect, this.onDeselect, }); @@ -838,6 +854,8 @@ class _AssetRow extends StatelessWidget { onSelect?.call(asset); } } else { + final asset = renderList.loadAsset(absoluteOffset + index); + onAssetTap(asset); context.pushRoute( GalleryViewerRoute( renderList: renderList, diff --git a/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart b/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart index 168c4ebffb..0df8137417 100644 --- a/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart +++ b/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.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_controls_provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; @@ -12,7 +13,7 @@ class CustomVideoPlayerControls extends HookConsumerWidget { const CustomVideoPlayerControls({ super.key, - this.hideTimerDuration = const Duration(seconds: 3), + this.hideTimerDuration = const Duration(seconds: 5), }); @override @@ -28,7 +29,12 @@ class CustomVideoPlayerControls extends HookConsumerWidget { final state = ref.read(videoPlaybackValueProvider).state; // Do not hide on paused - if (state != VideoPlaybackState.paused) { + if (state == VideoPlaybackState.paused) { + return; + } + + final asset = ref.read(currentAssetProvider); + if (asset != null && asset.isVideo) { ref.read(showControlsProvider.notifier).show = false; } }, diff --git a/mobile/lib/widgets/memories/memory_card.dart b/mobile/lib/widgets/memories/memory_card.dart index ba74b0496d..266c7636aa 100644 --- a/mobile/lib/widgets/memories/memory_card.dart +++ b/mobile/lib/widgets/memories/memory_card.dart @@ -71,7 +71,6 @@ class MemoryCard extends StatelessWidget { child: NativeVideoViewerPage( key: ValueKey(asset.id), asset: asset, - hideControlsTimer: const Duration(seconds: 2), showControls: false, placeholder: SizedBox.expand( child: ImmichImage(