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() | @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'); | ||||||
|  |     } | ||||||
|  |   } | ||||||
| } | } | ||||||
|  | |||||||
| @ -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'); | ||||||
|  |     } | ||||||
|  |   } | ||||||
| } | } | ||||||
|  | |||||||
| @ -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 | ||||||
|  | |||||||
| @ -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, | ||||||
|     ); |     ); | ||||||
|  | |||||||
| @ -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; | ||||||
|  | |||||||
| @ -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" | ||||||
|  | |||||||
| @ -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: | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user