The player names what it plays in its own history entry (watch, wrel, wtab): a reload or a
bookmark reopens it on the page that hosts it, with that page's list as the queue, or alone
from the catalog (GET /api/videos/{id}/row); a page that hosts none gets the shell's. Back
lands on the page's address without it (useBackToClose's url; ui/Modal passes it on). The
Files viewer and player do the same (fview, fplay). The Plex and channel-manager views are
always written in full, as their fallback is remembered state.
1704 lines
83 KiB
TypeScript
1704 lines
83 KiB
TypeScript
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 "../navigation/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,
|
||
ListVideo,
|
||
Maximize,
|
||
Minimize,
|
||
Minimize2,
|
||
PanelRightClose,
|
||
PanelRightOpen,
|
||
Scissors,
|
||
Settings,
|
||
SkipBack,
|
||
SkipForward,
|
||
Trash2,
|
||
Volume2,
|
||
VolumeX,
|
||
X,
|
||
} from "lucide-react";
|
||
import Avatar from "../../components/ui/Avatar";
|
||
import { useLatest, useStableCallback } from "../../lib/useLatest";
|
||
import AddToPlaylist from "../video/AddToPlaylist";
|
||
import { PopoverSurface } from "../../components/ui/AnchoredPopover";
|
||
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
|
||
import {
|
||
pushLayer,
|
||
removeLayer,
|
||
anyBlockingLayerAbove,
|
||
anyLayerAbove,
|
||
escapeHandled,
|
||
} from "../../lib/layerStack";
|
||
import { focusInNavList } from "../../lib/listNav";
|
||
import { trapTab, useDialogChrome } from "../../components/ui/dialogFocus";
|
||
import DownloadButton from "../downloads/DownloadButton";
|
||
import ClipPanel, { ClipCropFrame, type ClipCrop } from "./ClipPanel";
|
||
import { markInAt, markOutAt, openAt, type ClipRange } from "../../lib/clipRange";
|
||
import { api, type Video, type VideoStatus } from "../../lib/api";
|
||
import { markedValue, useVideoMarks } from "../../lib/progressCache";
|
||
import type { YTPlayer } from "../../lib/youtube";
|
||
import {
|
||
channelYouTubeUrl,
|
||
formatDate,
|
||
formatDuration,
|
||
formatCompact,
|
||
relativeTime,
|
||
} from "../../lib/format";
|
||
import { canFullscreenElements, fullscreenElement } from "../../lib/fullscreen";
|
||
import { dispatch, isActivationTarget, setScopeCoveredProbe, useKeyScope } from "../../lib/keymap";
|
||
import { useBackToClose } from "../../lib/history";
|
||
import { linkedPlayer, playerParamUpdates, type PlayerPanelTab } from "../../lib/playerUrlState";
|
||
import ChannelNameLink from "../video/ChannelNameLink";
|
||
import Toaster from "../../components/ui/Toaster";
|
||
import { useOverlayOrder } from "../../components/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 "../../components/ui/Button";
|
||
import { IconButton } from "../../components/ui/IconButton";
|
||
import { SURFACE } from "../../components/ui/surfaces";
|
||
import { IconLink, LinkButton } from "../../components/ui/LinkButton";
|
||
import { SectionLabel } from "../../components/ui/Headings";
|
||
import { useMediaQuery } from "../../lib/useMediaQuery";
|
||
import { useMe } from "../../lib/useMe";
|
||
import Modal from "../../components/ui/Modal";
|
||
import Tabs from "../../components/ui/Tabs";
|
||
import { useTabPanel } from "../../components/ui/tabPanel";
|
||
import { RelatedVideos } from "./RelatedVideos";
|
||
|
||
// 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. 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 = (
|
||
<span aria-hidden="true" data-meta-sep="">
|
||
·{" "}
|
||
</span>
|
||
);
|
||
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";
|
||
|
||
// The watched toggle's own word on an item: what it last asked for (`seq`, its write), and when — its
|
||
// click, then the server's last answer to any of its writes.
|
||
type OwnMark = { status: VideoStatus; seq: number; at: number };
|
||
|
||
// One of the toggle's writes was answered. Its answer has just come back as a session mark too (the
|
||
// write marks before it resolves), so the toggle's word is stamped after it and outranks it; a later
|
||
// mark from elsewhere still outranks the toggle. The latest write refused, the toggle's word is
|
||
// dropped and the item shows what the server holds.
|
||
function settled(marks: Record<string, OwnMark>, id: string, seq: number, ok: boolean) {
|
||
const own = marks[id];
|
||
if (!own) return marks;
|
||
if (!ok && own.seq === seq) {
|
||
const rest = { ...marks };
|
||
delete rest[id];
|
||
return rest;
|
||
}
|
||
return { ...marks, [id]: { ...own, at: Date.now() } };
|
||
}
|
||
|
||
export default function PlayerModal({
|
||
video,
|
||
startAt,
|
||
queue: queueProp,
|
||
startIndex,
|
||
onClose,
|
||
onState,
|
||
onWrite,
|
||
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;
|
||
/** The player's own marks (its watched toggle, the auto-mark at the end), for the item it is on —
|
||
* named by `video`, which may be none of the host's (a related video played in place). Returns
|
||
* the write: the toggle weighs its own word at the server's answer, and drops a refused one. */
|
||
onState: (id: string, status: VideoStatus, video?: Video) => Promise<unknown>;
|
||
/** A write the related list made itself, for a host that refetches once the player closes and its
|
||
* writes have landed (the feed's player session). */
|
||
onWrite?: (write: Promise<unknown>) => 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 { ref: descFadeRef, style: descFadeStyle } = useScrollFade();
|
||
// 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.
|
||
// 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<Map<string, number>>(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;
|
||
// A related video played in place (the owner's pick) is the item it now is: its progress is
|
||
// checkpointed, it is marked watched at its end, and its own watched / playlist / download
|
||
// controls show — while the queue stays where it was, and Back returns to the opened item. It is
|
||
// the SUBJECT of the player from here down; `active` stays the queue's item, which the related
|
||
// list, the counter and prev/next belong to. A queue step leaves it.
|
||
// `startAt` null resumes it where it was left (this session's stored position first); a number is
|
||
// where the user asked it to start — Restart's 0 — and beats that position.
|
||
const [related, setRelated] = useState<{ video: Video; startAt: number | null } | null>(null);
|
||
const [relatedOf, setRelatedOf] = useState(active.id);
|
||
if (relatedOf !== active.id) {
|
||
setRelatedOf(active.id);
|
||
setRelated(null);
|
||
}
|
||
// An address that names the item this player opens on (a reload, a bookmark) names its view too: the
|
||
// related video playing in place over it and the panel's tab come back with it. Read once.
|
||
const [linked] = useState(() => {
|
||
const here = linkedPlayer();
|
||
return here && here.watch === video.id ? here : null;
|
||
});
|
||
const linkedRelated = useQuery({
|
||
queryKey: qk.videoRow(linked?.related ?? ""),
|
||
queryFn: () => api.videoRow(linked!.related!),
|
||
enabled: !!linked?.related,
|
||
retry: false,
|
||
});
|
||
const [relatedAdopted, setRelatedAdopted] = useState(false);
|
||
if (!relatedAdopted && linkedRelated.data) {
|
||
setRelatedAdopted(true);
|
||
// Only over the item it was named with: a step of the queue before it arrived left that one.
|
||
if (active.id === linked?.watch && !related)
|
||
setRelated({ video: linkedRelated.data, startAt: null });
|
||
}
|
||
const subject = related?.video ?? active;
|
||
const subjectResume = related
|
||
? (related.startAt ?? related.video.position_seconds ?? 0)
|
||
: resumeAt;
|
||
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 player's place on the layer stack (registered below), for what must defer to a layer above.
|
||
const layerIdRef = useRef<number | null>(null);
|
||
// What the player does on its own (turning ready, the pointer leaving the video, the window
|
||
// regaining focus, a wheel over it, leaving fullscreen) takes the focus back only when it may have
|
||
// slipped into the iframe or off the card. Taking it off a control the viewer tabbed to restarted
|
||
// their Tab from the first button. Nor while a layer that owns the keyboard is open above the
|
||
// player (the related drawer, a dialog): its focus is outside the card by design, and the window
|
||
// regaining focus pulled it onto the card behind the scrim.
|
||
const reclaimFocus = useCallback(() => {
|
||
if (layerIdRef.current != null && anyBlockingLayerAbove(layerIdRef.current)) return;
|
||
const card = cardRef.current;
|
||
const active = document.activeElement;
|
||
if (
|
||
card &&
|
||
active instanceof HTMLElement &&
|
||
active.tagName !== "IFRAME" &&
|
||
card.contains(active)
|
||
)
|
||
return;
|
||
card?.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);
|
||
// Shut by clip mode rather than by the user (owner): the clip panel and the video take its width
|
||
// while a clip is marked, and it opens again when the clip panel closes. A press on its own toggle
|
||
// in between is the user's choice and holds.
|
||
const [shutForClip, setShutForClip] = 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 <body>, where the player's keys would no longer reach it.
|
||
const panelToggleRef = useRef<HTMLButtonElement>(null);
|
||
const refocusToggle = useRef(false);
|
||
const togglePanel = (shut: boolean) => {
|
||
refocusToggle.current = true;
|
||
setPanelShut(shut);
|
||
setShutForClip(false);
|
||
};
|
||
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,
|
||
reclaimFocus,
|
||
});
|
||
const { nativeControls, setNativeControls } = useNativeControlsYield({
|
||
playerRef,
|
||
stageRef,
|
||
reclaimFocus,
|
||
});
|
||
const {
|
||
isFullscreen,
|
||
maximized,
|
||
isFullscreenRef,
|
||
toggleFullscreen,
|
||
toggleTrueFullscreen,
|
||
exitBigView,
|
||
setMaximized,
|
||
} = useYtBigView({ stageRef, playerRef, reclaimFocus });
|
||
|
||
// 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 !== subject.id;
|
||
// Clip mode: the range being marked of the video that is playing, or null. Its panel sits under
|
||
// the video, which is its preview; another video starts without one.
|
||
const [clip, setClip] = useState<ClipRange | null>(null);
|
||
// The clip's crop frame over the picture, or null for the whole picture.
|
||
const [crop, setCrop] = useState<ClipCrop | null>(null);
|
||
const [clipFor, setClipFor] = useState(currentVideoId);
|
||
if (clipFor !== currentVideoId) {
|
||
setClipFor(currentVideoId);
|
||
setClip(null);
|
||
setCrop(null);
|
||
if (shutForClip) {
|
||
setPanelShut(false);
|
||
setShutForClip(false);
|
||
}
|
||
}
|
||
const clipOpen = clip != null;
|
||
// The picture the crop frame is drawn on. The iframe never tells its video's size: a short is
|
||
// 9:16, anything else is taken as the player's 16:9, and the cut fits the frame to the real picture
|
||
// (downloads/clip) — so a 4:3 video's frame drawn over its bars loses only what lies on the bars.
|
||
const picture = !navigated && subject.is_short ? 9 / 16 : 16 / 9;
|
||
// The docked clip panel's height, measured (it wraps with the window and the text size): the video
|
||
// above it ends there.
|
||
const clipDockRef = useRef<HTMLDivElement>(null);
|
||
const [clipDockH, setClipDockH] = useState(0);
|
||
const clipDocked = clipOpen && (maximized || isFullscreen);
|
||
// Its two places are two parents (the dock is IN the stage, the card's slot under it), and a panel
|
||
// rendered in the other one is a new panel: the typed name, Loop and the focus were lost on every
|
||
// F. So it renders once, into a host node of its own, and the host moves between the two.
|
||
const [clipHost] = useState(() => document.createElement("div"));
|
||
const clipSlotRef = useRef<HTMLDivElement>(null);
|
||
// The control focused in the panel. A move is a remove and an insert, and the focus goes with
|
||
// neither — leaving the big view, React has already removed the dock (and the host in it) before
|
||
// the move below runs, so the focus is on <body> by then and only this still knows where it was.
|
||
const clipFocusRef = useRef<HTMLElement | null>(null);
|
||
useEffect(() => {
|
||
const onIn = (e: FocusEvent) => {
|
||
clipFocusRef.current = e.target instanceof HTMLElement ? e.target : null;
|
||
};
|
||
const onOut = (e: FocusEvent) => {
|
||
// Left for somewhere else (not a removal, which fires nothing).
|
||
if (e.relatedTarget instanceof Node && !clipHost.contains(e.relatedTarget))
|
||
clipFocusRef.current = null;
|
||
};
|
||
clipHost.addEventListener("focusin", onIn);
|
||
clipHost.addEventListener("focusout", onOut);
|
||
return () => {
|
||
clipHost.removeEventListener("focusin", onIn);
|
||
clipHost.removeEventListener("focusout", onOut);
|
||
};
|
||
}, [clipHost]);
|
||
useLayoutEffect(() => {
|
||
const slot = clipDocked ? clipDockRef.current : clipSlotRef.current;
|
||
if (!clipOpen || !slot || clipHost.parentElement === slot) return;
|
||
const active = document.activeElement;
|
||
const lost = active == null || active === document.body;
|
||
const focused = clipHost.contains(active) ? active : lost ? clipFocusRef.current : null;
|
||
slot.appendChild(clipHost);
|
||
if (focused instanceof HTMLElement && clipHost.contains(focused))
|
||
focused.focus({ preventScroll: true });
|
||
}, [clipOpen, clipDocked, clipHost]);
|
||
useLayoutEffect(() => {
|
||
const el = clipDockRef.current;
|
||
if (!clipDocked || !el) return;
|
||
const measure = () => setClipDockH(el.offsetHeight);
|
||
measure();
|
||
const ro = new ResizeObserver(measure);
|
||
ro.observe(el);
|
||
return () => ro.disconnect();
|
||
}, [clipDocked]);
|
||
// The player's clock, read while the panel is open: the iframe reports no time of its own.
|
||
const [clipClock, setClipClock] = useState({ now: 0, duration: 0 });
|
||
useEffect(() => {
|
||
if (!clipOpen) return;
|
||
const read = () => {
|
||
const p = playerRef.current;
|
||
if (!p || typeof p.getCurrentTime !== "function") return;
|
||
const now = p.getCurrentTime() || 0;
|
||
const duration = p.getDuration() || 0;
|
||
// Paused, the clock stands still: no new state, no re-render of the whole modal.
|
||
setClipClock((c) => (c.now === now && c.duration === duration ? c : { now, duration }));
|
||
};
|
||
read();
|
||
const iv = window.setInterval(read, 200);
|
||
return () => window.clearInterval(iv);
|
||
}, [clipOpen]);
|
||
// The scissors gets the focus back when the panel closes: the one the user can see (the docked
|
||
// bar's in the big views — the card's lies under the maximised stage, and a Space on it reopened the
|
||
// panel), else the player itself (the card's is not there in a shut wide panel or for a video opened
|
||
// from a link). Also when the panel went with its video (a queue step), not only when closed.
|
||
const clipButtonRef = useRef<HTMLButtonElement>(null);
|
||
const clipDockButtonRef = useRef<HTMLButtonElement>(null);
|
||
const refocusClip = useRef(false);
|
||
const clipOpenRef = useLatest(clipOpen);
|
||
const closeClip = () => {
|
||
refocusClip.current = true;
|
||
setClip(null);
|
||
setCrop(null);
|
||
if (shutForClip) {
|
||
setPanelShut(false);
|
||
setShutForClip(false);
|
||
}
|
||
};
|
||
// Read at the moment the panel closes: the view then decides, a later F is not a close.
|
||
const refocusAfterClip = useEffectEvent(() => {
|
||
const lost = document.activeElement == null || document.activeElement === document.body;
|
||
if (!refocusClip.current && !lost) return;
|
||
refocusClip.current = false;
|
||
const big = maximized || isFullscreen;
|
||
const target = big ? clipDockButtonRef.current : clipButtonRef.current;
|
||
if (target) target.focus();
|
||
else focusModal();
|
||
});
|
||
useEffect(() => {
|
||
if (!clipOpen) refocusAfterClip();
|
||
}, [clipOpen]);
|
||
/** Mark where the player is now (`mark` gets the range so far, or null when the panel is shut). The
|
||
* panel lives under the video in the card, and in place of the docked bar when maximised; the
|
||
* browser's fullscreen steps back to the maximised view, which has room for it. */
|
||
const markClip = (mark: (r: ClipRange | null, t: number, d: number) => ClipRange) => {
|
||
const p = playerRef.current;
|
||
if (!p || typeof p.getCurrentTime !== "function") return;
|
||
const t = p.getCurrentTime() || 0;
|
||
const d = p.getDuration() || 0;
|
||
if (d <= 0) return;
|
||
if (isFullscreen) toggleTrueFullscreen();
|
||
if (!clip && wide && !panelShut) {
|
||
setPanelShut(true);
|
||
setShutForClip(true);
|
||
}
|
||
setClipClock({ now: t, duration: d });
|
||
setClip((r) => mark(r, t, d));
|
||
};
|
||
const openClip = () => markClip((_r, t, d) => openAt(t, d));
|
||
// 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")) : subject.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<number | null>(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 item the player is on, start others at 0.
|
||
const startSeconds =
|
||
start != null
|
||
? start
|
||
: id === subject.id
|
||
? (sessionPosRef.current.get(id) ?? subjectResume)
|
||
: 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. Each mark is kept under the id it was MADE for, and that 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 late write is
|
||
// recorded for the item it belongs to (shown again if the user steps back to it) and never
|
||
// painted on the next one; unkeyed it landed last and painted the NEXT video as watched, and a
|
||
// corrective click then sent onState(next, "new") for an item that was already new.
|
||
// The newest word wins: this toggle's own mark, or one the session confirmed elsewhere since the
|
||
// player opened (the related row's own menu, a notice's Undo — `useVideoMarks`), else the item as
|
||
// it was opened. The toggle alone missed those, and its first click re-sent the same status.
|
||
// The toggle's own writes come back as session marks too, stamped at their answer, so a click
|
||
// weighed only at its own time lost to the late answer of the click before it: each answer stamps
|
||
// the toggle's word again (`settled`). A refused one is dropped, so the toggle does not keep showing
|
||
// what the server turned down.
|
||
const [marks, setMarks] = useState<Record<string, OwnMark>>({});
|
||
const ownSeq = useRef(0);
|
||
const [openedAt] = useState(() => Date.now());
|
||
const sessionMarks = useVideoMarks();
|
||
const own = marks[subject.id];
|
||
const confirmed = markedValue(sessionMarks, subject.id, "status");
|
||
const elsewhere = confirmed && confirmed.at >= openedAt ? confirmed : undefined;
|
||
const status =
|
||
own && (!elsewhere || own.at >= elsewhere.at)
|
||
? own.status
|
||
: (elsewhere?.value ?? subject.status);
|
||
const watched = status === "watched";
|
||
const setWatched = (on: boolean) => {
|
||
const id = subject.id;
|
||
const next = on ? "watched" : "new";
|
||
const seq = ++ownSeq.current;
|
||
setMarks((m) => ({ ...m, [id]: { status: next, seq, at: Date.now() } }));
|
||
Promise.resolve(onState(id, next, subject)).then(
|
||
() => setMarks((m) => settled(m, id, seq, true)),
|
||
() => setMarks((m) => settled(m, id, seq, false)),
|
||
);
|
||
};
|
||
|
||
// 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 !== subject.id) {
|
||
setSyncedTo(subject.id);
|
||
setCurrentVideoId(subject.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 = subject.id;
|
||
}, [subject.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 <body>, 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<AutoMode>("playerAutoAdvance", "off");
|
||
const [loopMode, setLoopMode] = usePref<LoopMode>("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 = () => {
|
||
// Marking a clip, the end is a place, not a finish: stepping on would drop the clip.
|
||
if (clipOpenRef.current) return;
|
||
const { autoMode: a, loopMode: l } = modeRef.current;
|
||
if (l === "one") return replayCurrent();
|
||
// A related video's end is not the queue's: the list beside it is where the next one is picked.
|
||
if (related) return;
|
||
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);
|
||
// The related videos, on demand (the owner's pick: the modal has little room to spare). Wide, they
|
||
// take the side panel's place of the description, a tab beside it; stacked, a drawer over the video
|
||
// opened from a button beside the ⓘ. The list is the OPENED video's: one played from it leaves the
|
||
// list in place, to try the next one (the owner's pick).
|
||
const [panelTab, setPanelTab] = useState<PlayerPanelTab>(linked?.panel ?? "description");
|
||
const [relatedOpen, setRelatedOpen] = useState(false);
|
||
// The drawer is the stacked layout's: going wide while it is open, the list moves into the panel's
|
||
// tab instead. Left open, it reopened unasked when the window narrowed again.
|
||
if (wide && relatedOpen) {
|
||
setRelatedOpen(false);
|
||
setPanelTab("related");
|
||
}
|
||
const panelTabs = useTabPanel();
|
||
// Browser/mouse Back closes the player instead of leaving the page. Its entry's address names what
|
||
// it plays (the queue's item, a related one over it) and the wide panel's tab, so a reload or a
|
||
// bookmark reopens it as it is; the page's own entry under it never carries them.
|
||
useBackToClose(
|
||
onClose,
|
||
playerParamUpdates({
|
||
watch: active.id,
|
||
related: related?.video.id ?? null,
|
||
panel: wide ? panelTab : "description",
|
||
}),
|
||
);
|
||
// Turned off on this instance (a daily limit of 0): no tab, no button — nothing that could only
|
||
// be refused.
|
||
const relatedOn = useMe().related_enabled;
|
||
const playRelated = (v: Video, startAt: number | null) => {
|
||
setRelatedOpen(false);
|
||
// The video already playing (its row stays in the list): a new player for the same id would
|
||
// never be built (one per video), so a Restart is a seek, and a plain pick just keeps playing.
|
||
// Unless a description link put another video in the player meanwhile (`navigated`): then the
|
||
// pick goes back to this one — the seek moved the linked video, and the pick did nothing.
|
||
if (v.id === subject.id) {
|
||
if (navigated) loadVideo(subject.id, startAt);
|
||
else if (startAt != null) seekTo(startAt);
|
||
return;
|
||
}
|
||
setRelated({ video: v, startAt });
|
||
};
|
||
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. Its root lets
|
||
// the keyboard walk a list inside it (the related videos).
|
||
useEffect(() => {
|
||
const id = pushLayer({ root: () => dialogRef.current });
|
||
layerIdRef.current = id;
|
||
return () => {
|
||
removeLayer(id);
|
||
layerIdRef.current = null;
|
||
};
|
||
}, []);
|
||
|
||
useKeyScope("ytPlayer");
|
||
// Its keys wait under a layer above it (the drawer, a dialog — see onPlayerKey), so the help sheet
|
||
// opened there must not list them.
|
||
useEffect(() => {
|
||
setScopeCoveredProbe(
|
||
"ytPlayer",
|
||
() => layerIdRef.current != null && anyBlockingLayerAbove(layerIdRef.current),
|
||
);
|
||
return () => setScopeCoveredProbe("ytPlayer", null);
|
||
}, []);
|
||
|
||
// 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 take it back on player-ready (autoplay can grab focus)
|
||
// unless the viewer has since tabbed to a control (`reclaimFocus`).
|
||
//
|
||
// 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.
|
||
// On a row of a list in the player (the related videos) the arrows walk the list instead.
|
||
const volume = (e: KeyboardEvent, up: boolean) => {
|
||
if (focusInNavList()) return;
|
||
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) => {
|
||
if (focusInNavList()) return;
|
||
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;
|
||
}
|
||
// A layer that owns the keyboard above the player (the related drawer, a menu, a dialog) has the
|
||
// keys: on its ✕ the arrows changed the volume and seeked the video behind it. Escape is the
|
||
// exception, and `close` decides for itself which layer it closes.
|
||
if (
|
||
e.key !== "Escape" &&
|
||
layerIdRef.current != null &&
|
||
anyBlockingLayerAbove(layerIdRef.current)
|
||
)
|
||
return;
|
||
dispatch(e, "ytPlayer", {
|
||
clip: () => {
|
||
if (clip) closeClip();
|
||
else openClip();
|
||
},
|
||
markIn: () => markClip(markInAt),
|
||
markOut: () => markClip(markOutAt),
|
||
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;
|
||
// The clip panel is the player's own layer: it closes before the player does, and a crop
|
||
// turns off before the panel closes.
|
||
if (clip) {
|
||
ev.preventDefault();
|
||
if (crop) setCrop(null);
|
||
else closeClip();
|
||
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: subject.id,
|
||
resumeAt: subjectResume,
|
||
exactStart: related?.startAt != null,
|
||
mountRef,
|
||
playerRef,
|
||
currentIdRef,
|
||
navRef,
|
||
modeRef,
|
||
setWatched,
|
||
setPlayerError,
|
||
setLiveData,
|
||
reapplyStored,
|
||
reclaimFocus,
|
||
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 clip panel: under the video in the card, in the docked bar's place in the big views.
|
||
const clipPanel = clip && (
|
||
<ClipPanel
|
||
src={{ source: "youtube", ref: currentVideoId }}
|
||
clip={clip}
|
||
onChange={setClip}
|
||
duration={clipClock.duration}
|
||
current={clipClock.now}
|
||
picture={picture}
|
||
crop={crop}
|
||
onCrop={setCrop}
|
||
onSeek={(time, final) => {
|
||
// A seek past what is buffered fetches from there: only once the drag settles.
|
||
playerRef.current?.seekTo?.(time, final);
|
||
setClipClock((c) => ({ ...c, now: time }));
|
||
}}
|
||
onPlayFrom={(time) => {
|
||
seekTo(time);
|
||
setClipClock((c) => ({ ...c, now: time }));
|
||
}}
|
||
onClose={closeClip}
|
||
/>
|
||
);
|
||
// 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 = (
|
||
<IconButton
|
||
variant="ghost"
|
||
data-testid="player-close"
|
||
onClick={onClose}
|
||
label={t("player.close")}
|
||
title={t("player.closeEsc")}
|
||
>
|
||
<X />
|
||
</IconButton>
|
||
);
|
||
|
||
// 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. 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). */}
|
||
<div
|
||
className="relative flex w-full max-h-full"
|
||
// The design width (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,
|
||
// whose content sits beside the video (`below` is ~0), so the fit is exact and the card, which
|
||
// does not scroll there, always holds the whole video. Clip mode is the exception: its panel
|
||
// sits under the video, so the wide card takes the stacked one's floor and scroll too.
|
||
// Stacked: never narrower than MIN_VIDEO. The chrome under the video grows as the card narrows
|
||
// (its text wraps), so on a short window fitting it was a loop that ran the card down to 2 px
|
||
// (a laptop at 100%, a 1080p screen at 175%; the wide clip panel did the same at 133% with the
|
||
// largest text). Past the floor the card scrolls instead.
|
||
style={{
|
||
maxWidth: wide
|
||
? `calc(${clipOpen ? `max(${MIN_VIDEO}, ${fitWidth})` : fitWidth} + ${panelWidth})`
|
||
: `min(56rem, max(${MIN_VIDEO}, ${fitWidth}))`,
|
||
}}
|
||
>
|
||
<div
|
||
ref={cardRef}
|
||
tabIndex={-1}
|
||
// Stacked (and wide in clip mode), the card is what scrolls (what sits under the video): the
|
||
// wheel's, while it can.
|
||
data-wheel-scrolls={wide && !clipOpen ? undefined : ""}
|
||
className={`${SURFACE.panel} relative flex-1 min-w-0 max-h-full outline-hidden ${
|
||
wide ? `flex ${clipOpen ? "overflow-y-auto" : "overflow-hidden"}` : "overflow-y-auto"
|
||
}`}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
{/* The video and, in clip mode, the clip panel under it. Always this wrapper, so opening the
|
||
panel never moves the stage to another parent (that would re-create the YouTube iframe). */}
|
||
<div className={wide ? "flex-1 min-w-0 self-start" : undefined}>
|
||
<div
|
||
ref={stageRef}
|
||
// The wheel over the video is its volume, even inside a stacked card that scrolls.
|
||
data-wheel-volume=""
|
||
className={`player-stage media-marks relative aspect-video w-full bg-black overflow-hidden ${
|
||
wide ? (clipOpen ? "rounded-tl-2xl" : "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 ${clipDocked ? "" : "bottom-14"}`
|
||
: "relative w-full h-full"
|
||
}
|
||
// Docked, the clip panel takes the bar's place: the video stands above its height.
|
||
style={clipDocked ? { bottom: clipDockH } : undefined}
|
||
>
|
||
<div
|
||
ref={mountRef}
|
||
className={`w-full h-full ${playerError != null ? "invisible" : ""}`}
|
||
/>
|
||
{/* 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) => (
|
||
<div
|
||
key={i}
|
||
data-testid="yt-click-layer"
|
||
onClick={() => {
|
||
focusModal();
|
||
togglePlay();
|
||
}}
|
||
title={t("player.shortcutsHint")}
|
||
aria-hidden="true"
|
||
style={box}
|
||
className={`absolute z-base cursor-pointer ${
|
||
nativeControls ? "pointer-events-none" : ""
|
||
}`}
|
||
/>
|
||
))}
|
||
{/* The clip's crop frame, over the picture (this box is the iframe's). Pointers pass
|
||
it outside the frame, so a click elsewhere on the video is still play / pause. */}
|
||
{crop && playerError == null && (
|
||
<ClipCropFrame picture={picture} crop={crop} onCrop={setCrop} />
|
||
)}
|
||
</div>
|
||
{/* 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 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 && <Toaster overlay order={overlayOrder} />}
|
||
{clipDocked && (
|
||
<div ref={clipDockRef} className="absolute inset-x-0 bottom-0 z-menu" />
|
||
)}
|
||
{(maximized || isFullscreen) && !clipDocked && (
|
||
<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 media-marks 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={subject.id} variant="overlay" />}
|
||
{!navigated && (
|
||
<DownloadButton videoId={subject.id} title={subject.title} variant="overlay" />
|
||
)}
|
||
{/* Clip mode here: the clip panel takes this bar's place. */}
|
||
{playerError == null && (
|
||
<IconButton
|
||
ref={clipDockButtonRef}
|
||
variant="overlay"
|
||
onClick={openClip}
|
||
label={t("clip.open")}
|
||
>
|
||
<Scissors />
|
||
</IconButton>
|
||
)}
|
||
{!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" />
|
||
{/* 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() && (
|
||
<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 === YT_API_LOAD_FAILED
|
||
? t("player.apiLoadFailedBody")
|
||
: 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>
|
||
|
||
<div ref={clipSlotRef} />
|
||
{clipPanel && createPortal(clipPanel, clipHost)}
|
||
</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: panelWidth } : undefined}
|
||
>
|
||
{wide && panelShut ? (
|
||
<div
|
||
data-testid="player-strip"
|
||
className="absolute inset-0 flex flex-col items-center gap-1 py-2"
|
||
>
|
||
{/* The dialog is named by this heading's id: the panel's own one is not rendered. */}
|
||
<h2 id={titleId} className="sr-only">
|
||
{heading}
|
||
</h2>
|
||
{closeButton}
|
||
<IconButton
|
||
ref={panelToggleRef}
|
||
variant="ghost"
|
||
onClick={() => togglePanel(false)}
|
||
label={t("player.expandPanel")}
|
||
aria-expanded={false}
|
||
>
|
||
<PanelRightOpen />
|
||
</IconButton>
|
||
{hasQueue && (
|
||
<>
|
||
<span className="flex-1" />
|
||
<IconButton
|
||
variant="ghost"
|
||
onClick={goPrev}
|
||
disabled={index === 0}
|
||
label={t("player.previous")}
|
||
title={`${t("player.previous")} · Shift+←`}
|
||
>
|
||
<SkipBack />
|
||
</IconButton>
|
||
<IconButton
|
||
variant="ghost"
|
||
onClick={goNext}
|
||
disabled={index >= queue!.length - 1}
|
||
label={t("player.next")}
|
||
title={`${t("player.next")} · Shift+→`}
|
||
>
|
||
<SkipForward />
|
||
</IconButton>
|
||
</>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div
|
||
className={wide ? "absolute inset-0 overflow-y-auto" : undefined}
|
||
data-testid="player-info"
|
||
// A panel with something to scroll takes the wheel for that alone (lib/playerModalHooks).
|
||
data-wheel-scrolls={wide ? "" : undefined}
|
||
>
|
||
{wide && (
|
||
<div className="flex items-center justify-between px-2 pt-2">
|
||
<IconButton
|
||
ref={panelToggleRef}
|
||
variant="ghost"
|
||
onClick={() => togglePanel(true)}
|
||
label={t("player.collapsePanel")}
|
||
aria-expanded
|
||
>
|
||
<PanelRightClose />
|
||
</IconButton>
|
||
{closeButton}
|
||
</div>
|
||
)}
|
||
{hasQueue && (
|
||
<div className="flex flex-wrap items-center justify-center gap-x-2 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; stacked, Close on the
|
||
right (wide, it heads the panel). */}
|
||
<div className="flex items-start gap-3">
|
||
<h2 id={titleId} className="min-w-0 flex-1 text-lg font-semibold leading-snug">
|
||
{heading}
|
||
</h2>
|
||
{!wide && (
|
||
<IconButton
|
||
ref={setDescAnchor}
|
||
{...descPop.getReferenceProps()}
|
||
variant="ghost"
|
||
aria-expanded={showDesc}
|
||
label={t("player.description")}
|
||
>
|
||
<Info />
|
||
</IconButton>
|
||
)}
|
||
{!wide && relatedOn && (
|
||
<IconButton
|
||
variant="ghost"
|
||
onClick={() => setRelatedOpen(true)}
|
||
aria-expanded={relatedOpen}
|
||
label={t("player.related.title")}
|
||
>
|
||
<ListVideo />
|
||
</IconButton>
|
||
)}
|
||
{/* Inside the card, whose click stops at its edge: a portal's events still bubble
|
||
along the React tree, and from the backdrop a click on a row closed the player. */}
|
||
{relatedOpen && !wide && (
|
||
<Modal
|
||
title={t("player.related.title")}
|
||
onClose={() => setRelatedOpen(false)}
|
||
placement="drawer"
|
||
maxWidth="max-w-sm"
|
||
initialFocus="close"
|
||
>
|
||
<RelatedVideos videoId={active.id} onPlay={playRelated} onWrite={onWrite} />
|
||
</Modal>
|
||
)}
|
||
{(navigated || related) && (
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
// One step back: from a linked video to the one it was linked from, from a
|
||
// related video to the opened item.
|
||
onClick={() => (navigated ? loadVideo(subject.id, null) : setRelated(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>
|
||
<PlayerDescription
|
||
ref={descFadeRef}
|
||
style={descFadeStyle}
|
||
className="text-sm whitespace-pre-wrap wrap-break-word max-h-64 overflow-y-auto no-scrollbar leading-relaxed"
|
||
{...descriptionProps}
|
||
/>
|
||
</PopoverSurface>
|
||
{!wide && closeButton}
|
||
</div>
|
||
|
||
{/* 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. */}
|
||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 mt-3">
|
||
{!navigated && (
|
||
<Avatar
|
||
src={subject.channel_thumbnail}
|
||
fallback={subject.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 ${wide ? "min-w-0 truncate" : "shrink-0"}`}
|
||
>
|
||
{liveData?.author ?? detail.data.channel_title ?? t("player.channel")}
|
||
</a>
|
||
) : (
|
||
<span className={`font-medium ${wide ? "min-w-0 truncate" : "shrink-0"}`}>
|
||
{liveData?.author ?? ""}
|
||
</span>
|
||
)
|
||
) : (
|
||
// 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.
|
||
<div
|
||
className={`flex items-center gap-1.5 min-w-0 ${wide ? "flex-1" : "shrink-0"}`}
|
||
>
|
||
<ChannelNameLink
|
||
channelId={subject.channel_id}
|
||
title={subject.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(subject.channel_id, subject.channel_title ?? undefined);
|
||
}}
|
||
className="font-medium hover:text-accent truncate text-left"
|
||
/>
|
||
<IconLink
|
||
href={subject.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 ${wide ? META_OWN_LINE : META_MIN}`}
|
||
>
|
||
{subject.view_count != null && (
|
||
<span>
|
||
{metaSep}
|
||
{t("player.views", {
|
||
count: subject.view_count,
|
||
formattedCount: formatCompact(subject.view_count, lang),
|
||
})}
|
||
</span>
|
||
)}
|
||
<span>
|
||
{metaSep}
|
||
{relativeTime(subject.published_at, lang, t)}
|
||
{subject.published_at && ` · ${fullDate(subject.published_at)}`}
|
||
</span>
|
||
{subject.duration_seconds != null && (
|
||
<span>
|
||
{metaSep}
|
||
{formatDuration(subject.duration_seconds)}
|
||
</span>
|
||
)}
|
||
{subject.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 ${wide ? META_OWN_LINE : META_MIN}`}
|
||
>
|
||
{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, t)} ·{" "}
|
||
{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 && (
|
||
// Narrow: the actions end the channel line. Wide: their own line in the panel.
|
||
<div className={`flex items-center gap-3 ${wide ? "flex-wrap" : "ml-auto"}`}>
|
||
<IconButton
|
||
variant="outline"
|
||
size="md"
|
||
onClick={toggleFullscreen}
|
||
label={t("player.fullscreen")}
|
||
title={`${t("player.fullscreen")} · F`}
|
||
>
|
||
<Maximize />
|
||
</IconButton>
|
||
<AddToPlaylist videoId={subject.id} variant="outline" size="md" />
|
||
<DownloadButton
|
||
videoId={subject.id}
|
||
title={subject.title}
|
||
variant="outline"
|
||
size="md"
|
||
/>
|
||
{playerError == null && (
|
||
<IconButton
|
||
ref={clipButtonRef}
|
||
variant="outline"
|
||
size="md"
|
||
pressed={clipOpen}
|
||
onClick={() => (clip ? closeClip() : openClip())}
|
||
label={t("clip.open")}
|
||
title={`${t("clip.open")} · C`}
|
||
>
|
||
<Scissors />
|
||
</IconButton>
|
||
)}
|
||
<IconButton
|
||
variant="outline"
|
||
size="md"
|
||
pressed={watched}
|
||
onClick={() => setWatched(!watched)}
|
||
label={watched ? t("player.watchedUnmark") : t("player.markWatched")}
|
||
>
|
||
{watched ? <CheckCheck /> : <Check />}
|
||
</IconButton>
|
||
{onRemoveFromList && !related && (
|
||
<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>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{wide && !relatedOn && (
|
||
<section className="mt-5" data-testid="player-description">
|
||
<SectionLabel level={3} className="mb-2">
|
||
{t("player.description")}
|
||
</SectionLabel>
|
||
<PlayerDescription
|
||
className="text-sm whitespace-pre-wrap wrap-break-word leading-relaxed"
|
||
clamp
|
||
{...descriptionProps}
|
||
/>
|
||
</section>
|
||
)}
|
||
{wide && relatedOn && (
|
||
<div className="mt-5">
|
||
{/* The class replaces Tabs' own, so it keeps the row's layout and gap with
|
||
its tighter margin. */}
|
||
<Tabs
|
||
tabs={[
|
||
{ id: "description", label: t("player.description") },
|
||
{ id: "related", label: t("player.related.title") },
|
||
]}
|
||
active={panelTab}
|
||
onChange={(id) => setPanelTab(id === "related" ? "related" : "description")}
|
||
idBase={panelTabs.idBase}
|
||
label={t("player.panelTabs")}
|
||
className="flex flex-wrap gap-1 mb-3"
|
||
/>
|
||
<div {...panelTabs.panelProps(panelTab)}>
|
||
{panelTab === "description" ? (
|
||
<section data-testid="player-description">
|
||
<PlayerDescription
|
||
className="text-sm whitespace-pre-wrap wrap-break-word leading-relaxed"
|
||
clamp
|
||
{...descriptionProps}
|
||
/>
|
||
</section>
|
||
) : (
|
||
// Out into the panel's padding: in 20rem, every rem is the title's.
|
||
<div className="-mx-3">
|
||
<RelatedVideos
|
||
videoId={active.id}
|
||
onPlay={playRelated}
|
||
onWrite={onWrite}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</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,
|
||
);
|
||
}
|