import { useCallback, useEffect, useEffectEvent, useId, useLayoutEffect, useRef, useState, type CSSProperties, } from "react"; import { useTranslation } from "react-i18next"; import { createPortal } from "react-dom"; import { useNavigationActions } from "./navigationContext"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; import { usePref } from "../lib/usePref"; import { PlayerDescription, PlayerTransportControls } from "./playerModalControls"; import { AUTO_MODES, LOOP_MODES, type AutoMode, type LoopMode } from "../lib/playerModes"; import { AlertTriangle, ArrowLeft, Check, CheckCheck, ExternalLink, Info, Maximize, Minimize, Minimize2, PanelRightClose, PanelRightOpen, Settings, SkipBack, SkipForward, Trash2, Volume2, VolumeX, X, } from "lucide-react"; import Avatar from "./ui/Avatar"; import { useLatest, useStableCallback } from "../lib/useLatest"; import AddToPlaylist from "./AddToPlaylist"; import { PopoverSurface } from "./ui/AnchoredPopover"; import { useAnchoredPopover } from "./ui/anchoredPopoverHooks"; import { pushLayer, removeLayer, anyBlockingLayerAbove, anyLayerAbove, escapeHandled, } from "../lib/layerStack"; import { trapTab, useDialogChrome } from "./ui/dialogFocus"; import DownloadButton from "./DownloadButton"; import { api, type Video, type VideoStatus } from "../lib/api"; import type { YTPlayer } from "../lib/youtube"; import { channelYouTubeUrl, formatDate, formatDuration, formatCompact, relativeTime, } from "../lib/format"; import { canFullscreenElements, fullscreenElement } from "../lib/fullscreen"; import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap"; import { useBackToClose } from "../lib/history"; import ChannelNameLink from "./ChannelNameLink"; import Toaster from "./ui/Toaster"; import { useOverlayOrder } from "./ui/overlayOrder"; import { afterRemoval } from "../lib/playerQueue"; import { useNativeControlsYield, useYtBigView, useYtProgress, useYtVolume, YT_API_LOAD_FAILED, } from "../lib/playerModalHooks"; import { useScrollFade } from "../lib/useScrollFade"; import { Button } from "./ui/Button"; import { IconButton } from "./ui/IconButton"; import { SURFACE } from "./ui/surfaces"; import { IconLink, LinkButton } from "./ui/LinkButton"; import { SectionLabel } from "./ui/Headings"; import { useMediaQuery } from "../lib/useMediaQuery"; // Experiment (branch experiment/inline-player): play the video in-app via the // YouTube IFrame Player API (not a bare embed) so we can read playback position // and resume where the user left off. The modal closes via the in-card Close // 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). /** 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 * behind the modal is `overflow:hidden` anyway. */ function canScrollBy(el: HTMLElement | null, dir: -1 | 1): boolean { for (let node = el; node && node !== document.body; node = node.parentElement) { const style = getComputedStyle(node); if (!/(auto|scroll|overlay)/.test(style.overflowY)) continue; const room = dir < 0 ? node.scrollTop > 0 : node.scrollTop + node.clientHeight < node.scrollHeight - 1; // -1: sub-pixel heights if (room) return true; } return false; } // From this window width the player's details sit in a side panel beside the video. The panel is // a rem width, so it grows with the text size; the video takes what is left. const WIDE_PLAYER_PX = 1280; const SIDE_PANEL = "20rem"; // The side panel shut to a strip: one icon button wide, close / open / previous / next in a column. const PANEL_STRIP = "2.75rem"; // The narrowest the stacked video gets to make room for the chrome under it (see the card's maxWidth). const MIN_VIDEO = "36rem"; // YouTube's embed draws its controls at fixed CSS px from the iframe's edges, whatever its size // (measured 576×324 up to 2460×1384): the channel avatar, volume, captions and gear in a row along the // top (48 px), the seek bar across the full width 52–96 px up from the bottom, and the fullscreen button // above its right end, 80–128 px up and 24–72 px in from the right. The click layer's cutouts never // shrink below those, else on a short player (a small window, a browser zoomed to 150 % or more) the // layer lies over that button and a click on it pauses instead. Only the right-hand column needs the // fullscreen button's height: a full-width band that tall would hand the clicks on the picture to the // iframe, which takes the keyboard focus and with it our shortcuts. On a player too short for any // centre (under ~200 px, a phone) the layer is gone and YouTube's own touch controls take every tap. // Px, not rem: the text-size slider does not grow YouTube's controls. const YT_TOP_CONTROLS_PX = 56; const YT_SEEK_BAR_PX = 104; const YT_FULLSCREEN_BUTTON_PX = { above: 140, fromRight: 88 }; // The layer is these two boxes: the picture left of the fullscreen button down to the seek bar, and the // column above that button. const YT_CLICK_LAYER: CSSProperties[] = [ { left: 0, right: YT_FULLSCREEN_BUTTON_PX.fromRight, top: `max(12%, ${YT_TOP_CONTROLS_PX}px)`, bottom: `max(22%, ${YT_SEEK_BAR_PX}px)`, }, { right: 0, width: YT_FULLSCREEN_BUTTON_PX.fromRight, top: `max(12%, ${YT_TOP_CONTROLS_PX}px)`, bottom: `max(22%, ${YT_FULLSCREEN_BUTTON_PX.above}px)`, }, ]; // The meta line's items each lead with a "·" that parts them from what stands before: the channel name // on its line. In the wide panel the meta has a line of its own, where the first "·" would lead nothing. const metaSep = ( ); const META_OWN_LINE = "w-full [&>span:first-child>[data-meta-sep]]:hidden"; // Beside the channel name the meta takes what is left of the line, but never less than this: under it // the line wraps and the meta, or the actions after it, move down instead of squeezing. const META_MIN = "min-w-48 flex-1"; export default function PlayerModal({ video, startAt, queue: queueProp, startIndex, onClose, onState, onRemoveFromList, }: { video: Video; // Where to start the opened video: a number of seconds (0 = Restart), or null/undefined // to resume from the server-saved position carried on the video. startAt?: number | null; // Optional playback queue (e.g. a playlist). When present, the player advances through // it (auto-advance on end + prev/next), recreating per item to reuse the single-video // resume / auto-watch / progress logic. queue?: Video[]; startIndex?: number; onClose: () => void; onState: (id: string, status: VideoStatus) => void; /** Remove the playing video from the list it was opened from (the Playlists module passes its own * `removeItem`; the feed passes nothing — there is no list to remove it from). Returns whether it * actually happened, because the CONSEQUENCE is the player's: its launch queue is frozen, so it * drops the item itself and steps to the next one, closing when the list runs out. */ onRemoveFromList?: (video: Video) => Promise; // Open the active video's channel page in-app (closes the player first). The small external // icon next to the name keeps the open-on-YouTube behaviour. }) { const { t, i18n } = useTranslation(); const lang = i18n.language; const { openChannel } = useNavigationActions(); const qc = useQueryClient(); const { ref: descFadeRef, style: descFadeStyle } = useScrollFade(); // Browser/mouse Back closes the player instead of leaving the page. useBackToClose(onClose); // Freeze the launch queue: the player steps through the SAME filtered + sorted list it was // opened with, for its whole session. The live feed refetches and reorders/drops items under // us — e.g. auto-marking each video watched near its end removes it from a filtered feed — which // would otherwise shrink the queue mid-playback and break prev/next + Loop "All" (it could never // wrap back to the first item once earlier items dropped out). Snapshot once at mount via // useState's lazy initializer and ignore later prop changes. Items that turn out gone/ // non-embeddable still surface the existing playback-error overlay when reached. // // The setter exists for exactly ONE writer: removing a video from its playlist (below). // That is not the feed changing under us — it is this player's own action — and leaving the item // in the frozen queue would let prev/next step back onto a video that no longer exists. const [queue, setQueue] = useState(() => queueProp); // Precise upload date shown inline after the relative time (e.g. "9 yr ago · 12 Jan 2017"). const fullDate = (d: string | null | undefined) => formatDate(d, i18n.language); // Track the playing item by id (not a frozen index) so it survives the queue changing // under us — e.g. an item removed in another tab. The index is derived from the *live* // queue, so the N / M counter and prev/next neighbours stay correct without disrupting // playback of the current video. const [playingId, setPlayingId] = useState( // A caller with an explicit index (playlist) starts there; otherwise locate the opened // `video` in the queue (the feed passes its whole list + the clicked video, no index). startIndex != null ? (queue?.[startIndex]?.id ?? video.id) : video.id, ); // Locate the playing item in the frozen launch queue → drives prev/next + the N/M counter. // Because the queue is frozen, the playing id always stays in it (unlike the old live queue, // where a just-watched item could drop out and snap playback to queue[0]). let index = queue ? queue.findIndex((v) => v.id === playingId) : 0; if (index < 0) index = 0; const active = queue?.[index] ?? video; // the queue item at index, else the opened video (never undefined) const hasQueue = !!queue && queue.length > 1; // Positions this session has checkpointed (server-confirmed), per video id — see useYtProgress. // The launch queue is frozen at mount, so its items keep the position they were snapshotted with; // without this map, stepping next→prev back to an item played earlier in the SAME session would // resume at that snapshot and then persist it over the newer position. // A position this session already stored wins over `resumeAt` — it is the freshest truth we have — // so the map is read where the player starts (useYtProgress, loadVideo), never during render. const sessionPosRef = useRef>(new Map()); // startAt only applies to the item we opened with; advanced items resume their own pos. const resumeAt = startAt != null && active.id === video.id ? startAt : active.position_seconds || 0; const mountRef = useRef(null); const playerRef = useRef(null); // 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 // click. `stageRef` is the element we fullscreen (so the volume overlay stays visible). const cardRef = useRef(null); const stageRef = useRef(null); // 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); // Stable: it reads a ref and nothing else, and three hooks take it as a parameter (useYtVolume, // useYtNativeControls, useYtBigView) where a fresh identity each render would spread into their // dep lists too. const focusModal = useCallback(() => cardRef.current?.focus(), []); // The card fits the window, video AND what sits under it (the queue strip, title, channel row): the // video is 16:9 at the card's width, so on a short window (133% zoom on a 1080p screen: 685px) a // full-width card ran past the bottom and cut its channel row. `below` is that chrome's // height, measured (it wraps with the title and the text size), and caps the card's width below. const [below, setBelow] = useState(0); // A wide window (the owner's pick): the title, channel, actions, description and the queue // move into a side panel beside the video, so the video takes the window's whole height — at // 1920×911 it was capped at ~1008 px wide with empty space all round. Narrower, they stay under it. const wide = useMediaQuery(`(min-width: ${WIDE_PLAYER_PX}px)`, false); // The wide panel shut to a strip, for a bigger video. This player's own state: it holds while the // queue steps on (a next video plays in this same modal), and a video opened from a card mounts a // fresh player, which starts with the panel open (owner). const [panelShut, setPanelShut] = useState(false); // The toggle that was pressed is gone after the switch (shut → the strip's "open", and back): focus // goes to its counterpart, not to , where the player's keys would no longer reach it. const panelToggleRef = useRef(null); const refocusToggle = useRef(false); const togglePanel = (shut: boolean) => { refocusToggle.current = true; setPanelShut(shut); }; useEffect(() => { if (!refocusToggle.current) return; refocusToggle.current = false; panelToggleRef.current?.focus(); }, [panelShut]); useLayoutEffect(() => { const card = cardRef.current; const stage = stageRef.current; if (!card || !stage) return; const measure = () => { // A maximised stage is fixed to the viewport, out of the card's flow: keep the last value. if (getComputedStyle(stage).position === "fixed") return; setBelow(card.scrollHeight - stage.offsetHeight); }; measure(); const ro = new ResizeObserver(measure); for (const child of card.children) ro.observe(child); return () => ro.disconnect(); }, []); // The dialog contract every other `aria-modal` shell shares (ui/dialogFocus): the focus goes back // to whatever opened the player, and the page lock is the one counted lock. It opens focused on the // CARD, not its first button, so Space/F/arrows reach the shortcuts. This was once the one // hand-rolled shell — no accessible name, no Tab trap, no restore. useDialogChrome(cardRef, dialogRef, { focusScope: true }); const titleId = useId(); const togglePlay = () => { const p = playerRef.current; if (!p || typeof p.getPlayerState !== "function") return; if (p.getPlayerState() === 1) p.pauseVideo?.(); // 1 === playing else p.playVideo?.(); }; // 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, 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. const [currentVideoId, setCurrentVideoId] = useState(active.id); const currentIdRef = useRef(active.id); const navigated = currentVideoId !== active.id; // Title/author of a navigated-to video, read from the player (free, no API call). const [liveData, setLiveData] = useState<{ title?: string; author?: string } | null>(null); // What is playing, by name: the panel's title, and the dialog's name (`titleId`) in either layout. const heading = navigated ? (liveData?.title ?? t("player.loading")) : active.title; // IFrame Player API error code, when the current video can't play in the embed (e.g. an // auto-generated Topic "Art Track" with embedding disabled → 101/150, or removed/private → // 100). We overlay a friendly message + "Open on YouTube" instead of YouTube's bare error. const [playerError, setPlayerError] = useState(null); // Stable callbacks, not plain closures: both reach the player through refs, and the description's // links get them during render (see PlayerDescription). const loadVideo = useStableCallback((id: string, start: number | null) => { const p = playerRef.current; if (!p || typeof p.loadVideoById !== "function") return; // Explicit start wins; otherwise resume the active item, start others at 0. const startSeconds = start != null ? start : id === active.id ? (sessionPosRef.current.get(id) ?? resumeAt) : 0; setPlayerError(null); p.loadVideoById({ videoId: id, startSeconds: startSeconds || 0 }); currentIdRef.current = id; setCurrentVideoId(id); setLiveData(null); }); // Local mirror of watch status so the toggle reacts instantly; changes are propagated to the feed // (and server) via onState. The mirror carries the id it DESCRIBES, and a write for any other id // is ignored rather than displayed — that is the enforcement, and it has to be in code because // one writer arrives late by construction: useYtProgress's cleanup for the OUTGOING item runs // after the commit that already advanced `active`, and near the end of a clip it auto-marks that // item watched (playerModalHooks.ts `maybeAutoWatch`). Keyed, that stray write is simply not this // item's, so the toggle falls back to `active.status` and no ordering has to be argued about. // Unkeyed it landed last and painted the NEXT video as watched; a corrective click then sent // onState(next, "new") for an item that was already new. const [status, setStatus] = useState<{ id: string; value: VideoStatus }>({ id: active.id, value: active.status, }); const watched = (status.id === active.id ? status.value : active.status) === "watched"; const setWatched = (on: boolean) => { const next = on ? "watched" : "new"; setStatus({ id: active.id, value: next }); onState(active.id, next); }; // On queue advance (active changes), point `currentVideoId` at the new item — DURING that render, // not one commit later. As an effect, the frame between still had it on the previous id, so the // "you have navigated away" affordance flashed on every advance. React discards this pass instead // of committing it, so there is no such frame; the guard falsifies itself immediately. // // The watch status is NOT synced here: it is keyed by id above, which is both stale-proof and // immune to the late writer this block cannot see (see that comment). const [syncedTo, setSyncedTo] = useState(active.id); if (syncedTo !== active.id) { setSyncedTo(active.id); setCurrentVideoId(active.id); } // `currentIdRef` must NOT move into the block above, and the reason is EFFECT ORDERING, which the // committed test "resumes an item played earlier in the SAME session" measures. useYtProgress's // per-item effect cleanup persists the outgoing item's position, and its persist() is guarded on // `currentIdRef.current === id` so a video navigated to inside the player is never checkpointed // against the library item. React runs every cleanup BEFORE any setup, so in this effect the ref // still names the item being left while that cleanup runs, and flips to the new one just before // useYtProgress (declared below) sets up for it. Advanced to render time the flip happens first, // the outgoing checkpoint is skipped, and stepping next→prev back to an item resumes from the // frozen launch queue's stale position — which is the exact bug the session-position map exists // to prevent (748 instead of 830, in the test's numbers). useEffect(() => { currentIdRef.current = active.id; }, [active.id]); const seekTo = useStableCallback((seconds: number) => { const p = playerRef.current; if (p && typeof p.seekTo === "function") { p.seekTo(seconds, true); p.playVideo?.(); } }); // Prev/next stepping through the queue (the feed's order, or a playlist). Read the live // queue+index from a ref so the window keydown handler (bound once) always steps from the // current position, not a stale closure. const navRef = useLatest({ queue, index }); const goPrev = () => { const { queue: q, index: i } = navRef.current; if (q && i > 0) setPlayingId(q[i - 1]!.id); }; const goNext = () => { const { queue: q, index: i } = navRef.current; if (q && i < q.length - 1) setPlayingId(q[i + 1]!.id); }; // "Remove from this playlist", offered right where you are watching the thing you want gone. The // parent owns the removal (API + caches + the toast whose Undo puts it back); the player owns what happens NEXT, because it // is the only side that knows which item is playing — its queue is frozen at mount, so the parent // cannot tell. Step to the item that followed (clamped when the last one goes), and close when the // list runs out. // // Guarded against a double press by a REF, not by disabling the button: a trigger disabled for the // removal's lifetime was still disabled when focus came back to it (it once waited on a confirm) — // a no-op — so a keyboard user was left on , outside this aria-modal player. const removingRef = useRef(false); const removeFromList = async () => { if (!onRemoveFromList || removingRef.current) return; const victim = active; removingRef.current = true; const done = await onRemoveFromList(victim).finally(() => { removingRef.current = false; }); if (!done) return; // the request failed — leave playback alone // Decide from where the player is NOW, not where it was when the button was pressed: prev/next // and auto-advance keep working while the request runs, and a cursor from before the await // threw the viewer back onto a video they had already moved past. const { queue: q, index: i } = navRef.current; if ((q?.[i] ?? video).id !== victim.id) { // They moved on — take the removed item out of the queue and leave playback where it is. setQueue((cur) => cur?.filter((v) => v.id !== victim.id)); return; } const next = afterRemoval(q, i, victim.id); if (!next) { onClose(); // that was the last one return; } setQueue(next.queue); setPlayingId(next.playingId); }; // Relative seek within the current video (plain Arrow keys), matching YouTube's ±5s. const nudgeSeek = (delta: number) => { const p = playerRef.current; if (!p || typeof p.getCurrentTime !== "function") return; p.seekTo(Math.max(0, (p.getCurrentTime() || 0) + delta), true); }; // Auto-advance + loop are persistent per-user settings (usePref: server + `me` cache, adopted on // login) so they apply to every player and survive reloads. The player mounts after `me` is cached, // so it seeds from the cache — no localStorage mirror. A ref feeds the (once-bound) end handler. const [autoMode, setAutoMode] = usePref("playerAutoAdvance", "off"); const [loopMode, setLoopMode] = usePref("playerLoop", "off"); const modeRef = useLatest({ autoMode, loopMode }); const cycleAuto = () => { setAutoMode(AUTO_MODES[(AUTO_MODES.indexOf(autoMode) + 1) % AUTO_MODES.length]!); }; const cycleLoop = () => { setLoopMode(LOOP_MODES[(LOOP_MODES.indexOf(loopMode) + 1) % LOOP_MODES.length]!); }; const replayCurrent = () => { const p = playerRef.current; if (p && typeof p.seekTo === "function") { p.seekTo(0, true); p.playVideo?.(); } }; // Run when the current video ends, per the saved settings. Loop "one" repeats it; otherwise // advance in the chosen direction, wrapping at the ends only when loop is "all" (a single-item // list repeats). Uses the live queue + settings via refs so it's correct from the bound handler. const advanceOnEnd = () => { const { autoMode: a, loopMode: l } = modeRef.current; if (l === "one") return replayCurrent(); const { queue: q, index: i } = navRef.current; if (!q || q.length === 0) return; const wrap = l === "all"; if (a === "next") { if (i < q.length - 1) setPlayingId(q[i + 1]!.id); else if (wrap) { if (q.length === 1) replayCurrent(); else setPlayingId(q[0]!.id); } } else if (a === "prev") { if (i > 0) setPlayingId(q[i - 1]!.id); else if (wrap) { if (q.length === 1) replayCurrent(); else setPlayingId(q[q.length - 1]!.id); } } else if (a === "random" && q.length > 1) { let r = i; while (r === i) r = Math.floor(Math.random() * q.length); setPlayingId(q[r]!.id); } else if (a === "random" && wrap) { replayCurrent(); } }; // Lazy description (fetched only when it is opened). It opens from an ⓘ beside the title, by click // or tap (C3): it used to open on hovering the title, which a finger never does — measured live, // a tablet could not read a description at all. Anchored ABOVE, so it grows upward over the player // rather than off-screen; `flip` still rescues it when there is no room up there. const [showDesc, setShowDesc] = useState(false); const descPop = useAnchoredPopover({ open: showDesc, onOpenChange: setShowDesc, mode: "dialog", role: "dialog", placement: "top-start", offset: 8, }); const { setReference: setDescAnchor } = descPop.refs; const detail = useQuery({ queryKey: qk.videoDetail(currentVideoId), queryFn: () => api.videoDetail(currentVideoId), // On opening the description, and eagerly when navigated, so we can link the // linked video's channel. Both share the one cached result. // On a wide window the side panel shows the description outright (a stored video's is a DB read). enabled: showDesc || navigated || wide, staleTime: 5 * 60_000, }); // Its toast hosts' rank (see ui/Toaster): the player's two hosts — the stage one in true fullscreen, // the outer one otherwise — swap on every fullscreen change, and must not outrank a dialog opened // over the player by doing so. const overlayOrder = useOverlayOrder(); // This player is a dismissable layer: register it so a Modal or popover opened ABOVE it (e.g. the // download dialog, or an anchored menu) takes Escape first and this player defers. const layerIdRef = useRef(null); useEffect(() => { const id = pushLayer(); layerIdRef.current = id; return () => { removeLayer(id); layerIdRef.current = null; }; }, []); useKeyScope("ytPlayer"); // Keyboard shortcuts. The chords live in the shared keymap (`ytPlayer` // scope) so the help sheet lists them; only the behaviour behind each id is here. // These fire only while focus is on our page, not inside the cross-origin player iframe — so // we focus the modal card on open and again on player-ready (autoplay can grab focus). // // The handler itself is a useEffectEvent (React 19.2+): it is bound ONCE per player, but it reads // whatever is current at the moment a key arrives, which is what the disabled dep list used to // claim in prose and now is by construction. const onPlayerKey = useEffectEvent((e: KeyboardEvent) => { // Volume by keyboard (YouTube/Plex-style ±5). The only volume control that survives fullscreen, // where the wheel goes to YouTube's own iframe. But the modal card scrolls (a long description) // and Up/Down is how you scroll it from the keyboard — so defer whenever the focused element can // still scroll that way, exactly like Space defers to a focused button. NOT while the video fills // the screen though — in fullscreen OR in our own maximise — because the card is behind/off-screen // there: deferring would scroll something invisible and leave the viewer with no volume control at // all. Both flags, not just fullscreen: since F stopped taking the browser's lock, maximise is the // common case and checking only `isFullscreen` made the keys dead exactly where they matter most. const volume = (e: KeyboardEvent, up: boolean) => { const covered = isFullscreenRef.current || maximized; if (!covered && canScrollBy(document.activeElement as HTMLElement | null, up ? -1 : 1)) return; e.preventDefault(); nudgeVolume(up ? 5 : -5); }; const seek = (e: KeyboardEvent, secs: number) => { e.preventDefault(); nudgeSeek(secs); }; // The shared dialog Tab trap — only while nothing that owns the keyboard is layered above the // player: a Modal opened over it (the download dialog) traps for itself. The title's hover card // does not, so it must not switch the trap off. if (e.key === "Tab") { if (layerIdRef.current != null && anyBlockingLayerAbove(layerIdRef.current)) return; if (dialogRef.current) trapTab(e, dialogRef.current, cardRef.current); return; } dispatch(e, "ytPlayer", { close: (ev) => { // Defer to any layer opened above the player FIRST (a Modal like the download dialog, or an // anchored popover): it takes the Escape and we do nothing — so we don't also step out of // maximise or close the player underneath it. The consumed-event flag is the reliable signal // (UX-8): the layer's `document` listener runs before ours and its close can pop it off the // stack synchronously, so `anyLayerAbove` alone would miss it and close the player too. if (escapeHandled(ev)) return; if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) return; // In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal. if (fullscreenElement()) return; // Same for our own maximise: Esc steps back out of it before it closes the player. if (maximized) { ev.preventDefault(); setMaximized(false); return; } onClose(); }, fullscreen: (ev) => { ev.preventDefault(); toggleFullscreen(); }, playPause: (ev) => { // Let Space activate a focused button/link; otherwise toggle playback (and stop the // page from scrolling). if (isActivationTarget(ev)) return; ev.preventDefault(); togglePlay(); }, volumeUp: (ev) => volume(ev, true), volumeDown: (ev) => volume(ev, false), seekBack: (ev) => seek(ev, -5), seekFwd: (ev) => seek(ev, 5), prevVideo: (ev) => { ev.preventDefault(); goPrev(); }, nextVideo: (ev) => { ev.preventDefault(); goNext(); }, }); }); useEffect(() => { const onKey = (e: KeyboardEvent) => onPlayerKey(e); window.addEventListener("keydown", onKey); focusModal(); // (The scroll lock is the shared, counted one in useDialogChrome.) return () => { window.removeEventListener("keydown", onKey); // (The debounced volume write is flushed by useYtVolume's own unmount cleanup.) }; }, [focusModal]); // 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, sessionPosRef, qc, }); // The description's two places share one ladder (PlayerDescription) and these props. const descriptionProps = { detail, currentId: currentVideoId, onSeek: seekTo, onLoadVideo: loadVideo, }; // The card's width budget (its maxWidth, below): the video's, and the side panel's when wide. const fitWidth = `(100dvh - 3rem - ${below}px) * 16 / 9`; const panelWidth = panelShut ? PANEL_STRIP : SIDE_PANEL; // One Close, icon only: at the top right of the wide panel's head (or its strip), at the end of the // stacked card's title row under the video. const closeButton = ( ); // 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 // edge-fade mask makes it one) this z-overlay is scoped inside it, and the nav rail (z-rail) and the // header (z-chrome) paint over the modal — worst at browser zoom, where the centered card is smaller // relative to the rail/panel. (Same class as PlexPlayer; this one was the miss in that sweep.) return createPortal(
{/* The card. Previous / next live in the panel (its transport row, or the shut panel's strip) and on Shift+← / →: the full-height strips that once flanked the card cost the video 7rem of width for a second copy of the same two buttons (owner). */}
e.stopPropagation()} >
{/* Hide the iframe entirely on error so YouTube's own error screen can't bleed through our overlay. */} {/* In the big views the video area is inset by the control bar's height (`bottom-14`) so the bar below never overlaps it. The inset lives on THIS wrapper, not on `mountRef`: YT.Player REPLACES the mount element with its iframe (width/height 100% of the parent), which would drop any class on mountRef itself — the iframe then sizes to this wrapper. */}
{/* Interaction layer over the CENTRE of the video: catches click (play/pause) + wheel volume (the wheel event bubbles from here to the modal's listener — over the bare cross-origin iframe it would be swallowed) and stops the iframe stealing keyboard focus. It leaves the top AND bottom edges uncovered so YouTube's edge controls stay reachable, measured against the iframe it sits in (in the big views that excludes our docked bar) and never thinner than those controls (see YT_CLICK_LAYER). A mouse surface only, so hidden from assistive tech: the keyboard has the shortcuts it stands for. Armed in the big views too (that is what keeps wheel volume working after F, the reported gap) — it only yields to `nativeControls`, i.e. once the user opens YouTube's own settings menu (focus moves into the iframe), so that menu is navigable end to end. Hidden on error so the "Open on YouTube" CTA is clickable. */} {playerError == null && YT_CLICK_LAYER.map((box, i) => (
{ focusModal(); togglePlay(); }} title={t("player.shortcutsHint")} aria-hidden="true" style={box} className={`absolute z-base cursor-pointer ${ nativeControls ? "pointer-events-none" : "" }`} /> ))}
{/* Our own control bar for the big views (maximise / our fullscreen), where the card's controls are off-screen. It's IN the stage (so it survives true fullscreen, where a portal would not) and DOCKED in its own strip: the video area above is inset by this bar's height (see the iframe wrapper's `bottom-14`), so the bar NEVER overlaps YouTube's seek bar — that stays reachable by hovering the video's bottom edge, just above ours. Always visible (no auto-hide) so there's no guessing whether it will appear and no fight with the seek bar over the same pixels. stopPropagation so a click here doesn't toggle playback; the exit button also re-arms our shortcuts. Both of its dialogs work in true fullscreen: add-to-playlist's popover and download's Modal portal INTO the fullscreen element. */} {isFullscreen && } {(maximized || isFullscreen) && (
e.stopPropagation()} className="absolute inset-x-0 bottom-0 z-menu flex h-14 items-center justify-start gap-x-3 overflow-x-auto border-t border-white/10 bg-media-chrome/95 px-3 text-sm text-white" > {hasQueue && ( <> )} {/* Both work in fullscreen now: add-to-playlist's popover and download's Modal both portal into the fullscreen element rather than (a body-portaled node is painted underneath the element holding the lock, i.e. invisible). */} {!navigated && } {!navigated && ( )} {!navigated && ( )} {/* Absent where the browser cannot fullscreen an element (Brave on an iPad): there the window-filling view is the biggest, and YouTube's own button opens the system player. */} {canFullscreenElements() && ( {isFullscreen ? : } )}
)} {/* Discreet badge while YouTube's own controls have taken over (overlay yielded). */} {playerError == null && nativeControls && (
{t("player.nativeControls")}
)} {/* Volume level flash (auto-fades). pointer-events-none so it never blocks the video. */} {volumeUi != null && (
{volumeUi === 0 ? : }
{volumeUi}
)} {playerError != null && (

{t("player.unavailableTitle")}

{playerError === YT_API_LOAD_FAILED ? t("player.apiLoadFailedBody") : playerError === 101 || playerError === 150 ? t("player.embedDisabledBody") : t("player.unavailableBody")}

} > {t("player.openOnYoutube")}
)}
{/* Wide: the side panel. Its content scrolls inside a box the video's height, so a long description never makes the card taller than the video. */}
{wide && panelShut ? (
{/* The dialog is named by this heading's id: the panel's own one is not rendered. */}

{heading}

{closeButton} togglePanel(false)} label={t("player.expandPanel")} aria-expanded={false} > {hasQueue && ( <> = queue!.length - 1} label={t("player.next")} title={`${t("player.next")} · Shift+→`} > )}
) : (
{wide && (
togglePanel(true)} label={t("player.collapsePanel")} aria-expanded > {closeButton}
)} {hasQueue && (
)}
{/* Title row — the title and its ⓘ (the description) on the left; stacked, Close on the right (wide, it heads the panel). */}

