Files
siftlode/frontend/src/components/PlayerModal.tsx
T
peter f76a2afbf1 feat(ui): the four small gaps — player delete, repeat-move, a real channel link, YT search sorting
Items 2, 4, 5 and 7 of the bug hunt, one round. They are independent, but they are all the same
kind of thing: an action that exists somewhere else in the app and is missing where you actually
want it.

**5 — the channel name in the YT player is a link again.** It was a `<button>`, so middle-click and
Ctrl/Cmd-click did nothing on a thing that looks exactly like a link (the YouTube icon beside it IS
an anchor and opens a tab). It is now a real `<a>` whose href is the same `?chan=<id>` URL
`openChannel` pushes, with a plain left click still taking the in-app path — that path does more
than navigate, since the player must close and reclaim its own Back-to-close history entry first.
The "is this click ours or the browser's" test is now ONE predicate (`isPlainLeftClick`), which
VideoCard's card link also uses, so a second such link cannot forget the middle-click arm.

**2 — "Remove" from inside the player.** The parent owns the removal (the SAME `removeItem` the row
menu and the inline ✕ use: one confirm, one optimistic update, one undo-stack rebase), and the
player owns what happens next, because it is the only side that knows which item is playing — its
launch queue is frozen at mount. It drops the item, plays what followed, and closes when the list
runs out. That forced a real fix in Playlists: the player's mount was derived from `shown` every
render, so anything that dropped the launch row yanked the player off the screen mid-playback. That
was a latent edge (change the view and it happens) and the normal case for this feature. The parent
now snapshots what it launched with, and only onClose takes the player down.

**4 — "Move to <the last list you moved to>".** The submenu is two levels deep and moving a run of
videos into the same list is the shape this gets used in. The remembered id is a per-account pref;
the row is derived from `moveTargets`, which already excludes the list you are looking at, so it
cannot offer a move to where the video already is. Remembered only on a SUCCESSFUL move — a
shortcut to a move that failed is a shortcut to failing again. Absent, not disabled, when there is
nothing to repeat: there is nothing to explain.

**7 — the live-YouTube results can be sorted.** Client-side, over the page already loaded. Not a
new `order=` param, and this is the decision worth recording: the owner's case is "even one page of
results", i.e. reorder what is on screen; a re-query would REPLACE the results, which is not what
"sort these" means; and the zero-quota scrape source has no order parameter at all, so a
server-side control would silently do nothing (or quietly cost 100 quota units) depending on which
source served the page. Only keys a search hit carries are offered — `subscribers`/`priority`
describe a subscription, which a search hit is not, and `shuffle` on a ranked list is noise. Its
own pref, so sorting a search page never re-sorts the library behind it.

Verified in the browser on localdev against the real account: the channel name renders as
`<a href="…&chan=…">` and a ctrl-click / middle-click is left to the browser while a plain click
still opens the channel in-app; removing from the player stepped 1/3 → 1/2 onto the next video and
closed on the last one; the row menu grew "Move to “…”" only after a move and only on a list that
is not the target; and the results sort reordered by views 41.7M → 29.8M → 24.2M → 15.8M. (The
pane's own ctrl-click arrives without the modifier — a harness limit, so the modifier cases were
driven as real MouseEvents.) Scratch playlists and the touched prefs were cleaned up afterwards.

22 vitest, mutation-checked against 14 mutations (no href at all · href without the rest of the
query · every click hijacked in-app · the middle-click arm dropped from the predicate · plain click
no longer in-app · always jumping to the top of the list · the removed item left in the queue · no
clamp when the last item goes · an empty queue not reported as "nothing left" · sort-then-reverse
flipping ties · unknown values first · titles compared numerically · shuffle offered on search
results · direction ignored) — each red on exactly the test that names it. Item 4 has no test of
its own: its rule is enforced by construction, not by a check.
2026-09-10 22:34:12 +02:00

