Files
siftlode/frontend/src/lib/playerModalHooks.ts
T
peter 0b592345e6 fix(player): remove the leaked window "blur" listener in useNativeControlsYield
The native-controls-yield effect registered addEventListener("blur", onBlur) but its
cleanup removed "blur-sm" (a stray Tailwind-class find-replace artifact), so the blur
listener was never removed — leaking one per player open/close. Match the names.
2026-08-19 05:56:20 +02:00

464 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
useEffect,
useRef,
useState,
type Dispatch,
type MutableRefObject,
type RefObject,
type SetStateAction,
} from "react";
import type { QueryClient } from "@tanstack/react-query";
import { DEFAULT_VOLUME, normalizeVolume, stepVolume } from "./playerVolume";
import { LS, readAccountMerged, writeAccount } from "./storage";
import {
exitFullscreen,
fullscreenElement,
onFullscreenChange,
requestFullscreen,
} from "./fullscreen";
import { useBackToExit } from "./history";
import { api, type Video } from "./api";
import { qk } from "./queryKeys";
import type { YTNamespace, YTPlayer, YTPlayerEvent } from "./youtube";
import type { AutoMode, LoopMode } from "../components/playerModalControls";
// How close to the end (seconds) counts as "finished" → auto-mark watched.
const FINISH_MARGIN = 10;
// Bound consecutive unplayable items so an auto-advancing queue (esp. loop=all) can't spin over a run
// of dead videos — after the cap we stop on the error overlay instead of skipping forever.
const MAX_CONSECUTIVE_ERRORS = 3;
// --- IFrame Player API loader (singleton) ---
let apiPromise: Promise<YTNamespace> | null = null;
function loadYouTubeApi(): Promise<YTNamespace> {
if (apiPromise) return apiPromise;
apiPromise = new Promise<YTNamespace>((resolve) => {
const w = window;
if (w.YT && w.YT.Player) return resolve(w.YT);
const prev = w.onYouTubeIframeAPIReady;
w.onYouTubeIframeAPIReady = () => {
if (typeof prev === "function") prev();
// The API-ready callback only fires once the script has installed window.YT.
resolve(w.YT!);
};
const tag = document.createElement("script");
tag.src = "https://www.youtube.com/iframe_api";
document.head.appendChild(tag);
});
return apiPromise;
}
// Extracted hooks for the YouTube modal player (PlayerModal), the sibling of plexPlayerHooks.ts. The
// component owns the shared media refs (playerRef, stageRef, dialogRef) and passes them in via an opts
// object; each hook owns its own private state/timers and returns handlers + reactive values. The
// once-bound keyboard handler and layer-stack registration stay in the component shell (they orchestrate
// every hook through refs), exactly as PlexPlayer keeps its keydown handler while consuming these hooks.
/** Per-account volume for the YouTube player, mirroring Plex's usePlayerVolume. A YT.Player is created
* per video and always starts at 100, so the level lives in a ref and onReady re-applies it. Level 0 IS
* the muted state (that's how YouTube's own control behaves), so there's no separate `muted` flag.
* Mouse-wheel over the WHOLE modal (backdrop + card) adjusts volume, not just the small player area. */
export function useYtVolume({
playerRef,
dialogRef,
focusModal,
}: {
playerRef: MutableRefObject<YTPlayer | null>;
dialogRef: RefObject<HTMLDivElement | null>;
focusModal: () => void;
}): {
/** Level (0–100) to flash in the on-player overlay, or null when hidden. */
volumeUi: number | null;
/** Re-apply the remembered level to a freshly-created player (onReady), without flashing. */
reapplyStored: () => void;
/** Step the level by `delta` from the freshest good source (own ref, or the player on a fresh gesture). */
nudgeVolume: (delta: number) => void;
} {
const volTimerRef = useRef<number | undefined>(undefined);
// Sanitised HERE, at the boundary where the untrusted stored value enters (normalizeVolume): a `null`
// / `"80"` / missing key becomes the default ONCE, via a lazy useState so it happens once per mount.
const [volumeUi, setVolumeUi] = useState<number | null>(null);
const [storedVolume] = useState(() =>
normalizeVolume(readAccountMerged(LS.ytPlayerPrefs, { volume: DEFAULT_VOLUME }).volume),
);
const volumeRef = useRef(storedVolume);
const volSaveTimerRef = useRef<number | undefined>(undefined);
// When the last volume gesture happened — see nudgeVolume (a spin must not re-read the player).
const lastNudgeAtRef = useRef(0);
const flashVolume = (level: number) => {
setVolumeUi(level);
window.clearTimeout(volTimerRef.current);
volTimerRef.current = window.setTimeout(() => setVolumeUi(null), 1200);
};
const persistVolume = () => {
window.clearTimeout(volSaveTimerRef.current);
volSaveTimerRef.current = undefined;
writeAccount(LS.ytPlayerPrefs, { volume: volumeRef.current });
};
const applyVolume = (level: number, flash = true) => {
// Re-normalise against the LAST GOOD level (never the default) — the player can answer NaN, and
// jumping a quiet session to full blast is the surprise this feature exists to avoid.
const next = normalizeVolume(level, volumeRef.current);
const p = playerRef.current;
if (p && typeof p.setVolume === "function") {
if (next > 0) p.unMute?.();
p.setVolume(next);
if (next === 0) p.mute?.();
}
volumeRef.current = next;
// Debounced: one write when the spin settles; the unmount cleanup flushes a pending write.
window.clearTimeout(volSaveTimerRef.current);
volSaveTimerRef.current = window.setTimeout(persistVolume, 400);
if (flash) flashVolume(next);
};
const nudgeVolume = (delta: number) => {
// No early return when the player isn't ready: the level lives in volumeRef and onReady applies it,
// so a wheel spin in the first second after opening still lands.
const p = playerRef.current;
// Step from our own ref, EXCEPT when the gesture starts fresh — then read the player (the user may
// have moved YouTube's own slider). getVolume() lags setVolume across the iframe boundary, so
// re-reading it mid-spin resurrects a stale level and silently drops notches.
const now = Date.now();
const continuing = now - lastNudgeAtRef.current < 1000;
lastNudgeAtRef.current = now;
const live =
p && typeof p.getVolume === "function" ? (p.isMuted?.() ? 0 : p.getVolume()) : null;
const cur = continuing || live === null ? volumeRef.current : live;
applyVolume(stepVolume(cur, { delta, fallback: volumeRef.current }));
};
// Mouse-wheel volume over the WHOLE modal. Non-passive so preventDefault stops the modal/page
// scrolling. The cross-origin iframe swallows wheel over its native strips, but the central
// interaction overlay is our element (wheel there bubbles here), and everything outside works.
useEffect(() => {
const el = dialogRef.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
// Ctrl/⌘+wheel is the browser's zoom gesture — never swallow it.
if (e.ctrlKey || e.metaKey) return;
e.preventDefault();
focusModal(); // re-grab focus if it had slipped into the native controls
nudgeVolume(e.deltaY < 0 ? 5 : -5);
};
el.addEventListener("wheel", onWheel, { passive: false });
return () => el.removeEventListener("wheel", onWheel);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// On unmount, clear the flash timer and flush any pending debounced write — closing the player right
// after a wheel spin (the normal way to leave) must not drop the level the user just set.
useEffect(
() => () => {
window.clearTimeout(volTimerRef.current);
if (volSaveTimerRef.current != null) persistVolume();
},
[],
);
return {
volumeUi,
reapplyStored: () => applyVolume(volumeRef.current, false),
nudgeVolume,
};
}
/** Yield the interaction overlay to YouTube's native UI. Clicking a native control (gear / seek bar /
* CC) moves focus into the cross-origin player iframe — the only signal we get. While the iframe holds
* focus we drop the overlay's pointer-events so the (arbitrarily tall) settings menu is clickable end to
* end. Re-arm — restoring click-to-play, wheel volume and our keyboard shortcuts — when the pointer
* leaves the video or the window regains focus. */
export function useNativeControlsYield({
playerRef,
stageRef,
focusModal,
}: {
playerRef: MutableRefObject<YTPlayer | null>;
stageRef: RefObject<HTMLDivElement | null>;
focusModal: () => void;
}): {
nativeControls: boolean;
setNativeControls: (on: boolean) => void;
} {
const [nativeControls, setNativeControls] = useState(false);
useEffect(() => {
const iframeFocused = () => {
const p = playerRef.current;
const f = p && typeof p.getIframe === "function" ? p.getIframe() : null;
return !!f && document.activeElement === f;
};
// activeElement settles on the tick after blur, so defer the check.
const onBlur = () => window.setTimeout(() => iframeFocused() && setNativeControls(true), 0);
const rearm = () => {
setNativeControls(false);
focusModal();
};
const stage = stageRef.current;
window.addEventListener("blur", onBlur);
window.addEventListener("focus", rearm);
stage?.addEventListener("mouseleave", rearm);
return () => {
window.removeEventListener("blur", onBlur);
window.removeEventListener("focus", rearm);
stage?.removeEventListener("mouseleave", rearm);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return { nativeControls, setNativeControls };
}
/** The YouTube player's big views: `maximized` (the F key — a CSS layer that fills the viewport,
* deliberately NOT the Fullscreen API, so the cross-origin embed's OWN fullscreen button keeps working)
* and true browser fullscreen that WE own on the stage element (keeps our overlay + control bar on
* screen). Both back out one step at a time via the history layers (fullscreen → maximise → close). This
* is deliberately YT-specific and does NOT reuse Plex's useFullscreen. The refs are the bridge the
* once-bound keyboard handler reads. */
export function useYtBigView({
stageRef,
playerRef,
focusModal,
}: {
stageRef: RefObject<HTMLDivElement | null>;
playerRef: MutableRefObject<YTPlayer | null>;
focusModal: () => void;
}): {
isFullscreen: boolean;
maximized: boolean;
maximizedRef: MutableRefObject<boolean>;
isFullscreenRef: MutableRefObject<boolean>;
toggleFullscreen: () => void;
toggleTrueFullscreen: () => void;
exitBigView: () => void;
setMaximized: Dispatch<SetStateAction<boolean>>;
} {
// True while OUR stage is the fullscreen element (drives the overlay's pointer-events).
const [isFullscreen, setIsFullscreen] = useState(false);
// Our own "fills the viewport" mode (the F key). Not the Fullscreen API — see toggleFullscreen.
const [maximized, setMaximized] = useState(false);
const maximizedRef = useRef(false);
maximizedRef.current = maximized;
// Mirrored into a ref because the keydown handler is bound once and would otherwise read false forever.
const isFullscreenRef = useRef(false);
// Nested Back: peel fullscreen → maximise → close, one Back at a time (each a history layer only while
// active). Mirrors the Esc ladder in the component's keydown handler.
useBackToExit(isFullscreen, exitFullscreen);
useBackToExit(maximized, () => setMaximized(false));
const toggleFullscreen = () => setMaximized((m) => !m);
// True browser fullscreen WE own (the stage element) — keeps our overlay + controls on screen. We
// never surface YouTube's own fullscreen button, so the "cross-origin button looks dead while we hold
// the lock" problem that made F avoid the Fullscreen API doesn't arise here.
const toggleTrueFullscreen = () => {
if (fullscreenElement()) exitFullscreen();
else if (stageRef.current) requestFullscreen(stageRef.current);
};
// Leave the big view back to the modal card: drop fullscreen first if held, then un-maximise.
const exitBigView = () => {
if (fullscreenElement()) exitFullscreen();
setMaximized(false);
};
// Track the browser's fullscreen state. WE never request it via F (that maximises via CSS), so this is
// YouTube's own fullscreen — entered from its control bar, left by its button or Esc. On LEAVING,
// focus is stuck in the iframe (the user had to click into the player to reach YT's button), so our
// window-level shortcuts never fire — take focus back so F/Space/arrows work the moment fullscreen ends.
useEffect(() => {
const onChange = () => {
const fsEl = fullscreenElement();
// Refs only — this listener is bound once, so it must not close over render values.
const p = playerRef.current;
const frame = p && typeof p.getIframe === "function" ? p.getIframe() : null;
const ours = !!fsEl && (fsEl === stageRef.current || fsEl === frame);
isFullscreenRef.current = ours; // read by the once-bound keydown handler
setIsFullscreen(ours); // drives the overlay's pointer-events
if (!fsEl) focusModal();
};
return onFullscreenChange(onChange); // both spellings — see lib/fullscreen
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return {
isFullscreen,
maximized,
maximizedRef,
isFullscreenRef,
toggleFullscreen,
toggleTrueFullscreen,
exitBigView,
setMaximized,
};
}
/** Create the YouTube IFrame player for the active item, resume from the saved position, checkpoint
* progress every 5 s (+ a pagehide beacon), and auto-mark watched once playback reaches the end. Keyed
* on activeId (a queue advance recreates the player to reuse this single-video logic). The once-bound
* event closures read fresh queue/mode/volume via the refs passed in. Mirrors Plex's usePlexProgress —
* side-effect only, returns nothing. */
export function useYtProgress({
activeId,
resumeAt,
mountRef,
playerRef,
currentIdRef,
navRef,
modeRef,
setWatched,
setPlayerError,
setLiveData,
reapplyStored,
focusModal,
advanceOnEnd,
qc,
}: {
activeId: string;
resumeAt: number;
mountRef: RefObject<HTMLDivElement | null>;
playerRef: MutableRefObject<YTPlayer | null>;
currentIdRef: MutableRefObject<string>;
navRef: MutableRefObject<{ queue: Video[] | undefined; index: number }>;
modeRef: MutableRefObject<{ autoMode: AutoMode; loopMode: LoopMode }>;
setWatched: (on: boolean) => void;
setPlayerError: (code: number | null) => void;
setLiveData: (d: { title?: string; author?: string } | null) => void;
reapplyStored: () => void;
focusModal: () => void;
advanceOnEnd: () => void;
qc: QueryClient;
}): void {
const autoMarkedRef = useRef(false);
const errorStreakRef = useRef(0);
useEffect(() => {
let cancelled = false;
const id = activeId;
autoMarkedRef.current = false;
setPlayerError(null);
// Auto-watch only applies to the active item — not to other videos the player navigated to via
// description links. Cap the finish margin at half the clip so a very short video isn't auto-marked
// almost immediately.
const maybeAutoWatch = (current: number, duration: number): boolean => {
if (autoMarkedRef.current || duration <= 0 || currentIdRef.current !== id) return false;
const margin = Math.min(FINISH_MARGIN, duration / 2);
if (current > duration - margin) {
autoMarkedRef.current = true;
setWatched(true);
return true;
}
return false;
};
const persist = (): Promise<unknown> | void => {
const p = playerRef.current;
if (!p || typeof p.getCurrentTime !== "function") return;
try {
const cur = p.getCurrentTime();
const dur = typeof p.getDuration === "function" ? p.getDuration() : 0;
// If we just auto-marked watched, skip the near-end checkpoint (it would only clear the resume
// position, racing the watched write against the progress row it deletes).
if (maybeAutoWatch(cur, dur)) return;
// Only checkpoint the library item we opened with — a navigated-to video may not be in this
// user's library, so the server would 404 on it.
if (currentIdRef.current === id) {
return api.saveProgress(id, cur, dur).catch(() => {});
}
} catch {
/* player may be tearing down */
}
};
loadYouTubeApi().then((YT) => {
if (cancelled || !mountRef.current) return;
playerRef.current = new YT.Player(mountRef.current, {
width: "100%",
height: "100%",
videoId: id,
playerVars: {
autoplay: 1,
start: resumeAt || undefined,
rel: 0, // limit "related" to the same channel (full removal is no longer possible)
enablejsapi: 1,
origin: window.location.origin,
playsinline: 1,
// Best-effort HD hint (mostly a no-op now; YouTube hard-caps embed quality by on-screen size).
vq: "hd1080",
},
events: {
onReady: () => {
focusModal();
reapplyStored();
},
onStateChange: (e: YTPlayerEvent) => {
// 1 === playing → sync the navigated-to video's title/author for display.
if (e?.data === 1) {
errorStreakRef.current = 0; // a successful play breaks any unplayable run
const p = playerRef.current;
const d = p && typeof p.getVideoData === "function" ? p.getVideoData() : null;
if (d) setLiveData({ title: d.title, author: d.author });
}
// 0 === ended → mark watched, then advance to the next queue item if any.
if (e?.data === 0 && currentIdRef.current === id) {
if (!autoMarkedRef.current) {
autoMarkedRef.current = true;
setWatched(true);
}
advanceOnEnd();
}
},
onError: (e: YTPlayerEvent) => {
setPlayerError(typeof e?.data === "number" ? e.data : -1);
// onError (not 'ended') fires for an unplayable item, so advanceOnEnd() never runs and an
// auto-advancing queue would freeze — skip forward instead, but bounded so a run of dead
// items can't spin. We do NOT mark the broken video watched.
if (currentIdRef.current !== id) return; // a navigated description-link error: leave the queue alone
const { autoMode: a } = modeRef.current;
const { queue: q } = navRef.current;
if (a === "off" || !q || q.length <= 1) return;
errorStreakRef.current += 1;
if (errorStreakRef.current >= MAX_CONSECUTIVE_ERRORS) return; // give up; leave the overlay
advanceOnEnd();
},
},
});
});
// Periodic checkpoint so progress (and auto-watch) survive a crash/refresh.
const timer = window.setInterval(persist, 5000);
// Save on page unload too (F5/close/navigate): effect cleanup doesn't run on a full reload. A
// keepalive beacon so the POST survives the unload. Only for the library item we opened with.
const onHide = () => {
const p = playerRef.current;
if (!p || typeof p.getCurrentTime !== "function" || currentIdRef.current !== id) return;
try {
const cur = p.getCurrentTime();
const dur = typeof p.getDuration === "function" ? p.getDuration() : 0;
if (cur > 0 && dur > 0) api.saveProgressBeacon(id, cur, dur);
} catch {
/* player tearing down */
}
};
window.addEventListener("pagehide", onHide);
return () => {
cancelled = true;
window.clearInterval(timer);
window.removeEventListener("pagehide", onHide);
// Final flush, then mark the feed + playlists stale so resume bars reflect this session on the next
// natural refetch — WITHOUT refetching every loaded feed page on each player close (PERF-2).
Promise.resolve(persist()).finally(() => {
qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
qc.invalidateQueries({ queryKey: qk.playlist(), refetchType: "none" });
});
const p = playerRef.current;
if (p && typeof p.destroy === "function") {
try {
p.destroy();
} catch {
/* ignore */
}
}
playerRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeId]);
}