{heading}

{!wide && ( )} {navigated && ( )} {t("player.description")} {!wide && closeButton}
{/* Channel + meta on one line, with the actions pushed to the right. When navigated to a linked video we only have its author (from the player), so we show that as plain text and hide feed-video-specific bits. It wraps: the meta never gets narrower than META_MIN, and the actions move down as one group. Unwrapped, a narrow card (a tablet, a zoomed window) squeezed the meta into a one-word column between the name and the buttons. */}
{!navigated && ( )} {navigated ? ( detail.data?.channel_id ? ( {liveData?.author ?? detail.data.channel_title ?? t("player.channel")} ) : ( {liveData?.author ?? ""} ) ) : ( // Wide, the name takes what the avatar leaves of the panel's line and is cut // there: unshrinkable, a long one ran the 20rem panel into a sideways scroll.
{ // openChannel closes this player first and pushes only once its // Back-to-close entry is reclaimed — otherwise that reclaim undoes the push // and clicking the name merely closes the player // (lib/history.closeOverlaysThenNavigate). openChannel(active.channel_id, active.channel_title ?? undefined); }} className="font-medium hover:text-accent truncate text-left" />
)} {!navigated ? (
{active.view_count != null && ( {metaSep} {t("player.views", { count: active.view_count, formattedCount: formatCompact(active.view_count, lang), })} )} {metaSep} {relativeTime(active.published_at, lang, t)} {active.published_at && ` · ${fullDate(active.published_at)}`} {active.duration_seconds != null && ( {metaSep} {formatDuration(active.duration_seconds)} )} {active.live_status === "was_live" && ( {t("player.stream")} )}
) : ( // Linked video's stats come from the (already-fetched) video detail.
{detail.data?.view_count != null && ( {metaSep} {t("player.views", { count: detail.data.view_count, formattedCount: formatCompact(detail.data.view_count, lang), })} )} {detail.data?.published_at && ( {metaSep} {relativeTime(detail.data.published_at, lang, t)} ·{" "} {fullDate(detail.data.published_at)} )} {detail.data?.duration_seconds != null && ( {metaSep} {formatDuration(detail.data.duration_seconds)} )}
)} {/* Reliable, discoverable path into our big view straight from the windowed player — the docked bar's controls (incl. our own true-fullscreen ⛶) are off-screen here, and the only fullscreen button previously reachable in the windowed video was YouTube's own (which hides our chrome). Mirrors the F key (maximise, a CSS big view); from there the docked bar offers OUR true fullscreen. */} {!navigated && ( // Narrow: the actions end the channel line. Wide: their own line in the panel.
{onRemoveFromList && ( void removeFromList()} // Named with its object, like the list's menu row: a bare "Remove" among Fullscreen / // Download / Mark watched reads as "delete this video". label={t("playlists.removeItemOf", { title: active.title })} title={t("playlists.removeItem")} > )}
)}
{wide && (
{t("player.description")}
)}
)}
{/* Toasts raised while the player is open (a failed Remove, a lost connection) — the app's own Toaster is underneath this scrim. See ui/Toaster. In true fullscreen it moves into the stage, the element holding the lock: out here it was painted under it, i.e. not at all. */} {!isFullscreen && }
, document.body, ); }