Files
siftlode/frontend/src/lib/playerModalHooks.ts
T
peter ece74a42ab feat(player): a roomier YouTube player, its panel shut on demand
- The full-height previous/next strips beside the card are gone: the panel's
  transport row and Shift+arrows step on their own, and the video gains the
  7rem the strips' gutters took.
- Close is one icon in the card's top-right corner (the wide panel's head,
  the stacked card's title row).
- The panel's description is cut to three lines with More / Less
  (ClampedText now takes the linked text as children).
- Wide, the panel shuts to a 2.75rem strip (close, open, previous, next) for
  a bigger video; it stays shut while the queue steps on, and a video opened
  from a card starts it open. Focus moves to the counterpart toggle.
- The wheel has one meaning per place: over a panel (or the stacked card)
  with something to scroll it only scrolls, never falling through to the
  volume at the end; over the video, or a panel that fits, it is the volume.
- A long channel name is cut on the panel's line instead of running the
  20rem panel into a sideways scroll.
2026-10-09 00:27:29 +02:00

564 lines
25 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,
useEffectEvent,
useRef,
useState,
type Dispatch,
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 { patchCachedProgress } from "./progressCache";
import type { YTNamespace, YTPlayer, YTPlayerEvent } from "./youtube";
import { logYouTubeApiFailed, logYouTubeError, logYouTubeState } from "./mediaLog";
import type { AutoMode, LoopMode } from "./playerModes";
// 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) ---
/** `playerError` when the IFrame API itself never arrived — not one of YouTube's own error codes
* (2/5/100/101/150, all positive), and distinct from the -1 an unparseable one maps to. */
export const YT_API_LOAD_FAILED = -2;
/** How long the script may take before the player gives up on it. The real answer comes in well
* under a second; anything near this is a blocked or stalled request, not a slow one. */
export const YT_API_TIMEOUT_MS = 15_000;
let apiPromise: Promise<YTNamespace> | null = null;
function loadYouTubeApi(): Promise<YTNamespace> {
if (apiPromise) return apiPromise;
apiPromise = new Promise<YTNamespace>((resolve, reject) => {
const w = window;
if (w.YT && w.YT.Player) return resolve(w.YT);
const tag = document.createElement("script");
// A failure is not memoised: the next open loads the script again instead of waiting on a
// promise that already lost (a blocker switched off, the network back).
const fail = (why: string) => {
window.clearTimeout(timer);
apiPromise = null;
tag.remove();
reject(new Error(`YouTube IFrame API ${why}`));
};
const timer = window.setTimeout(() => fail("timed out"), YT_API_TIMEOUT_MS);
const prev = w.onYouTubeIframeAPIReady;
w.onYouTubeIframeAPIReady = () => {
window.clearTimeout(timer);
if (typeof prev === "function") prev();
// The API-ready callback only fires once the script has installed window.YT.
resolve(w.YT!);
};
tag.onerror = () => fail("failed to load");
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 —
* except over a `[data-wheel-scrolls]` panel that has anything to scroll, which keeps the wheel. */
export function useYtVolume({
playerRef,
dialogRef,
focusModal,
}: {
playerRef: RefObject<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.
const onWheelSpin = useEffectEvent((e: WheelEvent) => {
// Ctrl/⌘+wheel is the browser's zoom gesture — never swallow it.
if (e.ctrlKey || e.metaKey) return;
// One gesture, one meaning (owner): over a panel with something to scroll the wheel only ever
// scrolls it — at its end too, where it once fell through to the volume mid-spin. Over the video,
// or a panel that fits, it is the volume. The nearer mark wins: the video inside a stacked card
// that scrolls is still the volume.
const zone =
e.target instanceof Element
? e.target.closest<HTMLElement>("[data-wheel-scrolls],[data-wheel-volume]")
: null;
if (
zone?.hasAttribute("data-wheel-scrolls") &&
zone.scrollHeight > zone.clientHeight + 1 // +1: sub-pixel heights
)
return;
e.preventDefault();
focusModal(); // re-grab focus if it had slipped into the native controls
nudgeVolume(e.deltaY < 0 ? 5 : -5);
});
useEffect(() => {
const el = dialogRef.current;
if (!el) return;
const onWheel = (e: WheelEvent) => onWheelSpin(e);
el.addEventListener("wheel", onWheel, { passive: false });
return () => el.removeEventListener("wheel", onWheel);
}, [dialogRef]);
// 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: RefObject<YTPlayer | null>;
stageRef: RefObject<HTMLDivElement | null>;
focusModal: () => void;
}): {
nativeControls: boolean;
setNativeControls: (on: boolean) => void;
} {
const [nativeControls, setNativeControls] = useState(false);
const handOverToIframe = useEffectEvent(() => {
const p = playerRef.current;
const f = p && typeof p.getIframe === "function" ? p.getIframe() : null;
if (f && document.activeElement === f) setNativeControls(true);
});
const rearmOurControls = useEffectEvent(() => {
setNativeControls(false);
focusModal();
});
useEffect(() => {
// activeElement settles on the tick after blur, so defer the check.
const onBlur = () => window.setTimeout(() => handOverToIframe(), 0);
const rearm = () => rearmOurControls();
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);
};
}, [stageRef]);
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: RefObject<YTPlayer | null>;
focusModal: () => void;
}): {
isFullscreen: boolean;
maximized: boolean;
isFullscreenRef: RefObject<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);
// 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.
const syncFullscreen = useEffectEvent(() => {
const fsEl = fullscreenElement();
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();
});
useEffect(() => {
return onFullscreenChange(() => syncFullscreen()); // both spellings — see lib/fullscreen
}, []);
return {
isFullscreen,
maximized,
isFullscreenRef,
toggleFullscreen,
toggleTrueFullscreen,
exitBigView,
setMaximized,
};
}
/** The player's position and length, or null when there is no usable player — not built yet, or
* tearing down (its methods throw then). Outside the hook: the React Compiler cannot lower a
* conditional inside a try yet, and skipped useYtProgress for it. */
function playerTimes(p: YTPlayer | null): { cur: number; dur: number } | null {
if (!p || typeof p.getCurrentTime !== "function") return null;
try {
return {
cur: p.getCurrentTime(),
dur: typeof p.getDuration === "function" ? p.getDuration() : 0,
};
} catch {
return null;
}
}
/** 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,
sessionPosRef,
qc,
}: {
activeId: string;
resumeAt: number;
mountRef: RefObject<HTMLDivElement | null>;
playerRef: RefObject<YTPlayer | null>;
currentIdRef: RefObject<string>;
navRef: RefObject<{ queue: Video[] | undefined; index: number }>;
modeRef: RefObject<{ 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;
// Positions this player session has actually STORED, per video id. Written from the server's
// answer, read back by the modal's resume so stepping next→prev inside one session does not
// rewind to the value the frozen launch queue was snapshotted with.
sessionPosRef: RefObject<Map<string, number>>;
qc: QueryClient;
}): void {
const autoMarkedRef = useRef(false);
const errorStreakRef = useRef(0);
// The render values this player is built from, read ONCE when it is built (see the dep note at
// the end of the effect).
const itemSnapshot = useEffectEvent(() => ({
resumeAt,
setWatched,
setPlayerError,
setLiveData,
reapplyStored,
focusModal,
advanceOnEnd,
qc,
}));
useEffect(() => {
let cancelled = false;
const id = activeId;
const {
resumeAt,
setWatched,
setPlayerError,
setLiveData,
reapplyStored,
focusModal,
advanceOnEnd,
qc,
} = itemSnapshot();
// A position this session already stored for the item beats the queue's snapshot of it.
const startAt = sessionPosRef.current.get(id) ?? resumeAt;
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 times = playerTimes(playerRef.current);
if (!times) return;
const { cur, dur } = times;
// 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)
// Record what the server STORED, not `cur`: a near-end or under-5s checkpoint is
// cleared to 0 there, and the resume must agree with the row, not with our reading.
.then((res) => void sessionPosRef.current.set(id, res.position_seconds))
.catch(() => {})
);
}
};
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: startAt || 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) => {
const p0 = playerRef.current;
logYouTubeState(
e?.data,
p0 && typeof p0.getCurrentTime === "function" ? p0.getCurrentTime() : 0,
);
// 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) => {
logYouTubeError(typeof e?.data === "number" ? e.data : -1);
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();
},
},
});
})
// The script never arrived (blocked, offline, timed out) or the player could not be built:
// an error state with the way out, never an empty stage that waits forever.
.catch((err: unknown) => {
if (cancelled) return;
logYouTubeApiFailed(err);
setPlayerError(YT_API_LOAD_FAILED);
});
// The session's positions map (mutated in place, never replaced) — held here for the cleanup.
const sessionPos = sessionPosRef.current;
// 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 = () => {
if (currentIdRef.current !== id) return;
const times = playerTimes(playerRef.current);
if (times && times.cur > 0 && times.dur > 0) api.saveProgressBeacon(id, times.cur, times.dur);
};
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).
void Promise.resolve(persist()).finally(() => {
// Write this session's stored positions INTO the cached lists first. Marking them stale is
// not enough on its own: nothing refetches the feed until it remounts (staleTime 30s,
// refetchOnWindowFocus off), so a reopen used to resume from the position the cache was
// fetched with — and the next checkpoint then persisted that older value over this one.
patchCachedProgress(qc, sessionPos);
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;
};
// ONE PLAYER PER VIDEO is the invariant: re-running would destroy and rebuild the YouTube
// iframe, losing the playing position and every listener attached to it. So the deps are the id
// and refs (which never change).
//
// The render values the body uses — `resumeAt`, `setWatched`, `advanceOnEnd`, `reapplyStored`,
// `focusModal`, `qc`, … — are a PER-ITEM SNAPSHOT, taken once through `itemSnapshot` when this
// player is built: they describe the video it was built for, and re-reading them mid-session
// would be the bug, not the fix. `setWatched` is the clearest case: bound to the item, it lets
// this cleanup's late auto-mark land on the item being LEFT, which the modal then ignores. What
// genuinely has to stay live mid-session is behind refs (currentIdRef, modeRef, navRef,
// sessionPosRef, autoMarkedRef, errorStreakRef, playerRef) — and THAT split is the enforcement:
// a value that is not a snapshot does not belong in this closure. (This replaced an
// exhaustive-deps suppression, which made the React Compiler skip the whole hook.)
}, [activeId, mountRef, playerRef, currentIdRef, navRef, modeRef, sessionPosRef]);
}