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",