diff --git a/src/components/player/atoms/SpeedChangedPopout.tsx b/src/components/player/atoms/SpeedChangedPopout.tsx new file mode 100644 index 00000000..feb29dc0 --- /dev/null +++ b/src/components/player/atoms/SpeedChangedPopout.tsx @@ -0,0 +1,42 @@ +import { Icon, Icons } from "@/components/Icon"; +import { Flare } from "@/components/utils/Flare"; +import { Transition } from "@/components/utils/Transition"; +import { useOverlayStack } from "@/stores/interface/overlayStack"; +import { usePlayerStore } from "@/stores/player/store"; + +export function SpeedChangedPopout() { + const isSpeedBoosted = usePlayerStore((s) => s.interface.isSpeedBoosted); + const showSpeedIndicator = usePlayerStore( + (s) => s.interface.showSpeedIndicator, + ); + const currentOverlay = useOverlayStack((s) => s.currentOverlay); + const playbackRate = usePlayerStore((s) => s.mediaPlaying.playbackRate); + + return ( + + + + + + + + {isSpeedBoosted + ? "Boosted to 2x speed" + : `Speed: ${playbackRate}x`} + + + + + + ); +} diff --git a/src/components/player/atoms/index.ts b/src/components/player/atoms/index.ts index e563c6c7..7d6fbb8b 100644 --- a/src/components/player/atoms/index.ts +++ b/src/components/player/atoms/index.ts @@ -18,3 +18,4 @@ export * from "./NextEpisodeButton"; export * from "./Chromecast"; export * from "./CastingNotification"; export * from "./Captions"; +export * from "./SpeedChangedPopout"; diff --git a/src/components/player/base/SubtitleView.tsx b/src/components/player/base/SubtitleView.tsx index 496b230c..e4eb562a 100644 --- a/src/components/player/base/SubtitleView.tsx +++ b/src/components/player/base/SubtitleView.tsx @@ -116,6 +116,7 @@ export function SubtitleRenderer() { const styling = useSubtitleStore((s) => s.styling); const overrideCasing = useSubtitleStore((s) => s.overrideCasing); const delay = useSubtitleStore((s) => s.delay); + const playbackRate = usePlayerStore((s) => s.mediaPlaying.playbackRate); const parsedCaptions = useMemo( () => (srtData ? parseSubtitles(srtData, language) : []), @@ -125,9 +126,9 @@ export function SubtitleRenderer() { const visibileCaptions = useMemo( () => parsedCaptions.filter(({ start, end }) => - captionIsVisible(start, end, delay, videoTime), + captionIsVisible(start, end, delay, videoTime, playbackRate), ), - [parsedCaptions, videoTime, delay], + [parsedCaptions, videoTime, delay, playbackRate], ); return ( diff --git a/src/components/player/internals/KeyboardEvents.tsx b/src/components/player/internals/KeyboardEvents.tsx index aa5485bb..b4f079f5 100644 --- a/src/components/player/internals/KeyboardEvents.tsx +++ b/src/components/player/internals/KeyboardEvents.tsx @@ -16,6 +16,8 @@ export function KeyboardEvents() { const mediaPlaying = usePlayerStore((s) => s.mediaPlaying); const time = usePlayerStore((s) => s.progress.time); const { setVolume, toggleMute } = useVolume(); + const setSpeedBoosted = usePlayerStore((s) => s.setSpeedBoosted); + const setShowSpeedIndicator = usePlayerStore((s) => s.setShowSpeedIndicator); const { toggleLastUsed } = useCaptions(); const setShowVolume = useEmpheralVolumeStore((s) => s.setShowVolume); @@ -28,6 +30,15 @@ export function KeyboardEvents() { const [isRolling, setIsRolling] = useState(false); const volumeDebounce = useRef | undefined>(); const subtitleDebounce = useRef | undefined>(); + const speedIndicatorTimeoutRef = useRef< + ReturnType | undefined + >(); + const boostTimeoutRef = useRef | undefined>(); + const isPendingBoostRef = useRef(false); + + // Tracking previous playback rate when space is held down + const previousRateRef = useRef(1); + const isSpaceHeldRef = useRef(false); const setCurrentOverlay = useOverlayStack((s) => s.setCurrentOverlay); @@ -48,6 +59,13 @@ export function KeyboardEvents() { delay, setShowDelayIndicator, setCurrentOverlay, + previousRateRef, + isSpaceHeldRef, + setSpeedBoosted, + setShowSpeedIndicator, + speedIndicatorTimeoutRef, + boostTimeoutRef, + isPendingBoostRef, }); useEffect(() => { dataRef.current = { @@ -67,6 +85,13 @@ export function KeyboardEvents() { delay, setShowDelayIndicator, setCurrentOverlay, + previousRateRef, + isSpaceHeldRef, + setSpeedBoosted, + setShowSpeedIndicator, + speedIndicatorTimeoutRef, + boostTimeoutRef, + isPendingBoostRef, }; }, [ setShowVolume, @@ -85,10 +110,12 @@ export function KeyboardEvents() { delay, setShowDelayIndicator, setCurrentOverlay, + setSpeedBoosted, + setShowSpeedIndicator, ]); useEffect(() => { - const keyEventHandler = (evt: KeyboardEvent) => { + const keydownEventHandler = (evt: KeyboardEvent) => { if (evt.target && (evt.target as HTMLInputElement).nodeName === "INPUT") return; @@ -126,6 +153,57 @@ export function KeyboardEvents() { if (next) dataRef.current.display?.setPlaybackRate(next); } + // Handle spacebar hold for 2x speed + if ( + k === " " && + !dataRef.current.isSpaceHeldRef.current && + !dataRef.current.mediaPlaying.isPaused && + !dataRef.current.isPendingBoostRef.current + ) { + // Skip if a button is targeted + if ( + evt.target && + (evt.target as HTMLInputElement).nodeName === "BUTTON" + ) { + return; + } + + // Prevent the default spacebar behavior (play/pause) + evt.preventDefault(); + + // Save current rate + dataRef.current.previousRateRef.current = + dataRef.current.mediaPlaying.playbackRate; + + // Set pending boost flag + dataRef.current.isPendingBoostRef.current = true; + + // Add delay before boosting speed + if (dataRef.current.boostTimeoutRef.current) { + clearTimeout(dataRef.current.boostTimeoutRef.current); + } + + dataRef.current.boostTimeoutRef.current = setTimeout(() => { + // Only apply boost if the key is still held down + if (dataRef.current.isPendingBoostRef.current) { + dataRef.current.isSpaceHeldRef.current = true; + dataRef.current.isPendingBoostRef.current = false; + + // Show speed indicator + dataRef.current.setSpeedBoosted(true); + dataRef.current.setShowSpeedIndicator(true); + dataRef.current.setCurrentOverlay("speed"); + + // Clear any existing timeout + if (dataRef.current.speedIndicatorTimeoutRef.current) { + clearTimeout(dataRef.current.speedIndicatorTimeoutRef.current); + } + + dataRef.current.display?.setPlaybackRate(2); + } + }, 300); // 300ms delay before boost takes effect + } + // Video progress if (k === "ArrowRight") dataRef.current.display?.setTime(dataRef.current.time + 5); @@ -142,7 +220,10 @@ export function KeyboardEvents() { // Utils if (keyL === "f") dataRef.current.display?.toggleFullscreen(); - if (k === " " || keyL === "k") { + if ( + (k === " " || keyL === "k") && + !dataRef.current.isSpaceHeldRef.current + ) { if ( evt.target && (evt.target as HTMLInputElement).nodeName === "BUTTON" @@ -187,10 +268,53 @@ export function KeyboardEvents() { }, 3000); } }; - window.addEventListener("keydown", keyEventHandler); + + const keyupEventHandler = (evt: KeyboardEvent) => { + const k = evt.key; + + // Handle spacebar release + if (k === " ") { + // If we haven't applied the boost yet but were about to, cancel it + if (dataRef.current.isPendingBoostRef.current) { + dataRef.current.isPendingBoostRef.current = false; + if (dataRef.current.boostTimeoutRef.current) { + clearTimeout(dataRef.current.boostTimeoutRef.current); + } + + // The space key was released quickly, so trigger play/pause + const action = dataRef.current.mediaPlaying.isPaused + ? "play" + : "pause"; + dataRef.current.display?.[action](); + } else if (dataRef.current.isSpaceHeldRef.current) { + // We were in boost mode, restore previous rate + dataRef.current.display?.setPlaybackRate( + dataRef.current.previousRateRef.current, + ); + dataRef.current.isSpaceHeldRef.current = false; + + // Update UI state + dataRef.current.setSpeedBoosted(false); + + // Set a timeout to hide the speed indicator + if (dataRef.current.speedIndicatorTimeoutRef.current) { + clearTimeout(dataRef.current.speedIndicatorTimeoutRef.current); + } + + dataRef.current.speedIndicatorTimeoutRef.current = setTimeout(() => { + dataRef.current.setShowSpeedIndicator(false); + dataRef.current.setCurrentOverlay(null); + }, 1500); + } + } + }; + + window.addEventListener("keydown", keydownEventHandler); + window.addEventListener("keyup", keyupEventHandler); return () => { - window.removeEventListener("keydown", keyEventHandler); + window.removeEventListener("keydown", keydownEventHandler); + window.removeEventListener("keyup", keyupEventHandler); }; }, []); diff --git a/src/components/player/internals/VideoClickTarget.tsx b/src/components/player/internals/VideoClickTarget.tsx index 0d92a225..de736ad8 100644 --- a/src/components/player/internals/VideoClickTarget.tsx +++ b/src/components/player/internals/VideoClickTarget.tsx @@ -1,8 +1,9 @@ import classNames from "classnames"; -import { PointerEvent, useCallback } from "react"; +import { PointerEvent, useCallback, useRef, useState } from "react"; import { useEffectOnce, useTimeoutFn } from "react-use"; import { useShouldShowVideoElement } from "@/components/player/internals/VideoContainer"; +import { useOverlayStack } from "@/stores/interface/overlayStack"; import { PlayerHoverState } from "@/stores/player/slices/interface"; import { usePlayerStore } from "@/stores/player/store"; @@ -10,10 +11,15 @@ export function VideoClickTarget(props: { showingControls: boolean }) { const show = useShouldShowVideoElement(); const display = usePlayerStore((s) => s.display); const isPaused = usePlayerStore((s) => s.mediaPlaying.isPaused); + const playbackRate = usePlayerStore((s) => s.mediaPlaying.playbackRate); const updateInterfaceHovering = usePlayerStore( (s) => s.updateInterfaceHovering, ); + const setSpeedBoosted = usePlayerStore((s) => s.setSpeedBoosted); + const setShowSpeedIndicator = usePlayerStore((s) => s.setShowSpeedIndicator); const hovering = usePlayerStore((s) => s.interface.hovering); + const setCurrentOverlay = useOverlayStack((s) => s.setCurrentOverlay); + const [_, cancel, reset] = useTimeoutFn(() => { updateInterfaceHovering(PlayerHoverState.NOT_HOVERING); }, 3000); @@ -21,12 +27,31 @@ export function VideoClickTarget(props: { showingControls: boolean }) { cancel(); }); + const previousRateRef = useRef(playbackRate); + const isHoldingRef = useRef(false); + const speedIndicatorTimeoutRef = useRef(null); + const boostTimeoutRef = useRef(null); + const [isPendingBoost, setIsPendingBoost] = useState(false); + const toggleFullscreen = useCallback(() => { display?.toggleFullscreen(); }, [display]); const togglePause = useCallback( (e: PointerEvent) => { + // Don't toggle pause if holding for speed change + if (isHoldingRef.current) { + isHoldingRef.current = false; + return; + } + + // Cancel any pending boost if we're clicking to pause + if (isPendingBoost) { + clearTimeout(boostTimeoutRef.current!); + setIsPendingBoost(false); + isHoldingRef.current = false; + } + // pause on mouse click if (e.pointerType === "mouse") { if (e.button !== 0) return; @@ -44,9 +69,136 @@ export function VideoClickTarget(props: { showingControls: boolean }) { cancel(); } }, - [display, isPaused, hovering, updateInterfaceHovering, reset, cancel], + [ + display, + isPaused, + hovering, + updateInterfaceHovering, + reset, + cancel, + isPendingBoost, + ], ); + const handlePointerDown = useCallback( + (e: PointerEvent) => { + if (e.pointerType === "mouse" && e.button === 0 && !isPaused) { + // Store current rate before changing + previousRateRef.current = playbackRate; + + // Set a timeout before actually boosting speed + if (boostTimeoutRef.current) { + clearTimeout(boostTimeoutRef.current); + } + + setIsPendingBoost(true); + + boostTimeoutRef.current = setTimeout(() => { + // Only apply boost if we're still holding down + isHoldingRef.current = true; + setIsPendingBoost(false); + + // Show speed indicator + setSpeedBoosted(true); + setShowSpeedIndicator(true); + setCurrentOverlay("speed"); + + if (speedIndicatorTimeoutRef.current) { + clearTimeout(speedIndicatorTimeoutRef.current); + } + + // Set to 2x speed + display?.setPlaybackRate(2); + }, 300); // 300ms delay before boost takes effect + } + }, + [ + display, + playbackRate, + isPaused, + setSpeedBoosted, + setShowSpeedIndicator, + setCurrentOverlay, + ], + ); + + const handlePointerUp = useCallback( + (e: PointerEvent) => { + // If we have a pending boost that hasn't activated yet, clear it + if (isPendingBoost) { + clearTimeout(boostTimeoutRef.current!); + setIsPendingBoost(false); + togglePause(e); + return; + } + + if (isHoldingRef.current && e.pointerType === "mouse" && e.button === 0) { + // Restore previous rate + display?.setPlaybackRate(previousRateRef.current); + isHoldingRef.current = false; + + // Update state for speed indicator + setSpeedBoosted(false); + + // Set a timeout to hide the speed indicator + if (speedIndicatorTimeoutRef.current) { + clearTimeout(speedIndicatorTimeoutRef.current); + } + + speedIndicatorTimeoutRef.current = setTimeout(() => { + setShowSpeedIndicator(false); + setCurrentOverlay(null); + speedIndicatorTimeoutRef.current = null; + }, 1500); + } else { + // Regular click handler + togglePause(e); + } + }, + [ + display, + togglePause, + setSpeedBoosted, + setShowSpeedIndicator, + setCurrentOverlay, + isPendingBoost, + ], + ); + + // Handle case where mouse leaves the player while still pressed + const handlePointerLeave = useCallback(() => { + // Clear pending boost if mouse leaves + if (isPendingBoost) { + clearTimeout(boostTimeoutRef.current!); + setIsPendingBoost(false); + } + + if (isHoldingRef.current) { + display?.setPlaybackRate(previousRateRef.current); + isHoldingRef.current = false; + + // Update state for speed indicator + setSpeedBoosted(false); + + // Set a timeout to hide the speed indicator + if (speedIndicatorTimeoutRef.current) { + clearTimeout(speedIndicatorTimeoutRef.current); + } + + speedIndicatorTimeoutRef.current = setTimeout(() => { + setShowSpeedIndicator(false); + setCurrentOverlay(null); + speedIndicatorTimeoutRef.current = null; + }, 1500); + } + }, [ + display, + setSpeedBoosted, + setShowSpeedIndicator, + setCurrentOverlay, + isPendingBoost, + ]); + if (!show) return null; return ( @@ -56,7 +208,9 @@ export function VideoClickTarget(props: { showingControls: boolean }) { "cursor-none": !props.showingControls, })} onDoubleClick={toggleFullscreen} - onPointerUp={togglePause} + onPointerDown={handlePointerDown} + onPointerUp={handlePointerUp} + onPointerLeave={handlePointerLeave} /> ); } diff --git a/src/components/player/utils/captions.ts b/src/components/player/utils/captions.ts index d144340e..5f133bd4 100644 --- a/src/components/player/utils/captions.ts +++ b/src/components/player/utils/captions.ts @@ -13,9 +13,14 @@ export function captionIsVisible( end: number, delay: number, currentTime: number, + playbackRate: number = 1, ) { + // Calculate subtitle timing with delay adjustment + // When playback is sped up, we need to adjust the subtitle windows accordingly + // so they appear at the right time but also remain visible for the right duration const delayedStart = start / 1000 + delay; const delayedEnd = end / 1000 + delay; + return ( Math.max(0, delayedStart) <= currentTime && Math.max(0, delayedEnd) >= currentTime diff --git a/src/pages/parts/player/PlayerPart.tsx b/src/pages/parts/player/PlayerPart.tsx index 5e254332..cfb03add 100644 --- a/src/pages/parts/player/PlayerPart.tsx +++ b/src/pages/parts/player/PlayerPart.tsx @@ -184,6 +184,7 @@ export function PlayerPart(props: PlayerPartProps) { + void; } -export const useOverlayStack = create((set) => ({ +export const useOverlayStack = create((set) => ({ currentOverlay: null, setCurrentOverlay: (overlay) => set({ currentOverlay: overlay }), })); diff --git a/src/stores/player/slices/interface.ts b/src/stores/player/slices/interface.ts index 0787d0fe..fd9ede66 100644 --- a/src/stores/player/slices/interface.ts +++ b/src/stores/player/slices/interface.ts @@ -32,6 +32,8 @@ export interface InterfaceSlice { leftControlHovering: boolean; // is the cursor hovered over the left side of player controls isHoveringControls: boolean; // is the cursor hovered over any controls? timeFormat: VideoPlayerTimeFormat; // Time format of the video player + isSpeedBoosted: boolean; // is playback speed temporarily boosted to 2x + showSpeedIndicator: boolean; // should the speed indicator be shown }; updateInterfaceHovering(newState: PlayerHoverState): void; setSeeking(seeking: boolean): void; @@ -42,6 +44,8 @@ export interface InterfaceSlice { setLastVolume(state: number): void; hideNextEpisodeButton(): void; setShouldStartFromBeginning(val: boolean): void; + setSpeedBoosted(state: boolean): void; + setShowSpeedIndicator(state: boolean): void; } export const createInterfaceSlice: MakeSlice = (set, get) => ({ @@ -61,6 +65,8 @@ export const createInterfaceSlice: MakeSlice = (set, get) => ({ canAirplay: false, hideNextEpisodeBtn: false, shouldStartFromBeginning: false, + isSpeedBoosted: false, + showSpeedIndicator: false, }, setShouldStartFromBeginning(val) { @@ -112,4 +118,14 @@ export const createInterfaceSlice: MakeSlice = (set, get) => ({ s.interface.hideNextEpisodeBtn = true; }); }, + setSpeedBoosted(state) { + set((s) => { + s.interface.isSpeedBoosted = state; + }); + }, + setShowSpeedIndicator(state) { + set((s) => { + s.interface.showSpeedIndicator = state; + }); + }, });