953 lines
44 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { createPortal } from "react-dom";
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,
Check,
CheckCheck,
ChevronLeft,
ChevronRight,
ExternalLink,
Maximize,
Minimize,
Minimize2,
Settings,
Trash2,
Volume2,
VolumeX,
X,
} from "lucide-react";
import Avatar from "./ui/Avatar";
import AddToPlaylist from "./AddToPlaylist";
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
import { pushLayer, removeLayer, anyLayerAbove, escapeHandled } from "../lib/layerStack";
import DownloadButton from "./DownloadButton";
import { api, type Video, type VideoStatus } from "../lib/api";
import type { YTPlayer } from "../lib/youtube";
import {
channelYouTubeUrl,
formatDate,
formatDuration,
formatViews,
relativeTime,
} from "../lib/format";
import { renderDescription } from "../lib/descriptionLinks";
import { fullscreenElement } from "../lib/fullscreen";
import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap";
import { closeThenNavigate, useBackToClose } from "../lib/history";
import ChannelNameLink from "./ChannelNameLink";
import { afterRemoval } from "../lib/playerQueue";
import {
useNativeControlsYield,
useYtBigView,
useYtProgress,
useYtVolume,
} from "../lib/playerModalHooks";
import { useScrollFade } from "../lib/useScrollFade";
// 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 `<body>`: 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;
}
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<boolean>;
// 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 { openChannel } = useNavigationActions();
const qc = useQueryClient();
const descFade = 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 the playing 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<string>(
// 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.
const sessionPosRef = useRef<Map<string, number>>(new Map());
const sessionPos = sessionPosRef.current.get(active.id);
// startAt only applies to the item we opened with; advanced items resume their own pos. A
// position this session already stored wins over both — it is the freshest truth we have.
const resumeAt =
sessionPos ??
(startAt != null && active.id === video.id ? startAt : active.position_seconds || 0);
const mountRef = useRef<HTMLDivElement | null>(null);
const playerRef = useRef<YTPlayer | null>(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<HTMLDivElement | null>(null);
const stageRef = useRef<HTMLDivElement | null>(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<HTMLDivElement | null>(null);
const focusModal = () => cardRef.current?.focus();
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,
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.
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);
// 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<number | null>(null);
const loadVideo = (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 ? 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.
const [status, setStatus] = useState(active.status);
const watched = status === "watched";
const setWatched = (on: boolean) => {
const next = on ? "watched" : "new";
setStatus(next);
onState(active.id, next);
};
// On queue advance (active changes), sync local state to the new item.
useEffect(() => {
setStatus(active.status);
setCurrentVideoId(active.id);
currentIdRef.current = active.id;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active.id]);
const seekTo = (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 = useRef({ queue, index });
navRef.current = { 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 (confirm + API + caches); 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 while the confirm/request is in flight.
const [removing, setRemoving] = useState(false);
const removeFromList = async () => {
if (!onRemoveFromList || removing) return;
const victim = active;
setRemoving(true);
let done = false;
try {
done = await onRemoveFromList(victim);
} finally {
setRemoving(false);
}
if (!done) return; // cancelled at the confirm, or the request failed — leave playback alone
const next = afterRemoval(queue, index, 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<AutoMode>("playerAutoAdvance", "off");
const [loopMode, setLoopMode] = usePref<LoopMode>("playerLoop", "off");
const modeRef = useRef({ autoMode, loopMode });
modeRef.current = { 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 the title is hovered). Anchored ABOVE the title so it grows
// upward over the player rather than downward off-screen / behind the OS taskbar — `flip` still
// rescues it when there is no room up there either, which the old fixed `bottom` could not. The
// hover-intent delay (only pop it if the pointer lingers, so a quick pass doesn't flash the card)
// and the travel grace are now the platform's, along with the safePolygon bridge that replaces the
// pair of hand-managed timers.
const [showDesc, setShowDesc] = useState(false);
const descPop = useAnchoredPopover({
open: showDesc,
onOpenChange: setShowDesc,
mode: "hover",
placement: "top-start",
offset: 8,
hoverDelay: { open: 400, close: 150 },
});
const detail = useQuery({
queryKey: qk.videoDetail(currentVideoId),
queryFn: () => api.videoDetail(currentVideoId),
// On hover (for the description) and eagerly when navigated, so we can link the
// linked video's channel. Both share the one cached result.
enabled: showDesc || navigated,
staleTime: 5 * 60_000,
});
// This player is a dismissable layer: register it so a Modal or popover opened ABOVE it (e.g. the
// download dialog, or — after S4 — an anchored menu) takes Escape first and this player defers.
const layerIdRef = useRef<number | null>(null);
useEffect(() => {
const id = pushLayer();
layerIdRef.current = id;
return () => {
removeLayer(id);
layerIdRef.current = null;
};
}, []);
useKeyScope("ytPlayer");
// Keyboard shortcuts + background scroll lock. 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).
useEffect(() => {
// 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 || maximizedRef.current;
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);
};
const onKey = (e: KeyboardEvent) => {
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 (maximizedRef.current) {
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();
},
});
};
window.addEventListener("keydown", onKey);
focusModal();
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow;
// (The debounced volume write is flushed by useYtVolume's own unmount cleanup.)
};
}, [onClose]);
// 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,
});
// Portaled to <body>, 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(
<div
ref={dialogRef}
data-testid="player-modal"
className="fixed inset-0 z-overlay flex items-center justify-center p-4 sm:p-6 bg-black/80 overlay-backdrop"
onClick={onClose}
role="dialog"
aria-modal="true"
>
{/* The card, flanked by full-height glassy strips that step through the feed's order (or a
playlist). Each strip sits just outside the card, spans its full height, and fades out at
the ends. stopPropagation so a click steps instead of closing. Hidden on narrow screens
where there's no room beside the card. */}
<div className="relative flex w-full max-w-4xl max-h-full">
{hasQueue && (
<button
onClick={(e) => {
e.stopPropagation();
goPrev();
}}
disabled={index === 0}
aria-label={t("player.previous")}
title={`${t("player.previous")} · Shift+←`}
className="absolute right-full top-0 bottom-0 mr-2 hidden w-12 place-items-center rounded-2xl bg-white/5 text-white/40 backdrop-blur-xs transition hover:bg-white/15 hover:text-white disabled:pointer-events-none disabled:opacity-0 sm:grid"
>
<ChevronLeft className="h-8 w-8" />
</button>
)}
<div
ref={cardRef}
tabIndex={-1}
className="glass-card relative flex-1 min-w-0 max-h-full overflow-y-auto rounded-2xl outline-hidden"
onClick={(e) => e.stopPropagation()}
>
<div
ref={stageRef}
className={`player-stage relative aspect-video w-full bg-black rounded-t-2xl overflow-hidden ${
maximized ? "player-stage--max" : ""
}`}
>
{/* 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. */}
<div
className={
maximized || isFullscreen ? "absolute inset-x-0 top-0 bottom-14" : "w-full h-full"
}
>
<div
ref={mountRef}
className={`w-full h-full ${playerError != null ? "invisible" : ""}`}
/>
</div>
{/* 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.
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 && (
<div
onClick={() => {
focusModal();
togglePlay();
}}
title={t("player.shortcutsHint")}
aria-label={t("player.shortcutsHint")}
className={`absolute inset-x-0 top-[12%] bottom-[22%] 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
<body> portal would not) and DOCKED in its own strip: the video area above is inset by
this bar's height (see mountRef'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. Add-to-playlist works in true fullscreen (its
popover portals INTO the fullscreen element); download stays hidden there because it opens
a Modal, which still portals to <body> — outside the fullscreen element. */}
{(maximized || isFullscreen) && (
<div
onClick={(e) => 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-neutral-950/95 px-3 text-sm text-white"
>
{hasQueue && (
<>
<PlayerTransportControls
variant="docked"
index={index}
queueLength={queue!.length}
autoMode={autoMode}
loopMode={loopMode}
onPrev={goPrev}
onNext={goNext}
onCycleAuto={cycleAuto}
onCycleLoop={cycleLoop}
/>
<span className="h-4 w-px bg-white/20" />
</>
)}
{/* Both work in fullscreen now: add-to-playlist's popover and download's Modal both
portal into the fullscreen element rather than <body> (a body-portaled node is
painted underneath the element holding the lock, i.e. invisible). */}
{!navigated && (
<AddToPlaylist
videoId={active.id}
className="inline-flex h-8 w-8 items-center justify-center rounded-lg text-white/80 hover:bg-white/10 hover:text-white transition"
/>
)}
{!navigated && (
<DownloadButton
videoId={active.id}
title={active.title}
className="inline-flex h-8 w-8 items-center justify-center rounded-lg text-white/80 hover:bg-white/10 hover:text-white transition"
/>
)}
{!navigated && (
<button
onClick={() => setWatched(!watched)}
title={watched ? t("player.watchedUnmark") : t("player.markWatched")}
className={`inline-flex items-center gap-1 rounded-lg px-2 py-1 transition ${
watched
? "bg-white/25 text-white"
: "text-white/70 hover:bg-white/10 hover:text-white"
}`}
>
{watched ? <CheckCheck className="h-4 w-4" /> : <Check className="h-4 w-4" />}
<span className="hidden md:inline">
{watched ? t("player.watched") : t("player.markWatched")}
</span>
</button>
)}
<span className="h-4 w-px bg-white/20" />
<button
onClick={toggleTrueFullscreen}
title={isFullscreen ? t("player.exitFullscreen") : t("player.fullscreen")}
aria-label={isFullscreen ? t("player.exitFullscreen") : t("player.fullscreen")}
className="inline-flex h-8 w-8 items-center justify-center rounded-lg text-white/80 hover:bg-white/10 hover:text-white transition"
>
{isFullscreen ? (
<Minimize className="h-4 w-4" />
) : (
<Maximize className="h-4 w-4" />
)}
</button>
<button
onClick={(e) => {
e.stopPropagation();
exitBigView();
setNativeControls(false);
focusModal();
}}
title={t("player.exitMaximize")}
aria-label={t("player.exitMaximize")}
className="inline-flex items-center gap-1 rounded-lg px-2 py-1 text-white/80 hover:bg-white/10 hover:text-white transition"
>
<Minimize2 className="h-4 w-4" />
<span className="hidden sm:inline">{t("player.exitMaximize")}</span>
</button>
</div>
)}
{/* Discreet badge while YouTube's own controls have taken over (overlay yielded). */}
{playerError == null && nativeControls && (
<div className="pointer-events-none absolute top-3 left-3 z-menu flex items-center gap-1.5 rounded-full bg-black/60 px-2.5 py-1 text-2xs text-white/90 shadow-lg backdrop-blur-xs">
<Settings className="h-3.5 w-3.5" />
{t("player.nativeControls")}
</div>
)}
{/* Volume level flash (auto-fades). pointer-events-none so it never blocks the video. */}
{volumeUi != null && (
<div className="pointer-events-none absolute top-3 left-1/2 -translate-x-1/2 z-menu flex items-center gap-2 rounded-full bg-black/70 px-3 py-1.5 text-xs text-white shadow-lg">
{volumeUi === 0 ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
<div className="h-1.5 w-28 overflow-hidden rounded-full bg-white/25">
<div
className="h-full rounded-full bg-white transition-all"
style={{ width: `${volumeUi}%` }}
/>
</div>
<span className="w-7 text-right tabular-nums">{volumeUi}</span>
</div>
)}
{playerError != null && (
<div className="absolute inset-0 grid place-items-center bg-bg p-6 text-center">
<div className="max-w-sm">
<AlertTriangle className="w-8 h-8 text-warn mx-auto mb-3" />
<h3 className="text-base font-semibold">{t("player.unavailableTitle")}</h3>
<p className="text-sm text-muted mt-1.5 mb-4">
{playerError === 101 || playerError === 150
? t("player.embedDisabledBody")
: t("player.unavailableBody")}
</p>
<a
href={`https://www.youtube.com/watch?v=${currentVideoId}`}
target="_blank"
rel="noreferrer"
className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-xl font-semibold btn-primary transition"
>
<ExternalLink className="w-4 h-4" />
{t("player.openOnYoutube")}
</a>
</div>
</div>
)}
</div>
{hasQueue && (
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1.5 px-4 py-2 border-b border-border text-sm">
<PlayerTransportControls
variant="modal"
index={index}
queueLength={queue!.length}
autoMode={autoMode}
loopMode={loopMode}
onPrev={goPrev}
onNext={goNext}
onCycleAuto={cycleAuto}
onCycleLoop={cycleLoop}
/>
</div>
)}
<div className="p-4 sm:p-5">
{/* Title row — title (with hover description) on the left, Close on the right. */}
<div className="flex items-start gap-3">
<h2 className="min-w-0 flex-1 text-lg font-semibold leading-snug">
{/* Hover target is the text itself (inline), not the whole row. */}
<span
ref={descPop.refs.setReference}
{...descPop.getReferenceProps()}
className="cursor-default"
>
{navigated ? (liveData?.title ?? t("player.loading")) : active.title}
</span>
</h2>
{navigated && (
<button
onClick={() => loadVideo(active.id, null)}
title={t("player.backToOriginal")}
className="shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-surface transition"
>
<ArrowLeft className="w-4 h-4" />
{t("player.back")}
</button>
)}
<PopoverSurface
popover={descPop}
className="w-[min(35rem,calc(100vw-2rem))] bg-surface border border-border rounded-xl shadow-2xl p-4"
>
<div className="text-xs uppercase tracking-wide text-muted mb-2">
{t("player.description")}
</div>
{detail.isLoading ? (
<div className="text-sm text-muted">{t("player.loading")}</div>
) : detail.data?.description ? (
<div
ref={descFade.ref}
style={descFade.style}
className="text-sm whitespace-pre-wrap wrap-break-word max-h-64 overflow-y-auto no-scrollbar leading-relaxed"
>
{renderDescription(detail.data.description, {
currentId: currentVideoId,
onSeek: seekTo,
onLoadVideo: loadVideo,
t,
})}
</div>
) : (
<div className="text-sm text-muted">{t("player.noDescription")}</div>
)}
</PopoverSurface>
<button
data-testid="player-close"
onClick={onClose}
title={t("player.closeEsc")}
className="shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-surface transition"
>
{t("player.close")}
<X className="w-4 h-4" />
</button>
</div>
{/* Channel + meta on one line, with the watched toggle 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. */}
<div className="flex items-center gap-3 mt-3">
{!navigated && (
<Avatar
src={active.channel_thumbnail}
fallback={active.channel_title ?? ""}
className="w-9 h-9 rounded-full shrink-0"
/>
)}
{navigated ? (
detail.data?.channel_id ? (
<a
href={channelYouTubeUrl(detail.data.channel_id)}
target="_blank"
rel="noreferrer"
className="font-medium hover:text-accent shrink-0"
>
{liveData?.author ?? detail.data.channel_title ?? t("player.channel")}
</a>
) : (
<span className="font-medium shrink-0">{liveData?.author ?? ""}</span>
)
) : (
<div className="flex items-center gap-1.5 shrink-0 min-w-0">
<ChannelNameLink
channelId={active.channel_id}
title={active.channel_title}
onOpenInApp={() => {
const cid = active.channel_id;
const cname = active.channel_title ?? undefined;
// Close first, open the channel only once this player's own Back-to-close
// entry has been reclaimed — otherwise that reclaim undoes the push and
// clicking the name merely closes the player. The rule lives in history.ts
// now; the command palette needs exactly the same one.
closeThenNavigate(onClose, () => openChannel(cid, cname));
}}
className="font-medium hover:text-accent truncate text-left"
/>
<a
href={active.channel_url}
target="_blank"
rel="noreferrer"
title={t("channels.row.openOnYouTube")}
aria-label={t("channels.row.openOnYouTube")}
className="text-muted hover:text-accent shrink-0"
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
</div>
)}
{!navigated ? (
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted min-w-0">
{active.view_count != null && (
<span>
·{" "}
{t("player.views", {
count: active.view_count,
formattedCount: formatViews(active.view_count),
})}
</span>
)}
<span>
· {relativeTime(active.published_at)}
{active.published_at && ` · ${fullDate(active.published_at)}`}
</span>
{active.duration_seconds != null && (
<span>· {formatDuration(active.duration_seconds)}</span>
)}
{active.live_status === "was_live" && (
<span className="bg-accent text-accent-fg text-3xs font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded-sm">
{t("player.stream")}
</span>
)}
</div>
) : (
// Linked video's stats come from the (already-fetched) video detail.
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted min-w-0">
{detail.data?.view_count != null && (
<span>
·{" "}
{t("player.views", {
count: detail.data.view_count,
formattedCount: formatViews(detail.data.view_count),
})}
</span>
)}
{detail.data?.published_at && (
<span>
· {relativeTime(detail.data.published_at)} ·{" "}
{fullDate(detail.data.published_at)}
</span>
)}
{detail.data?.duration_seconds != null && (
<span>· {formatDuration(detail.data.duration_seconds)}</span>
)}
</div>
)}
{/* 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 && (
<button
onClick={toggleFullscreen}
title={`${t("player.fullscreen")} · F`}
aria-label={t("player.fullscreen")}
className="ml-auto shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg hover:bg-surface border border-border transition"
>
<Maximize className="w-4 h-4" />
</button>
)}
{!navigated && (
<AddToPlaylist
videoId={active.id}
className="shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg hover:bg-surface border border-border transition"
/>
)}
{!navigated && (
<DownloadButton
videoId={active.id}
title={active.title}
className="shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg hover:bg-surface border border-border transition"
/>
)}
{!navigated && onRemoveFromList && (
<button
onClick={() => void removeFromList()}
disabled={removing}
title={t("playlists.removeItem")}
aria-label={t("playlists.removeItem")}
className="shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-danger hover:bg-surface border border-border transition disabled:opacity-50"
>
<Trash2 className="w-4 h-4" />
</button>
)}
{!navigated && (
<button
onClick={() => setWatched(!watched)}
title={watched ? t("player.watchedUnmark") : t("player.markWatched")}
className={
"shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg transition " +
(watched
? "btn-primary shadow-xs"
: "text-muted hover:text-fg hover:bg-surface border border-border")
}
>
{watched ? <CheckCheck className="w-4 h-4" /> : <Check className="w-4 h-4" />}
{watched ? t("player.watched") : t("player.markWatched")}
</button>
)}
</div>
</div>
</div>
{hasQueue && (
<button
onClick={(e) => {
e.stopPropagation();
goNext();
}}
disabled={index >= queue!.length - 1}
aria-label={t("player.next")}
title={`${t("player.next")} · Shift+→`}
className="absolute left-full top-0 bottom-0 ml-2 hidden w-12 place-items-center rounded-2xl bg-white/5 text-white/40 backdrop-blur-xs transition hover:bg-white/15 hover:text-white disabled:pointer-events-none disabled:opacity-0 sm:grid"
>
<ChevronRight className="h-8 w-8" />
</button>
)}
</div>
</div>,
document.body,
);
}