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 { patchCachedProgress } from "./progressCache"; import type { YTNamespace, YTPlayer, YTPlayerEvent } from "./youtube"; import { 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) --- let apiPromise: Promise | null = null; function loadYouTubeApi(): Promise { if (apiPromise) return apiPromise; apiPromise = new Promise((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; dialogRef: RefObject; 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(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(null); const [storedVolume] = useState(() => normalizeVolume(readAccountMerged(LS.ytPlayerPrefs, { volume: DEFAULT_VOLUME }).volume), ); const volumeRef = useRef(storedVolume); const volSaveTimerRef = useRef(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; stageRef: RefObject; 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; playerRef: MutableRefObject; focusModal: () => void; }): { isFullscreen: boolean; maximized: boolean; maximizedRef: MutableRefObject; isFullscreenRef: MutableRefObject; toggleFullscreen: () => void; toggleTrueFullscreen: () => void; exitBigView: () => void; setMaximized: Dispatch>; } { // 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, sessionPosRef, qc, }: { activeId: string; resumeAt: number; mountRef: RefObject; playerRef: MutableRefObject; currentIdRef: MutableRefObject; 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; // 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: MutableRefObject>; 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 | 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) // 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(() => {}) ); } } 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) => { 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 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 = () => { 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(() => { // 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; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeId]); }