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]);
+}