mirror of
https://github.com/Stremio/stremio-web.git
synced 2026-07-26 23:32:11 +00:00
fix: debounce keyboard seeking
This commit is contained in:
parent
3836d1b37c
commit
9b84d99bef
4 changed files with 207 additions and 31 deletions
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
154
src/routes/Player/useKeyboardSeek.ts
Normal file
154
src/routes/Player/useKeyboardSeek.ts
Normal 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;
|
||||
Loading…
Reference in a new issue