feat(mobile): high precision seeking (#22346)

* millisecond precision video playback

* wrap in unawaited

* update commit
This commit is contained in:
Mert 2025-10-24 14:59:30 -04:00 committed by GitHub
parent 78fb815cdb
commit c73e3dacea
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 81 additions and 53 deletions

View File

@ -26,6 +26,7 @@ import 'package:wakelock_plus/wakelock_plus.dart';
@RoutePage() @RoutePage()
class NativeVideoViewerPage extends HookConsumerWidget { class NativeVideoViewerPage extends HookConsumerWidget {
static final log = Logger('NativeVideoViewer');
final Asset asset; final Asset asset;
final bool showControls; final bool showControls;
final int playbackDelayFactor; 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 // Used to show the placeholder during hero animations for remote videos to avoid a stutter
final isVisible = useState(Platform.isIOS && asset.isLocal); final isVisible = useState(Platform.isIOS && asset.isLocal);
final log = Logger('NativeVideoViewerPage');
final isCasting = ref.watch(castProvider.select((c) => c.isCasting)); final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
final isVideoReady = useState(false); final isVideoReady = useState(false);
@ -142,7 +141,7 @@ class NativeVideoViewerPage extends HookConsumerWidget {
interval: const Duration(milliseconds: 100), interval: const Duration(milliseconds: 100),
maxWaitTime: const Duration(milliseconds: 200), maxWaitTime: const Duration(milliseconds: 200),
); );
ref.listen(videoPlayerControlsProvider, (oldControls, newControls) async { ref.listen(videoPlayerControlsProvider, (oldControls, newControls) {
final playerController = controller.value; final playerController = controller.value;
if (playerController == null) { if (playerController == null) {
return; return;
@ -153,28 +152,14 @@ class NativeVideoViewerPage extends HookConsumerWidget {
return; return;
} }
final oldSeek = (oldControls?.position ?? 0) ~/ 1; final oldSeek = oldControls?.position.inMilliseconds;
final newSeek = newControls.position ~/ 1; final newSeek = newControls.position.inMilliseconds;
if (oldSeek != newSeek || newControls.restarted) { if (oldSeek != newSeek || newControls.restarted) {
seekDebouncer.run(() => playerController.seekTo(newSeek)); seekDebouncer.run(() => playerController.seekTo(newSeek));
} }
if (oldControls?.pause != newControls.pause || newControls.restarted) { if (oldControls?.pause != newControls.pause || newControls.restarted) {
// Make sure the last seek is complete before pausing or playing unawaited(_onPauseChange(context, playerController, seekDebouncer, newControls.pause));
// 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');
}
} }
}); });
@ -234,7 +219,7 @@ class NativeVideoViewerPage extends HookConsumerWidget {
return; 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 // Check if the video is buffering
if (playbackInfo.status == PlaybackStatus.playing) { if (playbackInfo.status == PlaybackStatus.playing) {
@ -391,4 +376,35 @@ class NativeVideoViewerPage extends HookConsumerWidget {
], ],
); );
} }
Future<void> _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');
}
}
} }

View File

@ -46,6 +46,7 @@ bool _isCurrentAsset(BaseAsset asset, BaseAsset? currentAsset) {
} }
class NativeVideoViewer extends HookConsumerWidget { class NativeVideoViewer extends HookConsumerWidget {
static final log = Logger('NativeVideoViewer');
final BaseAsset asset; final BaseAsset asset;
final bool showControls; final bool showControls;
final int playbackDelayFactor; 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 // Used to show the placeholder during hero animations for remote videos to avoid a stutter
final isVisible = useState(Platform.isIOS && asset.hasLocal); final isVisible = useState(Platform.isIOS && asset.hasLocal);
final log = Logger('NativeVideoViewerPage');
final isCasting = ref.watch(castProvider.select((c) => c.isCasting)); final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
Future<VideoSource?> createSource() async { Future<VideoSource?> createSource() async {
@ -169,7 +168,7 @@ class NativeVideoViewer extends HookConsumerWidget {
interval: const Duration(milliseconds: 100), interval: const Duration(milliseconds: 100),
maxWaitTime: const Duration(milliseconds: 200), maxWaitTime: const Duration(milliseconds: 200),
); );
ref.listen(videoPlayerControlsProvider, (oldControls, newControls) async { ref.listen(videoPlayerControlsProvider, (oldControls, newControls) {
final playerController = controller.value; final playerController = controller.value;
if (playerController == null) { if (playerController == null) {
return; return;
@ -180,28 +179,14 @@ class NativeVideoViewer extends HookConsumerWidget {
return; return;
} }
final oldSeek = (oldControls?.position ?? 0) ~/ 1; final oldSeek = oldControls?.position.inMilliseconds;
final newSeek = newControls.position ~/ 1; final newSeek = newControls.position.inMilliseconds;
if (oldSeek != newSeek || newControls.restarted) { if (oldSeek != newSeek || newControls.restarted) {
seekDebouncer.run(() => playerController.seekTo(newSeek)); seekDebouncer.run(() => playerController.seekTo(newSeek));
} }
if (oldControls?.pause != newControls.pause || newControls.restarted) { if (oldControls?.pause != newControls.pause || newControls.restarted) {
// Make sure the last seek is complete before pausing or playing unawaited(_onPauseChange(context, playerController, seekDebouncer, newControls.pause));
// 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');
}
} }
}); });
@ -263,7 +248,7 @@ class NativeVideoViewer extends HookConsumerWidget {
return; 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 // Check if the video is buffering
if (playbackInfo.status == PlaybackStatus.playing) { if (playbackInfo.status == PlaybackStatus.playing) {
@ -422,4 +407,31 @@ class NativeVideoViewer extends HookConsumerWidget {
], ],
); );
} }
Future<void> _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');
}
}
} }

