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:
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user