The second half of the button sweep: the remaining hand-rolled buttons in
feature components, the design lab and the components/ui composites now
stand on Button, IconButton, Pressable, Chip or DragHandle.
- Chip + DismissibleChip (filter / token / tag / suggest / ghost, 24px
floor, 44 on touch) replace five chip geometries; FilterChipGroup
renders Chips; overflow lanes measure a mirror painted by chipClass.
- The Plex control row is IconButton's player rung (36, 20px glyph,
round, overlay; 44 on touch); the YT modal bar is on the sm rung with
pressed cycle toggles and names that survive a folded label.
- New variants: IconButton ghost-accent, danger-soft; Button warn on a
new state-warn utility. The .btn-{tone} recipes and btnCls are gone.
- Fixed on the way: membership rows (collection editor, Plex playlist
add) announce checked/mixed; a one-letter tag chip was 21px wide;
descriptionLinks moved out of lib/ (it renders components).
- local/button-primitive: a raw <button>/<FocusSafeButton> outside the
control-primitive files is a lint error (contract-tested, seen red).
1014 lines
48 KiB
TypeScript
1014 lines
48 KiB
TypeScript
import { useCallback, useEffect, useEffectEvent, useId, 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,
|
|
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,
|
|
formatViews,
|
|
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 { 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";
|
|
|
|
// Experiment (branch experiment/inline-player): play the video in-app via the
|
|
// YouTube IFrame Player API (not a bare embed) so we can read playback position
|
|
// and resume where the user left off. The modal closes via the in-card Close
|
|
// button, the backdrop, or ESC (ESC only while focus is on our page, not inside
|
|
// the cross-origin player iframe — a browser security boundary we can't cross).
|
|
|
|
/** Can `el` (or an ancestor inside the modal) still scroll vertically in `dir` (-1 up, 1 down)?
|
|
* Used to leave ArrowUp/ArrowDown alone while there is content to scroll — the volume shortcut
|
|
* must not eat the only keyboard way to read a long description. Stops at `<body>`: the page
|
|
* behind the modal is `overflow:hidden` anyway. */
|
|
function canScrollBy(el: HTMLElement | null, dir: -1 | 1): boolean {
|
|
for (let node = el; node && node !== document.body; node = node.parentElement) {
|
|
const style = getComputedStyle(node);
|
|
if (!/(auto|scroll|overlay)/.test(style.overflowY)) continue;
|
|
const room =
|
|
dir < 0 ? node.scrollTop > 0 : node.scrollTop + node.clientHeight < node.scrollHeight - 1; // -1: sub-pixel heights
|
|
if (room) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export default function PlayerModal({
|
|
video,
|
|
startAt,
|
|
queue: queueProp,
|
|
startIndex,
|
|
onClose,
|
|
onState,
|
|
onRemoveFromList,
|
|
}: {
|
|
video: Video;
|
|
// Where to start the opened video: a number of seconds (0 = Restart), or null/undefined
|
|
// to resume from the server-saved position carried on the video.
|
|
startAt?: number | null;
|
|
// Optional playback queue (e.g. a playlist). When present, the player advances through
|
|
// it (auto-advance on end + prev/next), recreating per item to reuse the single-video
|
|
// resume / auto-watch / progress logic.
|
|
queue?: Video[];
|
|
startIndex?: number;
|
|
onClose: () => void;
|
|
onState: (id: string, status: VideoStatus) => void;
|
|
/** Remove the playing video from the list it was opened from (the Playlists module passes its own
|
|
* `removeItem`; the feed passes nothing — there is no list to remove it from). Returns whether it
|
|
* actually happened, because the CONSEQUENCE is the player's: its launch queue is frozen, so it
|
|
* drops the item itself and steps to the next one, closing when the list runs out. */
|
|
onRemoveFromList?: (video: Video) => Promise<boolean>;
|
|
// Open the active video's channel page in-app (closes the player first). The small external
|
|
// icon next to the name keeps the open-on-YouTube behaviour.
|
|
}) {
|
|
const { t, i18n } = useTranslation();
|
|
const { openChannel } = useNavigationActions();
|
|
const qc = useQueryClient();
|
|
const descFade = useScrollFade();
|
|
// Browser/mouse Back closes the player instead of leaving the page.
|
|
useBackToClose(onClose);
|
|
// Freeze the launch queue: the player steps through the SAME filtered + sorted list it was
|
|
// opened with, for its whole session. The live feed refetches and reorders/drops items under
|
|
// us — e.g. auto-marking each video watched near its end removes it from a filtered feed — which
|
|
// would otherwise shrink the queue mid-playback and break prev/next + Loop "All" (it could never
|
|
// wrap back to the first item once earlier items dropped out). Snapshot once at mount via
|
|
// useState's lazy initializer and ignore later prop changes. Items that turn out gone/
|
|
// non-embeddable still surface the existing playback-error overlay when reached.
|
|
//
|
|
// The setter exists for exactly ONE writer: removing 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 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 (confirm + API + caches); the player owns what happens NEXT, because it
|
|
// is the only side that knows which item is playing — its queue is frozen at mount, so the parent
|
|
// cannot tell. Step to the item that followed (clamped when the last one goes), and close when the
|
|
// list runs out.
|
|
//
|
|
// Guarded against a double press by a REF, not by disabling the button: a trigger disabled for
|
|
// the confirm's lifetime was still disabled when the dialog closed and restored focus to it — a
|
|
// no-op — so a keyboard user who cancelled 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; // cancelled at the confirm, or 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 under the confirm dialog, 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 the title is hovered). Anchored ABOVE the title so it grows
|
|
// upward over the player rather than downward off-screen / behind the OS taskbar — `flip` still
|
|
// rescues it when there is no room up there either, which the old fixed `bottom` could not. The
|
|
// hover-intent delay (only pop it if the pointer lingers, so a quick pass doesn't flash the card)
|
|
// and the travel grace are now the platform's, along with the safePolygon bridge that replaces the
|
|
// pair of hand-managed timers.
|
|
const [showDesc, setShowDesc] = useState(false);
|
|
const descPop = useAnchoredPopover({
|
|
open: showDesc,
|
|
onOpenChange: setShowDesc,
|
|
mode: "hover",
|
|
placement: "top-start",
|
|
offset: 8,
|
|
hoverDelay: { open: 400, close: 150 },
|
|
});
|
|
const detail = useQuery({
|
|
queryKey: qk.videoDetail(currentVideoId),
|
|
queryFn: () => api.videoDetail(currentVideoId),
|
|
// On hover (for the description) and eagerly when navigated, so we can link the
|
|
// linked video's channel. Both share the one cached result.
|
|
enabled: showDesc || navigated,
|
|
staleTime: 5 * 60_000,
|
|
});
|
|
|
|
// 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 just outside the card, spans its full height, and fades out at
|
|
the ends. stopPropagation so a click steps instead of closing. Hidden on narrow screens
|
|
where there's no room beside the card. */}
|
|
<div className="relative flex w-full max-w-4xl max-h-full">
|
|
{hasQueue && (
|
|
<Pressable
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
goPrev();
|
|
}}
|
|
disabled={index === 0}
|
|
aria-label={t("player.previous")}
|
|
title={`${t("player.previous")} · Shift+←`}
|
|
className="absolute right-full top-0 bottom-0 mr-2 hidden w-12 place-items-center rounded-2xl bg-white/5 text-white/40 backdrop-blur-xs transition hover:bg-white/15 hover:text-white disabled:pointer-events-none disabled:opacity-0 sm:grid"
|
|
>
|
|
<ChevronLeft className="h-8 w-8" />
|
|
</Pressable>
|
|
)}
|
|
<div
|
|
ref={cardRef}
|
|
tabIndex={-1}
|
|
className="glass-card relative flex-1 min-w-0 max-h-full overflow-y-auto rounded-2xl outline-hidden"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div
|
|
ref={stageRef}
|
|
className={`player-stage relative aspect-video w-full bg-black rounded-t-2xl overflow-hidden ${
|
|
maximized ? "player-stage--max" : ""
|
|
}`}
|
|
>
|
|
{/* Hide the iframe entirely on error so YouTube's own error screen can't bleed
|
|
through our overlay. */}
|
|
{/* In the big views the video area is inset by the control bar's height (`bottom-14`) so
|
|
the bar below never overlaps it. The inset lives on THIS wrapper, not on `mountRef`:
|
|
YT.Player REPLACES the mount element with its iframe (width/height 100% of the parent),
|
|
which would drop any class on mountRef itself — the iframe then sizes to this wrapper. */}
|
|
<div
|
|
className={
|
|
maximized || isFullscreen ? "absolute inset-x-0 top-0 bottom-14" : "w-full h-full"
|
|
}
|
|
>
|
|
<div
|
|
ref={mountRef}
|
|
className={`w-full h-full ${playerError != null ? "invisible" : ""}`}
|
|
/>
|
|
</div>
|
|
{/* Interaction layer over the CENTRE of the video: catches click (play/pause) + wheel
|
|
volume (the wheel event bubbles from here to the modal's listener — over the bare
|
|
cross-origin iframe it would be swallowed) and stops the iframe stealing keyboard focus.
|
|
It leaves the top AND bottom edges uncovered so YouTube's edge controls stay reachable.
|
|
Armed in the big views too (that is what keeps wheel volume working after F, the reported
|
|
gap) — it only yields to `nativeControls`, i.e. once the user opens YouTube's own
|
|
settings menu (focus moves into the iframe), so that menu is navigable end to end.
|
|
Hidden on error so the "Open on YouTube" CTA is clickable. */}
|
|
{playerError == null && (
|
|
<div
|
|
onClick={() => {
|
|
focusModal();
|
|
togglePlay();
|
|
}}
|
|
title={t("player.shortcutsHint")}
|
|
aria-label={t("player.shortcutsHint")}
|
|
className={`absolute inset-x-0 top-[12%] bottom-[22%] z-base cursor-pointer ${
|
|
nativeControls ? "pointer-events-none" : ""
|
|
}`}
|
|
/>
|
|
)}
|
|
{/* Our own control bar for the big views (maximise / our fullscreen), where the card's
|
|
controls are off-screen. It's IN the stage (so it survives true fullscreen, where a
|
|
<body> portal would not) and DOCKED in its own strip: the video area above is inset by
|
|
this bar's height (see mountRef's `bottom-14`), so the bar NEVER overlaps YouTube's seek
|
|
bar — that stays reachable by hovering the video's bottom edge, just above ours. Always
|
|
visible (no auto-hide) so there's no guessing whether it will appear and no fight with the
|
|
seek bar over the same pixels. stopPropagation so a click here doesn't toggle playback;
|
|
the exit button also re-arms our shortcuts. 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-neutral-950/95 px-3 text-sm text-white"
|
|
>
|
|
{hasQueue && (
|
|
<>
|
|
<PlayerTransportControls
|
|
variant="docked"
|
|
index={index}
|
|
queueLength={queue!.length}
|
|
autoMode={autoMode}
|
|
loopMode={loopMode}
|
|
onPrev={goPrev}
|
|
onNext={goNext}
|
|
onCycleAuto={cycleAuto}
|
|
onCycleLoop={cycleLoop}
|
|
/>
|
|
<span className="h-4 w-px bg-white/20" />
|
|
</>
|
|
)}
|
|
{/* Both work in fullscreen now: add-to-playlist's popover and download's Modal both
|
|
portal into the fullscreen element rather than <body> (a body-portaled node is
|
|
painted underneath the element holding the lock, i.e. invisible). */}
|
|
{!navigated && <AddToPlaylist videoId={active.id} 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>
|
|
<a
|
|
href={`https://www.youtube.com/watch?v=${currentVideoId}`}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="inline-flex h-8 items-center justify-center gap-1.5 rounded-lg px-3 text-sm font-medium state-filled touch-target"
|
|
>
|
|
<ExternalLink className="size-4" />
|
|
{t("player.openOnYoutube")}
|
|
</a>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{hasQueue && (
|
|
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1.5 px-4 py-2 border-b border-border text-sm">
|
|
<PlayerTransportControls
|
|
variant="modal"
|
|
index={index}
|
|
queueLength={queue!.length}
|
|
autoMode={autoMode}
|
|
loopMode={loopMode}
|
|
onPrev={goPrev}
|
|
onNext={goNext}
|
|
onCycleAuto={cycleAuto}
|
|
onCycleLoop={cycleLoop}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div className="p-4 sm:p-5">
|
|
{/* Title row — title (with hover description) on the left, Close on the right. */}
|
|
<div className="flex items-start gap-3">
|
|
<h2 id={titleId} className="min-w-0 flex-1 text-lg font-semibold leading-snug">
|
|
{/* Hover target is the text itself (inline), not the whole row. */}
|
|
<span
|
|
ref={descPop.refs.setReference}
|
|
{...descPop.getReferenceProps()}
|
|
className="cursor-default"
|
|
>
|
|
{navigated ? (liveData?.title ?? t("player.loading")) : active.title}
|
|
</span>
|
|
</h2>
|
|
{navigated && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => loadVideo(active.id, null)}
|
|
title={t("player.backToOriginal")}
|
|
icon={<ArrowLeft />}
|
|
>
|
|
{t("player.back")}
|
|
</Button>
|
|
)}
|
|
<PopoverSurface
|
|
popover={descPop}
|
|
className="w-[min(35rem,calc(100vw-2rem))] bg-surface border border-border rounded-xl shadow-2xl p-4"
|
|
>
|
|
<div className="text-xs uppercase tracking-wide text-muted mb-2">
|
|
{t("player.description")}
|
|
</div>
|
|
{detail.isLoading ? (
|
|
<div className="text-sm text-muted">{t("player.loading")}</div>
|
|
) : detail.data?.description ? (
|
|
<div
|
|
ref={descFade.ref}
|
|
style={descFade.style}
|
|
className="text-sm whitespace-pre-wrap wrap-break-word max-h-64 overflow-y-auto no-scrollbar leading-relaxed"
|
|
>
|
|
{renderDescription(detail.data.description, {
|
|
currentId: currentVideoId,
|
|
onSeek: seekTo,
|
|
onLoadVideo: loadVideo,
|
|
t,
|
|
})}
|
|
</div>
|
|
) : (
|
|
<div className="text-sm text-muted">{t("player.noDescription")}</div>
|
|
)}
|
|
</PopoverSurface>
|
|
<Button
|
|
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">
|
|
{!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"
|
|
/>
|
|
<a
|
|
href={active.channel_url}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
title={t("channels.row.openOnYouTube")}
|
|
aria-label={t("channels.row.openOnYouTube")}
|
|
className="text-muted hover:text-accent shrink-0"
|
|
>
|
|
<ExternalLink className="w-3.5 h-3.5" />
|
|
</a>
|
|
</div>
|
|
)}
|
|
{!navigated ? (
|
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted min-w-0">
|
|
{active.view_count != null && (
|
|
<span>
|
|
·{" "}
|
|
{t("player.views", {
|
|
count: active.view_count,
|
|
formattedCount: formatViews(active.view_count),
|
|
})}
|
|
</span>
|
|
)}
|
|
<span>
|
|
· {relativeTime(active.published_at)}
|
|
{active.published_at && ` · ${fullDate(active.published_at)}`}
|
|
</span>
|
|
{active.duration_seconds != null && (
|
|
<span>· {formatDuration(active.duration_seconds)}</span>
|
|
)}
|
|
{active.live_status === "was_live" && (
|
|
<span className="bg-accent text-accent-fg text-3xs font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded-sm">
|
|
{t("player.stream")}
|
|
</span>
|
|
)}
|
|
</div>
|
|
) : (
|
|
// Linked video's stats come from the (already-fetched) video detail.
|
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted min-w-0">
|
|
{detail.data?.view_count != null && (
|
|
<span>
|
|
·{" "}
|
|
{t("player.views", {
|
|
count: detail.data.view_count,
|
|
formattedCount: formatViews(detail.data.view_count),
|
|
})}
|
|
</span>
|
|
)}
|
|
{detail.data?.published_at && (
|
|
<span>
|
|
· {relativeTime(detail.data.published_at)} ·{" "}
|
|
{fullDate(detail.data.published_at)}
|
|
</span>
|
|
)}
|
|
{detail.data?.duration_seconds != null && (
|
|
<span>· {formatDuration(detail.data.duration_seconds)}</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Reliable, discoverable path into our big view straight from the windowed player —
|
|
the docked bar's controls (incl. our own true-fullscreen ⛶) are off-screen here, and
|
|
the only fullscreen button previously reachable in the windowed video was YouTube's
|
|
own (which hides our chrome). Mirrors the F key (maximise, a CSS big view); from there
|
|
the docked bar offers OUR true fullscreen. */}
|
|
{!navigated && (
|
|
<IconButton
|
|
variant="outline"
|
|
size="md"
|
|
onClick={toggleFullscreen}
|
|
label={t("player.fullscreen")}
|
|
title={`${t("player.fullscreen")} · F`}
|
|
className="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 row control: 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>
|
|
</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 left-full top-0 bottom-0 ml-2 hidden w-12 place-items-center rounded-2xl bg-white/5 text-white/40 backdrop-blur-xs transition hover:bg-white/15 hover:text-white disabled:pointer-events-none disabled:opacity-0 sm:grid"
|
|
>
|
|
<ChevronRight className="h-8 w-8" />
|
|
</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,
|
|
);
|
|
}
|