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 { X } from "lucide-react";
|
||||||
import { useBackToClose } from "../lib/history";
|
import { useBackToClose } from "../lib/history";
|
||||||
import { pushLayer, removeLayer, isTopmost } from "../lib/layerStack";
|
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
|
// Small centered modal shell: backdrop + ESC + scroll-lock close. Each open modal is a layer in the
|
||||||
// modal is a layer in the shared `layerStack` so ESC only closes the topmost one — e.g. an error
|
// shared `layerStack` so ESC only closes the topmost one — e.g. an error dialog over the tag editor
|
||||||
// dialog over the tag editor dismisses just the error and returns to the editor underneath, and a
|
// dismisses just the error and returns to the editor underneath, and a player below defers to it
|
||||||
// player below defers to it (R8 S3).
|
// (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({
|
export default function Modal({
|
||||||
title,
|
title,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -23,6 +26,7 @@ export default function Modal({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// Browser/mouse Back closes the topmost modal instead of navigating away.
|
// Browser/mouse Back closes the topmost modal instead of navigating away.
|
||||||
useBackToClose(onClose);
|
useBackToClose(onClose);
|
||||||
|
const fsRoot = useFullscreenElement();
|
||||||
|
|
||||||
// Backdrop-click close must only fire when the press STARTED on the backdrop too. Otherwise a
|
// 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
|
// 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 className="px-5 pb-5">{children}</div>
|
||||||
</div>
|
</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 AUTO_MODES: AutoMode[] = ["off", "next", "prev", "random"];
|
||||||
const LOOP_MODES: LoopMode[] = ["off", "one", "all"];
|
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)?
|
/** 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
|
* 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
|
* 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 && (
|
{hasQueue && (
|
||||||
<>
|
<>
|
||||||
<button
|
<PlayerTransportControls
|
||||||
onClick={goPrev}
|
variant="docked"
|
||||||
disabled={index === 0}
|
index={index}
|
||||||
title={`${t("player.previous")} · Shift+←`}
|
queueLength={queue!.length}
|
||||||
className="inline-flex items-center gap-1 text-white/80 enabled:hover:text-white disabled:opacity-30 transition"
|
autoMode={autoMode}
|
||||||
>
|
loopMode={loopMode}
|
||||||
<SkipBack className="h-4 w-4" />
|
onPrev={goPrev}
|
||||||
<span className="hidden sm:inline">{t("player.previous")}</span>
|
onNext={goNext}
|
||||||
</button>
|
onCycleAuto={cycleAuto}
|
||||||
<span className="tabular-nums text-white/70">
|
onCycleLoop={cycleLoop}
|
||||||
{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>
|
|
||||||
<span className="h-4 w-px bg-white/20" />
|
<span className="h-4 w-px bg-white/20" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{/* Works in fullscreen now: its menu rides the popover platform, which portals into
|
{/* Both work in fullscreen now: add-to-playlist's popover and download's Modal both
|
||||||
the fullscreen element instead of <body> (a body-portaled node is painted
|
portal into the fullscreen element rather than <body> (a body-portaled node is
|
||||||
underneath the element holding the lock, i.e. invisible). */}
|
painted underneath the element holding the lock, i.e. invisible). */}
|
||||||
{!navigated && (
|
{!navigated && (
|
||||||
<AddToPlaylist
|
<AddToPlaylist
|
||||||
videoId={active.id}
|
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"
|
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
|
{!navigated && (
|
||||||
still portals to <body> — so the dialog would open invisibly. Lifting that needs
|
|
||||||
the same fullscreen-aware root in Modal. */}
|
|
||||||
{!navigated && !isFullscreen && (
|
|
||||||
<DownloadButton
|
<DownloadButton
|
||||||
videoId={active.id}
|
videoId={active.id}
|
||||||
title={active.title}
|
title={active.title}
|
||||||
@@ -1050,61 +1100,17 @@ export default function PlayerModal({
|
|||||||
|
|
||||||
{hasQueue && (
|
{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">
|
<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
|
<PlayerTransportControls
|
||||||
onClick={goPrev}
|
variant="modal"
|
||||||
disabled={index === 0}
|
index={index}
|
||||||
title={`${t("player.previous")} · Shift+←`}
|
queueLength={queue!.length}
|
||||||
className="inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition"
|
autoMode={autoMode}
|
||||||
>
|
loopMode={loopMode}
|
||||||
<SkipBack className="w-4 h-4" /> {t("player.previous")}
|
onPrev={goPrev}
|
||||||
</button>
|
onNext={goNext}
|
||||||
<span className="text-muted tabular-nums">
|
onCycleAuto={cycleAuto}
|
||||||
{index + 1} / {queue!.length}
|
onCycleLoop={cycleLoop}
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user