fix: debounce keyboard seeking

This commit is contained in:
Timothy Z. 2026-07-20 17:02:28 +03:00
parent 3836d1b37c
commit 9b84d99bef
4 changed files with 207 additions and 31 deletions

View file

@ -1,5 +1,5 @@
import { ShortcutsProvider, useShortcuts } from './Shortcuts';
import { getKeyboardShortcutKey } from './keyboard';
import { getKeyboardShortcutKey, getKeyboardShortcutKeys } from './keyboard';
import onShortcut from './onShortcut';
export {
@ -7,4 +7,5 @@ export {
useShortcuts,
onShortcut,
getKeyboardShortcutKey,
getKeyboardShortcutKeys,
};

View file

@ -5,7 +5,7 @@ const { FullscreenProvider, useFullscreen } = require('./Fullscreen');
const { PlatformProvider, usePlatform } = require('./Platform');
const { ToastProvider, useToast } = require('./Toast');
const { TooltipProvider, Tooltip } = require('./Tooltips');
const { ShortcutsProvider, useShortcuts, onShortcut, getKeyboardShortcutKey } = require('./Shortcuts');
const { ShortcutsProvider, useShortcuts, onShortcut, getKeyboardShortcutKey, getKeyboardShortcutKeys } = require('./Shortcuts');
const { DiscordProvider, useDiscord, EMPTY_DISCORD_TIMESTAMPS, getPlaybackDiscordActivity } = require('./Discord');
const CONSTANTS = require('./CONSTANTS');
const { withCoreSuspender, useCoreSuspender } = require('./CoreSuspender');
@ -43,6 +43,7 @@ module.exports = {
useShortcuts,
onShortcut,
getKeyboardShortcutKey,
getKeyboardShortcutKeys,
ToastProvider,
useToast,
TooltipProvider,

View file

@ -11,7 +11,7 @@ const { default: useRouteFocused } = require('stremio/common/useRouteFocused');
const { useCore } = require('stremio/core');
const { useServices, useGamepad } = require('stremio/services');
const { useContentGamepadNavigation } = require('stremio/services/GamepadNavigation');
const { useSettings, useProfile, useFullscreen, useBinaryState, useToast, useStreamingServer, withCoreSuspender, usePlatform, onShortcut, getKeyboardShortcutKey, useDiscord, EMPTY_DISCORD_TIMESTAMPS, getPlaybackDiscordActivity } = require('stremio/common');
const { useSettings, useProfile, useFullscreen, useBinaryState, useToast, useStreamingServer, withCoreSuspender, usePlatform, onShortcut, getKeyboardShortcutKey, getKeyboardShortcutKeys, useDiscord, EMPTY_DISCORD_TIMESTAMPS, getPlaybackDiscordActivity } = require('stremio/common');
const { default: toPath } = require('stremio-router/toPath');
const { HorizontalNavBar, Transition, ContextMenu } = require('stremio/components');
const { default: Buffering } = require('./Buffering');
@ -29,6 +29,7 @@ const { default: SideDrawerButton } = require('./SideDrawerButton');
const { default: SideDrawer } = require('./SideDrawer');
const usePlayer = require('./usePlayer');
const { default: usePlayOnDevice } = require('./usePlayOnDevice');
const { default: useKeyboardSeek } = require('./useKeyboardSeek');
const useStatistics = require('./useStatistics');
const useVideo = require('./useVideo');
const { default: useSubtitles } = require('./useSubtitles');
@ -173,10 +174,6 @@ const Player = () => {
toggleSubtitlesMenu,
});
const overlayHidden = React.useMemo(() => {
return immersed && !casting && video.state.paused !== null && !video.state.paused && !menusOpen;
}, [immersed, casting, video.state.paused, menusOpen]);
const nextVideoPopupDismissed = React.useRef(false);
const defaultAudioTrackSelected = React.useRef(false);
const playingOnExternalDevice = React.useRef(false);
@ -265,10 +262,31 @@ const Player = () => {
video.setVolume(volume);
}, []);
const onSeekRequested = React.useCallback((time) => {
const commitSeek = React.useCallback((time) => {
video.setTime(time);
seek(time, video.state.duration, video.state.manifest?.name);
}, [video.state.duration, video.state.manifest]);
const {
time: keyboardSeekTime,
seekBy: seekByKeyboard,
seekTo: onSeekRequested,
cancel: cancelKeyboardSeek,
flush: flushKeyboardSeek,
release: releaseKeyboardSeek,
} = useKeyboardSeek({
time: video.state.time,
duration: video.state.duration,
onSeek: commitSeek,
setSeeking,
});
const onKeyboardSeekRequested = React.useCallback((offset) => {
setImmersedDebounced.cancel();
setImmersed(false);
seekByKeyboard(offset);
}, [seekByKeyboard]);
const overlayHidden = React.useMemo(() => {
return keyboardSeekTime === null && immersed && !casting && video.state.paused !== null && !video.state.paused && !menusOpen;
}, [keyboardSeekTime, immersed, casting, video.state.paused, menusOpen]);
const onPlaybackSpeedChanged = React.useCallback((rate, skipUpdate) => {
video.setPlaybackSpeed(rate);
@ -302,12 +320,13 @@ const Player = () => {
const onNextVideoRequested = React.useCallback(() => {
if (player.nextVideo !== null) {
cancelKeyboardSeek();
nextVideo();
const deepLinks = player.nextVideo.deepLinks;
handleNextVideoNavigation(deepLinks, profile.settings.bingeWatching, false);
}
}, [player.nextVideo, handleNextVideoNavigation, profile.settings]);
}, [player.nextVideo, handleNextVideoNavigation, profile.settings, cancelKeyboardSeek]);
const onVideoClick = React.useCallback(() => {
if (video.state.paused !== null && !longPress.current) {
@ -441,6 +460,7 @@ const Player = () => {
React.useEffect(() => {
setError(null);
cancelKeyboardSeek();
video.unload();
if (player.selected && player.stream?.type === 'Ready' && streamingServer.settings?.type !== 'Loading') {
@ -477,7 +497,7 @@ const Player = () => {
shellTransport: platform.shell.active ? platform.shell : null,
});
}
}, [streamingServer.baseUrl, player.selected, player.stream, streamSubtitles, forceTranscoding, casting]);
}, [streamingServer.baseUrl, player.selected, player.stream, streamSubtitles, forceTranscoding, casting, cancelKeyboardSeek]);
React.useEffect(() => {
!seeking && timeChanged(video.state.time, video.state.duration, video.state.manifest?.name);
@ -636,20 +656,14 @@ const Player = () => {
}, [video.state.paused, player.nextVideo, onPlayRequested, onPauseRequested, onNextVideoRequested]);
onShortcut('seekForward', (combo) => {
if (video.state.time !== null) {
const seekDuration = combo === 1 ? settings.seekShortTimeDuration : settings.seekTimeDuration;
setSeeking(true);
onSeekRequested(video.state.time + seekDuration);
}
}, [video.state.time, onSeekRequested], !menusOpen);
const seekDuration = combo === 1 ? settings.seekShortTimeDuration : settings.seekTimeDuration;
onKeyboardSeekRequested(seekDuration);
}, [settings.seekShortTimeDuration, settings.seekTimeDuration, onKeyboardSeekRequested], !menusOpen);
onShortcut('seekBackward', (combo) => {
if (video.state.time !== null) {
const seekDuration = combo === 1 ? settings.seekShortTimeDuration : settings.seekTimeDuration;
setSeeking(true);
onSeekRequested(video.state.time - seekDuration);
}
}, [video.state.time, onSeekRequested], !menusOpen);
const seekDuration = combo === 1 ? settings.seekShortTimeDuration : settings.seekTimeDuration;
onKeyboardSeekRequested(-seekDuration);
}, [settings.seekShortTimeDuration, settings.seekTimeDuration, onKeyboardSeekRequested], !menusOpen);
onShortcut('mute', () => {
video.state.muted === true ? onUnmuteRequested() : onMuteRequested();
@ -771,21 +785,23 @@ const Player = () => {
};
const onKeyUp = (e) => {
const keyboardKey = getKeyboardShortcutKey(e);
const keyboardKeys = getKeyboardShortcutKeys(e);
if (keyboardKey === 'KeyD' || keyboardKey === 'D') {
if (keyboardKeys.includes('KeyD') || keyboardKeys.includes('D')) {
releaseDetailsHold();
return;
}
if (keyboardKey !== 'Space' && keyboardKey !== 'ArrowRight' && keyboardKey !== 'ArrowLeft') return;
if (!keyboardKeys.includes('Space') && !keyboardKeys.includes('ArrowRight') && !keyboardKeys.includes('ArrowLeft')) return;
if (e.ctrlKey || e.metaKey || e.altKey) return;
if (keyboardKey === 'ArrowRight' || keyboardKey === 'ArrowLeft') {
setSeeking(false);
if (keyboardKeys.includes('ArrowRight') || keyboardKeys.includes('ArrowLeft')) {
releaseKeyboardSeek();
setImmersed(false);
setImmersedDebounced(true);
return;
}
if (keyboardKey === 'Space') {
if (keyboardKeys.includes('Space')) {
clearTimeout(pressTimer.current);
pressTimer.current = null;
if (longPress.current) {
@ -846,7 +862,9 @@ const Player = () => {
longPress.current = false;
}
finishDetailsHold();
setSeeking(false);
flushKeyboardSeek();
setImmersed(false);
setImmersedDebounced(true);
};
if (routeFocused) {
@ -856,6 +874,8 @@ const Player = () => {
window.addEventListener('mousedown', onMouseDownHold);
window.addEventListener('mouseup', onMouseUp);
window.addEventListener('blur', onBlur);
} else {
cancelKeyboardSeek();
}
return () => {
window.removeEventListener('keyup', onKeyUp);
@ -865,7 +885,7 @@ const Player = () => {
window.removeEventListener('mouseup', onMouseUp);
window.removeEventListener('blur', onBlur);
};
}, [routeFocused, menusOpen, video.state.volume, video.state.paused, finishDetailsHold, releaseDetailsHold]);
}, [routeFocused, menusOpen, video.state.volume, video.state.paused, finishDetailsHold, releaseDetailsHold, cancelKeyboardSeek, flushKeyboardSeek, releaseKeyboardSeek]);
React.useEffect(() => {
video.events.on('error', onError);
@ -974,7 +994,7 @@ const Player = () => {
ref={controlBarRef}
className={classnames(styles['layer'], styles['control-bar-layer'])}
paused={video.state.paused}
time={video.state.time}
time={keyboardSeekTime ?? video.state.time}
duration={video.state.duration}
buffered={video.state.buffered}
volume={video.state.volume}

View file

@ -0,0 +1,154 @@
// Copyright (C) 2017-2026 Smart code 203358507
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
import { useLiveRef } from 'stremio/common';
type DebouncedCallback = (() => void) & {
cancel: () => void,
flush: () => void,
};
type Props = {
time: number | null,
duration: number | null,
onSeek: (time: number) => void,
setSeeking: (seeking: boolean) => void,
};
type Hold = {
timeout: ReturnType<typeof setTimeout> | null,
interval: ReturnType<typeof setInterval> | null,
step: number,
};
const debounce = require('lodash.debounce') as (callback: () => void, wait: number) => DebouncedCallback;
const DEBOUNCE_TIME = 300;
const PREVIEW_TIME = 1500;
const HOLD_DELAY = 300;
const HOLD_INTERVAL = 100;
const HOLD_ACCELERATION = 1.05;
const MAX_HOLD_STEP = 0.1;
const useKeyboardSeek = ({ time, duration, onSeek, setSeeking }: Props) => {
const [target, setTarget] = useState<number | null>(null);
const targetRef = useRef<number | null>(null);
const pendingRef = useRef(false);
const holdRef = useRef<Hold | null>(null);
const timeRef = useLiveRef(time);
const durationRef = useLiveRef(duration);
const onSeekRef = useLiveRef(onSeek);
const setSeekingRef = useLiveRef(setSeeking);
const stopHold = useCallback(() => {
if (holdRef.current !== null) {
holdRef.current.timeout !== null && clearTimeout(holdRef.current.timeout);
holdRef.current.interval !== null && clearInterval(holdRef.current.interval);
holdRef.current = null;
}
}, []);
const move = useCallback((offset: number) => {
if (timeRef.current === null) return;
const currentTime = targetRef.current ?? timeRef.current;
const target = Math.max(currentTime + offset, 0);
const clampedTarget = durationRef.current !== null && !isNaN(durationRef.current) ?
Math.min(target, durationRef.current)
:
target;
targetRef.current = clampedTarget;
pendingRef.current = true;
setTarget(clampedTarget);
setSeekingRef.current(true);
}, []);
const reset = useCallback(debounce(() => {
targetRef.current = null;
pendingRef.current = false;
setTarget(null);
}, PREVIEW_TIME), []);
const commit = useCallback(debounce(() => {
if (pendingRef.current && targetRef.current !== null) {
pendingRef.current = false;
onSeekRef.current(targetRef.current);
setSeekingRef.current(false);
reset();
}
}, DEBOUNCE_TIME), []);
const cancel = useCallback(() => {
const active = pendingRef.current;
stopHold();
commit.cancel();
reset.cancel();
targetRef.current = null;
pendingRef.current = false;
setTarget(null);
if (active) {
setSeekingRef.current(false);
}
}, []);
const flush = useCallback(() => {
stopHold();
if (pendingRef.current) {
commit();
}
commit.flush();
setSeekingRef.current(false);
}, []);
const release = useCallback(() => {
stopHold();
if (pendingRef.current) {
commit();
}
}, []);
const seekTo = useCallback((time: number) => {
cancel();
onSeekRef.current(time);
}, []);
const seekBy = useCallback((offset: number) => {
if (timeRef.current === null || holdRef.current !== null) return;
reset.cancel();
commit.cancel();
move(offset);
const hold: Hold = {
timeout: null,
interval: null,
step: offset,
};
hold.timeout = setTimeout(() => {
hold.timeout = null;
const accelerate = () => {
const duration = durationRef.current;
if (duration !== null && !isNaN(duration) && duration > 0) {
hold.step = Math.sign(hold.step) * Math.min(Math.abs(hold.step) * HOLD_ACCELERATION, duration * MAX_HOLD_STEP);
}
};
accelerate();
hold.interval = setInterval(() => {
move(hold.step);
accelerate();
}, HOLD_INTERVAL);
}, HOLD_DELAY);
holdRef.current = hold;
}, []);
useLayoutEffect(() => {
return () => {
stopHold();
commit.cancel();
reset.cancel();
};
}, []);
return {
time: target,
seekBy,
seekTo,
cancel,
flush,
release,
};
};
export default useKeyboardSeek;