From e9ce989998a4868b7b862867e119470c1d8e28f8 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 1 Nov 2026 03:59:17 +0100 Subject: [PATCH] feat(player): the big view's bar is glyphs only, and its way out stays in sight On an iPad the bar's labelled row outgrew the screen and its last control, leaving the big view, scrolled off the right. Every control is its glyph now, named by its label and tooltip as in the windowed player; the controls scroll on a narrow window while fullscreen and leave stay pinned right. --- .../features/player/PlayerModal.dom.test.tsx | 19 +++ frontend/src/features/player/PlayerModal.tsx | 113 +++++++++--------- .../player/playerModalControls.dom.test.tsx | 14 +++ .../features/player/playerModalControls.tsx | 66 ++++------ frontend/src/i18n/locales/en/player.json | 1 - frontend/src/i18n/locales/hu/player.json | 1 - 6 files changed, 111 insertions(+), 103 deletions(-) diff --git a/frontend/src/features/player/PlayerModal.dom.test.tsx b/frontend/src/features/player/PlayerModal.dom.test.tsx index ef439ef9..363f1f5e 100644 --- a/frontend/src/features/player/PlayerModal.dom.test.tsx +++ b/frontend/src/features/player/PlayerModal.dom.test.tsx @@ -1648,6 +1648,25 @@ describe("PlayerModal — the docked bar's fullscreen", () => { it("is left out where it cannot", () => { expect(maximised(false)).toBe(1); }); + + // On an iPad the bar's row outgrew the screen and its last control — the way out of the big + // view — scrolled off the right edge. The way out stays put; only the controls before it scroll. + it("keeps the way out in sight: glyphs only, and out of the scrolling part", () => { + maximised(true); + const out = screen.getByRole("button", { name: "player.exitMaximize" }); + const fulls = screen.getAllByLabelText("player.fullscreen"); + const full = fulls[fulls.length - 1]!; + const bar = out.parentElement!; + // The windowed row keeps its own Mark watched; this is the bar's. + const watched = screen + .getAllByRole("button", { name: "player.markWatched" }) + .find((b) => bar.contains(b))!; + for (const b of [out, full, watched]) expect(b.textContent).toBe(""); + const scroller = watched.closest(".overflow-x-auto"); + expect(scroller).not.toBeNull(); + expect(scroller!.contains(out)).toBe(false); + expect(scroller!.contains(full)).toBe(false); + }); }); // Clip mode: the panel sits under the video (the video is its preview), Escape closes the panel and diff --git a/frontend/src/features/player/PlayerModal.tsx b/frontend/src/features/player/PlayerModal.tsx index d2bcd5ba..b74bbbdb 100644 --- a/frontend/src/features/player/PlayerModal.tsx +++ b/frontend/src/features/player/PlayerModal.tsx @@ -1204,59 +1204,64 @@ export default function PlayerModal({ {(maximized || isFullscreen) && !clipDocked && (
e.stopPropagation()} - className="absolute inset-x-0 bottom-0 z-menu flex h-14 items-center justify-start gap-x-3 overflow-x-auto border-t border-white/10 bg-media-chrome/95 media-marks px-3 text-sm text-white" + className="absolute inset-x-0 bottom-0 z-menu flex h-14 items-center gap-x-3 border-t border-white/10 bg-media-chrome/95 media-marks px-3 text-sm text-white" > - {hasQueue && ( - <> - - - - )} - {/* Both work in fullscreen now: add-to-playlist's popover and download's Modal both + {/* Glyphs only, each named by its label and tooltip. The controls scroll when a + narrow window cannot hold them; the way out (fullscreen, leave the big view) + stays outside that part, in sight: on an iPad it had scrolled off the right. */} +
+ {hasQueue && ( + <> + + + + )} + {/* 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 && } - {!navigated && ( - - )} - {/* Clip mode here: the clip panel takes this bar's place. */} - {playerError == null && ( - - - - )} - {!navigated && ( - - )} - + {!navigated && } + {!navigated && ( + + )} + {/* Clip mode here: the clip panel takes this bar's place. */} + {playerError == null && ( + + + + )} + {!navigated && ( + setWatched(!watched)} + label={watched ? t("player.watchedUnmark") : t("player.markWatched")} + > + {watched ? : } + + )} +
+ {/* Absent where the browser cannot fullscreen an element (Brave on an iPad): there the window-filling view is the biggest, and YouTube's own button opens the system player. */} @@ -1269,9 +1274,8 @@ export default function PlayerModal({ {isFullscreen ? : } )} - + +
)} {/* Discreet badge while YouTube's own controls have taken over (overlay yielded). */} diff --git a/frontend/src/features/player/playerModalControls.dom.test.tsx b/frontend/src/features/player/playerModalControls.dom.test.tsx index f72b89da..dce07efd 100644 --- a/frontend/src/features/player/playerModalControls.dom.test.tsx +++ b/frontend/src/features/player/playerModalControls.dom.test.tsx @@ -60,6 +60,20 @@ describe("PlayerTransportControls", () => { expect(loop.hasAttribute("data-lit")).toBe(false); }); + // The big view's bar spoke its controls' names from a width up, and on an iPad the row outgrew the + // screen: its last control scrolled out of sight. A glyph in both places; the name is the label. + for (const variant of ["modal", "docked"] as const) + it(`${variant}: every control is its glyph alone`, () => { + mount(variant, "next", "all"); + for (const name of [ + "player.previous", + "player.next", + /^player\.autoAdvance\.label: /, + /^player\.loop\.label: /, + ]) + expect(button(name).textContent).toBe(""); + }); + for (const variant of ["modal", "docked"] as const) it(`${variant}: each mode's tooltip names it and the mode it is in`, () => { for (const loopMode of LOOP_MODES) { diff --git a/frontend/src/features/player/playerModalControls.tsx b/frontend/src/features/player/playerModalControls.tsx index 9d5c62f9..408dd602 100644 --- a/frontend/src/features/player/playerModalControls.tsx +++ b/frontend/src/features/player/playerModalControls.tsx @@ -3,7 +3,6 @@ import { useTranslation } from "react-i18next"; import { ListEnd, ListStart, Repeat, Repeat1, Shuffle, SkipBack, SkipForward } from "lucide-react"; import type { VideoDetail } from "../../lib/api"; import type { AutoMode, LoopMode } from "../../lib/playerModes"; -import { Button } from "../../components/ui/Button"; import { IconButton } from "../../components/ui/IconButton"; import { StateMessage } from "../../components/ui/QueryState"; import { ClampedText } from "../../components/ui/ClampedText"; @@ -15,11 +14,11 @@ import { renderDescription } from "./descriptionLinks"; /** The queue transport cluster — prev / counter / next, then the auto-advance and loop modes. * Rendered in two places from ONE list of controls: the modal's queue bar (`variant:"modal"`, normal - * glass theme, glyphs only so the whole cluster fits one line of the side panel) and the big-view - * docked control bar (`variant:"docked"`, white-on-dark, labels shown from a breakpoint up). What a - * control does, its glyph and its tooltip are said once; only the primitive and the label's - * visibility differ. Every control's tooltip carries its full name (and the shortcut, where it has - * one), so a glyph-only one still says what it does and which mode it is in. The modes are cycles, + * glass theme) and the big-view docked control bar (`variant:"docked"`, white-on-dark). Glyphs only + * in both: the docked bar once spoke the names from a width up, and on an iPad its row outgrew the + * screen. What a control does, its glyph and its tooltip are said once; only the look differs. + * Every control's tooltip carries its full name (and the shortcut, where it has one), so a glyph + * still says what it does and which mode it is in. The modes are cycles, * not switches: `lit` when not off, and the name says which. Auto-advance wears list glyphs of its * own — the skip arrows are Previous / Next, beside it (DESIGN.md "A glyph means one thing"). The * wrapper (fragment vs. bordered row) stays at the call site. */ @@ -51,7 +50,8 @@ export function PlayerTransportControls({ }) { const { t } = useTranslation(); const docked = variant === "docked"; - const counter = docked ? "tabular-nums text-white/70" : "text-muted tabular-nums"; + // Never broken or squeezed: in a row that scrolls, a squeezed "1 / 55" stood on three lines. + const counter = `shrink-0 whitespace-nowrap tabular-nums ${docked ? "text-white/70" : "text-muted"}`; const divider = docked ? "h-4 w-px bg-white/20" : "w-px h-4 bg-border"; const autoName = `${t("player.autoAdvance.label")}: ${t(`player.autoAdvance.${autoMode}`)}`; const loopName = `${t("player.loop.label")}: ${t(`player.loop.${loopMode}`)}`; @@ -63,10 +63,6 @@ export function PlayerTransportControls({ onClick: () => void; disabled?: boolean; lit?: boolean; - /** From which width the docked bar shows the name beside the glyph. */ - labelFrom: "sm" | "md"; - /** The glyph after the name (Next points the way it goes). */ - trailing?: boolean; }; const step: Control[] = [ { @@ -76,7 +72,6 @@ export function PlayerTransportControls({ title: `${t("player.previous")} · Shift+←`, onClick: onPrev, disabled: !canPrev, - labelFrom: "sm", }, { key: "next", @@ -85,8 +80,6 @@ export function PlayerTransportControls({ title: `${t("player.next")} · Shift+→`, onClick: onNext, disabled: !canNext, - labelFrom: "sm", - trailing: true, }, ]; // Persistent playback settings — cycle on click, saved to your account. @@ -99,7 +92,6 @@ export function PlayerTransportControls({ title: `${autoName} · ${t("player.autoAdvance.hint")}`, onClick: onCycleAuto, lit: autoMode !== "off", - labelFrom: "md", }, { key: "loop", @@ -108,39 +100,21 @@ export function PlayerTransportControls({ title: `${loopName} · ${t("player.loop.hint")}`, onClick: onCycleLoop, lit: loopMode !== "off", - labelFrom: "md", }, ]; - const render = (c: Control) => - docked ? ( - - ) : ( - - {c.glyph} - - ); + const render = (c: Control) => ( + + {c.glyph} + + ); return ( <> {render(step[0]!)} diff --git a/frontend/src/i18n/locales/en/player.json b/frontend/src/i18n/locales/en/player.json index 76783093..e2169a3f 100644 --- a/frontend/src/i18n/locales/en/player.json +++ b/frontend/src/i18n/locales/en/player.json @@ -45,7 +45,6 @@ "channel": "Channel", "views": "{{formattedCount}} views", "stream": "stream", - "watched": "Watched", "markWatched": "Mark watched", "watchedUnmark": "Watched — click to unmark", "jumpToTime": "Jump to this time", diff --git a/frontend/src/i18n/locales/hu/player.json b/frontend/src/i18n/locales/hu/player.json index c0afd43a..ddc5cf69 100644 --- a/frontend/src/i18n/locales/hu/player.json +++ b/frontend/src/i18n/locales/hu/player.json @@ -45,7 +45,6 @@ "channel": "Csatorna", "views": "{{formattedCount}} megtekintés", "stream": "élő adás", - "watched": "Megtekintve", "markWatched": "Megtekintettnek jelöl", "watchedUnmark": "Megtekintve — kattints a jelölés visszavonásához", "jumpToTime": "Ugrás erre az időpontra",