feat(design): players on touch and the C5 control set, restart on the card (E28 S3f2)

Plex player: a finger's tap on the picture only shows or hides the controls,
the hide timer starts with playback, a double-tap on a side third skips 10 s,
the seek bar is a 2.75rem touch-none hit area with its head shown on touch.
The row is play/pause, Back by position, Next only with a next item, mute,
time, tracks, a fixed ⋯ (settings, media info, stats, shortcuts, download)
and fullscreen; Stop, Restart and the ±10 s buttons are gone.

A started card shows the video card's Continue + Restart pair (one shared
MediaResumeButtons, beside the picture's button), and its menu lists the
restart; the player opens that item at 0 (fromStart). YouTube modal: the
description opens from an ⓘ, the queue arrows sit inside the screen.
This commit is contained in:
2026-09-27 02:12:54 +02:00
parent 17916b411a
commit 89e65ca331
33 changed files with 1100 additions and 282 deletions
+244 -108
View File
@@ -17,13 +17,12 @@ import {
Loader2,
Maximize,
Minimize,
MoreHorizontal,
Pause,
Play,
RotateCcw,
Settings,
SkipBack,
SkipForward,
Square,
Volume2,
VolumeX,
} from "lucide-react";
@@ -39,7 +38,7 @@ import {
subShadowCss,
type PlexPlayerPrefs,
} from "../lib/plexPlayerPrefs";
import { Ctrl, PanelHead, Skip10 } from "./plexPlayerControls";
import { Ctrl, PanelHead } from "./plexPlayerControls";
import { PlexPlayerSettings } from "./PlexPlayerSettings";
import { PlexStatsOverlay } from "./PlexStatsOverlay";
import { createFragMeter } from "../lib/plexStats";
@@ -100,6 +99,13 @@ const MAX_SESSION_RECLAIMS = 2;
// Two losses inside two minutes is a session that keeps being lost; one an hour apart is not.
const RECLAIM_WINDOW_MS = 120_000;
// C5: Back restarts the item after this many seconds in, and steps to the previous one before it.
const BACK_TO_PREV_S = 3;
// C5: a touch double-tap on the picture's left or right third skips this far; each further tap
// inside the window skips again (the keyboard keeps ← / → at the viewer's own step).
const TAP_SKIP_S = 10;
const DOUBLE_TAP_MS = 300;
// The Plex module's rich, full-page player. Plays the LOCAL file: direct-playable files stream raw
// (native <video>), remux files stream via the seek-restart HLS session (hls.js). The visible
// timeline is ABSOLUTE (0…duration); the HLS session is relative to its start offset, so we map
@@ -108,9 +114,11 @@ const RECLAIM_WINDOW_MS = 120_000;
// `queue` (ordered rating_keys) drives play-through for a playlist: prev/next + auto-advance follow
// the queue instead of the item's own episode neighbours.
type Props = { itemId: string; onClose: () => void; queue?: string[] };
// `fromStart` (the card's Restart, C5) applies to the item the player opened with; every item it
// moves on to starts at its own resume point.
type Props = { itemId: string; onClose: () => void; queue?: string[]; fromStart?: boolean };
export default function PlexPlayer({ itemId, onClose, queue }: Props) {
export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }: Props) {
const { t } = useTranslation();
// `t` changes identity on a language switch. loadSession only uses it for error strings, so read it
// through a ref — keeping it OUT of loadSession's deps. Otherwise a mid-playback language change
@@ -224,7 +232,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
fineSeekStepRef.current = prefs.fineSeekStep;
const aoffRef = useRef(prefs.audioOffset);
aoffRef.current = prefs.audioOffset;
const [menuOpen, setMenuOpen] = useState(false); // gear = tuning (tabbed)
const [menuOpen, setMenuOpen] = useState(false); // the tuning panel (tabbed), from ⋯ → Settings
// The fixed ⋯ (C5): the rare tools — settings, media info, stats, shortcuts, the original file.
const [moreOpen, setMoreOpen] = useState(false);
const moreRef = useRef<HTMLDivElement>(null);
const [tracksOpen, setTracksOpen] = useState(false); // separate quick menu = audio/subtitle tracks
const [settingsTab, setSettingsTab] = useState<"sync" | "playback" | "subtitle" | "clock">(
"sync",
@@ -235,9 +246,15 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
const menuTriggerRef = useRef<HTMLButtonElement>(null);
const tracksRef = useRef<HTMLDivElement | null>(null);
const tracksTriggerRef = useRef<HTMLButtonElement>(null);
menuOpenRef.current = menuOpen || tracksOpen; // keep the control bar up while either menu is open
menuOpenRef.current = menuOpen || tracksOpen || moreOpen; // keep the control bar up while one is open
// Auto-hiding control bar (reveal on mouse move / any key; hide after 3s while playing).
const { uiVisible, wake, holdOnHover } = useControlsVisibility({
const {
uiVisible,
wake,
holdOnHover,
toggle: toggleControls,
show: showControls,
} = useControlsVisibility({
barAutoHideRef,
menuOpenRef,
videoRef,
@@ -250,6 +267,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// Each menu stays open until a click/Escape OUTSIDE it (so sliders can be dragged freely).
useDismiss(menuOpen, () => setMenuOpen(false), [menuRef, menuTriggerRef]);
useDismiss(tracksOpen, () => setTracksOpen(false), [tracksRef, tracksTriggerRef]);
useDismiss(moreOpen, () => setMoreOpen(false), [moreRef, menuTriggerRef]);
// The tracks menu is both dismiss-tracked and edge-faded, so its two refs are merged here.
// Must be stable: an inline arrow would detach (null) and re-attach the node on every render,
// and the fade's node-state ref would re-render in response — a loop. `fade.ref` is a setState.
@@ -572,6 +590,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// the cache when the player opened is not corrected by the refetch that follows; the player's own
// leave-patch (usePlexProgress) keeps the cache honest for everything played here.
const startedForRef = useRef<string | null>(null);
// Raised by the first `go()`: `fromStart` belongs to the item the player opened on, and coming BACK
// to that item (Back within 3 s of the next) resumes it like any other. A flag, not a consumed
// prop: StrictMode replays this effect, and a consumed one resumed on the replay.
const movedOnRef = useRef(false);
useEffect(() => {
if (!detail || detail.id !== id || startedForRef.current === id) return;
startedForRef.current = id;
@@ -585,9 +607,12 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
audioRef.current = aOrd;
setAudioOrd(aOrd);
setSubOrd(subOrdForLang(detail.subtitle_streams ?? [], subLang));
const resume = detail.status !== "watched" ? detail.position_seconds || 0 : 0;
const resume =
detail.status !== "watched" && !(fromStart && !movedOnRef.current)
? detail.position_seconds || 0
: 0;
void loadSession(resume);
}, [detail, id, loadSession, audioRef, setAudioOrd, setSubOrd]);
}, [detail, id, fromStart, loadSession, audioRef, setAudioOrd, setSubOrd]);
// Leaving the item: a session still in flight for it must not land after it, and its stream goes.
useEffect(
() => () => {
@@ -785,6 +810,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// reset below and would otherwise hand the old position to the new item.
settleProgress();
clockLiveRef.current = false;
movedOnRef.current = true;
setAbs(0);
setId(otherId);
}
@@ -801,6 +827,55 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
: null
: detail?.next_id;
// Back (C5): past the first BACK_TO_PREV_S seconds it restarts the item; inside them it steps to the
// previous one (at its resume point, as `go` opens every item) — or restarts again on the first.
const backToPrev = abs <= BACK_TO_PREV_S && !!prevId;
const backOrPrev = useCallback(() => {
if (playhead(absRef, loadStartRef) <= BACK_TO_PREV_S && prevId) go(prevId);
else seekTo(0);
}, [prevId, go, seekTo]);
// What the last press came from. A touch tap on the picture only shows or hides the controls (C3:
// measured live, a tap used to reveal the bar AND pause); a mouse click there still plays/pauses.
// Kept from `pointerdown`, because the `click` that follows is not a PointerEvent everywhere.
const pointerRef = useRef("mouse");
// The picture's taps: one shows/hides the controls; a second within DOUBLE_TAP_MS on the left or
// right third skips ∓TAP_SKIP_S, as does each further one (C5), and takes back the first tap's
// show/hide. The middle third has no skip, so a quick second tap there just toggles again.
const tapRef = useRef<{ at: number; zone: number; wasShown: boolean; skips: number } | null>(
null,
);
const onPictureClick = useCallback(
(e: React.MouseEvent<HTMLVideoElement>) => {
if (pointerRef.current !== "touch") {
togglePlay();
return;
}
// The wrapper's own click wakes the controls, which would undo a hide.
e.stopPropagation();
const r = e.currentTarget.getBoundingClientRect();
const x = r.width > 0 ? (e.clientX - r.left) / r.width : 0.5;
const zone = x < 1 / 3 ? -1 : x > 2 / 3 ? 1 : 0;
const now = performance.now();
const prev = tapRef.current;
if (zone !== 0 && prev && prev.zone === zone && now - prev.at < DOUBLE_TAP_MS) {
if (prev.skips === 0) showControls(prev.wasShown);
const skips = prev.skips + 1;
tapRef.current = { ...prev, at: now, skips };
seekTo(playhead(absRef, loadStartRef) + zone * TAP_SKIP_S);
flashOsd(
t(zone < 0 ? "plex.player.tapBack" : "plex.player.tapForward", {
n: skips * TAP_SKIP_S,
}),
);
return;
}
tapRef.current = { at: now, zone, wasShown: uiVisible, skips: 0 };
toggleControls();
},
[togglePlay, showControls, toggleControls, seekTo, flashOsd, t, uiVisible],
);
// Auto-skip intro/credits (countdown → jump to marker end / next item). The refs bridge to the
// earlier keyboard/back handlers that cancel a running countdown.
const { activeMarker, skipProgress, skipProgressRef, cancelAutoSkipRef, doSkip } = useAutoSkip({
@@ -837,6 +912,45 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
];
const activeTab = settingsTabs.some((x) => x.key === settingsTab) ? settingsTab : "sync";
// The ⋯ menu's rows (C5), in the lab's order. Settings and the media info need the item's detail.
const ico = "h-4 w-4 shrink-0";
const moreItems = [
...(detail
? [
{
id: "settings",
label: t("plex.player.settings"),
icon: <Settings className={ico} />,
run: () => setMenuOpen(true),
},
{
id: "info",
label: t("plex.player.infoBtn"),
icon: <Info className={ico} />,
run: () => setInfoOpen((v) => !v),
},
]
: []),
{
id: "stats",
label: t("plex.player.stats.title"),
icon: <Gauge className={ico} />,
run: () => setStatsOpen((v) => !v),
},
{
id: "help",
label: t("plex.player.help"),
icon: <Keyboard className={ico} />,
run: toggleKeyboardHelp,
},
{
id: "download",
label: t("plex.player.download"),
icon: <Download className={ico} />,
run: download,
},
];
// Auto-advance to the next episode when one finishes.
useEffect(() => {
const video = videoRef.current;
@@ -887,6 +1001,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
if (menuOpenRef.current) {
setMenuOpen(false);
setTracksOpen(false);
setMoreOpen(false);
} else if (infoOpenRef.current) setInfoOpen(false);
else if (statsOpenRef.current) setStatsOpen(false);
else if (upNextOpenRef.current) setUpNextOpen(false);
@@ -1030,6 +1145,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
else if (menuOpenRef.current) {
setMenuOpen(false);
setTracksOpen(false);
setMoreOpen(false);
} else if (infoOpenRef.current) setInfoOpen(false);
else if (statsOpenRef.current) setStatsOpen(false);
else if (upNextOpenRef.current) setUpNextOpen(false);
@@ -1086,7 +1202,14 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
<div
ref={wrapRef}
className="fixed left-0 top-0 z-overlay bg-black flex items-center justify-center select-none"
onMouseMove={wake}
onPointerDown={(e) => {
pointerRef.current = e.pointerType;
}}
// A pointer MOVE wakes the bar; a touch has none of its own, and the mouse events a browser
// fakes after a tap would show the bar the tap just hid.
onPointerMove={(e) => {
if (e.pointerType !== "touch") wake();
}}
onClick={wake}
onWheel={onWheel}
// Windowed (pseudo-fullscreen): everything is 25% larger for lean-back / HTPC legibility. Laid
@@ -1129,8 +1252,9 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// Fill the wrapper (which is sized to the viewport / screen) and letterbox by aspect via
// object-contain. NOT `w-auto h-auto max-*`: that caps the video at its intrinsic resolution
// (a 720p stream → 1280×720) and never scales it UP, so in fullscreen it sat tiny and centred.
className="w-full h-full object-contain"
onClick={togglePlay}
// `touch-manipulation`: a double-tap is the skip, never the browser's zoom.
className="w-full h-full object-contain touch-manipulation"
onClick={onPictureClick}
playsInline
>
{/* Text subtitles as native tracks (same-origin → sent with the session cookie). The effect
@@ -1234,7 +1358,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
<div
{...holdWhileHovered}
className={`absolute top-0 inset-x-0 p-4 bg-linear-to-b from-black/70 to-transparent transition-opacity ${
uiVisible ? "opacity-100" : "opacity-0"
uiVisible ? "opacity-100" : "opacity-0 pointer-events-none"
}`}
>
<div className="flex items-center gap-3 text-white">
@@ -1311,14 +1435,16 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
{/* Bottom controls */}
<div
{...holdWhileHovered}
className={`absolute bottom-0 inset-x-0 px-4 pb-3 pt-8 bg-linear-to-t from-black/80 to-transparent transition-opacity ${
className={`absolute bottom-0 inset-x-0 px-4 pb-3 pt-3 bg-linear-to-t from-black/80 to-transparent transition-opacity ${
uiVisible ? "opacity-100" : "opacity-0 pointer-events-none"
}`}
>
{/* Seek bar — click to jump, or drag the head (pointer capture) to scrub; the seek commits on
release so a long drag doesn't restart the stream on every step. */}
release so a long drag doesn't restart the stream on every step. The track stays thin
inside a 2.75rem hit area (C3): a finger could not find the 6px bar, and `touch-none`
makes a drag scrub instead of panning the page. */}
<div
className="relative h-1.5 rounded-full bg-white/25 cursor-pointer group mb-2"
className="group relative flex h-11 cursor-pointer touch-none items-center"
onPointerDown={(e) => {
const el = e.currentTarget as HTMLElement;
try {
@@ -1337,11 +1463,17 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
setHover({ frac, t: tt });
if (e.buttons & 1) setScrub(tt); // dragging with the primary button held
}}
onPointerUp={() => {
onPointerUp={(e) => {
if (scrub != null) {
seekTo(scrub);
setScrub(null);
}
// A finger never "leaves": its time tip goes with it.
if (e.pointerType === "touch") setHover(null);
}}
onPointerCancel={() => {
setScrub(null);
setHover(null);
}}
onMouseLeave={() => setHover(null)}
>
@@ -1354,65 +1486,56 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
{fmt(hover.t)}
</div>
)}
<div
className="absolute inset-y-0 left-0 bg-white/30 rounded-full"
style={{ width: `${bufPct}%` }}
/>
{/* intro/credit marker regions */}
{detail?.markers.map((m, i) => (
<div className="relative h-1.5 w-full rounded-full bg-white/25">
<div
key={i}
className="absolute inset-y-0 bg-warn/50"
style={{
left: `${(m.start_s / duration) * 100}%`,
width: `${((m.end_s - m.start_s) / duration) * 100}%`,
}}
className="absolute inset-y-0 left-0 bg-white/30 rounded-full"
style={{ width: `${bufPct}%` }}
/>
))}
<div
className="absolute inset-y-0 left-0 bg-accent rounded-full"
style={{ width: `${headPct}%` }}
/>
<div
className={`absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-3 h-3 rounded-full bg-accent transition-opacity ${
scrub != null ? "opacity-100" : "opacity-0 group-hover:opacity-100"
}`}
style={{ left: `${headPct}%` }}
/>
{/* intro/credit marker regions */}
{detail?.markers.map((m, i) => (
<div
key={i}
className="absolute inset-y-0 bg-warn/50"
style={{
left: `${(m.start_s / duration) * 100}%`,
width: `${((m.end_s - m.start_s) / duration) * 100}%`,
}}
/>
))}
<div
className="absolute inset-y-0 left-0 bg-accent rounded-full"
style={{ width: `${headPct}%` }}
/>
{/* The head: on hover for a mouse, always on touch (C3), where nothing hovers. */}
<div
className={`absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-3 h-3 rounded-full bg-accent transition-opacity ${
scrub != null
? "opacity-100"
: "opacity-0 group-hover:opacity-100 [@media(hover:none)]:opacity-100"
}`}
style={{ left: `${headPct}%` }}
/>
</div>
</div>
{/* The C5 row, one line on a portrait tablet: play/pause, Back, Next (only with a next
item), mute, the time, then audio & subtitles, the fixed ⋯ and fullscreen. No Stop
(closing is stopping), no Restart (Back is, and the card's), no ±10 s (← / → and the
touch double-tap are). */}
<div className="flex items-center gap-3 text-white">
<Ctrl
label={t("plex.player.rewind10")}
onClick={() => seekTo(playhead(absRef, loadStartRef) - 10)}
align="start"
>
<Skip10 dir="back" />
</Ctrl>
<Ctrl label={t("plex.player.playPause")} onClick={togglePlay}>
<Ctrl label={t("plex.player.playPause")} onClick={togglePlay} align="start">
{playing ? <Pause className="w-6 h-6" /> : <Play className="w-6 h-6" />}
</Ctrl>
<Ctrl
label={t("plex.player.forward10")}
onClick={() => seekTo(playhead(absRef, loadStartRef) + 10)}
label={backToPrev ? t("plex.player.prev") : t("plex.player.restart")}
onClick={backOrPrev}
>
<Skip10 dir="forward" />
<SkipBack className="w-5 h-5" />
</Ctrl>
<Ctrl label={t("plex.player.restart")} onClick={() => seekTo(0)}>
<RotateCcw className="w-5 h-5" />
</Ctrl>
<Ctrl label={t("plex.player.stop")} onClick={onClose}>
<Square className="w-5 h-5" />
</Ctrl>
{(detail?.kind === "episode" || queue) && (
<>
<Ctrl label={t("plex.player.prev")} onClick={() => go(prevId)} disabled={!prevId}>
<SkipBack className="w-5 h-5" />
</Ctrl>
<Ctrl label={t("plex.player.next")} onClick={() => go(nextId)} disabled={!nextId}>
<SkipForward className="w-5 h-5" />
</Ctrl>
</>
{nextId && (
<Ctrl label={t("plex.player.next")} onClick={() => go(nextId)}>
<SkipForward className="w-5 h-5" />
</Ctrl>
)}
<div className="flex items-center gap-2">
@@ -1427,8 +1550,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
keyboard and screen-reader support come with it — the hand-drawn bar it replaces had
none of them. The level shows above it while hovered. A pointer drag hands the focus
back so Space and the arrows keep driving the player, not the slider. */}
{/* Not on touch: a tablet's volume is its buttons (iPadOS ignores a page's), and the
row has to fit a portrait screen. */}
<div
className="relative w-24"
className="relative w-24 [@media(hover:none)]:hidden"
onPointerEnter={() => setVolHover(true)}
onPointerLeave={() => setVolHover(false)}
>
@@ -1449,7 +1574,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
</div>
</div>
<div className="text-xs tabular-nums text-white/90">
<div className="min-w-0 truncate text-xs tabular-nums text-white/90">
{fmt(abs)} / {fmt(duration)}
<span className="ml-1.5 text-white/55">-{fmt(Math.max(0, duration - abs))}</span>
{/* Projected finish time (wall clock now + time remaining), in the clock's own format —
@@ -1537,46 +1662,57 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
</div>
)}
{/* Gear = tuning, split into tabs (Sync / Playback / Subtitle). */}
{detail && (
<div className="relative">
<IconButton
variant="overlay"
size="player"
round
ref={menuTriggerRef}
onClick={() => setMenuOpen((o) => !o)}
aria-expanded={menuOpen}
label={t("plex.player.settings")}
{/* The fixed ⋯ (C5): the rare tools. Settings opens the tuning panel from the same spot,
so the ⋯ also closes it. */}
<div className="relative">
<IconButton
variant="overlay"
size="player"
round
ref={menuTriggerRef}
onClick={() => (menuOpen ? setMenuOpen(false) : setMoreOpen((o) => !o))}
aria-expanded={moreOpen || menuOpen}
label={t("plex.player.more")}
>
<MoreHorizontal />
</IconButton>
{moreOpen && (
<div
ref={moreRef}
onClick={(e) => e.stopPropagation()}
className="border border-white/15 bg-media-chrome/95 absolute bottom-full right-0 z-menu mb-2 flex w-60 flex-col rounded-xl p-1.5 text-sm text-white shadow-2xl"
>
<Settings />
</IconButton>
{menuOpen && (
<PlexPlayerSettings
menuRef={menuRef}
settingsTabs={settingsTabs}
activeTab={activeTab}
setSettingsTab={setSettingsTab}
prefs={prefs}
patchPrefs={patchPrefs}
changeAudioOffset={changeAudioOffset}
now={now}
/>
)}
</div>
)}
<Ctrl label={t("plex.player.stats.title")} onClick={() => setStatsOpen((v) => !v)}>
<Gauge className="w-5 h-5" />
</Ctrl>
<Ctrl label={t("plex.player.infoBtn")} onClick={() => setInfoOpen((v) => !v)}>
<Info className="w-5 h-5" />
</Ctrl>
<Ctrl label={t("plex.player.help")} onClick={toggleKeyboardHelp}>
<Keyboard className="w-5 h-5" />
</Ctrl>
<Ctrl label={t("plex.player.download")} onClick={download}>
<Download className="w-5 h-5" />
</Ctrl>
{moreItems.map((it) => (
<Pressable
key={it.id}
onClick={() => {
setMoreOpen(false);
// Back on the ⋯ before the tool opens: its row is about to unmount, and a
// dialog opened from here returns the focus to where it found it.
menuTriggerRef.current?.focus();
it.run();
}}
className="touch-target flex min-h-9 w-full items-center gap-2.5 rounded-lg px-2.5 text-left hover:bg-white/10"
>
{it.icon}
{it.label}
</Pressable>
))}
</div>
)}
{menuOpen && (
<PlexPlayerSettings
menuRef={menuRef}
settingsTabs={settingsTabs}
activeTab={activeTab}
setSettingsTab={setSettingsTab}
prefs={prefs}
patchPrefs={patchPrefs}
changeAudioOffset={changeAudioOffset}
now={now}
/>
)}
</div>
<Ctrl label={t("plex.player.fullscreen")} onClick={toggleFs} align="end">
{fs ? <Minimize className="w-5 h-5" /> : <Maximize className="w-5 h-5" />}
</Ctrl>