From a6e3ef81226a6197d1afab8070bafb405d72f207 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 31 Jul 2026 03:24:49 +0200 Subject: [PATCH] feat(player): download works in fullscreen + de-dup the transport controls (R8 S5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 ``; 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. --- frontend/src/components/Modal.tsx | 14 +- frontend/src/components/PlayerModal.tsx | 250 ++++++++++++------------ 2 files changed, 137 insertions(+), 127 deletions(-) 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. */} - - + )}