From c73e3dacea281aef6763f3b8b2f9d860d83cdf35 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Fri, 24 Oct 2025 14:59:30 -0400 Subject: [PATCH] feat(mobile): high precision seeking (#22346) * millisecond precision video playback * wrap in unawaited * update commit --- .../common/native_video_viewer.page.dart | 58 ++++++++++++------- .../asset_viewer/video_viewer.widget.dart | 54 ++++++++++------- .../video_player_controls_provider.dart | 10 ++-- .../video_player_value_provider.dart | 4 +- .../widgets/asset_viewer/video_position.dart | 2 +- mobile/pubspec.lock | 4 +- mobile/pubspec.yaml | 2 +- 7 files changed, 81 insertions(+), 53 deletions(-) diff --git a/mobile/lib/pages/common/native_video_viewer.page.dart b/mobile/lib/pages/common/native_video_viewer.page.dart index 7f39d07ec0..5f4eaeaaad 100644 --- a/mobile/lib/pages/common/native_video_viewer.page.dart +++ b/mobile/lib/pages/common/native_video_viewer.page.dart @@ -26,6 +26,7 @@ import 'package:wakelock_plus/wakelock_plus.dart'; @RoutePage() class NativeVideoViewerPage extends HookConsumerWidget { + static final log = Logger('NativeVideoViewer'); final Asset asset; final bool showControls; final int playbackDelayFactor; @@ -59,8 +60,6 @@ class NativeVideoViewerPage extends HookConsumerWidget { // Used to show the placeholder during hero animations for remote videos to avoid a stutter final isVisible = useState(Platform.isIOS && asset.isLocal); - final log = Logger('NativeVideoViewerPage'); - final isCasting = ref.watch(castProvider.select((c) => c.isCasting)); final isVideoReady = useState(false); @@ -142,7 +141,7 @@ class NativeVideoViewerPage extends HookConsumerWidget { interval: const Duration(milliseconds: 100), maxWaitTime: const Duration(milliseconds: 200), ); - ref.listen(videoPlayerControlsProvider, (oldControls, newControls) async { + ref.listen(videoPlayerControlsProvider, (oldControls, newControls) { final playerController = controller.value; if (playerController == null) { return; @@ -153,28 +152,14 @@ class NativeVideoViewerPage extends HookConsumerWidget { return; } - final oldSeek = (oldControls?.position ?? 0) ~/ 1; - final newSeek = newControls.position ~/ 1; + final oldSeek = oldControls?.position.inMilliseconds; + final newSeek = newControls.position.inMilliseconds; if (oldSeek != newSeek || newControls.restarted) { seekDebouncer.run(() => playerController.seekTo(newSeek)); } if (oldControls?.pause != newControls.pause || newControls.restarted) { - // Make sure the last seek is complete before pausing or playing - // Otherwise, `onPlaybackPositionChanged` can receive outdated events - if (seekDebouncer.isActive) { - await seekDebouncer.drain(); - } - - try { - if (newControls.pause) { - await playerController.pause(); - } else { - await playerController.play(); - } - } catch (error) { - log.severe('Error pausing or playing video: $error'); - } + unawaited(_onPauseChange(context, playerController, seekDebouncer, newControls.pause)); } }); @@ -234,7 +219,7 @@ class NativeVideoViewerPage extends HookConsumerWidget { return; } - ref.read(videoPlaybackValueProvider.notifier).position = Duration(seconds: playbackInfo.position); + ref.read(videoPlaybackValueProvider.notifier).position = Duration(milliseconds: playbackInfo.position); // Check if the video is buffering if (playbackInfo.status == PlaybackStatus.playing) { @@ -391,4 +376,35 @@ class NativeVideoViewerPage extends HookConsumerWidget { ], ); } + + Future _onPauseChange( + BuildContext context, + NativeVideoPlayerController controller, + Debouncer seekDebouncer, + bool isPaused, + ) async { + if (!context.mounted) { + return; + } + + // Make sure the last seek is complete before pausing or playing + // Otherwise, `onPlaybackPositionChanged` can receive outdated events + if (seekDebouncer.isActive) { + await seekDebouncer.drain(); + } + + if (!context.mounted) { + return; + } + + try { + if (isPaused) { + await controller.pause(); + } else { + await controller.play(); + } + } catch (error) { + log.severe('Error pausing or playing video: $error'); + } + } } 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 3af31950ad..916201aa70 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart @@ -46,6 +46,7 @@ bool _isCurrentAsset(BaseAsset asset, BaseAsset? currentAsset) { } class NativeVideoViewer extends HookConsumerWidget { + static final log = Logger('NativeVideoViewer'); final BaseAsset asset; final bool showControls; final int playbackDelayFactor; @@ -79,8 +80,6 @@ class NativeVideoViewer extends HookConsumerWidget { // Used to show the placeholder during hero animations for remote videos to avoid a stutter final isVisible = useState(Platform.isIOS && asset.hasLocal); - final log = Logger('NativeVideoViewerPage'); - final isCasting = ref.watch(castProvider.select((c) => c.isCasting)); Future createSource() async { @@ -169,7 +168,7 @@ class NativeVideoViewer extends HookConsumerWidget { interval: const Duration(milliseconds: 100), maxWaitTime: const Duration(milliseconds: 200), ); - ref.listen(videoPlayerControlsProvider, (oldControls, newControls) async { + ref.listen(videoPlayerControlsProvider, (oldControls, newControls) { final playerController = controller.value; if (playerController == null) { return; @@ -180,28 +179,14 @@ class NativeVideoViewer extends HookConsumerWidget { return; } - final oldSeek = (oldControls?.position ?? 0) ~/ 1; - final newSeek = newControls.position ~/ 1; + final oldSeek = oldControls?.position.inMilliseconds; + final newSeek = newControls.position.inMilliseconds; if (oldSeek != newSeek || newControls.restarted) { seekDebouncer.run(() => playerController.seekTo(newSeek)); } if (oldControls?.pause != newControls.pause || newControls.restarted) { - // Make sure the last seek is complete before pausing or playing - // Otherwise, `onPlaybackPositionChanged` can receive outdated events - if (seekDebouncer.isActive) { - await seekDebouncer.drain(); - } - - try { - if (newControls.pause) { - await playerController.pause(); - } else { - await playerController.play(); - } - } catch (error) { - log.severe('Error pausing or playing video: $error'); - } + unawaited(_onPauseChange(context, playerController, seekDebouncer, newControls.pause)); } }); @@ -263,7 +248,7 @@ class NativeVideoViewer extends HookConsumerWidget { return; } - ref.read(videoPlaybackValueProvider.notifier).position = Duration(seconds: playbackInfo.position); + ref.read(videoPlaybackValueProvider.notifier).position = Duration(milliseconds: playbackInfo.position); // Check if the video is buffering if (playbackInfo.status == PlaybackStatus.playing) { @@ -422,4 +407,31 @@ class NativeVideoViewer extends HookConsumerWidget { ], ); } + + Future _onPauseChange( + BuildContext context, + NativeVideoPlayerController controller, + Debouncer seekDebouncer, + bool isPaused, + ) async { + if (!context.mounted) { + return; + } + + // Make sure the last seek is complete before pausing or playing + // Otherwise, `onPlaybackPositionChanged` can receive outdated events + if (seekDebouncer.isActive) { + await seekDebouncer.drain(); + } + + try { + if (isPaused) { + await controller.pause(); + } else { + await controller.play(); + } + } catch (error) { + log.severe('Error pausing or playing video: $error'); + } + } } diff --git a/mobile/lib/providers/asset_viewer/video_player_controls_provider.dart b/mobile/lib/providers/asset_viewer/video_player_controls_provider.dart index 3cfc2e2f6f..44740268db 100644 --- a/mobile/lib/providers/asset_viewer/video_player_controls_provider.dart +++ b/mobile/lib/providers/asset_viewer/video_player_controls_provider.dart @@ -4,7 +4,7 @@ import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider class VideoPlaybackControls { const VideoPlaybackControls({required this.position, required this.pause, this.restarted = false}); - final double position; + final Duration position; final bool pause; final bool restarted; } @@ -13,7 +13,7 @@ final videoPlayerControlsProvider = StateNotifierProvider { VideoPlayerControls(this.ref) : super(videoPlayerControlsDefault); @@ -30,10 +30,10 @@ class VideoPlayerControls extends StateNotifier { state = videoPlayerControlsDefault; } - double get position => state.position; + Duration get position => state.position; bool get paused => state.pause; - set position(double value) { + set position(Duration value) { if (state.position == value) { return; } @@ -62,7 +62,7 @@ class VideoPlayerControls extends StateNotifier { } void restart() { - state = const VideoPlaybackControls(position: 0, pause: false, restarted: true); + state = const VideoPlaybackControls(position: Duration.zero, pause: false, restarted: true); ref.read(videoPlaybackValueProvider.notifier).value = ref .read(videoPlaybackValueProvider.notifier) .value diff --git a/mobile/lib/providers/asset_viewer/video_player_value_provider.dart b/mobile/lib/providers/asset_viewer/video_player_value_provider.dart index c478ddd6f5..31b0f4656e 100644 --- a/mobile/lib/providers/asset_viewer/video_player_value_provider.dart +++ b/mobile/lib/providers/asset_viewer/video_player_value_provider.dart @@ -33,8 +33,8 @@ class VideoPlaybackValue { }; return VideoPlaybackValue( - position: Duration(seconds: playbackInfo.position), - duration: Duration(seconds: videoInfo.duration), + position: Duration(milliseconds: playbackInfo.position), + duration: Duration(milliseconds: videoInfo.duration), state: status, volume: playbackInfo.volume, ); diff --git a/mobile/lib/widgets/asset_viewer/video_position.dart b/mobile/lib/widgets/asset_viewer/video_position.dart index c12bb5e682..9d9e2821ad 100644 --- a/mobile/lib/widgets/asset_viewer/video_position.dart +++ b/mobile/lib/widgets/asset_viewer/video_position.dart @@ -61,7 +61,7 @@ class VideoPosition extends HookConsumerWidget { return; } - ref.read(videoPlayerControlsProvider.notifier).position = seekToDuration.inSeconds.toDouble(); + ref.read(videoPlayerControlsProvider.notifier).position = seekToDuration; // This immediately updates the slider position without waiting for the video to update ref.read(videoPlaybackValueProvider.notifier).position = seekToDuration; diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 44bb2ae65e..8dc7076b48 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1233,8 +1233,8 @@ packages: dependency: "direct main" description: path: "." - ref: "893894b" - resolved-ref: "893894b98b832be8a995a8d5d4c2289d0ad2d246" + ref: d921ae2 + resolved-ref: d921ae210e294d2821954009ec2cc8aeae918725 url: "https://github.com/immich-app/native_video_player" source: git version: "1.3.1" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index a6d20a2cb3..2883b38e94 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -57,7 +57,7 @@ dependencies: native_video_player: git: url: https://github.com/immich-app/native_video_player - ref: '893894b' + ref: 'd921ae2' network_info_plus: ^6.1.3 octo_image: ^2.1.0 openapi: