diff --git a/src/common/Shortcuts/index.ts b/src/common/Shortcuts/index.ts index fd6dc1648..0cdbd16f0 100644 --- a/src/common/Shortcuts/index.ts +++ b/src/common/Shortcuts/index.ts @@ -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, }; diff --git a/src/common/index.js b/src/common/index.js index fefd96ec7..36860d6f4 100644 --- a/src/common/index.js +++ b/src/common/index.js @@ -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, diff --git a/src/routes/Player/Player.js b/src/routes/Player/Player.js index 8d1523501..978ce1c3e 100644 --- a/src/routes/Player/Player.js +++ b/src/routes/Player/Player.js @@ -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} diff --git a/src/routes/Player/useKeyboardSeek.ts b/src/routes/Player/useKeyboardSeek.ts new file mode 100644 index 000000000..ef2ed789c --- /dev/null +++ b/src/routes/Player/useKeyboardSeek.ts @@ -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 | null, + interval: ReturnType | 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(null); + const targetRef = useRef(null); + const pendingRef = useRef(false); + const holdRef = useRef(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;