diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart index 70eb6699aa..a64ea1b169 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -107,6 +107,7 @@ class _AssetViewerState extends ConsumerState { // PhotoViewGallery takes care of disposing it's controllers PhotoViewControllerBase? viewController; StreamSubscription? reloadSubscription; + final ValueNotifier videoScaleStateNotifier = ValueNotifier(PhotoViewScaleState.initial); late final int heroOffset; late PhotoViewControllerValue initialPhotoViewState; @@ -157,6 +158,7 @@ class _AssetViewerState extends ConsumerState { _prevPreCacheStream?.removeListener(_dummyListener); _nextPreCacheStream?.removeListener(_dummyListener); SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + videoScaleStateNotifier.dispose(); _stackChildrenKeepAlive?.close(); super.dispose(); } @@ -270,6 +272,7 @@ class _AssetViewerState extends ConsumerState { void _onPageChanged(int index, PhotoViewControllerBase? controller) { _onAssetChanged(index); viewController = controller; + videoScaleStateNotifier.value = PhotoViewScaleState.initial; // reset video zoom state } void _onDragStart( @@ -281,9 +284,13 @@ class _AssetViewerState extends ConsumerState { viewController = controller; dragDownPosition = details.localPosition; initialPhotoViewState = controller.value; + final isZoomed = scaleStateController.scaleState == PhotoViewScaleState.zoomedIn || - scaleStateController.scaleState == PhotoViewScaleState.covering; + scaleStateController.scaleState == PhotoViewScaleState.covering || + videoScaleStateNotifier.value == PhotoViewScaleState.zoomedIn || + videoScaleStateNotifier.value == PhotoViewScaleState.covering; + if (!showingBottomSheet && isZoomed) { blockGestures = true; } @@ -584,35 +591,29 @@ class _AssetViewerState extends ConsumerState { ); } - GlobalKey _getVideoPlayerKey(String id) { - videoPlayerKeys.putIfAbsent(id, () => GlobalKey()); - return videoPlayerKeys[id]!; - } - PhotoViewGalleryPageOptions _videoBuilder(BuildContext ctx, BaseAsset asset) { return PhotoViewGalleryPageOptions.customChild( + key: ValueKey(asset.heroTag), onDragStart: _onDragStart, onDragUpdate: _onDragUpdate, onDragEnd: _onDragEnd, - onTapDown: _onTapDown, + disableScaleGestures: true, heroAttributes: PhotoViewHeroAttributes(tag: '${asset.heroTag}_$heroOffset'), filterQuality: FilterQuality.high, - maxScale: 1.0, basePosition: Alignment.center, - child: SizedBox( - width: ctx.width, - height: ctx.height, - child: NativeVideoViewer( - key: _getVideoPlayerKey(asset.heroTag), - asset: asset, - image: Image( - key: ValueKey(asset), - image: getFullImageProvider(asset, size: ctx.sizeData), - fit: BoxFit.contain, - height: ctx.height, - width: ctx.width, - alignment: Alignment.center, - ), + tightMode: true, + child: NativeVideoViewer( + key: ValueKey(asset.heroTag), + asset: asset, + scaleStateNotifier: videoScaleStateNotifier, + disableScaleGestures: showingBottomSheet, + image: Image( + key: ValueKey(asset), + image: getFullImageProvider(asset, size: ctx.sizeData), + height: ctx.height, + width: ctx.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 08b5b25343..52d338f1dc 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/infrastructure/repositories/storage.repository.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart'; @@ -24,6 +25,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'; @@ -51,6 +53,8 @@ class NativeVideoViewer extends HookConsumerWidget { final bool showControls; final int playbackDelayFactor; final Widget image; + final ValueNotifier? scaleStateNotifier; + final bool disableScaleGestures; const NativeVideoViewer({ super.key, @@ -58,6 +62,8 @@ class NativeVideoViewer extends HookConsumerWidget { required this.image, this.showControls = true, this.playbackDelayFactor = 1, + this.scaleStateNotifier, + this.disableScaleGestures = false, }); @override @@ -132,6 +138,7 @@ class NativeVideoViewer extends HookConsumerWidget { final videoSource = useMemoized>(() => createSource()); final aspectRatio = useState(null); + useMemoized(() async { if (!context.mounted || aspectRatio.value != null) { return null; @@ -387,26 +394,47 @@ 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( + Size? videoContextSize; + + if (aspectRatio.value != null) { + final contextAspectRatio = context.width / context.height; + + if (aspectRatio.value! > contextAspectRatio) { + videoContextSize = Size(context.width, context.width / aspectRatio.value!); + } else { + videoContextSize = Size(context.height * aspectRatio.value!, context.height); + } + } + + 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) + 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, + child: AspectRatio( + key: ValueKey(asset), + aspectRatio: aspectRatio.value!, + child: isCurrent ? NativeVideoPlayerView(key: ValueKey(asset), onViewReady: initController) : null, + ), ), ), - ), - 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 c1324b8ac0..fd269fbcfe 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, ), ), ),