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"; import { BatchC, C5 } from "./batchC";
// The open decisions, in the order they are asked. The decided ones render below them as reference. // 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 // 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 / // 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"> <main className="mx-auto flex max-w-screen-2xl flex-col gap-6 p-4">
<header> <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"> <p className="text-sm text-muted">
Each decision shows its variants with the app&apos;s real CSS. The framed one is the 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. recommendation. Resize the window to tablet size to see them there too.
+126 -68
View File
@@ -9,13 +9,13 @@ import {
Maximize, Maximize,
MoreHorizontal, MoreHorizontal,
Pause, Pause,
Play,
Pin, Pin,
Rewind, Rewind,
RotateCcw, RotateCcw,
Settings, Settings,
SkipBack, SkipBack,
SkipForward, SkipForward,
Square,
Subtitles, Subtitles,
Volume2, Volume2,
X, 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: // 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 // 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 // control row running off-screen below 1024; the owner answered with a reworked control set, and
// fits is asked on its own. // two of its points are asked back.
const ico = "w-4 h-4"; const ico = "w-4 h-4";
@@ -210,31 +210,45 @@ export function BatchC() {
); );
} }
// ── C5 · the Plex control row on a narrow screen ───────────────────────────────────────────────── // ── C5 · the Plex player's controls, reworked (the owner's model, 2026-09-24) ─────────────────────
// Every control of today's row, in the order it sits; `keep` = stays in the row under variant A. // The owner's direction: Stop, Restart and ±10 s leave the row; Back restarts or steps to the previous
const CONTROLS: { icon: typeof Pause; label: string; keep: boolean }[] = [ // episode; Next exists only when there is a next item; restarting moves onto the card. What is left
{ icon: Rewind, label: "Back 10 s", keep: true }, // fits a portrait tablet in one row, with a fixed "⋯" for the rare tools.
{ icon: Pause, label: "Play / pause", keep: true },
{ icon: FastForward, label: "Forward 10 s", keep: true }, const ROW: { icon: typeof Pause; label: string }[] = [
{ icon: RotateCcw, label: "Restart from the beginning", keep: false }, { icon: Pause, label: "Play / pause" },
{ icon: Square, label: "Stop", keep: false }, { icon: SkipBack, label: "Back: restart, then the previous episode" },
{ icon: SkipBack, label: "Previous episode", keep: true }, { icon: SkipForward, label: "Next episode (only when there is one)" },
{ icon: SkipForward, label: "Next episode", keep: true }, { icon: Volume2, label: "Mute" },
{ icon: Volume2, label: "Mute", keep: true }, ];
{ icon: Subtitles, label: "Audio & subtitles", keep: true }, const MORE: { icon: typeof Pause; label: string }[] = [
{ icon: Settings, label: "Settings", keep: false }, { icon: Settings, label: "Player settings" },
{ icon: Activity, label: "Stats for nerds", keep: false }, { icon: Info, label: "Media info" },
{ icon: Info, label: "Media info", keep: false }, { icon: Activity, label: "Stats for nerds" },
{ icon: Keyboard, label: "Keyboard shortcuts", keep: false }, { icon: Keyboard, label: "Keyboard shortcuts" },
{ icon: Download, label: "Download original file", keep: false }, { icon: Download, label: "Download original file" },
]; ];
const ctl = "grid h-11 w-11 shrink-0 place-items-center rounded-full"; const ctl = "grid h-11 w-11 shrink-0 place-items-center rounded-full";
function Frame({ children }: { children: ReactNode }) { function PlayerRow({ touchZones }: { touchZones: boolean }) {
// Stands in for a portrait tablet: the frame is the screen width, narrower than the full row.
return ( return (
<div className="relative aspect-4/3 w-full overflow-hidden rounded-xl bg-linear-to-br from-slate-700 to-black text-white"> <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="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="relative flex h-11 items-center">
<div className="h-1 w-full rounded-full bg-white/30"> <div className="h-1 w-full rounded-full bg-white/30">
@@ -242,56 +256,99 @@ function Frame({ children }: { children: ReactNode }) {
</div> </div>
<span className="absolute left-2/5 h-4 w-4 -translate-x-1/2 rounded-full bg-white shadow" /> <span className="absolute left-2/5 h-4 w-4 -translate-x-1/2 rounded-full bg-white shadow" />
</div> </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>
</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 ( return (
<Frame> <div className="glass-card flex w-44 flex-col gap-1.5 rounded-xl p-1.5">
<div className="flex items-center"> <div className="relative aspect-2/3 overflow-hidden rounded-lg bg-linear-to-br from-accent/50 to-card">
{CONTROLS.filter((c) => c.keep).map((c) => ( {state !== "new" && (
<span key={c.label} className={ctl} title={c.label}> <span className="absolute inset-x-0 bottom-0 h-1 bg-white/25">
<c.icon className="h-5 w-5" /> <span className="block h-1 w-1/3 bg-accent" />
</span> </span>
))} )}
<span className="ml-auto truncate px-1 text-2xs text-white/80">12:04 / 42:21</span> {state === "new" && (
<span className={clsx(ctl, "bg-white/15")} title="More"> <span className="absolute inset-0 grid place-items-center bg-black/30">
<MoreHorizontal className="h-5 w-5" /> <span className="grid h-12 w-12 place-items-center rounded-full bg-black/60 text-white">
</span> <Play className="h-6 w-6" />
<span className={ctl} title="Fullscreen"> </span>
<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}
</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> </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 ( return (
<Frame> <div className="flex flex-wrap gap-3">
<div className="flex flex-wrap items-center"> <Sample label="Not started · hover">
{CONTROLS.map((c) => ( <MediaCard state="new" />
<span key={c.label} className={ctl} title={c.label}> </Sample>
<c.icon className="h-5 w-5" /> <Sample label="Started · hover">
</span> <MediaCard state="started" />
))} </Sample>
<span className={ctl} title="Fullscreen"> <Sample label="Started · touch: a tap resumes, the '…' has From the start">
<Maximize className="h-5 w-5" /> <MediaCard state="touch" />
</span> </Sample>
<span className="ml-auto px-1 text-2xs text-white/80">12:04 / 42:21</span> </div>
</div>
</Frame>
); );
} }
@@ -300,24 +357,25 @@ export function C5() {
<Decision <Decision
id="c5" id="c5"
code="C5" code="C5"
title="The Plex control row on a narrow screen" title="The Plex player's controls, reworked"
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." 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="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." 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 <Variant
letter="A" letter="A"
name="Everyday controls stay, the rest go into ⋯ More" name="Your model, with two refinements"
recommended 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>
<Variant <Variant
letter="B" letter="B"
name="The row wraps onto a second line" name="Your model, literally"
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." 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> </Variant>
</Decision> </Decision>
); );