We have short attention spans

now instead of watching your entire movie at one speed, you can do it in VARYING SPEEDS!
This commit is contained in:
mrapple 2025-05-10 22:19:12 +01:00
parent 9bfb2f8628
commit 02bace8a47
9 changed files with 356 additions and 12 deletions

View file

@ -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 (
<Transition
animation="slide-down"
show={showSpeedIndicator && currentOverlay === "speed"}
className="absolute inset-x-0 top-4 flex justify-center pointer-events-none"
>
<Flare.Base className="hover:flare-enabled pointer-events-auto bg-video-context-background pl-4 pr-6 py-3 group w-72 h-full rounded-lg transition-colors text-video-context-type-main">
<Flare.Light
enabled
flareSize={200}
cssColorVar="--colors-video-context-light"
backgroundClass="bg-video-context-background duration-100"
className="rounded-lg"
/>
<Flare.Child className="grid grid-cols-[auto,1fr] gap-3 pointer-events-auto relative transition-transform">
<Icon className="text-2xl" icon={Icons.TACHOMETER} />
<div className="w-full flex items-center justify-between">
<span className="text-sm">
{isSpeedBoosted
? "Boosted to 2x speed"
: `Speed: ${playbackRate}x`}
</span>
</div>
</Flare.Child>
</Flare.Base>
</Transition>
);
}

View file

@ -18,3 +18,4 @@ export * from "./NextEpisodeButton";
export * from "./Chromecast"; export * from "./Chromecast";
export * from "./CastingNotification"; export * from "./CastingNotification";
export * from "./Captions"; export * from "./Captions";
export * from "./SpeedChangedPopout";

View file

