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.
This commit is contained in:
2026-11-01 03:59:17 +01:00
parent 7299e82c22
commit e9ce989998
6 changed files with 111 additions and 103 deletions
@@ -1648,6 +1648,25 @@ describe("PlayerModal — the docked bar's fullscreen", () => {
it("is left out where it cannot", () => { it("is left out where it cannot", () => {
expect(maximised(false)).toBe(1); 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<HTMLElement>(".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 // Clip mode: the panel sits under the video (the video is its preview), Escape closes the panel and
+58 -55
View File
@@ -1204,59 +1204,64 @@ export default function PlayerModal({
{(maximized || isFullscreen) && !clipDocked && ( {(maximized || isFullscreen) && !clipDocked && (
<div <div
onClick={(e) => e.stopPropagation()} onClick={(e) => 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 && ( {/* 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)
<PlayerTransportControls stays outside that part, in sight: on an iPad it had scrolled off the right. */}
variant="docked" <div className="flex min-w-0 flex-1 items-center gap-x-3 overflow-x-auto">
index={index} {hasQueue && (
queueLength={queue!.length} <>
canPrev={canPrev} <PlayerTransportControls
canNext={canNext} variant="docked"
autoMode={autoMode} index={index}
loopMode={loopMode} queueLength={queue!.length}
onPrev={goPrev} canPrev={canPrev}
onNext={goNext} canNext={canNext}
onCycleAuto={cycleAuto} autoMode={autoMode}
onCycleLoop={cycleLoop} loopMode={loopMode}
/> onPrev={goPrev}
<span className="h-4 w-px bg-white/20" /> onNext={goNext}
</> onCycleAuto={cycleAuto}
)} onCycleLoop={cycleLoop}
{/* Both work in fullscreen now: add-to-playlist's popover and download's Modal both />
<span className="h-4 w-px bg-white/20" />
</>
)}
{/* Both work in fullscreen now: add-to-playlist's popover and download's Modal both
portal into the fullscreen element rather than <body> (a body-portaled node is portal into the fullscreen element rather than <body> (a body-portaled node is
painted underneath the element holding the lock, i.e. invisible). */} painted underneath the element holding the lock, i.e. invisible). */}
{!navigated && <AddToPlaylist videoId={subject.id} variant="overlay" />} {!navigated && <AddToPlaylist videoId={subject.id} variant="overlay" />}
{!navigated && ( {!navigated && (
<DownloadButton videoId={subject.id} title={subject.title} variant="overlay" /> <DownloadButton
)} videoId={subject.id}
{/* Clip mode here: the clip panel takes this bar's place. */} title={subject.title}
{playerError == null && ( variant="overlay"
<IconButton />
ref={clipDockButtonRef} )}
variant="overlay" {/* Clip mode here: the clip panel takes this bar's place. */}
onClick={openClip} {playerError == null && (
label={t("clip.open")} <IconButton
> ref={clipDockButtonRef}
<Scissors /> variant="overlay"
</IconButton> onClick={openClip}
)} label={t("clip.open")}
{!navigated && ( >
<Button <Scissors />
variant="overlay" </IconButton>
size="sm" )}
pressed={watched} {!navigated && (
onClick={() => setWatched(!watched)} <IconButton
label={watched ? t("player.watchedUnmark") : t("player.markWatched")} variant="overlay"
icon={watched ? <CheckCheck /> : <Check />} pressed={watched}
> onClick={() => setWatched(!watched)}
<span className="hidden md:inline"> label={watched ? t("player.watchedUnmark") : t("player.markWatched")}
{watched ? t("player.watched") : t("player.markWatched")} >
</span> {watched ? <CheckCheck /> : <Check />}
</Button> </IconButton>
)} )}
<span className="h-4 w-px bg-white/20" /> </div>
<span className="h-4 w-px shrink-0 bg-white/20" />
{/* Absent where the browser cannot fullscreen an element (Brave on an iPad): there {/* 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 the window-filling view is the biggest, and YouTube's own button opens the
system player. */} system player. */}
@@ -1269,9 +1274,8 @@ export default function PlayerModal({
{isFullscreen ? <Minimize /> : <Maximize />} {isFullscreen ? <Minimize /> : <Maximize />}
</IconButton> </IconButton>
)} )}
<Button <IconButton
variant="overlay" variant="overlay"
size="sm"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
exitBigView(); exitBigView();
@@ -1279,10 +1283,9 @@ export default function PlayerModal({
focusModal(); focusModal();
}} }}
label={t("player.exitMaximize")} label={t("player.exitMaximize")}
icon={<Minimize2 />}
> >
<span className="hidden sm:inline">{t("player.exitMaximize")}</span> <Minimize2 />
</Button> </IconButton>
</div> </div>
)} )}
{/* Discreet badge while YouTube's own controls have taken over (overlay yielded). */} {/* Discreet badge while YouTube's own controls have taken over (overlay yielded). */}
@@ -60,6 +60,20 @@ describe("PlayerTransportControls", () => {
expect(loop.hasAttribute("data-lit")).toBe(false); 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) for (const variant of ["modal", "docked"] as const)
it(`${variant}: each mode's tooltip names it and the mode it is in`, () => { it(`${variant}: each mode's tooltip names it and the mode it is in`, () => {
for (const loopMode of LOOP_MODES) { for (const loopMode of LOOP_MODES) {
@@ -3,7 +3,6 @@ import { useTranslation } from "react-i18next";
import { ListEnd, ListStart, Repeat, Repeat1, Shuffle, SkipBack, SkipForward } from "lucide-react"; import { ListEnd, ListStart, Repeat, Repeat1, Shuffle, SkipBack, SkipForward } from "lucide-react";
import type { VideoDetail } from "../../lib/api"; import type { VideoDetail } from "../../lib/api";
import type { AutoMode, LoopMode } from "../../lib/playerModes"; import type { AutoMode, LoopMode } from "../../lib/playerModes";
import { Button } from "../../components/ui/Button";
import { IconButton } from "../../components/ui/IconButton"; import { IconButton } from "../../components/ui/IconButton";
import { StateMessage } from "../../components/ui/QueryState"; import { StateMessage } from "../../components/ui/QueryState";
import { ClampedText } from "../../components/ui/ClampedText"; 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. /** 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 * 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 * glass theme) and the big-view docked control bar (`variant:"docked"`, white-on-dark). Glyphs only
* docked control bar (`variant:"docked"`, white-on-dark, labels shown from a breakpoint up). What a * in both: the docked bar once spoke the names from a width up, and on an iPad its row outgrew the
* control does, its glyph and its tooltip are said once; only the primitive and the label's * screen. What a control does, its glyph and its tooltip are said once; only the look differs.
* visibility differ. Every control's tooltip carries its full name (and the shortcut, where it has * Every control's tooltip carries its full name (and the shortcut, where it has one), so a glyph
* one), so a glyph-only one still says what it does and which mode it is in. The modes are cycles, * 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 * 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 * 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. */ * wrapper (fragment vs. bordered row) stays at the call site. */
@@ -51,7 +50,8 @@ export function PlayerTransportControls({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const docked = variant === "docked"; 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 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 autoName = `${t("player.autoAdvance.label")}: ${t(`player.autoAdvance.${autoMode}`)}`;
const loopName = `${t("player.loop.label")}: ${t(`player.loop.${loopMode}`)}`; const loopName = `${t("player.loop.label")}: ${t(`player.loop.${loopMode}`)}`;
@@ -63,10 +63,6 @@ export function PlayerTransportControls({
onClick: () => void; onClick: () => void;
disabled?: boolean; disabled?: boolean;
lit?: 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[] = [ const step: Control[] = [
{ {
@@ -76,7 +72,6 @@ export function PlayerTransportControls({
title: `${t("player.previous")} · Shift+←`, title: `${t("player.previous")} · Shift+←`,
onClick: onPrev, onClick: onPrev,
disabled: !canPrev, disabled: !canPrev,
labelFrom: "sm",
}, },
{ {
key: "next", key: "next",
@@ -85,8 +80,6 @@ export function PlayerTransportControls({
title: `${t("player.next")} · Shift+→`, title: `${t("player.next")} · Shift+→`,
onClick: onNext, onClick: onNext,
disabled: !canNext, disabled: !canNext,
labelFrom: "sm",
trailing: true,
}, },
]; ];
// Persistent playback settings — cycle on click, saved to your account. // Persistent playback settings — cycle on click, saved to your account.
@@ -99,7 +92,6 @@ export function PlayerTransportControls({
title: `${autoName} · ${t("player.autoAdvance.hint")}`, title: `${autoName} · ${t("player.autoAdvance.hint")}`,
onClick: onCycleAuto, onClick: onCycleAuto,
lit: autoMode !== "off", lit: autoMode !== "off",
labelFrom: "md",
}, },
{ {
key: "loop", key: "loop",
@@ -108,39 +100,21 @@ export function PlayerTransportControls({
title: `${loopName} · ${t("player.loop.hint")}`, title: `${loopName} · ${t("player.loop.hint")}`,
onClick: onCycleLoop, onClick: onCycleLoop,
lit: loopMode !== "off", lit: loopMode !== "off",
labelFrom: "md",
}, },
]; ];
const render = (c: Control) => const render = (c: Control) => (
docked ? ( <IconButton
<Button key={c.key}
key={c.key} variant={docked ? "overlay" : undefined}
variant="overlay" onClick={c.onClick}
size="sm" disabled={c.disabled}
icon={c.trailing ? undefined : c.glyph} lit={c.lit}
onClick={c.onClick} label={c.name}
disabled={c.disabled} title={c.title}
lit={c.lit} >
label={c.name} {c.glyph}
title={c.title} </IconButton>
> );
<span className={c.labelFrom === "sm" ? "hidden sm:inline" : "hidden md:inline"}>
{c.name}
</span>
{c.trailing && c.glyph}
</Button>
) : (
<IconButton
key={c.key}
onClick={c.onClick}
disabled={c.disabled}
lit={c.lit}
label={c.name}
title={c.title}
>
{c.glyph}
</IconButton>
);
return ( return (
<> <>
{render(step[0]!)} {render(step[0]!)}
-1
View File
@@ -45,7 +45,6 @@
"channel": "Channel", "channel": "Channel",
"views": "{{formattedCount}} views", "views": "{{formattedCount}} views",
"stream": "stream", "stream": "stream",
"watched": "Watched",
"markWatched": "Mark watched", "markWatched": "Mark watched",
"watchedUnmark": "Watched — click to unmark", "watchedUnmark": "Watched — click to unmark",
"jumpToTime": "Jump to this time", "jumpToTime": "Jump to this time",
-1
View File
@@ -45,7 +45,6 @@
"channel": "Csatorna", "channel": "Csatorna",
"views": "{{formattedCount}} megtekintés", "views": "{{formattedCount}} megtekintés",
"stream": "élő adás", "stream": "élő adás",
"watched": "Megtekintve",
"markWatched": "Megtekintettnek jelöl", "markWatched": "Megtekintettnek jelöl",
"watchedUnmark": "Megtekintve — kattints a jelölés visszavonásához", "watchedUnmark": "Megtekintve — kattints a jelölés visszavonásához",
"jumpToTime": "Ugrás erre az időpontra", "jumpToTime": "Ugrás erre az időpontra",