diff --git a/frontend/src/components/Modal.tsx b/frontend/src/components/Modal.tsx
index 170e3130..e6738433 100644
--- a/frontend/src/components/Modal.tsx
+++ b/frontend/src/components/Modal.tsx
@@ -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
): 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 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({
{children}
,
- document.body,
+ fsRoot ?? document.body,
);
}
diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx
index ed33fc3f..b5cda7fb 100644
--- a/frontend/src/components/PlayerModal.tsx
+++ b/frontend/src/components/PlayerModal.tsx
@@ -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 (
+ <>
+
+
+ {index + 1} / {queueLength}
+
+
+
+ {/* Persistent playback settings — cycle on click, saved to your account. */}
+
+
+ >
+ );
+}
+
/** 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 ``: the page
@@ -877,82 +979,30 @@ export default function PlayerModal({
>
{hasQueue && (
<>
-
-
- {index + 1} / {queue!.length}
-
-
-
-
-
+
>
)}
- {/* Works in fullscreen now: its menu rides the popover platform, which portals into
- the fullscreen element instead of (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 (a body-portaled node is
+ painted underneath the element holding the lock, i.e. invisible). */}
{!navigated && (
)}
- {/* Still hidden in fullscreen: this one opens a Modal, not a popover, and Modal
- still portals to — so the dialog would open invisibly. Lifting that needs
- the same fullscreen-aware root in Modal. */}
- {!navigated && !isFullscreen && (
+ {!navigated && (
-
-
- {index + 1} / {queue!.length}
-
-
-
- {/* Persistent playback settings — cycle on click, saved to your account. */}
-
-
+
)}