@ -116,6 +116,7 @@ export function SubtitleRenderer() {
const styling = useSubtitleStore((s) => s.styling); const styling = useSubtitleStore((s) => s.styling);
const overrideCasing = useSubtitleStore((s) => s.overrideCasing); const overrideCasing = useSubtitleStore((s) => s.overrideCasing);
const delay = useSubtitleStore((s) => s.delay); const delay = useSubtitleStore((s) => s.delay);
const playbackRate = usePlayerStore((s) => s.mediaPlaying.playbackRate);
const parsedCaptions = useMemo( const parsedCaptions = useMemo(
() => (srtData ? parseSubtitles(srtData, language) : []), () => (srtData ? parseSubtitles(srtData, language) : []),
@ -125,9 +126,9 @@ export function SubtitleRenderer() {
const visibileCaptions = useMemo( const visibileCaptions = useMemo(
() => () =>
parsedCaptions.filter(({ start, end }) => parsedCaptions.filter(({ start, end }) =>
captionIsVisible(start, end, delay, videoTime), captionIsVisible(start, end, delay, videoTime, playbackRate),
), ),
[parsedCaptions, videoTime, delay], [parsedCaptions, videoTime, delay, playbackRate],
); );
return ( return (

View file

@ -16,6 +16,8 @@ export function KeyboardEvents() {
const mediaPlaying = usePlayerStore((s) => s.mediaPlaying); const mediaPlaying = usePlayerStore((s) => s.mediaPlaying);
const time = usePlayerStore((s) => s.progress.time); const time = usePlayerStore((s) => s.progress.time);
const { setVolume, toggleMute } = useVolume(); const { setVolume, toggleMute } = useVolume();
const setSpeedBoosted = usePlayerStore((s) => s.setSpeedBoosted);
const setShowSpeedIndicator = usePlayerStore((s) => s.setShowSpeedIndicator);
const { toggleLastUsed } = useCaptions(); const { toggleLastUsed } = useCaptions();
const setShowVolume = useEmpheralVolumeStore((s) => s.setShowVolume); const setShowVolume = useEmpheralVolumeStore((s) => s.setShowVolume);
@ -28,6 +30,15 @@ export function KeyboardEvents() {
const [isRolling, setIsRolling] = useState(false); const [isRolling, setIsRolling] = useState(false);
const volumeDebounce = useRef<ReturnType<typeof setTimeout> | undefined>(); const volumeDebounce = useRef<ReturnType<typeof setTimeout> | undefined>();
const subtitleDebounce = useRef<ReturnType<typeof setTimeout> | undefined>(); const subtitleDebounce = useRef<ReturnType<typeof setTimeout> | undefined>();
const speedIndicatorTimeoutRef = useRef<
ReturnType<typeof setTimeout> | undefined
>();
const boostTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>();
const isPendingBoostRef = useRef<boolean>(false);
// Tracking previous playback rate when space is held down
const previousRateRef = useRef<number>(1);
const isSpaceHeldRef = useRef<boolean>(false);
const setCurrentOverlay = useOverlayStack((s) => s.setCurrentOverlay); const setCurrentOverlay = useOverlayStack((s) => s.setCurrentOverlay);
@ -48,6 +59,13 @@ export function KeyboardEvents() {
delay, delay,
setShowDelayIndicator, setShowDelayIndicator,
setCurrentOverlay, setCurrentOverlay,
previousRateRef,
isSpaceHeldRef,
setSpeedBoosted,
setShowSpeedIndicator,
speedIndicatorTimeoutRef,
boostTimeoutRef,
isPendingBoostRef,
}); });
useEffect(() => { useEffect(() => {
dataRef.current = { dataRef.current = {
@ -67,6 +85,13 @@ export function KeyboardEvents() {
delay, delay,
setShowDelayIndicator, setShowDelayIndicator,
setCurrentOverlay, setCurrentOverlay,
previousRateRef,
isSpaceHeldRef,
setSpeedBoosted,
setShowSpeedIndicator,
speedIndicatorTimeoutRef,
boostTimeoutRef,
isPendingBoostRef,
}; };
}, [ }, [
setShowVolume, setShowVolume,
@ -85,10 +110,12 @@ export function KeyboardEvents() {
delay, delay,
setShowDelayIndicator, setShowDelayIndicator,
setCurrentOverlay, setCurrentOverlay,
setSpeedBoosted,
setShowSpeedIndicator,
]); ]);
useEffect(() => { useEffect(() => {
const keyEventHandler = (evt: KeyboardEvent) => { const keydownEventHandler = (evt: KeyboardEvent) => {
if (evt.target && (evt.target as HTMLInputElement).nodeName === "INPUT") if (evt.target && (evt.target as HTMLInputElement).nodeName === "INPUT")
return; return;
@ -126,6 +153,57 @@ export function KeyboardEvents() {
if (next) dataRef.current.display?.setPlaybackRate(next); 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 // Video progress
if (k === "ArrowRight") if (k === "ArrowRight")
dataRef.current.display?.setTime(dataRef.current.time + 5); dataRef.current.display?.setTime(dataRef.current.time + 5);
@ -142,7 +220,10 @@ export function KeyboardEvents() {
// Utils // Utils
if (keyL === "f") dataRef.current.display?.toggleFullscreen(); if (keyL === "f") dataRef.current.display?.toggleFullscreen();
if (k === " " || keyL === "k") { if (
(k === " " || keyL === "k") &&
!dataRef.current.isSpaceHeldRef.current
) {
if ( if (
evt.target && evt.target &&
(evt.target as HTMLInputElement).nodeName === "BUTTON" (evt.target as HTMLInputElement).nodeName === "BUTTON"
@ -187,10 +268,53 @@ export function KeyboardEvents() {
}, 3000); }, 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 () => { return () => {
window.removeEventListener("keydown", keyEventHandler); window.removeEventListener("keydown", keydownEventHandler);
window.removeEventListener("keyup", keyupEventHandler);
}; };
}, []); }, []);

View file

@ -1,8 +1,9 @@
import classNames from "classnames"; import classNames from "classnames";
import { PointerEvent, useCallback } from "react"; import { PointerEvent, useCallback, useRef, useState } from "react";
import { useEffectOnce, useTimeoutFn } from "react-use"; import { useEffectOnce, useTimeoutFn } from "react-use";
import { useShouldShowVideoElement } from "@/components/player/internals/VideoContainer"; import { useShouldShowVideoElement } from "@/components/player/internals/VideoContainer";
import { useOverlayStack } from "@/stores/interface/overlayStack";
import { PlayerHoverState } from "@/stores/player/slices/interface"; import { PlayerHoverState } from "@/stores/player/slices/interface";
import { usePlayerStore } from "@/stores/player/store"; import { usePlayerStore } from "@/stores/player/store";
@ -10,10 +11,15 @@ export function VideoClickTarget(props: { showingControls: boolean }) {
const show = useShouldShowVideoElement(); const show = useShouldShowVideoElement();
const display = usePlayerStore((s) => s.display); const display = usePlayerStore((s) => s.display);
const isPaused = usePlayerStore((s) => s.mediaPlaying.isPaused); const isPaused = usePlayerStore((s) => s.mediaPlaying.isPaused);
const playbackRate = usePlayerStore((s) => s.mediaPlaying.playbackRate);
const updateInterfaceHovering = usePlayerStore( const updateInterfaceHovering = usePlayerStore(
(s) => s.updateInterfaceHovering, (s) => s.updateInterfaceHovering,
); );
const setSpeedBoosted = usePlayerStore((s) => s.setSpeedBoosted);
const setShowSpeedIndicator = usePlayerStore((s) => s.setShowSpeedIndicator);
const hovering = usePlayerStore((s) => s.interface.hovering); const hovering = usePlayerStore((s) => s.interface.hovering);
const setCurrentOverlay = useOverlayStack((s) => s.setCurrentOverlay);
const [_, cancel, reset] = useTimeoutFn(() => { const [_, cancel, reset] = useTimeoutFn(() => {
updateInterfaceHovering(PlayerHoverState.NOT_HOVERING); updateInterfaceHovering(PlayerHoverState.NOT_HOVERING);
}, 3000); }, 3000);
@ -21,12 +27,31 @@ export function VideoClickTarget(props: { showingControls: boolean }) {
cancel(); cancel();
}); });
const previousRateRef = useRef(playbackRate);
const isHoldingRef = useRef(false);
const speedIndicatorTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const boostTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [isPendingBoost, setIsPendingBoost] = useState(false);
const toggleFullscreen = useCallback(() => { const toggleFullscreen = useCallback(() => {
display?.toggleFullscreen(); display?.toggleFullscreen();
}, [display]); }, [display]);
const togglePause = useCallback( const togglePause = useCallback(
(e: PointerEvent<HTMLDivElement>) => { (e: PointerEvent<HTMLDivElement>) => {
// 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 // pause on mouse click
if (e.pointerType === "mouse") { if (e.pointerType === "mouse") {
if (e.button !== 0) return; if (e.button !== 0) return;
@ -44,9 +69,136 @@ export function VideoClickTarget(props: { showingControls: boolean }) {
cancel(); cancel();
} }
}, },
[display, isPaused, hovering, updateInterfaceHovering, reset, cancel], [
display,
isPaused,
hovering,
updateInterfaceHovering,
reset,
cancel,
isPendingBoost,
],
); );
const handlePointerDown = useCallback(
(e: PointerEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
// 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; if (!show) return null;
return ( return (
@ -56,7 +208,9 @@ export function VideoClickTarget(props: { showingControls: boolean }) {
"cursor-none": !props.showingControls, "cursor-none": !props.showingControls,
})} })}
onDoubleClick={toggleFullscreen} onDoubleClick={toggleFullscreen}
onPointerUp={togglePause} onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerLeave}
/> />
); );
} }

View file

@ -13,9 +13,14 @@ export function captionIsVisible(
end: number, end: number,
delay: number, delay: number,
currentTime: 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 delayedStart = start / 1000 + delay;
const delayedEnd = end / 1000 + delay; const delayedEnd = end / 1000 + delay;
return ( return (
Math.max(0, delayedStart) <= currentTime && Math.max(0, delayedStart) <= currentTime &&
Math.max(0, delayedEnd) >= currentTime Math.max(0, delayedEnd) >= currentTime

View file

@ -184,6 +184,7 @@ export function PlayerPart(props: PlayerPartProps) {
<Player.VolumeChangedPopout /> <Player.VolumeChangedPopout />
<Player.SubtitleDelayPopout /> <Player.SubtitleDelayPopout />
<Player.SpeedChangedPopout />
<UnreleasedEpisodeOverlay /> <UnreleasedEpisodeOverlay />
<Player.NextEpisodeButton <Player.NextEpisodeButton

View file

@ -1,13 +1,13 @@
import { create } from "zustand"; import { create } from "zustand";
type OverlayType = "volume" | "subtitle" | null; type OverlayType = "volume" | "subtitle" | "speed" | null;
interface OverlayStackStore { export interface OverlayStackState {
currentOverlay: OverlayType; currentOverlay: OverlayType;
setCurrentOverlay: (overlay: OverlayType) => void; setCurrentOverlay: (overlay: OverlayType) => void;
} }
export const useOverlayStack = create<OverlayStackStore>((set) => ({ export const useOverlayStack = create<OverlayStackState>((set) => ({
currentOverlay: null, currentOverlay: null,
setCurrentOverlay: (overlay) => set({ currentOverlay: overlay }), setCurrentOverlay: (overlay) => set({ currentOverlay: overlay }),
})); }));

View file

@ -32,6 +32,8 @@ export interface InterfaceSlice {
leftControlHovering: boolean; // is the cursor hovered over the left side of player controls leftControlHovering: boolean; // is the cursor hovered over the left side of player controls
isHoveringControls: boolean; // is the cursor hovered over any controls? isHoveringControls: boolean; // is the cursor hovered over any controls?
timeFormat: VideoPlayerTimeFormat; // Time format of the video player 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; updateInterfaceHovering(newState: PlayerHoverState): void;
setSeeking(seeking: boolean): void; setSeeking(seeking: boolean): void;
@ -42,6 +44,8 @@ export interface InterfaceSlice {
setLastVolume(state: number): void; setLastVolume(state: number): void;
hideNextEpisodeButton(): void; hideNextEpisodeButton(): void;
setShouldStartFromBeginning(val: boolean): void; setShouldStartFromBeginning(val: boolean): void;
setSpeedBoosted(state: boolean): void;
setShowSpeedIndicator(state: boolean): void;
} }
export const createInterfaceSlice: MakeSlice<InterfaceSlice> = (set, get) => ({ export const createInterfaceSlice: MakeSlice<InterfaceSlice> = (set, get) => ({
@ -61,6 +65,8 @@ export const createInterfaceSlice: MakeSlice<InterfaceSlice> = (set, get) => ({
canAirplay: false, canAirplay: false,
hideNextEpisodeBtn: false, hideNextEpisodeBtn: false,
shouldStartFromBeginning: false, shouldStartFromBeginning: false,
isSpeedBoosted: false,
showSpeedIndicator: false,
}, },
setShouldStartFromBeginning(val) { setShouldStartFromBeginning(val) {
@ -112,4 +118,14 @@ export const createInterfaceSlice: MakeSlice<InterfaceSlice> = (set, get) => ({
s.interface.hideNextEpisodeBtn = true; s.interface.hideNextEpisodeBtn = true;
}); });
}, },
setSpeedBoosted(state) {
set((s) => {
s.interface.isSpeedBoosted = state;
});
},
setShowSpeedIndicator(state) {
set((s) => {
s.interface.showSpeedIndicator = state;
});
},
}); });