From f0e2fced57a72ddf1f6be2def2d902868aa4bada Mon Sep 17 00:00:00 2001 From: Noel S Date: Sat, 21 Feb 2026 22:37:36 -0700 Subject: [PATCH] feat(mobile): video zooming in asset viewer (#22036) * wip * Functional implementation, still need to bug test. * Fixed flickering bugs * Fixed bug with drag actions interfering with zoom panning. Fixed video being zoomable when bottom sheet is shown. Code cleanup. * Add comments and simplify video controls * Clearer variable name * Fix bug where the redundant onTapDown would interfere with zooming gestures * Fix zoom not working the second time when viewing a video. * fix video of live photo retaining pan from photo portion * code cleanup and simplified widget stack --------- Co-authored-by: Alex --- .../asset_viewer/asset_page.widget.dart | 42 +++++++------- .../asset_viewer/video_viewer.widget.dart | 58 ++++++++++++++----- .../video_viewer_controls.widget.dart | 38 +++++++----- .../asset_viewer/center_play_button.dart | 31 +++++----- 4 files changed, 101 insertions(+), 68 deletions(-) diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart index ba52b67dfd..43b31b829c 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart @@ -53,6 +53,7 @@ class _AssetPageState extends ConsumerState { final _scrollController = ScrollController(); late final _proxyScrollController = ProxyScrollController(scrollController: _scrollController); + final ValueNotifier _videoScaleStateNotifier = ValueNotifier(PhotoViewScaleState.initial); double _snapOffset = 0.0; double _lastScrollOffset = 0.0; @@ -81,6 +82,7 @@ class _AssetPageState extends ConsumerState { _proxyScrollController.dispose(); _scaleBoundarySub?.cancel(); _eventSubscription?.cancel(); + _videoScaleStateNotifier.dispose(); super.dispose(); } @@ -255,10 +257,11 @@ class _AssetPageState extends ConsumerState { ref.read(isPlayingMotionVideoProvider.notifier).playing = true; void _onScaleStateChanged(PhotoViewScaleState scaleState) { - _isZoomed = switch (scaleState) { - PhotoViewScaleState.zoomedIn || PhotoViewScaleState.covering => true, - _ => false, - }; + _isZoomed = + scaleState == PhotoViewScaleState.zoomedIn || + scaleState == PhotoViewScaleState.covering || + _videoScaleStateNotifier.value == PhotoViewScaleState.zoomedIn || + _videoScaleStateNotifier.value == PhotoViewScaleState.covering; _viewer.setZoomed(_isZoomed); if (scaleState != PhotoViewScaleState.initial) { @@ -340,34 +343,33 @@ class _AssetPageState extends ConsumerState { } return PhotoView.customChild( + key: ValueKey(displayAsset), onDragStart: _onDragStart, onDragUpdate: _onDragUpdate, onDragEnd: _onDragEnd, onDragCancel: _onDragCancel, - onTapUp: _onTapUp, heroAttributes: heroAttributes, filterQuality: FilterQuality.high, - maxScale: 1.0, basePosition: Alignment.center, disableScaleGestures: true, - scaleStateChangedCallback: _onScaleStateChanged, + minScale: PhotoViewComputedScale.contained, + initialScale: PhotoViewComputedScale.contained, + tightMode: true, onPageBuild: _onPageBuild, enablePanAlways: true, backgroundDecoration: backgroundDecoration, - child: SizedBox( - width: context.width, - height: context.height, - child: NativeVideoViewer( + child: NativeVideoViewer( + key: ValueKey(displayAsset), + asset: displayAsset, + scaleStateNotifier: _videoScaleStateNotifier, + disableScaleGestures: showingDetails, + image: Image( key: ValueKey(displayAsset.heroTag), - asset: displayAsset, - image: Image( - key: ValueKey(displayAsset), - image: getFullImageProvider(displayAsset, size: context.sizeData), - fit: BoxFit.contain, - height: context.height, - width: context.width, - alignment: Alignment.center, - ), + image: getFullImageProvider(displayAsset, size: context.sizeData), + height: context.height, + width: context.width, + fit: BoxFit.contain, + alignment: Alignment.center, ), ), ); diff --git a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart index 643d3e87ef..0f6568e8fd 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart @@ -9,6 +9,7 @@ import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/services/setting.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; @@ -25,6 +26,7 @@ import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/utils/debounce.dart'; import 'package:immich_mobile/utils/hooks/interval_hook.dart'; +import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; import 'package:logging/logging.dart'; import 'package:native_video_player/native_video_player.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; @@ -52,6 +54,8 @@ class NativeVideoViewer extends HookConsumerWidget { final bool showControls; final int playbackDelayFactor; final Widget image; + final ValueNotifier? scaleStateNotifier; + final bool disableScaleGestures; const NativeVideoViewer({ super.key, @@ -59,6 +63,8 @@ class NativeVideoViewer extends HookConsumerWidget { required this.image, this.showControls = true, this.playbackDelayFactor = 1, + this.scaleStateNotifier, + this.disableScaleGestures = false, }); @override @@ -138,6 +144,7 @@ class NativeVideoViewer extends HookConsumerWidget { final videoSource = useMemoized>(() => createSource()); final aspectRatio = useState(null); + useMemoized(() async { if (!context.mounted || aspectRatio.value != null) { return null; @@ -313,6 +320,20 @@ class NativeVideoViewer extends HookConsumerWidget { Timer(const Duration(milliseconds: 200), checkIfBuffering); } + Size? videoContextSize(double? videoAspectRatio, BuildContext? context) { + Size? videoContextSize; + if (videoAspectRatio == null || context == null) { + return null; + } + final contextAspectRatio = context.width / context.height; + if (videoAspectRatio > contextAspectRatio) { + videoContextSize = Size(context.width, context.width / aspectRatio.value!); + } else { + videoContextSize = Size(context.height * aspectRatio.value!, context.height); + } + return videoContextSize; + } + ref.listen(currentAssetNotifier, (_, value) { final playerController = controller.value; if (playerController != null && value != asset) { @@ -393,26 +414,31 @@ class NativeVideoViewer extends HookConsumerWidget { } }); - return Stack( - children: [ - // This remains under the video to avoid flickering - // For motion videos, this is the image portion of the asset - Center(key: ValueKey(asset.heroTag), child: image), - if (aspectRatio.value != null && !isCasting) - Visibility.maintain( - key: ValueKey(asset), - visible: isVisible.value, - child: Center( + return SizedBox( + width: context.width, + height: context.height, + child: Stack( + children: [ + // Hide thumbnail once video is visible to avoid it showing in background when zooming out on video. + if (!isVisible.value || controller.value == null) Center(key: ValueKey(asset.heroTag), child: image), + if (aspectRatio.value != null && !isCasting && isCurrent) + Visibility.maintain( key: ValueKey(asset), - child: AspectRatio( + visible: isVisible.value, + child: PhotoView.customChild( key: ValueKey(asset), - aspectRatio: aspectRatio.value!, - child: isCurrent ? NativeVideoPlayerView(key: ValueKey(asset), onViewReady: initController) : null, + enableRotation: false, + disableScaleGestures: disableScaleGestures, + // Transparent to avoid a black flash when viewer becomes visible but video isn't loaded yet. + backgroundDecoration: const BoxDecoration(color: Colors.transparent), + scaleStateChangedCallback: (state) => scaleStateNotifier?.value = state, + childSize: videoContextSize(aspectRatio.value, context), + child: NativeVideoPlayerView(key: ValueKey(asset), onViewReady: initController), ), ), - ), - if (showControls) const Center(child: VideoViewerControls()), - ], + if (showControls) const Center(child: VideoViewerControls()), + ], + ), ); } diff --git a/mobile/lib/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart index a2c1372c83..28cfe5e73c 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart @@ -81,27 +81,35 @@ class VideoViewerControls extends HookConsumerWidget { } } + void toggleControlsVisibility() { + if (showBuffering) { + return; + } + if (showControls) { + ref.read(assetViewerProvider.notifier).setControls(false); + } else { + showControlsAndStartHideTimer(); + } + } + return GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: showControlsAndStartHideTimer, - child: AbsorbPointer( - absorbing: !showControls, + behavior: HitTestBehavior.translucent, + onTap: toggleControlsVisibility, + child: IgnorePointer( + ignoring: !showControls, child: Stack( children: [ if (showBuffering) const Center(child: DelayedLoadingIndicator(fadeInDuration: Duration(milliseconds: 400))) else - GestureDetector( - onTap: () => ref.read(assetViewerProvider.notifier).setControls(false), - child: CenterPlayButton( - backgroundColor: Colors.black54, - iconColor: Colors.white, - isFinished: state == VideoPlaybackState.completed, - isPlaying: - state == VideoPlaybackState.playing || (cast.isCasting && cast.castState == CastState.playing), - show: assetIsVideo && showControls, - onPressed: togglePlay, - ), + CenterPlayButton( + backgroundColor: Colors.black54, + iconColor: Colors.white, + isFinished: state == VideoPlaybackState.completed, + isPlaying: + state == VideoPlaybackState.playing || (cast.isCasting && cast.castState == CastState.playing), + show: assetIsVideo && showControls, + onPressed: togglePlay, ), ], ), diff --git a/mobile/lib/widgets/asset_viewer/center_play_button.dart b/mobile/lib/widgets/asset_viewer/center_play_button.dart index 26d0a41129..55d8be8095 100644 --- a/mobile/lib/widgets/asset_viewer/center_play_button.dart +++ b/mobile/lib/widgets/asset_viewer/center_play_button.dart @@ -21,23 +21,20 @@ class CenterPlayButton extends StatelessWidget { @override Widget build(BuildContext context) { - return ColoredBox( - color: Colors.transparent, - child: Center( - child: UnconstrainedBox( - child: AnimatedOpacity( - opacity: show ? 1.0 : 0.0, - duration: const Duration(milliseconds: 100), - child: DecoratedBox( - decoration: BoxDecoration(color: backgroundColor, shape: BoxShape.circle), - child: IconButton( - iconSize: 32, - padding: const EdgeInsets.all(12.0), - icon: isFinished - ? Icon(Icons.replay, color: iconColor) - : AnimatedPlayPause(color: iconColor, playing: isPlaying), - onPressed: onPressed, - ), + return Center( + child: UnconstrainedBox( + child: AnimatedOpacity( + opacity: show ? 1.0 : 0.0, + duration: const Duration(milliseconds: 100), + child: DecoratedBox( + decoration: BoxDecoration(color: backgroundColor, shape: BoxShape.circle), + child: IconButton( + iconSize: 32, + padding: const EdgeInsets.all(12.0), + icon: isFinished + ? Icon(Icons.replay, color: iconColor) + : AnimatedPlayPause(color: iconColor, playing: isPlaying), + onPressed: onPressed, ), ), ),