feat(design): C5 redrawn around the owner's reworked Plex controls

Stop, Restart and the 10 s skips leave the row, Back restarts or steps to
the previous episode, Next shows only when there is a next item, and the
card carries Resume + From the start. The lab asks back two points: how
Back tells restart from previous, and a touch path for the 10 s skip.
This commit is contained in:
2026-09-24 04:05:09 +02:00
parent f4bc60d659
commit 3f5634a44f
2 changed files with 128 additions and 70 deletions
+2 -2
View File
@@ -6,7 +6,7 @@ import { BatchB } from "./batchB";
import { BatchC, C5 } from "./batchC";
// The open decisions, in the order they are asked. The decided ones render below them as reference.
const OPEN = [{ id: "c5", code: "C5", title: "Player row", render: C5 }] as const;
const OPEN = [{ id: "c5", code: "C5", title: "Player controls", render: C5 }] as const;
// The design lab (E28): the living style guide. Dev-only — see main.tsx and devOnlyGuard.ts. It sits
// OUTSIDE <App>, so it owns the theme attributes on <html> itself, through the same data-theme /
@@ -116,7 +116,7 @@ export default function DesignLab() {
<main className="mx-auto flex max-w-screen-2xl flex-col gap-6 p-4">
<header>
<h1 className="text-2xl font-bold">Open · the player row on a narrow screen</h1>
<h1 className="text-2xl font-bold">Open · the Plex player controls</h1>
<p className="text-sm text-muted">
Each decision shows its variants with the app&apos;s real CSS. The framed one is the
recommendation. Resize the window to tablet size to see them there too.
+126 -68
View File
@@ -9,13 +9,13 @@ import {
Maximize,
MoreHorizontal,
Pause,
Play,
Pin,
Rewind,
RotateCcw,
Settings,
SkipBack,
SkipForward,
Square,
Subtitles,
Volume2,
X,
@@ -27,8 +27,8 @@ import { FADE_ABOVE_PANEL, FADE_BELOW_PANEL, H, LAYER, STRONG } from "./labToken
// E28 S2 · Batch C — behaviour on touch and on a narrow window. C1–C4 are DECIDED (owner, 2026-09-24:
// all as proposed) and stay as references. C5 is open: the live player pass (E28 S2d) found the Plex
// control row running off-screen below 1024, which C3's 44px buttons make worse, so how the row
// fits is asked on its own.
// control row running off-screen below 1024; the owner answered with a reworked control set, and
// two of its points are asked back.
const ico = "w-4 h-4";
@@ -210,31 +210,45 @@ export function BatchC() {
);
}
// ── C5 · the Plex control row on a narrow screen ─────────────────────────────────────────────────
// Every control of today's row, in the order it sits; `keep` = stays in the row under variant A.
const CONTROLS: { icon: typeof Pause; label: string; keep: boolean }[] = [
{ icon: Rewind, label: "Back 10 s", keep: true },
{ icon: Pause, label: "Play / pause", keep: true },
{ icon: FastForward, label: "Forward 10 s", keep: true },
{ icon: RotateCcw, label: "Restart from the beginning", keep: false },
{ icon: Square, label: "Stop", keep: false },
{ icon: SkipBack, label: "Previous episode", keep: true },
{ icon: SkipForward, label: "Next episode", keep: true },
{ icon: Volume2, label: "Mute", keep: true },
{ icon: Subtitles, label: "Audio & subtitles", keep: true },
{ icon: Settings, label: "Settings", keep: false },
{ icon: Activity, label: "Stats for nerds", keep: false },
{ icon: Info, label: "Media info", keep: false },
{ icon: Keyboard, label: "Keyboard shortcuts", keep: false },
{ icon: Download, label: "Download original file", keep: false },
// ── C5 · the Plex player's controls, reworked (the owner's model, 2026-09-24) ─────────────────────
// The owner's direction: Stop, Restart and ±10 s leave the row; Back restarts or steps to the previous
// episode; Next exists only when there is a next item; restarting moves onto the card. What is left
// fits a portrait tablet in one row, with a fixed "⋯" for the rare tools.
const ROW: { icon: typeof Pause; label: string }[] = [
{ icon: Pause, label: "Play / pause" },
{ icon: SkipBack, label: "Back: restart, then the previous episode" },
{ icon: SkipForward, label: "Next episode (only when there is one)" },
{ icon: Volume2, label: "Mute" },
];
const MORE: { icon: typeof Pause; label: string }[] = [
{ icon: Settings, label: "Player settings" },
{ icon: Info, label: "Media info" },
{ icon: Activity, label: "Stats for nerds" },
{ icon: Keyboard, label: "Keyboard shortcuts" },
{ icon: Download, label: "Download original file" },
];
const ctl = "grid h-11 w-11 shrink-0 place-items-center rounded-full";
function Frame({ children }: { children: ReactNode }) {
// Stands in for a portrait tablet: the frame is the screen width, narrower than the full row.
function PlayerRow({ touchZones }: { touchZones: boolean }) {
return (
<div className="relative aspect-4/3 w-full overflow-hidden rounded-xl bg-linear-to-br from-slate-700 to-black text-white">
{touchZones && (
// Double-tap zones: the left and right thirds of the picture skip ∓10 s on touch.
<div className="absolute inset-x-0 top-0 bottom-28 flex justify-between text-2xs text-white/70">
<span className="flex w-1/3 items-start justify-center border-r border-dashed border-white/25 pt-6">
<span className="flex items-center gap-1">
<Rewind className="h-4 w-4" /> double-tap −10 s
</span>
</span>
<span className="flex w-1/3 items-start justify-center border-l border-dashed border-white/25 pt-6">
<span className="flex items-center gap-1">
double-tap +10 s <FastForward className="h-4 w-4" />
</span>
</span>
</div>
)}
<div className="absolute inset-x-0 bottom-0 flex flex-col gap-1 bg-linear-to-t from-black/85 to-transparent px-2 pb-2 pt-10">
<div className="relative flex h-11 items-center">
<div className="h-1 w-full rounded-full bg-white/30">
@@ -242,56 +256,99 @@ function Frame({ children }: { children: ReactNode }) {
</div>
<span className="absolute left-2/5 h-4 w-4 -translate-x-1/2 rounded-full bg-white shadow" />
</div>
{children}
<div className="flex items-center">
{ROW.map((c) => (
<span key={c.label} className={ctl} title={c.label}>
<c.icon className="h-5 w-5" />
</span>
))}
<span className="ml-auto truncate px-1 text-2xs text-white/80">12:04 / 42:21</span>
<span className={ctl} title="Audio & subtitles">
<Subtitles className="h-5 w-5" />
</span>
<span className={clsx(ctl, "bg-white/15")} title="More">
<MoreHorizontal className="h-5 w-5" />
</span>
<span className={ctl} title="Fullscreen">
<Maximize className="h-5 w-5" />
</span>
</div>
<div className="glass-menu absolute bottom-16 right-2 flex w-52 flex-col rounded-xl p-1 text-xs text-fg">
{MORE.map((c) => (
<span key={c.label} className="flex min-h-11 items-center gap-2.5 rounded-lg px-2.5">
<c.icon className={ico} />
{c.label}
</span>
))}
</div>
</div>
</div>
);
}
function OverflowRow() {
function MediaCard({ state }: { state: "new" | "started" | "touch" }) {
const pill = "inline-flex h-8 items-center gap-1.5 rounded-full px-3 text-xs font-medium";
return (
<Frame>
<div className="flex items-center">
{CONTROLS.filter((c) => c.keep).map((c) => (
<span key={c.label} className={ctl} title={c.label}>
<c.icon className="h-5 w-5" />
<div className="glass-card flex w-44 flex-col gap-1.5 rounded-xl p-1.5">
<div className="relative aspect-2/3 overflow-hidden rounded-lg bg-linear-to-br from-accent/50 to-card">
{state !== "new" && (
<span className="absolute inset-x-0 bottom-0 h-1 bg-white/25">
<span className="block h-1 w-1/3 bg-accent" />
</span>
))}
<span className="ml-auto truncate px-1 text-2xs text-white/80">12:04 / 42:21</span>
<span className={clsx(ctl, "bg-white/15")} title="More">
<MoreHorizontal className="h-5 w-5" />
</span>
<span className={ctl} title="Fullscreen">
<Maximize className="h-5 w-5" />
</span>
</div>
<div className="glass-menu absolute bottom-16 right-2 flex w-56 flex-col rounded-xl p-1 text-xs text-fg">
{CONTROLS.filter((c) => !c.keep).map((c) => (
<span key={c.label} className="flex min-h-11 items-center gap-2.5 rounded-lg px-2.5">
<c.icon className={ico} />
{c.label}
)}
{state === "new" && (
<span className="absolute inset-0 grid place-items-center bg-black/30">
<span className="grid h-12 w-12 place-items-center rounded-full bg-black/60 text-white">
<Play className="h-6 w-6" />
</span>
</span>
))}
)}
{state === "started" && (
<span className="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-black/30">
<span className={clsx(pill, "bg-accent text-accent-fg")}>
<Play className="h-4 w-4" /> Resume 14:02
</span>
<span className={clsx(pill, "bg-black/60 text-white")}>
<RotateCcw className="h-3.5 w-3.5" /> From the start
</span>
</span>
)}
</div>
</Frame>
<div className="flex items-start gap-1">
<div className="min-w-0 flex-1 px-0.5">
<div className="truncate text-xs font-medium">Darkman III</div>
<div className="text-2xs text-muted">
{state === "new" ? "1996 · not started" : "1996 · 14 min in"}
</div>
</div>
{state === "touch" && (
<span
className={clsx(
"grid h-11 w-11 shrink-0 place-items-center rounded-lg text-muted",
LAYER,
)}
>
<MoreHorizontal className="h-4 w-4" />
</span>
)}
</div>
</div>
);
}
function WrappedRow() {
function Cards() {
return (
<Frame>
<div className="flex flex-wrap items-center">
{CONTROLS.map((c) => (
<span key={c.label} className={ctl} title={c.label}>
<c.icon className="h-5 w-5" />
</span>
))}
<span className={ctl} title="Fullscreen">
<Maximize className="h-5 w-5" />
</span>
<span className="ml-auto px-1 text-2xs text-white/80">12:04 / 42:21</span>
</div>
</Frame>
<div className="flex flex-wrap gap-3">
<Sample label="Not started · hover">
<MediaCard state="new" />
</Sample>
<Sample label="Started · hover">
<MediaCard state="started" />
</Sample>
<Sample label="Started · touch: a tap resumes, the '…' has From the start">
<MediaCard state="touch" />
</Sample>
</div>
);
}
@@ -300,24 +357,25 @@ export function C5() {
<Decision
id="c5"
code="C5"
title="The Plex control row on a narrow screen"
question="Found while playing the player at tablet size (C3 was measured live after you asked): the row has 15 controls plus the time. How should it fit when the screen is narrower than the row? Each frame is narrower than the row, as a portrait tablet is; the buttons are C3's 44."
now="The row neither wraps nor scrolls. At 767 and at 820 portrait, Stats, Media info, Keyboard shortcuts, Download and Fullscreen sit past the right edge and cannot be reached; at 1024 it just fits (1022 of 1024). With 44px buttons it would need ≈ 780px."
title="The Plex player's controls, reworked"
question="Your model: no Stop (closing is stopping), no Restart and no ±10 s buttons, Back restarts or goes to the previous episode, Next only where there is a next item, and restarting moves onto the card (Play when not started; Resume + From the start when started). It fits a 767 portrait tablet in one row, with a fixed '⋯'. Two points need your call: how Back tells 'restart' from 'previous', and how a tablet skips 10 s without a keyboard."
now="15 controls plus the time in one row that neither wraps nor scrolls: at 767 and at 820 portrait five of them are off-screen and unreachable. The card shows one Play ring; Resume and 'Clear resume point' exist only on the detail page."
>
<Variant
letter="A"
name="Everyday controls stay, the rest go into ⋯ More"
name="Your model, with two refinements"
recommended
why="The row keeps what you use while watching: skip ±10, play, previous / next episode, mute, audio & subtitles, fullscreen. Restart, Stop, Settings, Stats, Media info, Shortcuts and Download move into a ⋯ menu, but only when the row runs out of room: on a wide screen it looks like today. One row covers the least of the picture. This is how the YouTube, Netflix and Plex apps do it on a tablet."
why="1) Back decides by the position, the music-player rule: more than 3 s in → restart; within the first 3 s → the previous episode (at its resume point if it has one, else its start). That is your 'first press restarts, the second goes back', without a press counter: a counter goes wrong when you arrive at an episode by Next and want the one before it at once (the first press would restart an episode already at 0:00). 2) On touch, a double-tap on the left or right third of the picture skips ∓10 s (the YouTube and Netflix gesture); a single tap still only shows the controls (C3). Without it a tablet can't go back 10 s at all: on a 42-minute film one pixel of the seek bar is ≈ 3.5 s, and a finger lands within 10–20 px, so ±40–70 s."
>
<OverflowRow />
<PlayerRow touchZones />
<Cards />
</Variant>
<Variant
letter="B"
name="The row wraps onto a second line"
why="Every control stays one tap away, nothing is hidden. The price: the chrome is twice as tall and covers more of the picture while it is up, and where each button sits changes with the screen width, so muscle memory doesn't carry between the tablet and the desktop."
name="Your model, literally"
why="Back counts presses: the first restarts, a second one goes to the previous episode. ±10 s only from the keyboard (← / →), so on a tablet the seek bar is the only way to move, at a precision of tens of seconds. The row and the cards are the same as A."
>
<WrappedRow />
<PlayerRow touchZones={false} />
</Variant>
</Decision>
);