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