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
@@ -509,6 +509,28 @@ describe("PlayerModal — no side strips", () => {
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", () => {
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const onClose = vi.fn();
+10 -11
View File
@@ -1298,7 +1298,7 @@ export default function PlayerModal({
</div>
)}
{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
variant="modal"
index={index}
@@ -1539,6 +1539,15 @@ export default function PlayerModal({
<Scissors />
</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 && (
<IconButton
variant="outline-danger"
@@ -1552,16 +1561,6 @@ export default function PlayerModal({
<Trash2 />
</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>
@@ -118,6 +118,14 @@ describe("RelatedVideos", () => {
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
// travels on the video it hands over.
it("plays a row from where the user left it", async () => {
@@ -164,7 +164,6 @@ export function RelatedVideos({
value={region}
onChange={setRegion}
ariaLabel={t("player.related.region")}
size="sm"
fullWidth
className="mb-3"
/>
@@ -4,6 +4,7 @@ import { 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";
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.
* 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. */
* 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). Only the theme +
* 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({
variant,
index,
@@ -40,16 +43,61 @@ export function PlayerTransportControls({
}) {
const { t } = useTranslation();
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 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 (
<>
<Button
variant={chrome}
variant="overlay"
size="sm"
icon={<SkipBack />}
onClick={onPrev}
@@ -57,51 +105,43 @@ export function PlayerTransportControls({
label={t("player.previous")}
title={`${t("player.previous")} · Shift+←`}
>
<span className={navLabel}>{t("player.previous")}</span>
<span className="hidden sm:inline">{t("player.previous")}</span>
</Button>
<span className={counter}>
{index + 1} / {queueLength}
</span>
{count}
<Button
variant={chrome}
variant="overlay"
size="sm"
onClick={onNext}
disabled={index === queueLength - 1}
label={t("player.next")}
title={`${t("player.next")} · Shift+→`}
>
<span className={navLabel}>{t("player.next")}</span>
<span className="hidden sm:inline">{t("player.next")}</span>
<SkipForward />
</Button>
<span className={divider} />
{/* Persistent playback settings — cycle on click, saved to your account. */}
<Button
variant={chrome}
variant="overlay"
size="sm"
pressed={autoMode !== "off"}
icon={
autoMode === "prev" ? <SkipBack /> : autoMode === "random" ? <Shuffle /> : <SkipForward />
}
icon={autoIcon}
onClick={onCycleAuto}
label={`${t("player.autoAdvance.label")}: ${t(`player.autoAdvance.${autoMode}`)}`}
label={autoName}
title={t("player.autoAdvance.hint")}
>
<span className={toggleLabel}>
{t("player.autoAdvance.label")}: {t(`player.autoAdvance.${autoMode}`)}
</span>
<span className="hidden md:inline">{autoName}</span>
</Button>
<Button
variant={chrome}
variant="overlay"
size="sm"
pressed={loopMode !== "off"}
icon={loopMode === "one" ? <Repeat1 /> : <Repeat />}
icon={loopIcon}
onClick={onCycleLoop}
label={`${t("player.loop.label")}: ${t(`player.loop.${loopMode}`)}`}
label={loopName}
title={t("player.loop.hint")}
>
<span className={toggleLabel}>
{t("player.loop.label")}: {t(`player.loop.${loopMode}`)}
</span>
<span className="hidden md:inline">{loopName}</span>
</Button>
</>
);
+1 -1
View File
@@ -31,7 +31,7 @@
"title": "Related",
"region": "Which related videos",
"topical": "On topic",
"hu": "Hungarian trending",
"hu": "Trending",
"followed": "You follow this channel",
"empty": "No related videos you haven't seen yet.",
"emptySoFar": "Nothing you haven't seen among these yet — there are more.",
+1 -1
View File
@@ -31,7 +31,7 @@
"title": "Kapcsolódók",
"region": "Milyen kapcsolódó videók",
"topical": "Témába vágó",
"hu": "Magyar trend",
"hu": "Trend",
"followed": "Követed ezt a csatornát",
"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.",