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 <alex.tran1502@gmail.com>
This commit is contained in:
Noel S
2026-02-21 22:37:36 -07:00
committed by GitHub
parent 8ba20cbd44
commit f0e2fced57
4 changed files with 101 additions and 68 deletions

View File

@@ -53,6 +53,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
final _scrollController = ScrollController(); final _scrollController = ScrollController();
late final _proxyScrollController = ProxyScrollController(scrollController: _scrollController); late final _proxyScrollController = ProxyScrollController(scrollController: _scrollController);
final ValueNotifier<PhotoViewScaleState> _videoScaleStateNotifier = ValueNotifier(PhotoViewScaleState.initial);
double _snapOffset = 0.0; double _snapOffset = 0.0;
double _lastScrollOffset = 0.0; double _lastScrollOffset = 0.0;
@@ -81,6 +82,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
_proxyScrollController.dispose(); _proxyScrollController.dispose();
_scaleBoundarySub?.cancel(); _scaleBoundarySub?.cancel();
_eventSubscription?.cancel(); _eventSubscription?.cancel();
_videoScaleStateNotifier.dispose();
super.dispose(); super.dispose();
} }
@@ -255,10 +257,11 @@ class _AssetPageState extends ConsumerState<AssetPage> {
ref.read(isPlayingMotionVideoProvider.notifier).playing = true; ref.read(isPlayingMotionVideoProvider.notifier).playing = true;
void _onScaleStateChanged(PhotoViewScaleState scaleState) { void _onScaleStateChanged(PhotoViewScaleState scaleState) {
_isZoomed = switch (scaleState) { _isZoomed =
PhotoViewScaleState.zoomedIn || PhotoViewScaleState.covering => true, scaleState == PhotoViewScaleState.zoomedIn ||
_ => false, scaleState == PhotoViewScaleState.covering ||
}; _videoScaleStateNotifier.value == PhotoViewScaleState.zoomedIn ||
_videoScaleStateNotifier.value == PhotoViewScaleState.covering;
_viewer.setZoomed(_isZoomed); _viewer.setZoomed(_isZoomed);
if (scaleState != PhotoViewScaleState.initial) { if (scaleState != PhotoViewScaleState.initial) {
@@ -340,34 +343,33 @@ class _AssetPageState extends ConsumerState<AssetPage> {
} }
return PhotoView.customChild( return PhotoView.customChild(
key: ValueKey(displayAsset),
onDragStart: _onDragStart, onDragStart: _onDragStart,
onDragUpdate: _onDragUpdate, onDragUpdate: _onDragUpdate,
onDragEnd: _onDragEnd, onDragEnd: _onDragEnd,
onDragCancel: _onDragCancel, onDragCancel: _onDragCancel,
onTapUp: _onTapUp,
heroAttributes: heroAttributes, heroAttributes: heroAttributes,
filterQuality: FilterQuality.high, filterQuality: FilterQuality.high,
maxScale: 1.0,
basePosition: Alignment.center, basePosition: Alignment.center,
disableScaleGestures: true, disableScaleGestures: true,
scaleStateChangedCallback: _onScaleStateChanged, minScale: PhotoViewComputedScale.contained,
initialScale: PhotoViewComputedScale.contained,
tightMode: true,
onPageBuild: _onPageBuild, onPageBuild: _onPageBuild,
enablePanAlways: true, enablePanAlways: true,
backgroundDecoration: backgroundDecoration, backgroundDecoration: backgroundDecoration,
child: SizedBox( child: NativeVideoViewer(
width: context.width, key: ValueKey(displayAsset),
height: context.height, asset: displayAsset,
child: NativeVideoViewer( scaleStateNotifier: _videoScaleStateNotifier,
disableScaleGestures: showingDetails,
image: Image(
key: ValueKey(displayAsset.heroTag), key: ValueKey(displayAsset.heroTag),
asset: displayAsset, image: getFullImageProvider(displayAsset, size: context.sizeData),
image: Image( height: context.height,
key: ValueKey(displayAsset), width: context.width,
image: getFullImageProvider(displayAsset, size: context.sizeData), fit: BoxFit.contain,
fit: BoxFit.contain, alignment: Alignment.center,
height: context.height,
width: context.width,
alignment: Alignment.center,
),
), ),
), ),
); );

View File

@@ -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/models/store.model.dart';
import 'package:immich_mobile/domain/services/setting.service.dart'; import 'package:immich_mobile/domain/services/setting.service.dart';
import 'package:immich_mobile/entities/store.entity.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/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.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/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/services/app_settings.service.dart';
import 'package:immich_mobile/utils/debounce.dart'; import 'package:immich_mobile/utils/debounce.dart';
import 'package:immich_mobile/utils/hooks/interval_hook.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:logging/logging.dart';
import 'package:native_video_player/native_video_player.dart'; import 'package:native_video_player/native_video_player.dart';
import 'package:wakelock_plus/wakelock_plus.dart'; import 'package:wakelock_plus/wakelock_plus.dart';
@@ -52,6 +54,8 @@ class NativeVideoViewer extends HookConsumerWidget {
final bool showControls; final bool showControls;
final int playbackDelayFactor; final int playbackDelayFactor;
final Widget image; final Widget image;
final ValueNotifier<PhotoViewScaleState>? scaleStateNotifier;
final bool disableScaleGestures;
const NativeVideoViewer({ const NativeVideoViewer({
super.key, super.key,
@@ -59,6 +63,8 @@ class NativeVideoViewer extends HookConsumerWidget {
required this.image, required this.image,
this.showControls = true, this.showControls = true,
this.playbackDelayFactor = 1, this.playbackDelayFactor = 1,
this.scaleStateNotifier,
this.disableScaleGestures = false,
}); });
@override @override
@@ -138,6 +144,7 @@ class NativeVideoViewer extends HookConsumerWidget {
final videoSource = useMemoized<Future<VideoSource?>>(() => createSource()); final videoSource = useMemoized<Future<VideoSource?>>(() => createSource());
final aspectRatio = useState<double?>(null); final aspectRatio = useState<double?>(null);
useMemoized(() async { useMemoized(() async {
if (!context.mounted || aspectRatio.value != null) { if (!context.mounted || aspectRatio.value != null) {
return null; return null;
@@ -313,6 +320,20 @@ class NativeVideoViewer extends HookConsumerWidget {
Timer(const Duration(milliseconds: 200), checkIfBuffering); 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) { ref.listen(currentAssetNotifier, (_, value) {
final playerController = controller.value; final playerController = controller.value;
if (playerController != null && value != asset) { if (playerController != null && value != asset) {
@@ -393,26 +414,31 @@ class NativeVideoViewer extends HookConsumerWidget {
} }
}); });
return Stack( return SizedBox(
children: [ width: context.width,
// This remains under the video to avoid flickering height: context.height,
// For motion videos, this is the image portion of the asset child: Stack(
Center(key: ValueKey(asset.heroTag), child: image), children: [
if (aspectRatio.value != null && !isCasting) // Hide thumbnail once video is visible to avoid it showing in background when zooming out on video.
Visibility.maintain( if (!isVisible.value || controller.value == null) Center(key: ValueKey(asset.heroTag), child: image),
key: ValueKey(asset), if (aspectRatio.value != null && !isCasting && isCurrent)
visible: isVisible.value, Visibility.maintain(
child: Center(
key: ValueKey(asset), key: ValueKey(asset),
child: AspectRatio( visible: isVisible.value,
child: PhotoView.customChild(
key: ValueKey(asset), key: ValueKey(asset),
aspectRatio: aspectRatio.value!, enableRotation: false,
child: isCurrent ? NativeVideoPlayerView(key: ValueKey(asset), onViewReady: initController) : null, 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()), ],
], ),
); );
} }

View File

@@ -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( return GestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.translucent,
onTap: showControlsAndStartHideTimer, onTap: toggleControlsVisibility,
child: AbsorbPointer( child: IgnorePointer(
absorbing: !showControls, ignoring: !showControls,
child: Stack( child: Stack(
children: [ children: [
if (showBuffering) if (showBuffering)
const Center(child: DelayedLoadingIndicator(fadeInDuration: Duration(milliseconds: 400))) const Center(child: DelayedLoadingIndicator(fadeInDuration: Duration(milliseconds: 400)))
else else
GestureDetector( CenterPlayButton(
onTap: () => ref.read(assetViewerProvider.notifier).setControls(false), backgroundColor: Colors.black54,
child: CenterPlayButton( iconColor: Colors.white,
backgroundColor: Colors.black54, isFinished: state == VideoPlaybackState.completed,
iconColor: Colors.white, isPlaying:
isFinished: state == VideoPlaybackState.completed, state == VideoPlaybackState.playing || (cast.isCasting && cast.castState == CastState.playing),
isPlaying: show: assetIsVideo && showControls,
state == VideoPlaybackState.playing || (cast.isCasting && cast.castState == CastState.playing), onPressed: togglePlay,
show: assetIsVideo && showControls,
onPressed: togglePlay,
),
), ),
], ],
), ),

View File

@@ -21,23 +21,20 @@ class CenterPlayButton extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ColoredBox( return Center(
color: Colors.transparent, child: UnconstrainedBox(
child: Center( child: AnimatedOpacity(
child: UnconstrainedBox( opacity: show ? 1.0 : 0.0,
child: AnimatedOpacity( duration: const Duration(milliseconds: 100),
opacity: show ? 1.0 : 0.0, child: DecoratedBox(
duration: const Duration(milliseconds: 100), decoration: BoxDecoration(color: backgroundColor, shape: BoxShape.circle),
child: DecoratedBox( child: IconButton(
decoration: BoxDecoration(color: backgroundColor, shape: BoxShape.circle), iconSize: 32,
child: IconButton( padding: const EdgeInsets.all(12.0),
iconSize: 32, icon: isFinished
padding: const EdgeInsets.all(12.0), ? Icon(Icons.replay, color: iconColor)
icon: isFinished : AnimatedPlayPause(color: iconColor, playing: isPlaying),
? Icon(Icons.replay, color: iconColor) onPressed: onPressed,
: AnimatedPlayPause(color: iconColor, playing: isPlaying),
onPressed: onPressed,
),
), ),
), ),
), ),