At text size 1.3 a 242px feed thumbnail put the 63px Play 13px into the hover row (the Play grows with the root font, the picture's height does not). A picture narrower than 14rem centres the Play and the resume pair above the row; the picture box is a size container for it. The Downloads title and channel links were 21px tall inline boxes; they stand on a 24px line now (B1). The player's description says a failed load, with a retry, instead of 'No description' (both the side panel and the popover).
1139 lines
54 KiB
TypeScript
1139 lines
54 KiB
TypeScript
import {
|
||
useCallback,
|
||
useEffect,
|
||
useEffectEvent,
|
||
useId,
|
||
useLayoutEffect,
|
||
useRef,
|
||
useState,
|
||
} 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 { PlayerTransportControls } from "./playerModalControls";
|
||
import { AUTO_MODES, LOOP_MODES, type AutoMode, type LoopMode } from "../lib/playerModes";
|
||
import {
|
||
AlertTriangle,
|
||
ArrowLeft,
|
||
Check,
|
||
CheckCheck,
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
ExternalLink,
|
||
Info,
|
||
Maximize,
|
||
Minimize,
|
||
Minimize2,
|
||
Settings,
|
||
Trash2,
|
||
Volume2,
|
||
VolumeX,
|
||
X,
|
||
} from "lucide-react";
|
||
import Avatar from "./ui/Avatar";
|
||
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 { renderDescription } from "./descriptionLinks";
|
||
import { 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 { StateMessage } from "./ui/QueryState";
|
||
import { useOverlayOrder } from "./ui/overlayOrder";
|
||
import { afterRemoval } from "../lib/playerQueue";
|
||
import {
|
||
useNativeControlsYield,
|
||
useYtBigView,
|
||
useYtProgress,
|
||
useYtVolume,
|
||
} from "../lib/playerModalHooks";
|
||
import { useScrollFade } from "../lib/useScrollFade";
|
||
import { Button } from "./ui/Button";
|
||
import { IconButton } from "./ui/IconButton";
|
||
import { Pressable } from "./ui/Pressable";
|
||
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 `<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;
|
||
}
|
||
|
||
// From this window width the player's details sit in a side panel beside the video (E31). 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 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 = (
|
||
<span aria-hidden="true" data-meta-sep="">
|
||
·{" "}
|
||
</span>
|
||
);
|
||
const META_OWN_LINE = "w-full [&>span:first-child>[data-meta-sep]]:hidden";
|
||
|
||
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 lang = i18n.language;
|
||
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 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<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);
|
||
// 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 (E31 S1). `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 (E31, the owner's pick "A"): 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);
|
||
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. Until E11 this was the one
|
||
// hand-rolled shell — no accessible name, no Tab trap, no restore (deep-review round 7).
|
||
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,
|
||
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. 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 = (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 (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 <body>, outside this aria-modal player.
|
||
const removingRef = useRef(false);
|
||
const removeFromList = async () => {
|
||
if (!onRemoveFromList || removingRef.current) return;
|
||
const victim = active;
|
||
removingRef.current = true;
|
||
let done = false;
|
||
try {
|
||
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<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 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 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 — 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. 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 || 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);
|
||
};
|
||
// 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 (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();
|
||
},
|
||
});
|
||
});
|
||
|
||
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,
|
||
});
|
||
|
||
// 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"
|
||
aria-labelledby={titleId}
|
||
>
|
||
{/* The card, flanked by full-height glassy strips that step through the feed's order (or a
|
||
playlist). Each strip sits in a gutter of the card's row, spans its full height, and fades
|
||
out at the ends; the gutter keeps it on the screen (C3: outside the card, a 1024 window cut
|
||
it and a portrait tablet lost it). 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-h-full ${hasQueue ? "sm:px-14" : ""}`}
|
||
// The design width (63rem with the side strips, else 56rem), narrowed so the 16:9 video plus the
|
||
// chrome under it fits the window's height less the backdrop's padding (`p-6` = 3rem).
|
||
// Wide: no design cap — the video is as wide as the window's height allows, plus the side panel.
|
||
style={{
|
||
maxWidth: wide
|
||
? `calc((100dvh - 3rem - ${below}px) * 16 / 9 + ${SIDE_PANEL}${hasQueue ? " + 7rem" : ""})`
|
||
: `min(${hasQueue ? "63rem" : "56rem"}, calc((100dvh - 3rem - ${below}px) * 16 / 9${
|
||
hasQueue ? " + 7rem" : ""
|
||
}))`,
|
||
}}
|
||
>
|
||
{hasQueue && (
|
||
<Pressable
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
goPrev();
|
||
}}
|
||
disabled={index === 0}
|
||
aria-label={t("player.previous")}
|
||
title={`${t("player.previous")} · Shift+←`}
|
||
className="absolute left-0 top-0 bottom-0 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" />
|
||
</Pressable>
|
||
)}
|
||
<div
|
||
ref={cardRef}
|
||
tabIndex={-1}
|
||
className={`${SURFACE.panel} relative flex-1 min-w-0 max-h-full outline-hidden ${
|
||
wide ? "flex overflow-hidden" : "overflow-y-auto"
|
||
}`}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div
|
||
ref={stageRef}
|
||
className={`player-stage relative aspect-video w-full bg-black overflow-hidden ${
|
||
wide ? "flex-1 min-w-0 self-start rounded-l-2xl" : "rounded-t-2xl"
|
||
} ${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. Both of its dialogs work in true fullscreen:
|
||
add-to-playlist's popover and download's Modal portal INTO the fullscreen element. */}
|
||
{isFullscreen && <Toaster overlay order={overlayOrder} />}
|
||
{(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-media-chrome/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} variant="overlay" />}
|
||
{!navigated && (
|
||
<DownloadButton videoId={active.id} title={active.title} variant="overlay" />
|
||
)}
|
||
{!navigated && (
|
||
<Button
|
||
variant="overlay"
|
||
size="sm"
|
||
pressed={watched}
|
||
onClick={() => setWatched(!watched)}
|
||
label={watched ? t("player.watchedUnmark") : t("player.markWatched")}
|
||
icon={watched ? <CheckCheck /> : <Check />}
|
||
>
|
||
<span className="hidden md:inline">
|
||
{watched ? t("player.watched") : t("player.markWatched")}
|
||
</span>
|
||
</Button>
|
||
)}
|
||
<span className="h-4 w-px bg-white/20" />
|
||
<IconButton
|
||
variant="overlay"
|
||
onClick={toggleTrueFullscreen}
|
||
label={isFullscreen ? t("player.exitFullscreen") : t("player.fullscreen")}
|
||
>
|
||
{isFullscreen ? <Minimize /> : <Maximize />}
|
||
</IconButton>
|
||
<Button
|
||
variant="overlay"
|
||
size="sm"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
exitBigView();
|
||
setNativeControls(false);
|
||
focusModal();
|
||
}}
|
||
label={t("player.exitMaximize")}
|
||
icon={<Minimize2 />}
|
||
>
|
||
<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>
|
||
<LinkButton
|
||
href={`https://www.youtube.com/watch?v=${currentVideoId}`}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
variant="primary"
|
||
icon={<ExternalLink />}
|
||
>
|
||
{t("player.openOnYoutube")}
|
||
</LinkButton>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 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. */}
|
||
<div
|
||
className={wide ? "relative shrink-0 border-l border-border" : undefined}
|
||
style={wide ? { width: SIDE_PANEL } : undefined}
|
||
>
|
||
<div
|
||
className={wide ? "absolute inset-0 overflow-y-auto" : undefined}
|
||
data-testid="player-info"
|
||
// The wheel scrolls this panel while it can (lib/playerModalHooks), the volume elsewhere.
|
||
data-wheel-scrolls={wide ? "" : undefined}
|
||
>
|
||
{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 — the title and its ⓘ (the description) on the left, Close on the right. */}
|
||
<div className="flex items-start gap-3">
|
||
<h2 id={titleId} className="min-w-0 flex-1 text-lg font-semibold leading-snug">
|
||
{navigated ? (liveData?.title ?? t("player.loading")) : active.title}
|
||
</h2>
|
||
{!wide && (
|
||
<IconButton
|
||
ref={descPop.refs.setReference}
|
||
{...descPop.getReferenceProps()}
|
||
variant="ghost"
|
||
aria-expanded={showDesc}
|
||
label={t("player.description")}
|
||
>
|
||
<Info />
|
||
</IconButton>
|
||
)}
|
||
{navigated && (
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => loadVideo(active.id, null)}
|
||
title={t("player.backToOriginal")}
|
||
icon={<ArrowLeft />}
|
||
>
|
||
{t("player.back")}
|
||
</Button>
|
||
)}
|
||
<PopoverSurface
|
||
popover={descPop}
|
||
className={`${SURFACE.popover} w-[min(35rem,calc(100vw-2rem))] p-4`}
|
||
>
|
||
<SectionLabel level={3} className="mb-2">
|
||
{t("player.description")}
|
||
</SectionLabel>
|
||
{detail.isLoading ? (
|
||
<div className="text-sm text-muted">{t("player.loading")}</div>
|
||
) : detail.isError && !detail.data ? (
|
||
<StateMessage tone="error" compact onRetry={() => void detail.refetch()}>
|
||
{t("player.descriptionFailed")}
|
||
</StateMessage>
|
||
) : 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
|
||
variant="ghost"
|
||
size="sm"
|
||
data-testid="player-close"
|
||
onClick={onClose}
|
||
title={t("player.closeEsc")}
|
||
>
|
||
{t("player.close")}
|
||
<X />
|
||
</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 ${wide ? "flex-wrap" : ""}`}>
|
||
{!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={() => {
|
||
// 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"
|
||
/>
|
||
<IconLink
|
||
href={active.channel_url}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
label={t("channels.row.openOnYouTube")}
|
||
className="shrink-0"
|
||
>
|
||
<ExternalLink />
|
||
</IconLink>
|
||
</div>
|
||
)}
|
||
{!navigated ? (
|
||
<div
|
||
className={`flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted min-w-0 ${wide ? META_OWN_LINE : ""}`}
|
||
>
|
||
{active.view_count != null && (
|
||
<span>
|
||
{metaSep}
|
||
{t("player.views", {
|
||
count: active.view_count,
|
||
formattedCount: formatCompact(active.view_count, lang),
|
||
})}
|
||
</span>
|
||
)}
|
||
<span>
|
||
{metaSep}
|
||
{relativeTime(active.published_at, lang)}
|
||
{active.published_at && ` · ${fullDate(active.published_at)}`}
|
||
</span>
|
||
{active.duration_seconds != null && (
|
||
<span>
|
||
{metaSep}
|
||
{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 ${wide ? META_OWN_LINE : ""}`}
|
||
>
|
||
{detail.data?.view_count != null && (
|
||
<span>
|
||
{metaSep}
|
||
{t("player.views", {
|
||
count: detail.data.view_count,
|
||
formattedCount: formatCompact(detail.data.view_count, lang),
|
||
})}
|
||
</span>
|
||
)}
|
||
{detail.data?.published_at && (
|
||
<span>
|
||
{metaSep}
|
||
{relativeTime(detail.data.published_at, lang)} ·{" "}
|
||
{fullDate(detail.data.published_at)}
|
||
</span>
|
||
)}
|
||
{detail.data?.duration_seconds != null && (
|
||
<span>
|
||
{metaSep}
|
||
{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 && (
|
||
<IconButton
|
||
variant="outline"
|
||
size="md"
|
||
onClick={toggleFullscreen}
|
||
label={t("player.fullscreen")}
|
||
title={`${t("player.fullscreen")} · F`}
|
||
// Narrow: the actions end the channel line. Wide: their own line in the panel.
|
||
className={wide ? undefined : "ml-auto"}
|
||
>
|
||
<Maximize />
|
||
</IconButton>
|
||
)}
|
||
{!navigated && <AddToPlaylist videoId={active.id} variant="outline" size="md" />}
|
||
{!navigated && (
|
||
<DownloadButton
|
||
videoId={active.id}
|
||
title={active.title}
|
||
variant="outline"
|
||
size="md"
|
||
/>
|
||
)}
|
||
{!navigated && onRemoveFromList && (
|
||
<IconButton
|
||
variant="outline-danger"
|
||
size="md"
|
||
onClick={() => 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")}
|
||
>
|
||
<Trash2 />
|
||
</IconButton>
|
||
)}
|
||
{!navigated && (
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
pressed={watched}
|
||
onClick={() => setWatched(!watched)}
|
||
title={watched ? t("player.watchedUnmark") : t("player.markWatched")}
|
||
icon={watched ? <CheckCheck /> : <Check />}
|
||
>
|
||
{watched ? t("player.watched") : t("player.markWatched")}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
{wide && (
|
||
<section className="mt-5" data-testid="player-description">
|
||
<SectionLabel level={3} className="mb-2">
|
||
{t("player.description")}
|
||
</SectionLabel>
|
||
{detail.isLoading ? (
|
||
<div className="text-sm text-muted">{t("player.loading")}</div>
|
||
) : detail.isError && !detail.data ? (
|
||
<StateMessage tone="error" compact onRetry={() => void detail.refetch()}>
|
||
{t("player.descriptionFailed")}
|
||
</StateMessage>
|
||
) : detail.data?.description ? (
|
||
<div className="text-sm whitespace-pre-wrap wrap-break-word leading-relaxed">
|
||
{renderDescription(detail.data.description, {
|
||
currentId: currentVideoId,
|
||
onSeek: seekTo,
|
||
onLoadVideo: loadVideo,
|
||
t,
|
||
})}
|
||
</div>
|
||
) : (
|
||
<div className="text-sm text-muted">{t("player.noDescription")}</div>
|
||
)}
|
||
</section>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{hasQueue && (
|
||
<Pressable
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
goNext();
|
||
}}
|
||
disabled={index >= queue!.length - 1}
|
||
aria-label={t("player.next")}
|
||
title={`${t("player.next")} · Shift+→`}
|
||
className="absolute right-0 top-0 bottom-0 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" />
|
||
</Pressable>
|
||
)}
|
||
</div>
|
||
{/* 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 && <Toaster overlay order={overlayOrder} />}
|
||
</div>,
|
||
document.body,
|
||
);
|
||
}
|