feat(player): download works in fullscreen + de-dup the transport controls (R8 S5)

Two R8 S5 (player big-view control-bar) follow-ups:

- Modal now portals into `document.fullscreenElement ?? document.body` (reactive), the
  same fullscreen-aware root the popover platform uses. A node under <body> is painted
  beneath whatever holds the fullscreen lock, so the download dialog — a Modal, opened
  from the player's control bar — would open invisibly in true fullscreen. With that
  fixed, the `!isFullscreen` guard on the player's DownloadButton is dropped, so download
  joins add-to-playlist as working in true fullscreen. (Every Modal is now fullscreen-
  aware; the fix is at the primitive, not the call site.)

- The queue transport cluster (prev / counter / next + auto-advance + loop toggles) was
  duplicated inline in two places — the modal queue bar and the big-view docked control
  bar — with identical logic and only theme + label-visibility differing. Extracted a
  `<PlayerTransportControls variant="modal"|"docked">`; a mode/icon change now lands once.
  Behaviour-preserving (the docked bar's trailing divider and the wrapper rows stay at the
  call sites). Note the modal variant keeps its `bg-accent/15` active tint, which is a
  latent no-op under Tailwind v3's hex --accent — left as-is for R10 S0's styling audit.

Gate green (tsc/eslint/76 vitest). Device-UAT'd in real Chrome: transport controls render
correctly in both the modal queue bar and the docked bar; the download button now shows in
true fullscreen and its dialog portals into the fullscreen stage, fully visible and
centered. No console errors.
This commit is contained in:
2026-07-31 03:24:49 +02:00
parent 2984aa3240
commit a6e3ef8122
2 changed files with 137 additions and 127 deletions
+9 -5
View File
@@ -4,11 +4,14 @@ import { useTranslation } from "react-i18next";
import { X } from "lucide-react";
import { useBackToClose } from "../lib/history";
import { pushLayer, removeLayer, isTopmost } from "../lib/layerStack";
import { useFullscreenElement } from "../lib/fullscreen";
// Small centered modal shell (portaled to <body>): backdrop + ESC + scroll-lock close. Each open
// modal is a layer in the shared `layerStack` so ESC only closes the topmost one — e.g. an error
// dialog over the tag editor dismisses just the error and returns to the editor underneath, and a
// player below defers to it (R8 S3).
// Small centered modal shell: backdrop + ESC + scroll-lock close. Each open modal is a layer in the
// shared `layerStack` so ESC only closes the topmost one — e.g. an error dialog over the tag editor
// dismisses just the error and returns to the editor underneath, and a player below defers to it
// (R8 S3). Portals into `document.fullscreenElement ?? document.body`: a node under <body> is painted
// beneath whatever holds the fullscreen lock, so a Modal opened from a player in true fullscreen (the
// download dialog) would otherwise open invisibly.
export default function Modal({
title,
onClose,
@@ -23,6 +26,7 @@ export default function Modal({
const { t } = useTranslation();
// Browser/mouse Back closes the topmost modal instead of navigating away.
useBackToClose(onClose);
const fsRoot = useFullscreenElement();
// Backdrop-click close must only fire when the press STARTED on the backdrop too. Otherwise a
// drag that begins inside the dialog (e.g. selecting text in an input) and is released out on
@@ -79,6 +83,6 @@ export default function Modal({
<div className="px-5 pb-5">{children}</div>
</div>
</div>,
document.body,
fsRoot ?? document.body,
);
}
+128 -122
View File
@@ -68,6 +68,108 @@ type LoopMode = "off" | "one" | "all";
const AUTO_MODES: AutoMode[] = ["off", "next", "prev", "random"];
const LOOP_MODES: LoopMode[] = ["off", "one", "all"];
/** The queue transport cluster — prev / counter / next, then the auto-advance and loop mode toggles.
* Rendered in two places with identical logic: the modal's queue bar (`variant:"modal"`, normal glass
* theme, labels always shown) and the big-view docked control bar (`variant:"docked"`, white-on-dark,
* labels hidden until a breakpoint). Only the theme + label-visibility differ; the behaviour is one,
* so a mode/icon change lands once. The wrapper (fragment vs. bordered row) stays at the call site. */
function PlayerTransportControls({
variant,
index,
queueLength,
autoMode,
loopMode,
onPrev,
onNext,
onCycleAuto,
onCycleLoop,
}: {
variant: "docked" | "modal";
index: number;
queueLength: number;
autoMode: AutoMode;
loopMode: LoopMode;
onPrev: () => void;
onNext: () => void;
onCycleAuto: () => void;
onCycleLoop: () => void;
}) {
const { t } = useTranslation();
const docked = variant === "docked";
const navBtn = docked
? "inline-flex items-center gap-1 text-white/80 enabled:hover:text-white disabled:opacity-30 transition"
: "inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition";
const navLabel = docked ? "hidden sm:inline" : "";
const counter = docked ? "tabular-nums text-white/70" : "text-muted tabular-nums";
const divider = docked ? "h-4 w-px bg-white/20" : "w-px h-4 bg-border";
const toggle = (on: boolean) =>
docked
? `inline-flex items-center gap-1 rounded-lg px-2 py-1 transition ${
on ? "bg-white/20 text-white" : "text-white/70 hover:bg-white/10 hover:text-white"
}`
: `inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 transition ${
on ? "bg-accent/15 text-accent" : "text-muted hover:bg-surface hover:text-fg"
}`;
const toggleLabel = docked ? "hidden md:inline" : "";
return (
<>
<button
onClick={onPrev}
disabled={index === 0}
title={`${t("player.previous")} · Shift+←`}
className={navBtn}
>
<SkipBack className="h-4 w-4" />
<span className={navLabel}>{t("player.previous")}</span>
</button>
<span className={counter}>
{index + 1} / {queueLength}
</span>
<button
onClick={onNext}
disabled={index === queueLength - 1}
title={`${t("player.next")} · Shift+→`}
className={navBtn}
>
<span className={navLabel}>{t("player.next")}</span>
<SkipForward className="h-4 w-4" />
</button>
<span className={divider} />
{/* Persistent playback settings — cycle on click, saved to your account. */}
<button
onClick={onCycleAuto}
title={t("player.autoAdvance.hint")}
className={toggle(autoMode !== "off")}
>
{autoMode === "prev" ? (
<SkipBack className="h-3.5 w-3.5" />
) : autoMode === "random" ? (
<Shuffle className="h-3.5 w-3.5" />
) : (
<SkipForward className="h-3.5 w-3.5" />
)}
<span className={toggleLabel}>
{t("player.autoAdvance.label")}: {t(`player.autoAdvance.${autoMode}`)}
</span>
</button>
<button
onClick={onCycleLoop}
title={t("player.loop.hint")}
className={toggle(loopMode !== "off")}
>
{loopMode === "one" ? (
<Repeat1 className="h-3.5 w-3.5" />
) : (
<Repeat className="h-3.5 w-3.5" />
)}
<span className={toggleLabel}>
{t("player.loop.label")}: {t(`player.loop.${loopMode}`)}
</span>
</button>
</>
);
}
/** 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
@@ -877,82 +979,30 @@ export default function PlayerModal({
>
{hasQueue && (
<>
<button
onClick={goPrev}
disabled={index === 0}
title={`${t("player.previous")} · Shift+←`}
className="inline-flex items-center gap-1 text-white/80 enabled:hover:text-white disabled:opacity-30 transition"
>
<SkipBack className="h-4 w-4" />
<span className="hidden sm:inline">{t("player.previous")}</span>
</button>
<span className="tabular-nums text-white/70">
{index + 1} / {queue!.length}
</span>
<button
onClick={goNext}
disabled={index === queue!.length - 1}
title={`${t("player.next")} · Shift+→`}
className="inline-flex items-center gap-1 text-white/80 enabled:hover:text-white disabled:opacity-30 transition"
>
<span className="hidden sm:inline">{t("player.next")}</span>
<SkipForward className="h-4 w-4" />
</button>
<span className="h-4 w-px bg-white/20" />
<button
onClick={cycleAuto}
title={t("player.autoAdvance.hint")}
className={`inline-flex items-center gap-1 rounded-lg px-2 py-1 transition ${
autoMode !== "off"
? "bg-white/20 text-white"
: "text-white/70 hover:bg-white/10 hover:text-white"
}`}
>
{autoMode === "prev" ? (
<SkipBack className="h-3.5 w-3.5" />
) : autoMode === "random" ? (
<Shuffle className="h-3.5 w-3.5" />
) : (
<SkipForward className="h-3.5 w-3.5" />
)}
<span className="hidden md:inline">
{t("player.autoAdvance.label")}: {t(`player.autoAdvance.${autoMode}`)}
</span>
</button>
<button
onClick={cycleLoop}
title={t("player.loop.hint")}
className={`inline-flex items-center gap-1 rounded-lg px-2 py-1 transition ${
loopMode !== "off"
? "bg-white/20 text-white"
: "text-white/70 hover:bg-white/10 hover:text-white"
}`}
>
{loopMode === "one" ? (
<Repeat1 className="h-3.5 w-3.5" />
) : (
<Repeat className="h-3.5 w-3.5" />
)}
<span className="hidden md:inline">
{t("player.loop.label")}: {t(`player.loop.${loopMode}`)}
</span>
</button>
<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" />
</>
)}
{/* Works in fullscreen now: its menu rides the popover platform, which portals into
the fullscreen element instead of <body> (a body-portaled node is painted
underneath the element holding the lock, i.e. invisible). */}
{/* Both work in fullscreen now: add-to-playlist's popover and download's Modal both
portal into the fullscreen element rather than <body> (a body-portaled node is
painted underneath the element holding the lock, i.e. invisible). */}
{!navigated && (
<AddToPlaylist
videoId={active.id}
className="inline-flex h-8 w-8 items-center justify-center rounded-lg text-white/80 hover:bg-white/10 hover:text-white transition"
/>
)}
{/* Still hidden in fullscreen: this one opens a Modal, not a popover, and Modal
still portals to <body> — so the dialog would open invisibly. Lifting that needs
the same fullscreen-aware root in Modal. */}
{!navigated && !isFullscreen && (
{!navigated && (
<DownloadButton
videoId={active.id}
title={active.title}
@@ -1050,61 +1100,17 @@ export default function PlayerModal({
{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">
<button
onClick={goPrev}
disabled={index === 0}
title={`${t("player.previous")} · Shift+←`}
className="inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition"
>
<SkipBack className="w-4 h-4" /> {t("player.previous")}
</button>
<span className="text-muted tabular-nums">
{index + 1} / {queue!.length}
</span>
<button
onClick={goNext}
disabled={index === queue!.length - 1}
title={`${t("player.next")} · Shift+→`}
className="inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition"
>
{t("player.next")} <SkipForward className="w-4 h-4" />
</button>
<span className="w-px h-4 bg-border" />
{/* Persistent playback settings — cycle on click, saved to your account. */}
<button
onClick={cycleAuto}
title={t("player.autoAdvance.hint")}
className={`inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 transition ${
autoMode !== "off"
? "bg-accent/15 text-accent"
: "text-muted hover:bg-surface hover:text-fg"
}`}
>
{autoMode === "prev" ? (
<SkipBack className="h-3.5 w-3.5" />
) : autoMode === "random" ? (
<Shuffle className="h-3.5 w-3.5" />
) : (
<SkipForward className="h-3.5 w-3.5" />
)}
{t("player.autoAdvance.label")}: {t(`player.autoAdvance.${autoMode}`)}
</button>
<button
onClick={cycleLoop}
title={t("player.loop.hint")}
className={`inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 transition ${
loopMode !== "off"
? "bg-accent/15 text-accent"
: "text-muted hover:bg-surface hover:text-fg"
}`}
>
{loopMode === "one" ? (
<Repeat1 className="h-3.5 w-3.5" />
) : (
<Repeat className="h-3.5 w-3.5" />
)}
{t("player.loop.label")}: {t(`player.loop.${loopMode}`)}
</button>
<PlayerTransportControls
variant="modal"
index={index}
queueLength={queue!.length}
autoMode={autoMode}
loopMode={loopMode}
onPrev={goPrev}
onNext={goNext}
onCycleAuto={cycleAuto}
onCycleLoop={cycleLoop}
/>
</div>
)}