View File

@ -4,7 +4,7 @@ import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider
class VideoPlaybackControls { class VideoPlaybackControls {
const VideoPlaybackControls({required this.position, required this.pause, this.restarted = false}); const VideoPlaybackControls({required this.position, required this.pause, this.restarted = false});
final double position; final Duration position;
final bool pause; final bool pause;
final bool restarted; final bool restarted;
} }
@ -13,7 +13,7 @@ final videoPlayerControlsProvider = StateNotifierProvider<VideoPlayerControls, V
return VideoPlayerControls(ref); return VideoPlayerControls(ref);
}); });
const videoPlayerControlsDefault = VideoPlaybackControls(position: 0, pause: false); const videoPlayerControlsDefault = VideoPlaybackControls(position: Duration.zero, pause: false);
class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> { class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
VideoPlayerControls(this.ref) : super(videoPlayerControlsDefault); VideoPlayerControls(this.ref) : super(videoPlayerControlsDefault);
@ -30,10 +30,10 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
state = videoPlayerControlsDefault; state = videoPlayerControlsDefault;
} }
double get position => state.position; Duration get position => state.position;
bool get paused => state.pause; bool get paused => state.pause;
set position(double value) { set position(Duration value) {
if (state.position == value) { if (state.position == value) {
return; return;
} }
@ -62,7 +62,7 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
} }
void restart() { 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 ref.read(videoPlaybackValueProvider.notifier).value = ref
.read(videoPlaybackValueProvider.notifier) .read(videoPlaybackValueProvider.notifier)
.value .value

View File

@ -33,8 +33,8 @@ class VideoPlaybackValue {
}; };
return VideoPlaybackValue( return VideoPlaybackValue(
position: Duration(seconds: playbackInfo.position), position: Duration(milliseconds: playbackInfo.position),
duration: Duration(seconds: videoInfo.duration), duration: Duration(milliseconds: videoInfo.duration),
state: status, state: status,
volume: playbackInfo.volume, volume: playbackInfo.volume,
); );

View File

@ -61,7 +61,7 @@ class VideoPosition extends HookConsumerWidget {
return; 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 // This immediately updates the slider position without waiting for the video to update
ref.read(videoPlaybackValueProvider.notifier).position = seekToDuration; ref.read(videoPlaybackValueProvider.notifier).position = seekToDuration;

View File

@ -1233,8 +1233,8 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
path: "." path: "."
ref: "893894b" ref: d921ae2
resolved-ref: "893894b98b832be8a995a8d5d4c2289d0ad2d246" resolved-ref: d921ae210e294d2821954009ec2cc8aeae918725
url: "https://github.com/immich-app/native_video_player" url: "https://github.com/immich-app/native_video_player"
source: git source: git
version: "1.3.1" version: "1.3.1"

View File

@ -57,7 +57,7 @@ dependencies:
native_video_player: native_video_player:
git: git:
url: https://github.com/immich-app/native_video_player url: https://github.com/immich-app/native_video_player
ref: '893894b' ref: 'd921ae2'
network_info_plus: ^6.1.3 network_info_plus: ^6.1.3
octo_image: ^2.1.0 octo_image: ^2.1.0
openapi: openapi: