mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-31 18:47:09 -04:00 
			
		
		
		
	feat(mobile): high precision seeking (#22346)
* millisecond precision video playback * wrap in unawaited * update commit
This commit is contained in:
		
							parent
							
								
									78fb815cdb
								
							
						
					
					
						commit
						c73e3dacea
					
				| @ -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<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'); | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -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<VideoSource?> 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<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'); | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -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, V | ||||
|   return VideoPlayerControls(ref); | ||||
| }); | ||||
| 
 | ||||
| const videoPlayerControlsDefault = VideoPlaybackControls(position: 0, pause: false); | ||||
| const videoPlayerControlsDefault = VideoPlaybackControls(position: Duration.zero, pause: false); | ||||
| 
 | ||||
| class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> { | ||||
|   VideoPlayerControls(this.ref) : super(videoPlayerControlsDefault); | ||||
| @ -30,10 +30,10 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> { | ||||
|     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<VideoPlaybackControls> { | ||||
|   } | ||||
| 
 | ||||
|   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 | ||||
|  | ||||
| @ -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, | ||||
|     ); | ||||
|  | ||||
| @ -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; | ||||
|  | ||||
| @ -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" | ||||
|  | ||||
| @ -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: | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user