feat(player): the side panel's list controls and watch toggle are glyphs on one line

The queue row (previous, count, next, auto-advance, loop) wrapped onto three lines in the
narrow side panel; glyph-only, it fits one. Mark watched joins the action icons as a toggle
(check / double check). Every control keeps its full name and shortcut as its tooltip and
accessible name; the docked bar keeps its labels. The related switch moves up to the toolbar
rung (12px), and its second side reads just "Trending".
This commit is contained in:
2026-10-25 02:43:01 +01:00
parent 5d009964d8
commit 620dd8178f
8 changed files with 114 additions and 44 deletions
+4 -2
View File
@@ -559,7 +559,8 @@ of a full-width panel and nothing on the right.
row is not a card and keeps its one play button: started, that button continues, its "…" has Continue + row is not a card and keeps its one play button: started, that button continues, its "…" has Continue +
"Play from the beginning", and its picture carries the cards' progress bar. "Play from the beginning", and its picture carries the cards' progress bar.
- **The YouTube modal:** its chrome uses the `Button` rungs (44 on touch); Previous / Next live in the panel - **The YouTube modal:** its chrome uses the `Button` rungs (44 on touch); Previous / Next live in the panel
(its transport row, or the shut panel's strip) and on Shift+← / →, with no strips beside the card. Close is (its transport row: glyphs only — Previous, the count, Next, auto-advance, loop on one line, each named in
its tooltip — or the shut panel's strip) and on Shift+← / →, with no strips beside the card. Close is
an icon at the top right of the panel (wide: its head, or its strip) and at the end of the title row an icon at the top right of the panel (wide: its head, or its strip) and at the end of the title row
under the video (stacked). Wide, the panel shows the description outright, clamped to three under the video (stacked). Wide, the panel shows the description outright, clamped to three
lines with a More (`ClampedText`; a Tab onto a link below the cut opens it), and collapses to a 2.75rem lines with a More (`ClampedText`; a Tab onto a link below the cut opens it), and collapses to a 2.75rem
@@ -576,7 +577,7 @@ of a full-width panel and nothing on the right.
now is (owner's pick D1 a): its title, its own watched / playlist / download controls, its progress now is (owner's pick D1 a): its title, its own watched / playlist / download controls, its progress
kept, marked watched at its end — and its end does not step the queue. What the user watched or hid kept, marked watched at its end — and its end does not step the queue. What the user watched or hid
is never in it; a list read so far with nothing unseen says so above Show more. Over it, a two-way is never in it; a list read so far with nothing unseen says so above Show more. Over it, a two-way
switch: on topic or Hungarian trending, kept on the account. Turned off on the instance (a daily switch: on topic or trending (the Hungarian chart, labelled just "Trending"), kept on the account. Turned off on the instance (a daily
limit of 0, owner's pick D2 a), there is no tab and no button; a refusal a retry cannot answer (the limit of 0, owner's pick D2 a), there is no tab and no button; a refusal a retry cannot answer (the
day's cap, turned off, the shared budget) offers no Retry. day's cap, turned off, the shared budget) offers no Retry.
- **Clip mode (both players, owner's picks A + P1 + a):** a scissors in the control row (Plex: the C5 row, - **Clip mode (both players, owner's picks A + P1 + a):** a scissors in the control row (Plex: the C5 row,
@@ -812,3 +813,4 @@ of a full-width panel and nothing on the right.
| 2026-10-18 | Activity page (owner D1, D2, G1–G3 ★) | One page, "Napló" / "Activity": four tabs (Runs, Admin actions, Server log, Quota) under one filter bar (text, a period of 1 h · 24 h · 7 d · 30 d · 90 d defaulting to 7 days, an account), the view in the URL; runs and admin actions as dense rows that open in place as a log line does, filtered and paged on the server; a run's or a request's log lines one pivot away, as a history entry | One merged timeline; a page per source; a default period per tab; card rows; the audit log's client-side table | Each source keeps its own table and retention, read in one place; one bar keeps what you narrowed across tabs; about 1.8k runs a day need a log's density and a server-side filter | | 2026-10-18 | Activity page (owner D1, D2, G1–G3 ★) | One page, "Napló" / "Activity": four tabs (Runs, Admin actions, Server log, Quota) under one filter bar (text, a period of 1 h · 24 h · 7 d · 30 d · 90 d defaulting to 7 days, an account), the view in the URL; runs and admin actions as dense rows that open in place as a log line does, filtered and paged on the server; a run's or a request's log lines one pivot away, as a history entry | One merged timeline; a page per source; a default period per tab; card rows; the audit log's client-side table | Each source keeps its own table and retention, read in one place; one bar keeps what you narrowed across tabs; about 1.8k runs a day need a log's density and a server-side filter |
| 2026-10-24 | Related videos (owner U1–U3 ★, D1 A, D3) | On demand: a tab beside the description (wide), a drawer (stacked); the narrow thumbnailed row, its menu alone, a followed check; plays in place and the list stays the opened video's; an on-topic / Hungarian-trending switch kept on the account | Always shown; the end-of-video grid alone; a dialog; a big card or a text row; a list that follows the playing video | The modal is full on a small screen; a picture is what a choice among strangers rests on; a list that stays put is tried one by one, and spends no more of the day's cap | | 2026-10-24 | Related videos (owner U1–U3 ★, D1 A, D3) | On demand: a tab beside the description (wide), a drawer (stacked); the narrow thumbnailed row, its menu alone, a followed check; plays in place and the list stays the opened video's; an on-topic / Hungarian-trending switch kept on the account | Always shown; the end-of-video grid alone; a dialog; a big card or a text row; a list that follows the playing video | The modal is full on a small screen; a picture is what a choice among strangers rests on; a list that stays put is tried one by one, and spends no more of the day's cap |
| 2026-10-25 | Related: one played; off (owner D1 a, D2 a) | Played from the list, it is the player's item: its title, controls and progress, marked watched at its end, the queue left where it was; a daily limit of 0 takes the tab and the button away | A linked video's in-place play (no controls, nothing kept); a tab that only shows a refusal | What is watched should count as watched, wherever it was started from; a control that can only be refused is not offered | | 2026-10-25 | Related: one played; off (owner D1 a, D2 a) | Played from the list, it is the player's item: its title, controls and progress, marked watched at its end, the queue left where it was; a daily limit of 0 takes the tab and the button away | A linked video's in-place play (no controls, nothing kept); a tab that only shows a refusal | What is watched should count as watched, wherever it was started from; a control that can only be refused is not offered |
| 2026-10-25 | Player panel glyphs (owner 1A, 2A, 3) | In the side panel the queue row is glyph-only on one line, and Mark watched is a glyph toggle among the actions (✓ / ✓✓); the docked bar keeps its labels; the related switch at the default size, "Trending" | Labelled buttons wrapping onto three lines; glyphs on the docked bar too; Mark watched on its own line | The panel is narrow and the list controls are the same few every time; each glyph keeps its full name and shortcut in its tooltip; the docked bar has the width for words |
@@ -509,6 +509,28 @@ describe("PlayerModal — no side strips", () => {
expect(row.className).not.toMatch(/px-14/); expect(row.className).not.toMatch(/px-14/);
}); });
// The panel is narrow: worded, the queue row wrapped onto three lines. Each glyph keeps its name.
it("keeps the panel's queue row and the watch toggle to glyphs, each still named", () => {
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0);
const B = vid("B", 0);
renderModal(
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
const named = [
"player.previous",
"player.next",
/^player\.autoAdvance\.label: /,
/^player\.loop\.label: /,
"player.markWatched",
];
for (const name of named) {
const [button] = screen.getAllByRole("button", { name });
expect(button!.textContent!.trim(), String(name)).toBe("");
expect(button!.querySelector("svg"), String(name)).not.toBeNull();
}
});
it("closes from one icon-only button", () => { it("closes from one icon-only button", () => {
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {})); vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const onClose = vi.fn(); const onClose = vi.fn();
+10 -11
View File
@@ -1298,7 +1298,7 @@ export default function PlayerModal({
</div> </div>
)} )}
{hasQueue && ( {hasQueue && (
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1.5 px-4 py-2 border-b border-border text-sm"> <div className="flex flex-wrap items-center justify-center gap-x-2 gap-y-1.5 px-4 py-2 border-b border-border text-sm">
<PlayerTransportControls <PlayerTransportControls
variant="modal" variant="modal"
index={index} index={index}
@@ -1539,6 +1539,15 @@ export default function PlayerModal({
<Scissors /> <Scissors />
</IconButton> </IconButton>
)} )}
<IconButton
variant="outline"
size="md"
pressed={watched}
onClick={() => setWatched(!watched)}
label={watched ? t("player.watchedUnmark") : t("player.markWatched")}
>
{watched ? <CheckCheck /> : <Check />}
</IconButton>
{onRemoveFromList && !related && ( {onRemoveFromList && !related && (
<IconButton <IconButton
variant="outline-danger" variant="outline-danger"
@@ -1552,16 +1561,6 @@ export default function PlayerModal({
<Trash2 /> <Trash2 />
</IconButton> </IconButton>
)} )}
<Button
variant="outline"
size="sm"
pressed={watched}
onClick={() => setWatched(!watched)}
title={watched ? t("player.watchedUnmark") : t("player.markWatched")}
icon={watched ? <CheckCheck /> : <Check />}
>
{watched ? t("player.watched") : t("player.markWatched")}
</Button>
</div> </div>
)} )}
</div> </div>
@@ -118,6 +118,14 @@ describe("RelatedVideos", () => {
expect(saved).toHaveBeenCalledWith({ relatedRegion: "hu" }); expect(saved).toHaveBeenCalledWith({ relatedRegion: "hu" });
}); });
it("sets the region switch at the toolbar rung, not the 10px caption one", async () => {
vi.spyOn(api, "related").mockResolvedValue(page([row("A")]));
setup();
await title("Video A");
const track = screen.getByRole("group", { name: "player.related.region" });
expect(track.className).toMatch(/\btext-xs\b/);
});
// A plain pick resumes (null: the player knows the freshest position); the row's own position // A plain pick resumes (null: the player knows the freshest position); the row's own position
// travels on the video it hands over. // travels on the video it hands over.
it("plays a row from where the user left it", async () => { it("plays a row from where the user left it", async () => {
@@ -164,7 +164,6 @@ export function RelatedVideos({
value={region} value={region}
onChange={setRegion} onChange={setRegion}
ariaLabel={t("player.related.region")} ariaLabel={t("player.related.region")}
size="sm"
fullWidth fullWidth
className="mb-3" className="mb-3"
/> />
@@ -4,6 +4,7 @@ import { 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 { Button } from "../../components/ui/Button";
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";
import { renderDescription } from "./descriptionLinks"; import { renderDescription } from "./descriptionLinks";
@@ -14,9 +15,11 @@ import { renderDescription } from "./descriptionLinks";
/** The queue transport cluster — prev / counter / next, then the auto-advance and loop mode toggles. /** 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 * 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, * theme, glyphs only so the whole cluster fits one line of the side panel) and the big-view docked
* labels hidden until a breakpoint). Only the theme + label-visibility differ; the behaviour is one, * control bar (`variant:"docked"`, white-on-dark, labels shown from a breakpoint up). Only the theme +
* so a mode/icon change lands once. The wrapper (fragment vs. bordered row) stays at the call site. */ * label visibility differ; the behaviour is one, so a mode/icon change lands once. Every control keeps
* its full name (with the shortcut) as its tooltip and accessible name, so a glyph-only one still says
* what it does and which mode it is in. The wrapper (fragment vs. bordered row) stays at the call site. */
export function PlayerTransportControls({ export function PlayerTransportControls({
variant, variant,
index, index,
@@ -40,16 +43,61 @@ export function PlayerTransportControls({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const docked = variant === "docked"; const docked = variant === "docked";
// The docked bar sits on the video (always dark): the overlay variant; the modal's is a page surface.
const chrome = docked ? "overlay" : "ghost";
const navLabel = docked ? "hidden sm:inline" : "";
const counter = docked ? "tabular-nums text-white/70" : "text-muted tabular-nums"; 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 divider = docked ? "h-4 w-px bg-white/20" : "w-px h-4 bg-border";
const toggleLabel = docked ? "hidden md:inline" : ""; const autoName = `${t("player.autoAdvance.label")}: ${t(`player.autoAdvance.${autoMode}`)}`;
const loopName = `${t("player.loop.label")}: ${t(`player.loop.${loopMode}`)}`;
const autoIcon =
autoMode === "prev" ? <SkipBack /> : autoMode === "random" ? <Shuffle /> : <SkipForward />;
const loopIcon = loopMode === "one" ? <Repeat1 /> : <Repeat />;
const count = (
<span className={counter}>
{index + 1} / {queueLength}
</span>
);
if (!docked)
return (
<>
<IconButton
onClick={onPrev}
disabled={index === 0}
label={t("player.previous")}
title={`${t("player.previous")} · Shift+←`}
>
<SkipBack />
</IconButton>
{count}
<IconButton
onClick={onNext}
disabled={index === queueLength - 1}
label={t("player.next")}
title={`${t("player.next")} · Shift+→`}
>
<SkipForward />
</IconButton>
<span className={divider} />
<IconButton
pressed={autoMode !== "off"}
onClick={onCycleAuto}
label={autoName}
title={`${autoName} · ${t("player.autoAdvance.hint")}`}
>
{autoIcon}
</IconButton>
<IconButton
pressed={loopMode !== "off"}
onClick={onCycleLoop}
label={loopName}
title={`${loopName} · ${t("player.loop.hint")}`}
>
{loopIcon}
</IconButton>
</>
);
return ( return (
<> <>
<Button <Button
variant={chrome} variant="overlay"
size="sm" size="sm"
icon={<SkipBack />} icon={<SkipBack />}
onClick={onPrev} onClick={onPrev}
@@ -57,51 +105,43 @@ export function PlayerTransportControls({
label={t("player.previous")} label={t("player.previous")}
title={`${t("player.previous")} · Shift+←`} title={`${t("player.previous")} · Shift+←`}
> >
<span className={navLabel}>{t("player.previous")}</span> <span className="hidden sm:inline">{t("player.previous")}</span>
</Button> </Button>
<span className={counter}> {count}
{index + 1} / {queueLength}
</span>
<Button <Button
variant={chrome} variant="overlay"
size="sm" size="sm"
onClick={onNext} onClick={onNext}
disabled={index === queueLength - 1} disabled={index === queueLength - 1}
label={t("player.next")} label={t("player.next")}
title={`${t("player.next")} · Shift+→`} title={`${t("player.next")} · Shift+→`}
> >
<span className={navLabel}>{t("player.next")}</span> <span className="hidden sm:inline">{t("player.next")}</span>
<SkipForward /> <SkipForward />
</Button> </Button>
<span className={divider} /> <span className={divider} />
{/* Persistent playback settings — cycle on click, saved to your account. */} {/* Persistent playback settings — cycle on click, saved to your account. */}
<Button <Button
variant={chrome} variant="overlay"
size="sm" size="sm"
pressed={autoMode !== "off"} pressed={autoMode !== "off"}
icon={ icon={autoIcon}
autoMode === "prev" ? <SkipBack /> : autoMode === "random" ? <Shuffle /> : <SkipForward />
}
onClick={onCycleAuto} onClick={onCycleAuto}
label={`${t("player.autoAdvance.label")}: ${t(`player.autoAdvance.${autoMode}`)}`} label={autoName}
title={t("player.autoAdvance.hint")} title={t("player.autoAdvance.hint")}
> >
<span className={toggleLabel}> <span className="hidden md:inline">{autoName}</span>
{t("player.autoAdvance.label")}: {t(`player.autoAdvance.${autoMode}`)}
</span>
</Button> </Button>
<Button <Button
variant={chrome} variant="overlay"
size="sm" size="sm"
pressed={loopMode !== "off"} pressed={loopMode !== "off"}
icon={loopMode === "one" ? <Repeat1 /> : <Repeat />} icon={loopIcon}
onClick={onCycleLoop} onClick={onCycleLoop}
label={`${t("player.loop.label")}: ${t(`player.loop.${loopMode}`)}`} label={loopName}
title={t("player.loop.hint")} title={t("player.loop.hint")}
> >
<span className={toggleLabel}> <span className="hidden md:inline">{loopName}</span>
{t("player.loop.label")}: {t(`player.loop.${loopMode}`)}
</span>
</Button> </Button>
</> </>
); );
+1 -1
View File
@@ -31,7 +31,7 @@
"title": "Related", "title": "Related",
"region": "Which related videos", "region": "Which related videos",
"topical": "On topic", "topical": "On topic",
"hu": "Hungarian trending", "hu": "Trending",
"followed": "You follow this channel", "followed": "You follow this channel",
"empty": "No related videos you haven't seen yet.", "empty": "No related videos you haven't seen yet.",
"emptySoFar": "Nothing you haven't seen among these yet — there are more.", "emptySoFar": "Nothing you haven't seen among these yet — there are more.",
+1 -1
View File
@@ -31,7 +31,7 @@
"title": "Kapcsolódók", "title": "Kapcsolódók",
"region": "Milyen kapcsolódó videók", "region": "Milyen kapcsolódó videók",
"topical": "Témába vágó", "topical": "Témába vágó",
"hu": "Magyar trend", "hu": "Trend",
"followed": "Követed ezt a csatornát", "followed": "Követed ezt a csatornát",
"empty": "Nincs olyan kapcsolódó videó, amit még nem láttál.", "empty": "Nincs olyan kapcsolódó videó, amit még nem láttál.",
"emptySoFar": "Ezek közt még nincs olyan, amit nem láttál — van több is.", "emptySoFar": "Ezek közt még nincs olyan, amit nem láttál — van több is.",