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:
+4
-2
@@ -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 +
|
||||
"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
|
||||
(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
|
||||
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
|
||||
@@ -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
|
||||
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
|
||||
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
|
||||
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,
|
||||
@@ -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-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 | 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/);
|
||||
});
|
||||
|
||||
// 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();
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user