1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-01-01 08:31:59 +00:00

extra smooth seeking, add comments

This commit is contained in:
mertalev 2024-11-12 12:32:08 -05:00
parent e59912e16e
commit caee381721
No known key found for this signature in database
GPG key ID: CA85EF6600C9E8AD
5 changed files with 104 additions and 25 deletions

View file

@ -292,6 +292,7 @@ class GalleryViewerPage extends HookConsumerWidget {
} }
PhotoViewGalleryPageOptions buildVideo(BuildContext context, Asset asset) { PhotoViewGalleryPageOptions buildVideo(BuildContext context, Asset asset) {
// This key is to prevent the video player from being re-initialized during the hero animation
final key = GlobalKey(); final key = GlobalKey();
final tag = getHeroTag(asset); final tag = getHeroTag(asset);
return PhotoViewGalleryPageOptions.customChild( return PhotoViewGalleryPageOptions.customChild(

View file

@ -12,8 +12,8 @@ import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/asset.service.dart'; import 'package:immich_mobile/services/asset.service.dart';
import 'package:immich_mobile/utils/debounce.dart';
import 'package:immich_mobile/utils/hooks/interval_hook.dart'; import 'package:immich_mobile/utils/hooks/interval_hook.dart';
import 'package:immich_mobile/utils/throttle.dart';
import 'package:immich_mobile/widgets/asset_viewer/custom_video_player_controls.dart'; import 'package:immich_mobile/widgets/asset_viewer/custom_video_player_controls.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:native_video_player/native_video_player.dart'; import 'package:native_video_player/native_video_player.dart';
@ -43,6 +43,11 @@ class NativeVideoViewerPage extends HookConsumerWidget {
final controller = useState<NativeVideoPlayerController?>(null); final controller = useState<NativeVideoPlayerController?>(null);
final lastVideoPosition = useRef(-1); final lastVideoPosition = useRef(-1);
final isBuffering = useRef(false); final isBuffering = useRef(false);
// When a video is opened through the timeline, `isCurrent` will immediately be true.
// When swiping from video A to video B, `isCurrent` will initially be true for video A and false for video B.
// If the swipe is completed, `isCurrent` will be true for video B after a delay.
// If the swipe is canceled, `currentAsset` will not have changed and video A will continue to play.
final currentAsset = useState(ref.read(currentAssetProvider)); final currentAsset = useState(ref.read(currentAssetProvider));
final isCurrent = currentAsset.value == asset; final isCurrent = currentAsset.value == asset;
@ -193,8 +198,12 @@ class NativeVideoViewerPage extends HookConsumerWidget {
}); });
// When the position changes, seek to the position // When the position changes, seek to the position
final seekThrottler = // Debounce the seek to avoid seeking too often
useThrottler(interval: const Duration(milliseconds: 200)); // But also don't delay the seek too much to maintain visual feedback
final seekDebouncer = useDebouncer(
interval: const Duration(milliseconds: 100),
maxWaitTime: const Duration(milliseconds: 200),
);
ref.listen(videoPlayerControlsProvider.select((value) => value.position), ref.listen(videoPlayerControlsProvider.select((value) => value.position),
(_, position) async { (_, position) async {
final playerController = controller.value; final playerController = controller.value;
@ -208,21 +217,10 @@ class NativeVideoViewerPage extends HookConsumerWidget {
} }
// Find the position to seek to // Find the position to seek to
final int seek = (asset.duration * (position / 100.0)).inSeconds; final seek = position ~/ 1;
if (seek != playbackInfo.position) { if (seek != playbackInfo.position) {
try { seekDebouncer.run(() => playerController.seekTo(seek));
final maybeSeek =
seekThrottler.run(() => playerController.seekTo(seek));
if (maybeSeek != null) {
await maybeSeek;
}
} catch (error) {
log.severe('Error seeking to position $position: $error');
}
} }
ref.read(videoPlaybackValueProvider.notifier).position =
Duration(seconds: seek);
}); });
// // When the custom video controls pause or play // // When the custom video controls pause or play
@ -233,6 +231,12 @@ class NativeVideoViewerPage extends HookConsumerWidget {
return; 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 { try {
if (pause) { if (pause) {
await videoController.pause(); await videoController.pause();
@ -250,6 +254,10 @@ class NativeVideoViewerPage extends HookConsumerWidget {
return; return;
} }
final videoPlayback =
VideoPlaybackValue.fromNativeController(videoController);
ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback;
try { try {
await videoController.play(); await videoController.play();
await videoController.setVolume(0.9); await videoController.setVolume(0.9);
@ -266,11 +274,12 @@ class NativeVideoViewerPage extends HookConsumerWidget {
final videoPlayback = final videoPlayback =
VideoPlaybackValue.fromNativeController(videoController); VideoPlaybackValue.fromNativeController(videoController);
// No need to update the UI when it's about to loop
if (videoPlayback.state == VideoPlaybackState.completed && loopVideo) { if (videoPlayback.state == VideoPlaybackState.completed && loopVideo) {
return; return;
} }
ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback; ref.read(videoPlaybackValueProvider.notifier).status =
videoPlayback.state;
if (videoPlayback.state == VideoPlaybackState.playing) { if (videoPlayback.state == VideoPlaybackState.playing) {
// Sync with the controls playing // Sync with the controls playing
WakelockPlus.enable(); WakelockPlus.enable();
@ -281,6 +290,11 @@ class NativeVideoViewerPage extends HookConsumerWidget {
} }
void onPlaybackPositionChanged() { void onPlaybackPositionChanged() {
// When seeking, these events sometimes move the slider to an older position
if (seekDebouncer.isActive) {
return;
}
final videoController = controller.value; final videoController = controller.value;
if (videoController == null || !context.mounted) { if (videoController == null || !context.mounted) {
return; return;
@ -388,7 +402,7 @@ class NativeVideoViewerPage extends HookConsumerWidget {
return Stack( return Stack(
children: [ children: [
placeholder, placeholder, // this is always under the video to avoid flickering
Center( Center(
key: ValueKey('player-${asset.hashCode}'), key: ValueKey('player-${asset.hashCode}'),
child: aspectRatio.value != null child: aspectRatio.value != null
@ -404,7 +418,6 @@ class NativeVideoViewerPage extends HookConsumerWidget {
) )
: null, : null,
), ),
// covers the video with the placeholder
if (showControls) if (showControls)
Center( Center(
key: ValueKey('controls-${asset.hashCode}'), key: ValueKey('controls-${asset.hashCode}'),

View file

@ -125,6 +125,16 @@ class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
); );
} }
set status(VideoPlaybackState value) {
if (state.state == value) return;
state = VideoPlaybackValue(
position: state.position,
duration: state.duration,
state: value,
volume: state.volume,
);
}
void reset() { void reset() {
state = videoPlaybackValueDefault; state = videoPlaybackValueDefault;
} }

View file

@ -3,20 +3,52 @@ import 'dart:async';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
/// Used to debounce function calls with the [interval] provided. /// Used to debounce function calls with the [interval] provided.
/// If [maxWaitTime] is provided, the first [run] call as well as the next call since [maxWaitTime] has passed will be immediately executed, even if [interval] is not satisfied.
class Debouncer { class Debouncer {
Debouncer({required this.interval}); Debouncer({required this.interval, this.maxWaitTime});
final Duration interval; final Duration interval;
final Duration? maxWaitTime;
Timer? _timer; Timer? _timer;
FutureOr<void> Function()? _lastAction; FutureOr<void> Function()? _lastAction;
DateTime? _lastActionTime;
Future<void>? _actionFuture;
void run(FutureOr<void> Function() action) { void run(FutureOr<void> Function() action) {
_lastAction = action; _lastAction = action;
_timer?.cancel(); _timer?.cancel();
if (maxWaitTime != null &&
// _actionFuture == null && // TODO: should this check be here?
(_lastActionTime == null ||
DateTime.now().difference(_lastActionTime!) > maxWaitTime!)) {
_callAndRest();
return;
}
_timer = Timer(interval, _callAndRest); _timer = Timer(interval, _callAndRest);
} }
Future<void>? drain() {
if (_timer != null && _timer!.isActive) {
_timer!.cancel();
if (_lastAction != null) {
_callAndRest();
}
}
return _actionFuture;
}
@pragma('vm:prefer-inline')
void _callAndRest() { void _callAndRest() {
_lastAction?.call(); _lastActionTime = DateTime.now();
final action = _lastAction;
_lastAction = null;
final result = action!();
if (result is Future) {
_actionFuture = result.whenComplete(() {
_actionFuture = null;
});
}
_timer = null; _timer = null;
} }
@ -24,31 +56,48 @@ class Debouncer {
_timer?.cancel(); _timer?.cancel();
_timer = null; _timer = null;
_lastAction = null; _lastAction = null;
_lastActionTime = null;
_actionFuture = null;
} }
bool get isActive =>
_actionFuture != null || (_timer != null && _timer!.isActive);
} }
/// Creates a [Debouncer] that will be disposed automatically. If no [interval] is provided, a /// Creates a [Debouncer] that will be disposed automatically. If no [interval] is provided, a
/// default interval of 300ms is used to debounce the function calls /// default interval of 300ms is used to debounce the function calls
Debouncer useDebouncer({ Debouncer useDebouncer({
Duration interval = const Duration(milliseconds: 300), Duration interval = const Duration(milliseconds: 300),
Duration? maxWaitTime,
List<Object?>? keys, List<Object?>? keys,
}) => }) =>
use(_DebouncerHook(interval: interval, keys: keys)); use(
_DebouncerHook(
interval: interval,
maxWaitTime: maxWaitTime,
keys: keys,
),
);
class _DebouncerHook extends Hook<Debouncer> { class _DebouncerHook extends Hook<Debouncer> {
const _DebouncerHook({ const _DebouncerHook({
required this.interval, required this.interval,
this.maxWaitTime,
super.keys, super.keys,
}); });
final Duration interval; final Duration interval;
final Duration? maxWaitTime;
@override @override
HookState<Debouncer, Hook<Debouncer>> createState() => _DebouncerHookState(); HookState<Debouncer, Hook<Debouncer>> createState() => _DebouncerHookState();
} }
class _DebouncerHookState extends HookState<Debouncer, _DebouncerHook> { class _DebouncerHookState extends HookState<Debouncer, _DebouncerHook> {
late final debouncer = Debouncer(interval: hook.interval); late final debouncer = Debouncer(
interval: hook.interval,
maxWaitTime: hook.maxWaitTime,
);
@override @override
Debouncer build(_) => debouncer; Debouncer build(_) => debouncer;

View file

@ -56,10 +56,16 @@ class VideoPosition extends HookConsumerWidget {
ref.read(videoPlayerControlsProvider.notifier).play(); ref.read(videoPlayerControlsProvider.notifier).play();
} }
}, },
onChanged: (position) { onChanged: (value) {
final inSeconds =
(duration * (value / 100.0)).inSeconds;
final position = inSeconds.toDouble();
ref ref
.read(videoPlayerControlsProvider.notifier) .read(videoPlayerControlsProvider.notifier)
.position = position; .position = position;
// This immediately updates the slider position without waiting for the video to update
ref.read(videoPlaybackValueProvider.notifier).position =
Duration(seconds: inSeconds);
}, },
), ),
), ),