diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index 25fe79bc..77adbcab 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -5,6 +5,13 @@ import { useNavigationActions } from "./NavigationProvider"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; import { usePref } from "../lib/usePref"; +import { + AUTO_MODES, + LOOP_MODES, + PlayerTransportControls, + type AutoMode, + type LoopMode, +} from "./playerModalControls"; import { AlertTriangle, ArrowLeft, @@ -16,12 +23,7 @@ import { Maximize, Minimize, Minimize2, - Repeat, - Repeat1, Settings, - Shuffle, - SkipBack, - SkipForward, Volume2, VolumeX, X, @@ -32,7 +34,7 @@ import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover"; import { pushLayer, removeLayer, anyLayerAbove } from "../lib/layerStack"; import DownloadButton from "./DownloadButton"; import { api, type Video, type VideoStatus } from "../lib/api"; -import type { YTNamespace, YTPlayer, YTPlayerEvent } from "../lib/youtube"; +import type { YTPlayer } from "../lib/youtube"; import { channelYouTubeUrl, formatDate, @@ -41,15 +43,14 @@ import { relativeTime, } from "../lib/format"; import { renderDescription } from "../lib/descriptionLinks"; +import { fullscreenElement } from "../lib/fullscreen"; +import { useBackToClose } from "../lib/history"; import { - exitFullscreen, - fullscreenElement, - onFullscreenChange, - requestFullscreen, -} from "../lib/fullscreen"; -import { useBackToClose, useBackToExit } from "../lib/history"; -import { DEFAULT_VOLUME, normalizeVolume, stepVolume } from "../lib/playerVolume"; -import { LS, readAccountMerged, writeAccount } from "../lib/storage"; + useNativeControlsYield, + useYtBigView, + useYtProgress, + useYtVolume, +} from "../lib/playerModalHooks"; import { useScrollFade } from "../lib/useScrollFade"; // Experiment (branch experiment/inline-player): play the video in-app via the @@ -58,119 +59,6 @@ import { useScrollFade } from "../lib/useScrollFade"; // button, the backdrop, or ESC (ESC only while focus is on our page, not inside // the cross-origin player iframe — a browser security boundary we can't cross). -// How close to the end (seconds) counts as "finished" → auto-mark watched. -const FINISH_MARGIN = 10; - -// Persistent playback settings (stored in users.preferences). Auto-advance = what plays when a -// video ends; loop = whether it repeats the current video ("one"), wraps the list at its ends -// ("all"), or neither ("off"). Both apply to any queued player (feed or playlist). -type AutoMode = "off" | "next" | "prev" | "random"; -type LoopMode = "off" | "one" | "all"; -const AUTO_MODES: AutoMode[] = ["off", "next", "prev", "random"]; -const LOOP_MODES: LoopMode[] = ["off", "one", "all"]; - -/** The queue transport cluster — prev / counter / next, then the auto-advance and loop mode toggles. - * Rendered in two places with identical logic: the modal's queue bar (`variant:"modal"`, normal glass - * theme, labels always shown) and the big-view docked control bar (`variant:"docked"`, white-on-dark, - * labels hidden until a breakpoint). Only the theme + label-visibility differ; the behaviour is one, - * so a mode/icon change lands once. The wrapper (fragment vs. bordered row) stays at the call site. */ -function PlayerTransportControls({ - variant, - index, - queueLength, - autoMode, - loopMode, - onPrev, - onNext, - onCycleAuto, - onCycleLoop, -}: { - variant: "docked" | "modal"; - index: number; - queueLength: number; - autoMode: AutoMode; - loopMode: LoopMode; - onPrev: () => void; - onNext: () => void; - onCycleAuto: () => void; - onCycleLoop: () => void; -}) { - const { t } = useTranslation(); - const docked = variant === "docked"; - const navBtn = docked - ? "inline-flex items-center gap-1 text-white/80 enabled:hover:text-white disabled:opacity-30 transition" - : "inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition"; - const navLabel = docked ? "hidden sm:inline" : ""; - const counter = docked ? "tabular-nums text-white/70" : "text-muted tabular-nums"; - const divider = docked ? "h-4 w-px bg-white/20" : "w-px h-4 bg-border"; - const toggle = (on: boolean) => - docked - ? `inline-flex items-center gap-1 rounded-lg px-2 py-1 transition ${ - on ? "bg-white/20 text-white" : "text-white/70 hover:bg-white/10 hover:text-white" - }` - : `inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 transition ${ - on ? "bg-accent/15 text-accent" : "text-muted hover:bg-surface hover:text-fg" - }`; - const toggleLabel = docked ? "hidden md:inline" : ""; - return ( - <> - - - {index + 1} / {queueLength} - - - - {/* Persistent playback settings — cycle on click, saved to your account. */} - - - - ); -} - /** Can `el` (or an ancestor inside the modal) still scroll vertically in `dir` (-1 up, 1 down)? * Used to leave ArrowUp/ArrowDown alone while there is content to scroll — the volume shortcut * must not eat the only keyboard way to read a long description. Stops at ``: the page @@ -186,26 +74,6 @@ function canScrollBy(el: HTMLElement | null, dir: -1 | 1): boolean { return false; } -// --- 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; -} - export default function PlayerModal({ video, startAt, @@ -265,11 +133,6 @@ export default function PlayerModal({ startAt != null && active.id === video.id ? startAt : active.position_seconds || 0; const mountRef = useRef(null); const playerRef = useRef(null); - const autoMarkedRef = useRef(false); - // 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; - const errorStreakRef = useRef(0); // Keyboard/wheel controls. We keep focus on the modal card (not the cross-origin player // iframe) so F / Space / Esc keep working until the user clicks into YouTube's native // controls; the interaction overlay below also stops the iframe stealing focus on a video @@ -279,52 +142,6 @@ export default function PlayerModal({ // The whole modal (backdrop + card) — the wheel-to-volume target, so scrolling anywhere over the // modal adjusts volume (not just the small player area). const dialogRef = useRef(null); - const volTimerRef = useRef(undefined); - // Volume level to flash in the on-player overlay (null = hidden). Auto-fades after a moment. - const [volumeUi, setVolumeUi] = useState(null); - // When the user interacts with YouTube's own controls (gear/seek/CC), focus moves into the - // cross-origin player iframe. We detect that and drop the interaction overlay's pointer-events - // so YouTube's settings menu — which expands DOWN over the video, past our overlay — is fully - // navigable (previously the overlay ate clicks below the top items). Moving the pointer off the - // video, or the window regaining focus, re-arms our click/scroll/keyboard shortcuts. - const [nativeControls, setNativeControls] = useState(false); - // True while OUR stage element is the fullscreen element. In fullscreen the interaction overlay - // yields entirely (see the overlay's pointer-events below): YouTube's control bar only appears - // while the iframe itself sees mouse movement, so an armed overlay kept the bar — and with it - // the native fullscreen button — permanently hidden after pressing F. - 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 (deps: [onClose]) and would - // otherwise read `false` forever — the house pattern here (see navRef/volumeRef). - const isFullscreenRef = useRef(false); - // Nested Back: peel fullscreen → maximise → close, one browser/mouse Back at a time (each is a - // history layer only while active). Mirrors the Esc ladder in the keydown handler below. - useBackToExit(isFullscreen, exitFullscreen); - useBackToExit(maximized, () => setMaximized(false)); - // Volume survives the modal, the next video and a reload: a YT.Player is created per video and - // always starts at full volume. Per-account, like the Plex player's prefs. Level 0 IS the muted - // state (that's how YouTube's own control behaves), so there's no separate `muted` flag to - // contradict it. - // - // Ref + explicit read/write rather than `useAccountPersistedObject`: nothing RENDERS the level - // (the on-player flash has its own `volumeUi` state), the once-bound wheel/key listeners need a - // ref anyway, and the hook's write lives inside a `setState` updater — which is both a second - // write on every settle and not something React must run while the component is unmounting. - // Sanitised HERE, at the boundary where the untrusted value enters (`normalizeVolume`, see - // lib/playerVolume): a stored `null` / `"80"` / missing key becomes the default once, instead of - // travelling into the arithmetic. Read via a lazy `useState` initializer so it happens once, not - // on every render (the house pattern — see the frozen queue above). - 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 focusModal = () => cardRef.current?.focus(); const togglePlay = () => { const p = playerRef.current; @@ -333,84 +150,28 @@ export default function PlayerModal({ p.pauseVideo?.(); // 1 === playing else p.playVideo?.(); }; - /** F = maximise, deliberately WITHOUT the Fullscreen API. - * - * The measured reason (Chrome/Brave/Firefox alike, so it is spec behaviour, not a browser bug): - * a cross-origin embed reads its fullscreen state from ITS OWN document. While WE hold the - * browser's fullscreen lock — on our wrapper or even on the iframe element — the YouTube player - * never learns it is fullscreen, so its own fullscreen button stays in the "enter" state and a - * click on it changes nothing visible. That is the reported bug: every other YouTube control - * (gear, CC, volume) works, only its fullscreen button looks dead. - * - * So we don't take the lock at all: `maximized` is a CSS layer that fills the viewport. The page - * stays non-fullscreen, which means the embed's own fullscreen button works exactly as it does - * anywhere else — and OUR shortcuts and overlays (wheel volume, the level flash, the queue bar) - * keep working, because our DOM is what's on screen. The one thing we give up is hiding the - * browser's own chrome; YouTube's button provides that, correctly, when the user wants it. - */ - const toggleFullscreen = () => setMaximized((m) => !m); - // True browser fullscreen that WE own (the stage element). Unlike YouTube's own fullscreen it keeps - // OUR overlay + control bar on screen, so wheel/arrow volume and our controls work inside it too. - // We never surface YouTube's own fullscreen button, so the "a cross-origin embed's own button looks - // dead while we hold the lock" problem that made F avoid the Fullscreen API does not arise. - 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); - }; - const flashVolume = (level: number) => { - setVolumeUi(level); - window.clearTimeout(volTimerRef.current); - volTimerRef.current = window.setTimeout(() => setVolumeUi(null), 1200); - }; - /** Write the current level out now, cancelling any pending debounce. */ - const persistVolume = () => { - window.clearTimeout(volSaveTimerRef.current); - volSaveTimerRef.current = undefined; - writeAccount(LS.ytPlayerPrefs, { volume: volumeRef.current }); - }; - /** Push a level (0–100) to the player and remember it; 0 also mutes (a player left at zero - * volume but unmuted shows YouTube's speaker icon as ON, which reads as broken). */ - const applyVolume = (level: number, flash = true) => { - // Callers hand over an already-normalised level (stepVolume / the stored value), but the - // player can still answer NaN, so re-normalise here against the LAST GOOD level — never the - // default: 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, not one per notch. The unmount cleanup flushes - // a pending write, so the last change is never lost. - 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 yet: the level lives in `volumeRef` and onReady - // applies it, so a wheel spin in the first second after opening (the natural reaction to a - // video that starts loud) now lands instead of being silently dropped. - const p = playerRef.current; - // Whose level do we step from? Our own ref, EXCEPT when the gesture starts fresh — then read - // the player, because the user may have moved YouTube's own slider in the meantime. - // `getVolume()` is answered across the iframe boundary and lags our `setVolume` by a beat, so - // re-reading it mid-spin resurrects a stale level and silently drops notches (measured: five - // wheel notches moved 100 → 90 instead of 75). - 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 })); - }; + // Per-account volume (wheel over the whole modal + ↑/↓): the level lives in the hook's ref and is + // re-applied to each freshly-created player via reapplyStored (onReady). + const { volumeUi, reapplyStored, nudgeVolume } = useYtVolume({ + playerRef, + dialogRef, + focusModal, + }); + const { nativeControls, setNativeControls } = useNativeControlsYield({ + playerRef, + stageRef, + focusModal, + }); + const { + isFullscreen, + maximized, + maximizedRef, + isFullscreenRef, + toggleFullscreen, + toggleTrueFullscreen, + exitBigView, + setMaximized, + } = useYtBigView({ stageRef, playerRef, focusModal }); // The player can navigate to other videos (YouTube links in a description). The // currently-playing id may differ from the active item. @@ -639,232 +400,29 @@ export default function PlayerModal({ return () => { window.removeEventListener("keydown", onKey); document.body.style.overflow = prevOverflow; - window.clearTimeout(volTimerRef.current); - // Flush a debounced volume write — closing the player right after a wheel spin (the normal - // way to leave) must not drop the level the user just set. - if (volSaveTimerRef.current != null) persistVolume(); + // (The debounced volume write is flushed by useYtVolume's own unmount cleanup.) }; }, [onClose]); - // Mouse-wheel volume over the WHOLE modal (backdrop + card), not just the small player area. - // Non-passive so preventDefault stops the modal/page scrolling. Note: the cross-origin iframe - // swallows wheel events over its own native-control strips, but the central interaction overlay - // is our element (wheel there bubbles here), and everything outside the video works directly. - useEffect(() => { - const el = dialogRef.current; - if (!el) return; - const onWheel = (e: WheelEvent) => { - // Ctrl/⌘+wheel is the browser's zoom gesture — never swallow it. Volume is the PLAIN wheel - // (and ↑/↓); taking the modifier too left zoom unreachable while the player was open. - 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); - }, []); - - // Track the browser's fullscreen state. WE never request it any more (F maximises via CSS), so - // this is YouTube's own fullscreen — entered from its control bar, left by its button or Esc. - 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 - // LEAVING fullscreen is where the user got stranded: to reach YouTube's fullscreen button - // they had to click INTO the player, which moves keyboard focus into the cross-origin iframe - // — and it stays there afterwards. Our window-level shortcuts then never fire (F went to - // YouTube instead, giving a second, control-less fullscreen), so the maximised view had no - // way out. Taking focus back here restores F/Space/arrows the moment fullscreen ends. - if (!fsEl) focusModal(); - }; - return onFullscreenChange(onChange); // both spellings — see lib/fullscreen - }, []); - - // Yield the interaction overlay to YouTube's native UI. Clicking a native control (gear / seek - // bar / CC) moves focus into the player iframe — the only cross-origin 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 (a click back on our - // card blurs the iframe → the parent window fires 'focus'). - 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-sm", onBlur); - window.removeEventListener("focus", rearm); - stage?.removeEventListener("mouseleave", rearm); - }; - }, []); - - // Create the player, resume from the saved position, persist progress, and - // auto-mark watched once playback reaches the end. - useEffect(() => { - let cancelled = false; - const id = active.id; - autoMarkedRef.current = false; - setPlayerError(null); - - // Auto-watch only applies to the active item — not to other videos the player - // navigated to via description links. - const maybeAutoWatch = (current: number, duration: number): boolean => { - if (autoMarkedRef.current || duration <= 0 || currentIdRef.current !== id) return false; - // Cap the finish margin at half the clip, so a video shorter than FINISH_MARGIN isn't - // auto-marked watched almost immediately (its threshold would otherwise go negative). - 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 progress checkpoint: it would - // only clear the resume position, and firing both at once needlessly races the - // watched write against the progress row it deletes. - if (maybeAutoWatch(cur, dur)) return; - // Only checkpoint the feed video 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. The IFrame API's setPlaybackQuality/suggestedQuality are hard no-ops - // now (YouTube removed them), and the undocumented `vq` URL param is only occasionally - // honoured — kept because it's harmless. NOTE: YouTube hard-caps an embed's max quality by - // the player's on-screen size; a windowed embed is stuck ~360p and only true fullscreen - // lifts the cap (after which the higher pick persists for the session). We can't beat that - // programmatically — a CSS transform-scale trick was tried and does NOT fool the cap. - vq: "hd1080", - }, - events: { - // Keep keyboard focus on the modal, not the iframe, so shortcuts work right away, and - // restore the remembered volume — a fresh player always starts at 100. - onReady: () => { - focusModal(); - applyVolume(volumeRef.current, false); - }, - 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(); - } - }, - // The embed couldn't play this video (embedding disabled, removed, private…). - // Surface our own message + an "Open on YouTube" escape hatch. - onError: (e: YTPlayerEvent) => { - setPlayerError(typeof e?.data === "number" ? e.data : -1); - // The IFrame API fires onError (not 'ended') for an unplayable item, so advanceOnEnd() - // never runs and an auto-advancing queue would freeze here. Skip forward instead — but - // bounded, so a run of dead items (or loop=all over a broken queue) can't spin. We do - // NOT mark the broken video watched (only the ended-handler does that). - 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, so - // without this the resume point lags up to 5s (a seek right before F5 would be lost). A keepalive - // beacon so the POST survives the unload. Only for the library item we opened with — a navigated - // description-link video may not be in this user's library (the server would 404). - 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: - // opening/closing the player through a watch session otherwise re-runs the whole infinite query - // each time). Progress is already persisted server-side; only the on-screen resume bar waits. - 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 - }, [active.id]); + // Create the player, resume from the saved position, checkpoint progress, and auto-mark watched at + // the end — recreated per active item. The once-bound event closures read fresh queue/mode/volume via + // the refs passed in. See lib/playerModalHooks. + useYtProgress({ + activeId: active.id, + resumeAt, + mountRef, + playerRef, + currentIdRef, + navRef, + modeRef, + setWatched, + setPlayerError, + setLiveData, + reapplyStored, + focusModal, + advanceOnEnd, + qc, + }); // Portaled to , like every other full-screen overlay here. A fixed, full-viewport modal // must not render inside the page scroller: once that scroller is a stacking context (its diff --git a/frontend/src/components/playerModalControls.tsx b/frontend/src/components/playerModalControls.tsx new file mode 100644 index 00000000..ddc89092 --- /dev/null +++ b/frontend/src/components/playerModalControls.tsx @@ -0,0 +1,115 @@ +import { useTranslation } from "react-i18next"; +import { Repeat, Repeat1, Shuffle, SkipBack, SkipForward } from "lucide-react"; + +// Presentational controls for the YouTube modal player (PlayerModal), split out of the god component — +// the sibling of plexPlayerControls.tsx. No state or effects here; all behaviour comes in via props. + +// Persistent playback settings (stored in users.preferences). Auto-advance = what plays when a video +// ends; loop = whether it repeats the current video ("one"), wraps the list at its ends ("all"), or +// neither ("off"). Both apply to any queued player (feed or playlist). +export type AutoMode = "off" | "next" | "prev" | "random"; +export type LoopMode = "off" | "one" | "all"; +export const AUTO_MODES: AutoMode[] = ["off", "next", "prev", "random"]; +export const LOOP_MODES: LoopMode[] = ["off", "one", "all"]; + +/** The queue transport cluster — prev / counter / next, then the auto-advance and loop mode toggles. + * Rendered in two places with identical logic: the modal's queue bar (`variant:"modal"`, normal glass + * theme, labels always shown) and the big-view docked control bar (`variant:"docked"`, white-on-dark, + * labels hidden until a breakpoint). Only the theme + label-visibility differ; the behaviour is one, + * so a mode/icon change lands once. The wrapper (fragment vs. bordered row) stays at the call site. */ +export function PlayerTransportControls({ + variant, + index, + queueLength, + autoMode, + loopMode, + onPrev, + onNext, + onCycleAuto, + onCycleLoop, +}: { + variant: "docked" | "modal"; + index: number; + queueLength: number; + autoMode: AutoMode; + loopMode: LoopMode; + onPrev: () => void; + onNext: () => void; + onCycleAuto: () => void; + onCycleLoop: () => void; +}) { + const { t } = useTranslation(); + const docked = variant === "docked"; + const navBtn = docked + ? "inline-flex items-center gap-1 text-white/80 enabled:hover:text-white disabled:opacity-30 transition" + : "inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition"; + const navLabel = docked ? "hidden sm:inline" : ""; + const counter = docked ? "tabular-nums text-white/70" : "text-muted tabular-nums"; + const divider = docked ? "h-4 w-px bg-white/20" : "w-px h-4 bg-border"; + const toggle = (on: boolean) => + docked + ? `inline-flex items-center gap-1 rounded-lg px-2 py-1 transition ${ + on ? "bg-white/20 text-white" : "text-white/70 hover:bg-white/10 hover:text-white" + }` + : `inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 transition ${ + on ? "bg-accent/15 text-accent" : "text-muted hover:bg-surface hover:text-fg" + }`; + const toggleLabel = docked ? "hidden md:inline" : ""; + return ( + <> + + + {index + 1} / {queueLength} + + + + {/* Persistent playback settings — cycle on click, saved to your account. */} + + + + ); +} diff --git a/frontend/src/lib/playerModalHooks.ts b/frontend/src/lib/playerModalHooks.ts new file mode 100644 index 00000000..9bdc59b1 --- /dev/null +++ b/frontend/src/lib/playerModalHooks.ts @@ -0,0 +1,463 @@ +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 | 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-sm", 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, + 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; + 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).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]); +}