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 "./CastingNotification";
export * from "./Captions";
export * from "./SpeedChangedPopout";

View file

@ -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 (

View file

@ -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<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);
@ -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);
};
}, []);

View file

@ -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<NodeJS.Timeout | null>(null);
const boostTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [isPendingBoost, setIsPendingBoost] = useState(false);
const toggleFullscreen = useCallback(() => {
display?.toggleFullscreen();
}, [display]);
const togglePause = useCallback(
(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
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<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;
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}
/>
);
}

View file

@ -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

View file

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

View file

@ -1,13 +1,13 @@
import { create } from "zustand";
type OverlayType = "volume" | "subtitle" | null;
type OverlayType = "volume" | "subtitle" | "speed" | null;
interface OverlayStackStore {
export interface OverlayStackState {
currentOverlay: OverlayType;
setCurrentOverlay: (overlay: OverlayType) => void;
}
export const useOverlayStack = create<OverlayStackStore>((set) => ({
export const useOverlayStack = create<OverlayStackState>((set) => ({
currentOverlay: null,
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
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<InterfaceSlice> = (set, get) => ({
@ -61,6 +65,8 @@ export const createInterfaceSlice: MakeSlice<InterfaceSlice> = (set, get) => ({
canAirplay: false,
hideNextEpisodeBtn: false,
shouldStartFromBeginning: false,
isSpeedBoosted: false,
showSpeedIndicator: false,
},
setShouldStartFromBeginning(val) {
@ -112,4 +118,14 @@ export const createInterfaceSlice: MakeSlice<InterfaceSlice> = (set, get) => ({
s.interface.hideNextEpisodeBtn = true;
});
},
setSpeedBoosted(state) {
set((s) => {
s.interface.isSpeedBoosted = state;
});
},
setShowSpeedIndicator(state) {
set((s) => {
s.interface.showSpeedIndicator = state;
});
},
});