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
+30
View File
@@ -712,6 +712,36 @@ describe("Home", () => {
expect(setPage).toHaveBeenCalledWith("plex");
});
// C5: what a tile's Play would continue, its menu can start over — the one way to on touch.
it("a started film's menu starts it over: the player opens from the start", async () => {
const movie = {
...ytCard("m1", "A Film"),
source: "plex",
kind: "movie",
aspect: "poster",
position_seconds: 600,
video: null,
plex: { id: "m1", type: "movie", title: "A Film", thumb: "", position_seconds: 600 },
};
discover.mockResolvedValue({
budget: "any",
hero: null,
continue_watching: [movie],
comfort: [],
});
mount();
fireEvent.click((await screen.findAllByRole("button", { name: "card.menu.more" }))[0]!);
const restart = screen
.getAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === "card.restartTitle");
fireEvent.click(restart!);
await waitFor(() =>
expect(setJumpTo).toHaveBeenCalledWith(
expect.objectContaining({ id: "m1", type: "play", fromStart: true }),
),
);
});
it("rotates the tier on each press so the button offers a different KIND of answer", async () => {
// Measured 2026-09-06: exclusion alone left it walking the resume rail and cycling nine cards,
// never once reaching the shelf. The press count travels to the server as the rotation.
+30 -4
View File
@@ -10,6 +10,7 @@ import {
PinOff,
Play,
RotateCcw,
SkipBack,
Star,
} from "lucide-react";
import { qk } from "../lib/queryKeys";
@@ -51,7 +52,8 @@ import { SURFACE } from "./ui/surfaces";
import clsx from "clsx";
import { IconButton } from "./ui/IconButton";
import { MoreButton } from "./ui/MoreButton";
import { HOVER_YIELD } from "./ui/reveal";
import { HOVER_REVEAL, HOVER_YIELD } from "./ui/reveal";
import { MediaResumeButtons } from "./MediaResumeButtons";
import { MediaCard, MediaPlayGlyph } from "./ui/MediaCard";
import {
MEDIA_BAR,
@@ -171,6 +173,17 @@ function useHomeCardMenu({
disabled: !(target.next ?? target).video && !(target.next ?? target).plex,
onSelect: () => onPlay(target),
},
// What the Play row would continue can also start over (C5), with the card menus' words.
...((target.next ?? target).position_seconds > 0
? [
{
id: "restart",
label: t("card.restartTitle"),
icon: <SkipBack className="w-4 h-4" />,
onSelect: () => onPlay(target, 0),
},
]
: []),
...(target.video
? videoActions
.items(target.video, menu.anchor, { pin: false })
@@ -344,6 +357,7 @@ function Rail({
shelf
navIndex={i}
onOpen={() => onPlay(c)}
onRestart={() => onPlay(c, 0)}
onToggleWatched={onToggleWatched}
onAdd={plexAdd}
next={c.next?.plex}
@@ -569,10 +583,21 @@ function Hero({
/>
</span>
) : null}
<div className={MEDIA_HOVER_LAYER}>
<MediaPlayGlyph />
</div>
<div className={MEDIA_HOVER_LAYER}>{!started && <MediaPlayGlyph />}</div>
</Pressable>
{/* A started hero continues on a click, and offers the start as a card does (C5): the pair
is a sibling over the picture, never inside its button. */}
{started && (
<div
className={clsx(
HOVER_REVEAL,
"pointer-events-none absolute inset-0 z-base flex items-center justify-center gap-2 *:pointer-events-auto",
card.aspect === "poster" && "flex-col",
)}
>
<MediaResumeButtons onContinue={() => onPlay(card)} onRestart={() => onPlay(card, 0)} />
</div>
)}
{/* The card's own hover row, as on every card: a sibling of the picture's button. */}
{card.video ? (
<div className={MEDIA_BAR}>
@@ -851,6 +876,7 @@ export default function Home() {
setJumpTo({
id: target.plex.id,
type: target.plex.type === "show" ? "show" : "play",
fromStart: startAt === 0,
replace: true,
});
setPage("plex");
@@ -0,0 +1,66 @@
import type { MouseEvent } from "react";
import { useTranslation } from "react-i18next";
import { Play, RotateCcw } from "lucide-react";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
// The started card's hover pair (DESIGN "Players", C5: restarting lives on the card): Continue in the
// accent, Restart dark translucent. ONE component for the YouTube card and the Plex cards, so the one
// media card says the same two words in the same two looks. A small picture (a shelf's landscape, the
// dense video card) takes the icon-only pair, which fits beside the hover row. Touch has no hover: a
// tap on the picture continues, and the card's "…" carries the restart.
export function MediaResumeButtons({
small = false,
onContinue,
onRestart,
}: {
small?: boolean;
onContinue: (e: MouseEvent) => void;
onRestart: (e: MouseEvent) => void;
}) {
const { t } = useTranslation();
return small ? (
<>
<IconButton
variant="primary"
size="md"
round
onClick={onContinue}
label={t("card.continue")}
title={t("card.continueTitle")}
>
<Play className="fill-current" />
</IconButton>
<IconButton
variant="media"
size="md"
onClick={onRestart}
label={t("card.restart")}
title={t("card.restartTitle")}
>
<RotateCcw />
</IconButton>
</>
) : (
<>
<Button
size="sm"
round
onClick={onContinue}
title={t("card.continueTitle")}
icon={<Play className="fill-current" />}
>
{t("card.continue")}
</Button>
<Button
variant="media"
size="sm"
onClick={onRestart}
title={t("card.restartTitle")}
icon={<RotateCcw />}
>
{t("card.restart")}
</Button>
</>
);
}
@@ -401,3 +401,45 @@ describe("PlayerModal — the watch toggle after a queue advance", () => {
expect(screen.queryAllByTitle("player.watchedUnmark").length).toBeGreaterThan(0);
});
});
// C3: the description used to open on hovering the title, which a finger never does. It opens from
// an ⓘ beside the title now, by click or tap, and a hover over the title opens nothing.
describe("PlayerModal — the description", () => {
it("opens from the ⓘ, not from hovering the title", async () => {
vi.spyOn(api, "videoDetail").mockResolvedValue({
description: "A long description",
} as Awaited<ReturnType<typeof api.videoDetail>>);
const A = vid("A", 0);
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
fireEvent.mouseEnter(screen.getByRole("heading", { name: "Video A" }));
fireEvent.pointerEnter(screen.getByRole("heading", { name: "Video A" }));
await new Promise((r) => setTimeout(r, 500));
expect(screen.queryByText("A long description")).toBeNull();
const info = screen.getByRole("button", { name: "player.description" });
fireEvent.click(info);
expect(await screen.findByText("A long description")).toBeTruthy();
expect(info.getAttribute("aria-expanded")).toBe("true");
});
});
// C3: the queue's side arrows sit in a gutter of the card's row, not outside it, so no window cuts
// them off (outside the card, a 1024 window cut them and a portrait tablet lost them).
describe("PlayerModal — the side arrows", () => {
it("stand inside the row's gutter", () => {
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 arrow = [...document.querySelectorAll<HTMLElement>("[aria-label='player.next']")].find(
(b) => b.className.includes("w-12"),
)!;
expect(arrow).toBeTruthy();
expect(arrow.className).toMatch(/\bright-0\b/);
expect(arrow.className).not.toMatch(/left-full/);
expect(arrow.parentElement!.className).toMatch(/sm:px-14/);
});
});
+26 -22
View File
@@ -15,6 +15,7 @@ import {
ChevronLeft,
ChevronRight,
ExternalLink,
Info,
Maximize,
Minimize,
Minimize2,
@@ -403,25 +404,23 @@ export default function PlayerModal({
}
};
// Lazy description (fetched only when the title is hovered). Anchored ABOVE the title so it grows
// upward over the player rather than downward off-screen / behind the OS taskbar — `flip` still
// rescues it when there is no room up there either, which the old fixed `bottom` could not. The
// hover-intent delay (only pop it if the pointer lingers, so a quick pass doesn't flash the card)
// and the travel grace are now the platform's, along with the safePolygon bridge that replaces the
// pair of hand-managed timers.
// Lazy description (fetched only when it is opened). It opens from an ⓘ beside the title, by click
// or tap (C3): it used to open on hovering the title, which a finger never does — measured live,
// a tablet could not read a description at all. Anchored ABOVE, so it grows upward over the player
// rather than off-screen; `flip` still rescues it when there is no room up there.
const [showDesc, setShowDesc] = useState(false);
const descPop = useAnchoredPopover({
open: showDesc,
onOpenChange: setShowDesc,
mode: "hover",
mode: "dialog",
role: "dialog",
placement: "top-start",
offset: 8,
hoverDelay: { open: 400, close: 150 },
});
const detail = useQuery({
queryKey: qk.videoDetail(currentVideoId),
queryFn: () => api.videoDetail(currentVideoId),
// On hover (for the description) and eagerly when navigated, so we can link the
// On opening the description, and eagerly when navigated, so we can link the
// linked video's channel. Both share the one cached result.
enabled: showDesc || navigated,
staleTime: 5 * 60_000,
@@ -574,10 +573,13 @@ export default function PlayerModal({
aria-labelledby={titleId}
>
{/* The card, flanked by full-height glassy strips that step through the feed's order (or a
playlist). Each strip sits just outside the card, spans its full height, and fades out at
the ends. stopPropagation so a click steps instead of closing. Hidden on narrow screens
where there's no room beside the card. */}
<div className="relative flex w-full max-w-4xl max-h-full">
playlist). Each strip sits in a gutter of the card's row, spans its full height, and fades
out at the ends; the gutter keeps it on the screen (C3: outside the card, a 1024 window cut
it and a portrait tablet lost it). stopPropagation so a click steps instead of closing.
Hidden on narrow screens where there's no room beside the card. */}
<div
className={`relative flex w-full max-h-full ${hasQueue ? "max-w-[63rem] sm:px-14" : "max-w-4xl"}`}
>
{hasQueue && (
<Pressable
onClick={(e) => {
@@ -587,7 +589,7 @@ export default function PlayerModal({
disabled={index === 0}
aria-label={t("player.previous")}
title={`${t("player.previous")} · Shift+←`}
className="absolute right-full top-0 bottom-0 mr-2 hidden w-12 place-items-center rounded-2xl bg-white/5 text-white/40 backdrop-blur-xs transition hover:bg-white/15 hover:text-white disabled:pointer-events-none disabled:opacity-0 sm:grid"
className="absolute left-0 top-0 bottom-0 hidden w-12 place-items-center rounded-2xl bg-white/5 text-white/40 backdrop-blur-xs transition hover:bg-white/15 hover:text-white disabled:pointer-events-none disabled:opacity-0 sm:grid"
>
<ChevronLeft className="h-8 w-8" />
</Pressable>
@@ -778,18 +780,20 @@ export default function PlayerModal({
)}
<div className="p-4 sm:p-5">
{/* Title row — title (with hover description) on the left, Close on the right. */}
{/* Title row — the title and its ⓘ (the description) on the left, Close on the right. */}
<div className="flex items-start gap-3">
<h2 id={titleId} className="min-w-0 flex-1 text-lg font-semibold leading-snug">
{/* Hover target is the text itself (inline), not the whole row. */}
<span
{navigated ? (liveData?.title ?? t("player.loading")) : active.title}
</h2>
<IconButton
ref={descPop.refs.setReference}
{...descPop.getReferenceProps()}
className="cursor-default"
variant="ghost"
aria-expanded={showDesc}
label={t("player.description")}
>
{navigated ? (liveData?.title ?? t("player.loading")) : active.title}
</span>
</h2>
<Info />
</IconButton>
{navigated && (
<Button
variant="ghost"
@@ -999,7 +1003,7 @@ export default function PlayerModal({
disabled={index >= queue!.length - 1}
aria-label={t("player.next")}
title={`${t("player.next")} · Shift+→`}
className="absolute left-full top-0 bottom-0 ml-2 hidden w-12 place-items-center rounded-2xl bg-white/5 text-white/40 backdrop-blur-xs transition hover:bg-white/15 hover:text-white disabled:pointer-events-none disabled:opacity-0 sm:grid"
className="absolute right-0 top-0 bottom-0 hidden w-12 place-items-center rounded-2xl bg-white/5 text-white/40 backdrop-blur-xs transition hover:bg-white/15 hover:text-white disabled:pointer-events-none disabled:opacity-0 sm:grid"
>
<ChevronRight className="h-8 w-8" />
</Pressable>
+26 -7
View File
@@ -152,7 +152,7 @@ export default function PlexBrowse() {
jumpTo.type === "show"
? { kind: "show", id: jumpTo.id }
: jumpTo.type === "play"
? { kind: "player", id: jumpTo.id }
? { kind: "player", id: jumpTo.id, fromStart: jumpTo.fromStart }
: { kind: "info", id: jumpTo.id },
// A cross-module hand-off continues the navigation that brought us here rather than adding
// to it. It also makes this effect idempotent, which matters more than it looks: under
@@ -334,6 +334,11 @@ export default function PlexBrowse() {
if (card.type === "show") sub.open({ kind: "show", id: card.id });
else sub.open({ kind: "player", id: card.id });
}
// The started card's Restart (C5): the same trip, from 0 instead of the resume point.
function onRestartCard(card: PlexCard) {
scrollRef.current = scroller()?.scrollTop ?? 0;
sub.open({ kind: "player", id: card.id, fromStart: true });
}
function onInfo(card: PlexCard) {
scrollRef.current = scroller()?.scrollTop ?? 0;
rememberInfoScroll(0); // fresh info page starts at the top
@@ -370,11 +375,11 @@ export default function PlexBrowse() {
resync: () => qc.invalidateQueries({ queryKey: qk.plexLibrary() }),
});
const actions = usePlexCardActions(
{ onOpen: onCard, onInfo, onToggleWatched: toggleWatched },
{ onOpen: onCard, onRestart: onRestartCard, onInfo, onToggleWatched: toggleWatched },
menu.open,
);
const epActions = usePlexCardActions(
{ onOpen: onCard, onToggleWatched: toggleWatched },
{ onOpen: onCard, onRestart: onRestartCard, onToggleWatched: toggleWatched },
epMenu.open,
);
// Apply a metadata filter (clicked on an info page / show hero / cast) then show the unified grid.
@@ -408,7 +413,12 @@ export default function PlexBrowse() {
<Suspense
fallback={createPortal(<div className="fixed inset-0 z-overlay bg-black" />, document.body)}
>
<PlexPlayer itemId={sub.view.id} queue={sub.view.queue} onClose={sub.back} />
<PlexPlayer
itemId={sub.view.id}
queue={sub.view.queue}
fromStart={sub.view.fromStart}
onClose={sub.back}
/>
</Suspense>
);
}
@@ -419,7 +429,9 @@ export default function PlexBrowse() {
<PlexPlaylistView
playlistId={pid}
onBack={sub.back}
onPlay={(itemRk, queue) => sub.open({ kind: "player", id: itemRk, queue })}
onPlay={(itemRk, queue, fromStart) =>
sub.open({ kind: "player", id: itemRk, queue, fromStart })
}
onInfoCard={onInfo}
/>
</Suspense>
@@ -434,6 +446,7 @@ export default function PlexBrowse() {
onBack={sub.back}
onPlay={(rk, queue) => sub.open({ kind: "player", id: rk, queue })}
onOpenCard={onCard}
onRestartCard={onRestartCard}
onInfoCard={onInfo}
/>
</Suspense>
@@ -446,7 +459,7 @@ export default function PlexBrowse() {
id={infoId}
onBack={sub.back}
onPlay={() => sub.open({ kind: "player", id: infoId })}
onPlayItem={(id) => sub.open({ kind: "player", id })}
onPlayItem={(id, fromStart) => sub.open({ kind: "player", id, fromStart })}
onFilter={applyFilter}
onOpenPerson={onOpenPerson}
/>
@@ -459,6 +472,7 @@ export default function PlexBrowse() {
name={personName}
onBack={sub.back}
onOpenCard={onCard}
onRestartCard={onRestartCard}
onInfoCard={onInfo}
onFilter={applyFilter}
/>
@@ -485,7 +499,9 @@ export default function PlexBrowse() {
showId={showId}
seasonId={seasonId}
onBack={sub.back}
onPlay={(epRk, queue) => sub.open({ kind: "player", id: epRk, queue })}
onPlay={(epRk, queue, fromStart) =>
sub.open({ kind: "player", id: epRk, queue, fromStart })
}
/>
);
}
@@ -510,6 +526,7 @@ export default function PlexBrowse() {
<PlexDiscover
scope={scope}
onOpenCard={onCard}
onRestartCard={onRestartCard}
onInfoCard={onInfo}
onGenre={(g) => applyFilter({ genres: [g] })}
onSeeAllRecent={() => sub.open({ kind: "grid" })}
@@ -685,6 +702,7 @@ export default function PlexBrowse() {
navIndex={i}
card={c}
onOpen={() => onCard(c)}
onRestart={() => onRestartCard(c)}
onToggleWatched={toggleWatched}
onAdd={actions.add}
menuProps={menu.triggerProps}
@@ -707,6 +725,7 @@ export default function PlexBrowse() {
ep={ep}
withShowTitle
onPlay={() => onCard(ep)}
onRestart={() => onRestartCard(ep)}
onToggleWatched={() => toggleWatched(ep)}
onAdd={() => epActions.add(ep)}
menuProps={epMenu.triggerProps}
@@ -95,6 +95,7 @@ function mount(items: PlexCard[], onPlay = vi.fn()) {
onBack={() => {}}
onPlay={onPlay}
onOpenCard={() => {}}
onRestartCard={() => {}}
onInfoCard={() => {}}
/>
</QueryClientProvider>,
@@ -192,6 +193,7 @@ describe("PlexCollectionView — after the sort joined the query key", () => {
onBack={() => {}}
onPlay={() => {}}
onOpenCard={() => {}}
onRestartCard={() => {}}
onInfoCard={() => {}}
/>
</QueryClientProvider>
@@ -56,12 +56,14 @@ export default function PlexCollectionView({
onBack,
onPlay,
onOpenCard,
onRestartCard,
onInfoCard,
}: {
collectionId: string;
onBack: () => void;
onPlay: (rk: string, queue: string[]) => void;
onOpenCard: (card: PlexCard) => void;
onRestartCard: (card: PlexCard) => void;
onInfoCard: (card: PlexCard) => void;
}) {
const { t } = useTranslation();
@@ -185,7 +187,12 @@ export default function PlexCollectionView({
},
});
const actions = usePlexCardActions(
{ onOpen: onOpenCard, onInfo: onInfoCard, onToggleWatched: toggleWatched },
{
onOpen: onOpenCard,
onRestart: onRestartCard,
onInfo: onInfoCard,
onToggleWatched: toggleWatched,
},
menu.open,
);
@@ -430,6 +437,7 @@ export default function PlexCollectionView({
card={c}
navIndex={i}
onOpen={() => onOpenCard(c)}
onRestart={() => onRestartCard(c)}
onToggleWatched={toggleWatched}
onAdd={actions.add}
menuProps={menu.triggerProps}
@@ -98,6 +98,7 @@ describe("PlexDiscover", () => {
<PlexDiscover
scope="both"
onOpenCard={noop}
onRestartCard={() => {}}
onInfoCard={noop}
onGenre={noop}
onSeeAllRecent={noop}
@@ -136,6 +137,7 @@ describe("PlexDiscover", () => {
<PlexDiscover
scope="both"
onOpenCard={noop}
onRestartCard={() => {}}
onInfoCard={noop}
onGenre={noop}
onSeeAllRecent={noop}
@@ -169,6 +171,7 @@ describe("PlexDiscover", () => {
<PlexDiscover
scope="both"
onOpenCard={noop}
onRestartCard={() => {}}
onInfoCard={noop}
onGenre={onGenre}
onSeeAllRecent={noop}
@@ -186,6 +189,7 @@ describe("PlexDiscover", () => {
<PlexDiscover
scope="both"
onOpenCard={noop}
onRestartCard={() => {}}
onInfoCard={noop}
onGenre={noop}
onSeeAllRecent={onSeeAllRecent}
@@ -202,6 +206,7 @@ describe("PlexDiscover", () => {
<PlexDiscover
scope="both"
onOpenCard={noop}
onRestartCard={() => {}}
onInfoCard={noop}
onGenre={noop}
onSeeAllRecent={noop}
@@ -228,6 +233,7 @@ describe("PlexDiscover", () => {
<PlexDiscover
scope="both"
onOpenCard={noop}
onRestartCard={() => {}}
onInfoCard={noop}
onGenre={noop}
onSeeAllRecent={noop}
@@ -252,6 +258,7 @@ describe("PlexDiscover", () => {
<PlexDiscover
scope="both"
onOpenCard={noop}
onRestartCard={() => {}}
onInfoCard={noop}
onGenre={noop}
onSeeAllRecent={noop}
@@ -274,6 +281,7 @@ describe("PlexDiscover · keyboard", () => {
<PlexDiscover
scope="both"
onOpenCard={noop}
onRestartCard={() => {}}
onInfoCard={noop}
onGenre={noop}
onSeeAllRecent={noop}
+7 -2
View File
@@ -31,6 +31,7 @@ function CardRail({
onSeeAll,
cards,
onOpenCard,
onRestartCard,
onInfoCard,
onToggleWatched,
action,
@@ -41,6 +42,7 @@ function CardRail({
onSeeAll?: () => void;
cards: PlexCard[];
onOpenCard: (c: PlexCard) => void;
onRestartCard: (c: PlexCard) => void;
onInfoCard: (c: PlexCard) => void;
onToggleWatched: (c: PlexCard) => void;
action?: ReactNode; // optional header control (e.g. the Surprise-me reshuffle button)
@@ -48,7 +50,7 @@ function CardRail({
// One menu per rail (see ui/ContextMenu), not one per poster.
const menu = useContextMenu<PlexCard>();
const actions = usePlexCardActions(
{ onOpen: onOpenCard, onInfo: onInfoCard, onToggleWatched },
{ onOpen: onOpenCard, onRestart: onRestartCard, onInfo: onInfoCard, onToggleWatched },
menu.open,
);
const heading =
@@ -76,6 +78,7 @@ function CardRail({
card={c}
navIndex={i}
onOpen={() => onOpenCard(c)}
onRestart={() => onRestartCard(c)}
onToggleWatched={onToggleWatched}
onAdd={actions.add}
menuProps={menu.triggerProps}
@@ -93,12 +96,14 @@ function CardRail({
export default function PlexDiscover({
scope,
onOpenCard,
onRestartCard,
onInfoCard,
onGenre,
onSeeAllRecent,
}: {
scope: string;
onOpenCard: (c: PlexCard) => void;
onRestartCard: (c: PlexCard) => void;
onInfoCard: (c: PlexCard) => void;
onGenre: (genre: string) => void;
onSeeAllRecent: () => void;
@@ -184,7 +189,7 @@ export default function PlexDiscover({
return <p className="text-muted text-sm">{t("plex.discover.empty")}</p>;
}
const railProps = { onOpenCard, onInfoCard, onToggleWatched: toggleWatched };
const railProps = { onOpenCard, onRestartCard, onInfoCard, onToggleWatched: toggleWatched };
return (
<div>
+2 -1
View File
@@ -347,7 +347,7 @@ type Props = {
// When provided, a cast member's photo/name opens their filmography page instead of filtering.
onOpenPerson?: (name: string) => void;
// Play a sibling title from a collection strip (opens its player).
onPlayItem?: (id: string) => void;
onPlayItem?: (id: string, fromStart?: boolean) => void;
// The item's library key — enables the admin "Manage collections" editor (page variant, movies).
library?: string;
};
@@ -720,6 +720,7 @@ export default function PlexInfo({
shelf
navIndex={i}
onOpen={() => onPlayItem?.(m.id)}
onRestart={onPlayItem && (() => onPlayItem(m.id, true))}
/>
</div>
))}
+24 -1
View File
@@ -102,7 +102,12 @@ function Grid({
}
function setup(c: PlexCard, opts: Omit<Parameters<typeof Grid>[0], "c" | "handlers"> = {}) {
const handlers = { onOpen: vi.fn(), onInfo: vi.fn(), onToggleWatched: vi.fn() };
const handlers = {
onOpen: vi.fn(),
onInfo: vi.fn(),
onToggleWatched: vi.fn(),
onRestart: vi.fn(),
};
render(
<QueryClientProvider client={new QueryClient()}>
<FloatingTree>
@@ -131,6 +136,24 @@ describe("a Plex title's menu", () => {
]);
});
// C5: on touch the card's hover pair is gone, so the menu is where a started title starts over.
it("a started title continues from its Play row and starts over from the next one", async () => {
const { handlers } = setup(card({ status: "in_progress", position_seconds: 600 }));
rightClick();
expect(labels().slice(0, 2)).toEqual(["card.continue", "card.restartTitle"]);
fireEvent.click(item("card.restartTitle"));
// A row acts a task after the menu closes (closeThenAct).
await vi.waitFor(() =>
expect(handlers.onRestart).toHaveBeenCalledWith(expect.objectContaining({ id: "m1" })),
);
});
it("a watched title has no restart row: it plays from the start anyway", () => {
setup(card({ status: "watched", position_seconds: 600 }));
rightClick();
expect(labels()).not.toContain("card.restartTitle");
});
it("a show opens its page and has no watched, add or details rows", () => {
setup(card({ type: "show" }));
rightClick();
@@ -100,6 +100,7 @@ describe("PlexPersonView", () => {
name="Jane Doe"
onBack={() => {}}
onOpenCard={() => {}}
onRestartCard={() => {}}
onInfoCard={() => {}}
onFilter={onFilter}
/>,
@@ -139,6 +140,7 @@ describe("PlexPersonView", () => {
name="Nobody"
onBack={() => {}}
onOpenCard={() => {}}
onRestartCard={() => {}}
onInfoCard={() => {}}
onFilter={() => {}}
/>,
@@ -0,0 +1,367 @@
// @vitest-environment jsdom
//
// The Plex player's controls on touch and the C5 control set (DESIGN "Players", E28 S3f2): a tap on
// the picture only shows or hides the controls, a double-tap on a side third skips 10 s, Back goes by
// position, Next exists only with a next item, the rare tools sit behind a fixed ⋯, and the card's
// Restart opens the player at 0. jsdom has no media pipeline: the <video> is stubbed as in
// PlexPlayer.session.dom.test.tsx, and where the player asks a session to start is the observable.
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
import { render, cleanup, waitFor, screen, fireEvent, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
t: (key: string, o?: { n?: number }) => (o?.n != null ? `${key}:${o.n}` : key),
i18n: { language: "en" },
}),
};
});
import PlexPlayer from "./PlexPlayer";
import { api, type PlexItemDetail } from "../lib/api";
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
type Media = HTMLMediaElement & { _t?: number; _paused?: boolean };
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
// jsdom has no PointerEvent, and the player tells a finger from a mouse by `pointerType`.
class PointerEventStub extends MouseEvent {
pointerType: string;
constructor(type: string, init: PointerEventInit = {}) {
super(type, init);
this.pointerType = init.pointerType ?? "mouse";
}
}
globalThis.PointerEvent ??= PointerEventStub as unknown as typeof PointerEvent;
Object.defineProperty(HTMLMediaElement.prototype, "currentTime", {
configurable: true,
get(this: Media) {
return this._t ?? 0;
},
set(this: Media, v: number) {
this._t = v;
},
});
Object.defineProperty(HTMLMediaElement.prototype, "paused", {
configurable: true,
get(this: Media) {
return this._paused ?? true;
},
});
Object.defineProperty(HTMLMediaElement.prototype, "load", {
configurable: true,
value(this: Media) {
this._t = 0;
},
});
Object.defineProperty(HTMLMediaElement.prototype, "seekable", {
configurable: true,
get: () => ({ length: 0, end: () => 0 }),
});
Object.defineProperty(HTMLMediaElement.prototype, "textTracks", {
configurable: true,
get: () => Object.assign([], { addEventListener() {}, removeEventListener() {} }),
});
});
let play: ReturnType<typeof vi.fn>;
let pause: ReturnType<typeof vi.fn>;
beforeEach(() => {
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "7");
play = vi.fn(function (this: Media) {
this._paused = false;
this.dispatchEvent(new Event("play"));
return Promise.resolve();
});
pause = vi.fn(function (this: Media) {
this._paused = true;
this.dispatchEvent(new Event("pause"));
});
Object.defineProperty(HTMLMediaElement.prototype, "play", { configurable: true, value: play });
Object.defineProperty(HTMLMediaElement.prototype, "pause", { configurable: true, value: pause });
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.useRealTimers();
sessionStorage.clear();
localStorage.clear();
});
const item = (id: string, position = 0, next: string | null = null): PlexItemDetail =>
({
id,
title: `Title ${id}`,
type: "movie",
duration_seconds: 3000,
status: "new",
position_seconds: position,
next_id: next,
prev_id: null,
audio_streams: [{ ord: 0, label: "English", language: "en", default: true }],
subtitle_streams: [],
markers: [],
}) as unknown as PlexItemDetail;
const direct = (id: string) =>
({ mode: "direct", url: `/media/${id}`, start_s: 0 }) as Awaited<
ReturnType<typeof api.plexSession>
>;
function mount(opts: {
details: Record<string, PlexItemDetail>;
queue?: string[];
first?: string;
fromStart?: boolean;
}) {
vi.spyOn(api, "plexItem").mockImplementation((id: string) => Promise.resolve(opts.details[id]!));
const session = vi
.spyOn(api, "plexSession")
.mockImplementation((id: string) => Promise.resolve(direct(id)));
vi.spyOn(api, "plexProgress").mockResolvedValue(undefined as never);
vi.spyOn(api, "plexSubtitleStatus").mockResolvedValue({ state: "preparing", code: null, gen: 0 });
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const first = opts.first ?? opts.queue?.[0] ?? Object.keys(opts.details)[0]!;
render(
<QueryClientProvider client={qc}>
<PlexPlayer itemId={first} onClose={() => {}} queue={opts.queue} fromStart={opts.fromStart} />
</QueryClientProvider>,
);
return { session };
}
const video = () => document.querySelector("video") as Media;
/** Which item the player last asked a session for, and where in it. */
const lastStart = (session: { mock: { calls: unknown[][] } }) =>
session.mock.calls[session.mock.calls.length - 1]?.slice(0, 2);
async function playingAt(src: string, t: number) {
await waitFor(() => expect(video()?.getAttribute("src")).toBe(src));
const v = video();
v._t = t;
act(() => void v.dispatchEvent(new Event("timeupdate")));
}
/** The bottom band (the seek bar and the row): hidden = faded AND out of the pointer's way. */
const bandHidden = () =>
screen
.getByLabelText("plex.player.playPause")
.closest("[class*='bottom-0']")!
.className.includes("opacity-0");
/** A press on the picture, from a finger or a mouse, at `x` of a 900px-wide picture. */
function pressPicture(pointerType: "touch" | "mouse", x = 450) {
const v = video();
vi.spyOn(v, "getBoundingClientRect").mockReturnValue({
left: 0,
top: 0,
width: 900,
height: 500,
right: 900,
bottom: 500,
x: 0,
y: 0,
toJSON: () => ({}),
});
fireEvent.pointerDown(v, { pointerType });
fireEvent.click(v, { clientX: x, clientY: 250 });
}
describe("a tap on the picture (C3)", () => {
it("a mouse click plays or pauses; a finger's tap only shows or hides the controls", async () => {
mount({ details: { A: item("A") } });
await playingAt("/media/A", 100);
const plays = play.mock.calls.length;
const pauses = pause.mock.calls.length;
pressPicture("touch");
expect(bandHidden()).toBe(true);
pressPicture("touch");
expect(bandHidden()).toBe(false);
expect(play.mock.calls.length).toBe(plays);
expect(pause.mock.calls.length).toBe(pauses);
pressPicture("mouse");
expect(play.mock.calls.length + pause.mock.calls.length).toBe(plays + pauses + 1);
});
it("the hide timer starts with playback, not with the first touch", async () => {
mount({ details: { A: item("A") } });
await playingAt("/media/A", 100);
vi.useFakeTimers();
video()._paused = true;
act(() => void video().play());
expect(bandHidden()).toBe(false);
act(() => vi.advanceTimersByTime(3000));
expect(bandHidden()).toBe(true);
});
});
describe("a double-tap on a side third skips 10 s (C5)", () => {
it("skips from where it plays, again for each further tap, and takes back the first tap's toggle", async () => {
const { session } = mount({ details: { A: item("A") } });
await playingAt("/media/A", 1500);
expect(bandHidden()).toBe(false);
pressPicture("touch", 800);
expect(bandHidden()).toBe(true); // the first tap alone is a toggle…
pressPicture("touch", 800);
expect(bandHidden()).toBe(false); // …which the second takes back
await waitFor(() => expect(lastStart(session)).toEqual(["A", 1510]));
expect(screen.getByText("plex.player.tapForward:10")).toBeTruthy();
pressPicture("touch", 800);
await waitFor(() => expect(lastStart(session)).toEqual(["A", 1520]));
expect(screen.getByText("plex.player.tapForward:20")).toBeTruthy();
pressPicture("touch", 50); // the other side, a fresh first tap: a toggle, no skip
expect(lastStart(session)).toEqual(["A", 1520]);
});
it("the middle third never skips: a quick second tap there just toggles back", async () => {
const { session } = mount({ details: { A: item("A") } });
await playingAt("/media/A", 1500);
const calls = session.mock.calls.length;
pressPicture("touch", 450);
pressPicture("touch", 450);
expect(bandHidden()).toBe(false);
expect(session.mock.calls.length).toBe(calls);
});
it("the left third skips back", async () => {
const { session } = mount({ details: { A: item("A") } });
await playingAt("/media/A", 1500);
pressPicture("touch", 100);
pressPicture("touch", 100);
await waitFor(() => expect(lastStart(session)).toEqual(["A", 1490]));
expect(screen.getByText("plex.player.tapBack:10")).toBeTruthy();
});
});
describe("the C5 row", () => {
it("has no Stop, Restart or ±10 s buttons, and keeps the rare tools behind the ⋯", async () => {
mount({ details: { A: item("A") } });
await playingAt("/media/A", 100);
for (const gone of [
"plex.player.stop",
"plex.player.rewind10",
"plex.player.forward10",
"plex.player.stats.title",
"plex.player.infoBtn",
"plex.player.help",
"plex.player.download",
"plex.player.settings",
])
expect(screen.queryByLabelText(gone), gone).toBeNull();
fireEvent.click(screen.getByLabelText("plex.player.more"));
const rows = [
"plex.player.settings",
"plex.player.infoBtn",
"plex.player.stats.title",
"plex.player.help",
"plex.player.download",
];
for (const r of rows) expect(screen.getByRole("button", { name: r }), r).toBeTruthy();
// A row closes the menu and opens its tool: Settings → the tuning panel.
fireEvent.click(screen.getByRole("button", { name: "plex.player.settings" }));
expect(screen.queryByRole("button", { name: "plex.player.stats.title" })).toBeNull();
expect(screen.getByLabelText("plex.player.more").getAttribute("aria-expanded")).toBe("true");
});
it("Next shows only when there is a next item", async () => {
mount({ details: { A: item("A") } });
await playingAt("/media/A", 100);
expect(screen.queryByLabelText("plex.player.next")).toBeNull();
cleanup();
mount({ details: { A: item("A"), B: item("B") }, queue: ["A", "B"] });
await playingAt("/media/A", 100);
expect(screen.getByLabelText("plex.player.next")).toBeTruthy();
});
});
describe("Back goes by position (C5)", () => {
it("more than 3 s in: back to the start of this item", async () => {
// B opened at its resume point, so a session asked at 0 can only be the restart.
const { session } = mount({
details: { A: item("A"), B: item("B", 900) },
queue: ["A", "B"],
first: "B",
});
await playingAt("/media/B", 40);
fireEvent.click(screen.getByLabelText("plex.player.restart"));
// Inside the loaded media the restart is a native seek: the clock goes to 0, on B.
await waitFor(() => expect(video()._t).toBe(0));
expect(lastStart(session)?.[0]).toBe("B");
});
it("within the first 3 s: the previous item, at its resume point", async () => {
const { session } = mount({
details: { A: item("A", 700), B: item("B") },
queue: ["A", "B"],
first: "B",
});
await playingAt("/media/B", 2);
fireEvent.click(screen.getByLabelText("plex.player.prev"));
await waitFor(() => expect(lastStart(session)).toEqual(["A", 700]));
});
it("the first item has only the restart", async () => {
const { session } = mount({ details: { A: item("A", 900), B: item("B") }, queue: ["A", "B"] });
await playingAt("/media/A", 2);
expect(screen.queryByLabelText("plex.player.prev")).toBeNull();
fireEvent.click(screen.getByLabelText("plex.player.restart"));
await waitFor(() => expect(video()._t).toBe(0));
expect(lastStart(session)?.[0]).toBe("A");
});
});
describe("the card's Restart (C5)", () => {
it("opens the item at 0 instead of its resume point", async () => {
const { session } = mount({ details: { A: item("A", 900) }, fromStart: true });
await waitFor(() => expect(session).toHaveBeenCalled());
expect(session.mock.calls[0]![1]).toBe(0);
});
it("without it, the item resumes", async () => {
const { session } = mount({ details: { A: item("A", 900) } });
await waitFor(() => expect(session).toHaveBeenCalled());
expect(session.mock.calls[0]![1]).toBe(900);
});
it("only for the item it opened with: the next one resumes", async () => {
const { session } = mount({
details: { A: item("A", 900), B: item("B", 300) },
queue: ["A", "B"],
fromStart: true,
});
await playingAt("/media/A", 100);
fireEvent.click(screen.getByLabelText("plex.player.next"));
await waitFor(() => expect(lastStart(session)).toEqual(["B", 300]));
});
});
describe("the seek bar (C3)", () => {
it("is a 2.75rem hit area a finger drags without panning, with the head shown on touch", async () => {
mount({ details: { A: item("A") } });
await playingAt("/media/A", 100);
const hit = document.querySelector<HTMLElement>(".touch-none.h-11");
expect(hit).not.toBeNull();
const head = hit!.querySelector("[class*='rounded-full'][class*='w-3']");
expect(head?.className).toContain("[@media(hover:none)]:opacity-100");
});
});
@@ -180,7 +180,7 @@ it("a seek that restarts the session is saved at its target while the new sessio
});
const beacon = vi.spyOn(api, "plexProgressBeacon").mockImplementation(() => {});
await playingAt("/media/A", 1500);
fireEvent.click(screen.getByLabelText("plex.player.forward10"));
fireEvent.keyDown(document.body, { key: "ArrowRight" }); // the 10 s step
await waitFor(() => expect(api.plexSession).toHaveBeenCalledTimes(2)); // a restart, not a native seek
await tick();
act(() => void window.dispatchEvent(new Event("pagehide")));
@@ -526,8 +526,8 @@ it("a seek made while a restart is pending wins over it", async () => {
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/A"));
Object.defineProperty(video(), "seekable", { get: () => ({ length: 1, end: () => 100 }) });
await playingAt("/media/A", 95);
fireEvent.click(screen.getByLabelText("plex.player.forward10")); // 105: past the buffer → restart
fireEvent.click(screen.getByLabelText("plex.player.rewind10")); // 95: inside the OLD buffer
fireEvent.keyDown(document.body, { key: "ArrowRight" }); // the 10 s step // 105: past the buffer → restart
fireEvent.keyDown(document.body, { key: "ArrowLeft" }); // 95: inside the OLD buffer
await waitFor(() => expect(startsAsked("A")).toEqual([0, 105, 95]));
await act(async () => pending.resolve({ mode: "direct", url: "/media/A2", start_s: 0 } as never));
await tick();
@@ -594,7 +594,7 @@ describe("a session request that fails", () => {
},
});
await playingAt("/media/A", 1500);
fireEvent.click(screen.getByLabelText("plex.player.forward10"));
fireEvent.keyDown(document.body, { key: "ArrowRight" }); // the 10 s step
await screen.findByText("plex.player.errGeneric");
await playingAt("/media/A", 1600); // the old stream plays on
unmount();
@@ -154,14 +154,18 @@ describe("PlexPlayer · Tab stays inside the player", () => {
// DOES trap, so the player stood its own trap down for it and Tab walked out past the last
// control into the nav rail. Measured in a real browser: 30 Tabs, 9 escapes. The menus render
// inside the player, so the player's trap is the one that must stay on.
const gear = [...player().querySelectorAll<HTMLElement>("button")].find(
(b) => b.getAttribute("aria-label") === "plex.player.settings",
);
expect(gear).toBeDefined();
act(() => gear!.click());
const items = [...player().querySelectorAll<HTMLElement>("button:not([disabled])")];
// The menu really opened — the trap now has to span its controls too.
expect(items.length).toBeGreaterThan(12);
const buttons = () => [...player().querySelectorAll<HTMLElement>("button:not([disabled])")];
const before = buttons().length;
// The tuning panel opens from the ⋯ (C5): ⋯, then its Settings row.
const more = buttons().find((b) => b.getAttribute("aria-label") === "plex.player.more");
expect(more).toBeDefined();
act(() => more!.click());
const settings = buttons().find((b) => b.textContent === "plex.player.settings");
expect(settings).toBeDefined();
act(() => settings!.click());
const items = buttons();
// The panel really opened — the trap now has to span its controls too.
expect(items.length).toBeGreaterThan(before + 3);
const last = items[items.length - 1]!;
last.focus();
withLayout(() => press("Tab"));
+199 -63
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,6 +1486,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
{fmt(hover.t)}
</div>
)}
<div className="relative h-1.5 w-full rounded-full bg-white/25">
<div
className="absolute inset-y-0 left-0 bg-white/30 rounded-full"
style={{ width: `${bufPct}%` }}
@@ -1373,46 +1506,36 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
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"
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" />
</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}>
{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,20 +1662,44 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
</div>
)}
{/* Gear = tuning, split into tabs (Sync / Playback / Subtitle). */}
{detail && (
{/* 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={() => setMenuOpen((o) => !o)}
aria-expanded={menuOpen}
label={t("plex.player.settings")}
onClick={() => (menuOpen ? setMenuOpen(false) : setMoreOpen((o) => !o))}
aria-expanded={moreOpen || menuOpen}
label={t("plex.player.more")}
>
<Settings />
<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"
>
{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}
@@ -1564,19 +1713,6 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
/>
)}
</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>
<Ctrl label={t("plex.player.fullscreen")} onClick={toggleFs} align="end">
{fs ? <Minimize className="w-5 h-5" /> : <Maximize className="w-5 h-5" />}
</Ctrl>
+2 -1
View File
@@ -405,7 +405,7 @@ export default function PlexPlaylistView({
}: {
playlistId: number;
onBack: () => void;
onPlay: (itemRk: string, queue: string[]) => void;
onPlay: (itemRk: string, queue: string[], fromStart?: boolean) => void;
/** A title's media-info page, from its menu's "Details". */
onInfoCard?: (card: PlexCard) => void;
}) {
@@ -558,6 +558,7 @@ export default function PlexPlaylistView({
const titleActions = usePlexCardActions(
{
onOpen: (c) => onPlay(c.id, order),
onRestart: (c) => onPlay(c.id, order, true),
onInfo: onInfoCard,
onToggleWatched: (c) =>
void toggleWatchState(c, t, {
+2 -47
View File
@@ -8,7 +8,6 @@ import {
Clock,
Play,
Radio,
RotateCcw,
// Aliased: `Video` is this app's own domain type (lib/api), and it wins the name.
Video as VideoIcon,
} from "lucide-react";
@@ -23,7 +22,6 @@ import { isPlainLeftClick } from "../lib/nav";
import { endsTextSelection } from "../lib/textSelection";
import ChannelNameLink from "./ChannelNameLink";
import { IconButton } from "./ui/IconButton";
import { Button } from "./ui/Button";
import { SURFACE } from "./ui/surfaces";
import { MoreButton } from "./ui/MoreButton";
import { HOVER_REVEAL, HOVER_YIELD } from "./ui/reveal";
@@ -36,6 +34,7 @@ import {
mediaTitleClass,
} from "./ui/mediaCardStyles";
import { MediaCard, MediaProgress } from "./ui/MediaCard";
import { MediaResumeButtons } from "./MediaResumeButtons";
/** The "…" that opens the video's action list, and whether its menu is the open one. */
type More = { props: Record<string, unknown>; open: boolean };
@@ -294,51 +293,7 @@ function Thumb({
{onOpen && (
<div className={MEDIA_HOVER_LAYER}>
{inProgress ? (
<>
{small ? (
<IconButton
variant="primary"
size="md"
round
onClick={open(null)}
label={t("card.continue")}
title={t("card.continueTitle")}
>
<Play className="fill-current" />
</IconButton>
) : (
<Button
size="sm"
round
onClick={open(null)}
title={t("card.continueTitle")}
icon={<Play className="fill-current" />}
>
{t("card.continue")}
</Button>
)}
{small ? (
<IconButton
variant="media"
size="md"
onClick={open(0)}
label={t("card.restart")}
title={t("card.restartTitle")}
>
<RotateCcw />
</IconButton>
) : (
<Button
variant="media"
size="sm"
onClick={open(0)}
title={t("card.restartTitle")}
icon={<RotateCcw />}
>
{t("card.restart")}
</Button>
)}
</>
<MediaResumeButtons small={small} onContinue={open(null)} onRestart={open(0)} />
) : (
<IconButton
variant="media"
@@ -166,6 +166,57 @@ describe("PlexPosterCard with a next episode", () => {
});
});
// C5: a started item's picture offers Continue and Restart (the video card's pair) instead of a Play;
// they sit beside the picture's own button, never inside it.
describe("a started Plex card", () => {
const started: PlexCard = {
...movie("in_progress"),
position_seconds: 600,
duration_seconds: 6000,
};
it("shows Continue + Restart, not the Play, and each does its own thing", () => {
const onOpen = vi.fn();
const onRestart = vi.fn();
const { container } = render(
<PlexPosterCard card={started} onOpen={onOpen} onRestart={onRestart} />,
);
const picture = screen.getByRole("button", { name: "M" });
expect(picture.querySelector(".reveal-hover.inset-0 svg")).toBeNull();
const cont = screen.getByRole("button", { name: "card.continue" });
const restart = screen.getByRole("button", { name: "card.restart" });
expect(picture.contains(cont) || picture.contains(restart)).toBe(false);
fireEvent.click(restart);
expect(onRestart).toHaveBeenCalledTimes(1);
expect(onOpen).not.toHaveBeenCalled();
fireEvent.click(cont);
expect(onOpen).toHaveBeenCalledTimes(1);
// Mouse only: on touch the tap continues and the "…" has the restart.
expect(cont.closest(".reveal-hover")).not.toBeNull();
expect(container.querySelector(".flex-col.reveal-hover")).not.toBeNull(); // a poster stacks
});
it("an episode's landscape picture keeps the pair side by side", () => {
const { container } = render(
<EpisodeCard
ep={{ ...episode("in_progress"), position_seconds: 60, duration_seconds: 1200 }}
onPlay={vi.fn()}
onRestart={vi.fn()}
/>,
);
expect(screen.getByRole("button", { name: "card.restart" })).toBeTruthy();
expect(container.querySelector(".flex-col.reveal-hover")).toBeNull();
});
it("a not-started or watched card keeps the one Play", () => {
render(<PlexPosterCard card={movie("watched")} onOpen={vi.fn()} onRestart={vi.fn()} />);
expect(screen.queryByRole("button", { name: "card.restart" })).toBeNull();
expect(
screen.getByRole("button", { name: "M" }).querySelector(".reveal-hover.inset-0 svg"),
).not.toBeNull();
});
});
describe("SeasonCard a11y", () => {
it("names the watched-toggle and the add-season button", () => {
render(<SeasonCard se={season()} onOpen={vi.fn()} onToggleWatched={vi.fn()} onAdd={vi.fn()} />);
+33 -3
View File
@@ -11,7 +11,8 @@ import { plexAddLabelKey } from "./usePlexCardActions";
import { IconButton } from "./ui/IconButton";
import { MoreButton } from "./ui/MoreButton";
import { Pressable } from "./ui/Pressable";
import { HOVER_YIELD } from "./ui/reveal";
import { HOVER_REVEAL, HOVER_YIELD } from "./ui/reveal";
import { MediaResumeButtons } from "./MediaResumeButtons";
import {
MEDIA_BADGE,
MEDIA_BAR,
@@ -173,12 +174,15 @@ function CardTitle({
}
/** A Plex card's picture: the whole box is the card's open/play button (Enter opens it from the
* keyboard walk), with the hover row docked beside it rather than inside it. */
* keyboard walk), with the hover row docked beside it rather than inside it. A started item shows the
* video card's Continue + Restart pair instead of the Play (C5); like the hover row, the pair is a
* sibling laid over the picture, never inside its button. */
function PlexPicture({
aspect,
thumb,
label,
onOpen,
onRestart,
plays,
small = false,
pct = 0,
@@ -190,6 +194,8 @@ function PlexPicture({
/** The button's name: the picture has no text of its own. */
label: string;
onOpen: () => void;
/** Play from the start, where the surface can (the player's `fromStart`). */
onRestart?: () => void;
/** The click plays (a movie, an episode) — only then does the hover draw a Play. */
plays: boolean;
/** A shelf's short landscape picture: the smaller Play, clear of the hover row under it. */
@@ -199,6 +205,8 @@ function PlexPicture({
/** The badges printed on the picture. */
children?: ReactNode;
}) {
// The pct is the resume point's: a watched or untouched item has none, and plays from its start.
const started = plays && pct > 0 && !!onRestart;
return (
<div className={mediaPictureBox(aspect)}>
<div
@@ -217,9 +225,23 @@ function PlexPicture({
>
<img src={thumb} alt="" loading="lazy" decoding="async" className={MEDIA_IMG} />
{children}
<div className={MEDIA_HOVER_LAYER}>{plays && <MediaPlayGlyph small={small} />}</div>
<div className={MEDIA_HOVER_LAYER}>
{plays && !started && <MediaPlayGlyph small={small} />}
</div>
<MediaProgress pct={pct} />
</div>
{started && (
<div
className={clsx(
HOVER_REVEAL,
"pointer-events-none absolute inset-0 z-base flex items-center justify-center gap-2 *:pointer-events-auto",
// A poster is too narrow for the pair side by side (150px); it stacks, as in the lab.
aspect === "poster" && "flex-col",
)}
>
<MediaResumeButtons small={small} onContinue={onOpen} onRestart={onRestart} />
</div>
)}
{bar}
</div>
);
@@ -243,9 +265,12 @@ export function PlexPosterCard({
shelf = false,
next,
onInfo,
onRestart,
}: {
card: PlexCard;
onOpen: () => void;
/** Play the item (or the `next` episode) from its start, not its resume point (C5). */
onRestart?: () => void;
onToggleWatched?: (c: PlexCard) => void;
/** Add a movie or an episode to a playlist (the list's `usePlexCardActions().add`). */
onAdd?: (c: PlexCard) => void;
@@ -316,6 +341,7 @@ export function PlexPosterCard({
thumb={(wide && card.type !== "episode" && card.art) || card.thumb}
label={card.title}
onOpen={onOpen}
onRestart={onRestart}
plays={isPlayable}
small={shelf && landscape}
pct={pct}
@@ -591,6 +617,7 @@ export function RelatedStrip({
export function EpisodeCard({
ep,
onPlay,
onRestart,
onAdd,
onToggleWatched,
withShowTitle,
@@ -601,6 +628,8 @@ export function EpisodeCard({
}: {
ep: PlexCard;
onPlay: () => void;
/** Play the episode from its start, not its resume point (C5). */
onRestart?: () => void;
onAdd?: () => void;
onToggleWatched?: () => void;
withShowTitle?: boolean;
@@ -628,6 +657,7 @@ export function EpisodeCard({
thumb={ep.thumb}
label={ep.title}
onOpen={onPlay}
onRestart={onRestart}
plays
pct={pct}
bar={
+2
View File
@@ -22,6 +22,8 @@ export type PlexJumpTarget = {
* from INSIDE the Plex module (the header typeahead), where the entry being replaced would be a
* sub-view the user really was looking at. */
replace?: boolean;
/** `play` only: from the start, not the resume point (the card's Restart, C5). */
fromStart?: boolean;
};
export type PlexState = {
@@ -4,49 +4,6 @@ import { RotateCcw } from "lucide-react";
import { IconButton } from "./ui/IconButton";
import { Slider as Range, Checkbox } from "./ui/NativeControls";
// ±10s skip glyph: an outline circular arrow with a "10" in the centre — the YouTube/Plex-native
// muscle-memory shape. Deliberately distinct from the episode-level SkipBack/SkipForward (double
// triangles) and from Restart's plain RotateCcw. The arrowhead is an L-corner at the arc's open end,
// the same construction lucide uses for RotateCcw/RotateCw, so it sits consistently in the bar.
export function Skip10({ dir }: { dir: "back" | "forward" }) {
const back = dir === "back";
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="w-6 h-6"
aria-hidden="true"
>
{back ? (
<>
<path d="M4 8.5 A8 8 0 1 1 4 12" />
<path d="M4 8.5 L4 4 M4 8.5 L8.5 8.5" />
</>
) : (
<>
<path d="M20 8.5 A8 8 0 1 0 20 12" />
<path d="M20 8.5 L20 4 M20 8.5 L15.5 8.5" />
</>
)}
<text
x="12"
y="14.5"
textAnchor="middle"
fontSize="8"
fontWeight={700}
fill="currentColor"
stroke="none"
>
10
</text>
</svg>
);
}
// --- settings-panel building blocks ------------------------------------------------------------
export function PanelHead({ children }: { children: ReactNode }) {
return (
+2 -1
View File
@@ -11,7 +11,8 @@ export type Sub =
| { kind: "collections" } // browse-all-collections surface (header pill "Collection" mode)
| { kind: "show"; id: string }
| { kind: "season"; showId: string; seasonId: string }
| { kind: "player"; id: string; queue?: string[] }
// `fromStart`: the card's Restart (C5) — the first item plays from 0, not its resume point.
| { kind: "player"; id: string; queue?: string[]; fromStart?: boolean }
| { kind: "info"; id: string }
| { kind: "person"; name: string }
| { kind: "playlist"; id: number }
+8 -3
View File
@@ -71,7 +71,7 @@ export function PlexInfoView({
id: string;
onBack: () => void;
onPlay: () => void;
onPlayItem: (id: string) => void;
onPlayItem: (id: string, fromStart?: boolean) => void;
onFilter: (patch: Partial<PlexFilters>) => void;
onOpenPerson: (name: string) => void;
}) {
@@ -119,12 +119,14 @@ export function PlexPersonView({
name,
onBack,
onOpenCard,
onRestartCard,
onInfoCard,
onFilter,
}: {
name: string;
onBack: () => void;
onOpenCard: (card: PlexCard) => void;
onRestartCard: (card: PlexCard) => void;
onInfoCard: (card: PlexCard) => void;
onFilter: (patch: Partial<PlexFilters>) => void;
}) {
@@ -170,10 +172,11 @@ export function PlexPersonView({
});
const actions = usePlexCardActions(
{ onOpen: onOpenCard, onInfo: onInfoCard, onToggleWatched: toggle },
{ onOpen: onOpenCard, onRestart: onRestartCard, onInfo: onInfoCard, onToggleWatched: toggle },
menu.open,
);
const cardMenu = (c: PlexCard) => ({
onRestart: () => onRestartCard(c),
onAdd: actions.add,
menuProps: menu.triggerProps,
menuButtonProps: menu.buttonProps,
@@ -654,7 +657,7 @@ export function PlexSeasonView({
showId: string;
seasonId: string;
onBack: () => void;
onPlay: (epRk: string, queue: string[]) => void;
onPlay: (epRk: string, queue: string[], fromStart?: boolean) => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
@@ -706,6 +709,7 @@ export function PlexSeasonView({
const episodes = usePlexCardActions(
{
onOpen: (ep) => onPlay(ep.id, allKeys),
onRestart: (ep) => onPlay(ep.id, allKeys, true),
onToggleWatched: (ep) => void markEpisode(ep),
},
epMenu.open,
@@ -796,6 +800,7 @@ export function PlexSeasonView({
navIndex={i}
ep={ep}
onPlay={() => onPlay(ep.id, allKeys)}
onRestart={() => onPlay(ep.id, allKeys, true)}
onAdd={() => episodes.add(ep)}
onToggleWatched={() => markEpisode(ep)}
menuProps={epMenu.triggerProps}
@@ -27,7 +27,7 @@ const PLAIN: Record<string, [number, string]> = {
"PlaylistsRail.tsx": [1, "the load-error retry"],
"PlexCollectionEditor.tsx": [1, "a checkbox row in a picker"],
"PlexInfo.tsx": [1, "the rating filter (a number)"],
"PlexPlayer.tsx": [3, "track picker rows"],
"PlexPlayer.tsx": [4, "track picker rows and the ⋯ menu's rows"],
"PlexPlayerSettings.tsx": [2, "setting options"],
"PlexPlaylistAdd.tsx": [1, "a checkbox row in a picker"],
"PlexPlaylistView.tsx": [1, "a show's poster stack (aria-hidden)"],
+23 -3
View File
@@ -1,6 +1,6 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Check, Info, ListPlus, Play, Tv2, Undo2 } from "lucide-react";
import { Check, Info, ListPlus, Play, SkipBack, Tv2, Undo2 } from "lucide-react";
import { type PlexCard } from "../lib/api";
import { pinPlex, useComfortPin } from "./comfortPin";
@@ -18,6 +18,9 @@ export interface PlexActionHandlers {
/** The media-info page. Omitted where a surface has none (the episode lists). */
onInfo?: (c: PlexCard) => void;
onToggleWatched?: (c: PlexCard) => void;
/** Play from the start instead of the resume point (C5). Where given, a started title lists it
* under Play — on touch, the only way to it (the card's hover pair is mouse-only). */
onRestart?: (c: PlexCard) => void;
}
const icon = "w-4 h-4";
@@ -29,6 +32,11 @@ export const plexAddLabelKey = (c: PlexCard): string =>
/** A show opens its drill-down; a movie or an episode plays. */
const isPlexPlayable = (c: PlexCard): boolean => c.type !== "show";
/** Has a resume point: the click continues it, and "from the start" is a choice. A watched item is
* stored at 0, so it never counts. */
export const plexStarted = (c: PlexCard): boolean =>
isPlexPlayable(c) && c.status !== "watched" && (c.position_seconds ?? 0) > 0;
/** `open` is the host menu's own flag (the shelf is fetched only once a menu opened). Returns the
* item builder, `add` for the card's own hover button, and the dialog to render beside the menu
* (one per list, so a card does not mount a modal of its own). */
@@ -48,18 +56,30 @@ export function usePlexCardActions(handlers: PlexActionHandlers, open: boolean)
c: PlexCard,
{ play = true, pin: withPin = true }: { play?: boolean; pin?: boolean } = {},
): ActionMenuItem[] => {
const { onOpen, onInfo, onToggleWatched } = handlers;
const { onOpen, onInfo, onToggleWatched, onRestart } = handlers;
const playable = isPlexPlayable(c);
const watched = c.status === "watched";
const started = plexStarted(c);
return [
...(play
? [
{
id: "play",
label: playable ? t("plex.play") : t("plex.openShow"),
label: !playable ? t("plex.openShow") : started ? t("card.continue") : t("plex.play"),
icon: playable ? <Play className={icon} /> : <Tv2 className={icon} />,
onSelect: () => onOpen(c),
},
// The same words and glyph as the video card's menu (useVideoActions).
...(started && onRestart
? [
{
id: "restart",
label: t("card.restartTitle"),
icon: <SkipBack className={icon} />,
onSelect: () => onRestart(c),
},
]
: []),
]
: []),
...(playable && onToggleWatched
+4 -4
View File
@@ -313,10 +313,10 @@ function MediaCard({ state }: { state: "new" | "started" | "touch" }) {
{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
<Play className="h-4 w-4" /> Continue
</span>
<span className={clsx(pill, "bg-black/60 text-white")}>
<RotateCcw className="h-3.5 w-3.5" /> From the start
<RotateCcw className="h-3.5 w-3.5" /> Restart
</span>
</span>
)}
@@ -352,7 +352,7 @@ function Cards() {
<Sample label="Started · hover">
<MediaCard state="started" />
</Sample>
<Sample label="Started · touch: a tap resumes, the '…' has From the start">
<Sample label="Started · touch: a tap continues, the '…' has the restart">
<MediaCard state="touch" />
</Sample>
</div>
@@ -365,7 +365,7 @@ function C5() {
id="c5"
code="C5"
title="The Plex player's controls"
rule="The owner's model. No Stop (closing is stopping), no Restart and no ±10 s buttons. Back: more than 3 s in → restart; within the first 3 s → the previous item, at its resume point if it has one. Next shows only when there is a next item (an episode, a playlist), starts it at its resume point, and leaves one behind (not watched, unless already in the credits). On touch a double-tap on the left / right third skips ∓10 s; the keyboard keeps ← / →. The rare tools sit in a fixed '⋯'. Restarting lives on the card: not started → Play; started → Resume + From the start (touch: a tap resumes, the '…' has From the start)."
rule="The owner's model. No Stop (closing is stopping), no Restart and no ±10 s buttons. Back: more than 3 s in → restart; within the first 3 s → the previous item, at its resume point if it has one. Next shows only when there is a next item (an episode, a playlist), starts it at its resume point, and leaves one behind (not watched, unless already in the credits). On touch a double-tap on the left / right third skips ∓10 s; the keyboard keeps ← / →. The rare tools sit in a fixed '⋯'. Restarting lives on the card: not started → Play; started → Continue + Restart, the video card's pair (touch: a tap continues, the '…' has the restart)."
>
<div className="flex flex-wrap items-start gap-4">
<div className="w-full max-w-xl">
+3 -3
View File
@@ -171,10 +171,10 @@
"skipCredits": "Skip credits",
"playPause": "Play / pause (space)",
"restart": "Restart from beginning",
"rewind10": "Back 10 seconds",
"forward10": "Forward 10 seconds",
"prev": "Previous episode",
"next": "Next episode",
"tapBack": "−{{n}} s",
"tapForward": "+{{n}} s",
"mute": "Mute (m)",
"volume": "Volume",
"fullscreen": "Fullscreen (f)",
@@ -186,9 +186,9 @@
"errNotFound": "This file can't be played — the media isn't reachable on the server (the Plex media mount is missing or the path map is wrong). Ask the admin to check the Plex configuration.",
"errTranscode": "This file's format needs transcoding, which isn't supported yet.",
"errGeneric": "Playback couldn't start. Please try again.",
"stop": "Stop",
"help": "Keyboard shortcuts",
"infoBtn": "Media info",
"more": "More",
"upNext": {
"title": "Up next",
"playNow": "Play now",
+3 -3
View File
@@ -171,10 +171,10 @@
"skipCredits": "Stáblista átugrása",
"playPause": "Lejátszás / szünet (szóköz)",
"restart": "Újra az elejéről",
"rewind10": "10 másodperc vissza",
"forward10": "10 másodperc előre",
"prev": "Előző rész",
"next": "Következő rész",
"tapBack": "−{{n}} mp",
"tapForward": "+{{n}} mp",
"mute": "Némítás (m)",
"volume": "Hangerő",
"fullscreen": "Teljes képernyő (f)",
@@ -186,9 +186,9 @@
"errNotFound": "Ez a fájl nem játszható le — a média nem érhető el a szerveren (hiányzik a Plex media-mount, vagy hibás az útvonal-leképezés). Kérd meg az adminisztrátort, hogy ellenőrizze a Plex beállításait.",
"errTranscode": "Ennek a fájlnak a formátuma transzkódolást igényel, ami még nem támogatott.",
"errGeneric": "A lejátszást nem sikerült elindítani. Próbáld újra.",
"stop": "Leállítás",
"help": "Billentyűparancsok",
"infoBtn": "Média infó",
"more": "Továbbiak",
"upNext": {
"title": "Következik",
"playNow": "Lejátszás most",
@@ -7,12 +7,13 @@ import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { useControlsVisibility } from "./plexPlayerHooks";
const setup = () => {
const video = { paused: false } as HTMLVideoElement;
const setup = (menuOpen = false) => {
const video = document.createElement("video");
Object.defineProperty(video, "paused", { configurable: true, value: false });
return renderHook(() =>
useControlsVisibility({
barAutoHideRef: { current: true },
menuOpenRef: { current: false },
menuOpenRef: { current: menuOpen },
videoRef: { current: video },
}),
);
@@ -44,4 +45,19 @@ describe("useControlsVisibility", () => {
act(() => vi.advanceTimersByTime(100));
expect(result.current.uiVisible).toBe(false);
});
// C3: on touch nothing moves a mouse, so a tap toggles the bar and playback arms the timer.
it("toggle hides a shown bar at once and shows a hidden one", () => {
const { result } = setup();
act(() => result.current.toggle());
expect(result.current.uiVisible).toBe(false);
act(() => result.current.toggle());
expect(result.current.uiVisible).toBe(true);
});
it("a tap does not hide the bar under an open menu", () => {
const { result } = setup(true);
act(() => result.current.toggle());
expect(result.current.uiVisible).toBe(true);
});
});
+43 -6
View File
@@ -73,25 +73,50 @@ export function useFullscreen(wrapRef: RefObject<HTMLElement | null>): {
// `holdOnHover` is the pointer resting on the chrome: the bar stays while it is there (as on YouTube
// and Plex Web — E28 F22, it used to fade out from under a mouse parked on Next), and leaving it
// starts a fresh 3 s.
//
// On touch (C3) nothing moves a mouse, so the timer is also armed by PLAYBACK starting — before, the
// bar stayed up until the first tap however long the video played — and a tap on the picture only
// shows or hides the bar (`toggle`), never pauses. `show(false)` hides at once: a double-tap skip
// takes back the show/hide its first tap did.
export function useControlsVisibility(opts: {
barAutoHideRef: RefObject<boolean>;
menuOpenRef: RefObject<boolean>;
videoRef: RefObject<HTMLVideoElement | null>;
}): { uiVisible: boolean; wake: () => void; holdOnHover: (over: boolean) => void } {
}): {
uiVisible: boolean;
wake: () => void;
holdOnHover: (over: boolean) => void;
toggle: () => void;
show: (visible: boolean) => void;
} {
const { barAutoHideRef, menuOpenRef, videoRef } = opts;
const [uiVisible, setUiVisible] = useState(true);
const visibleRef = useRef(true);
const hideTimer = useRef<number | null>(null);
const hovered = useRef(false);
const setVisible = useCallback((v: boolean) => {
visibleRef.current = v;
setUiVisible(v);
}, []);
const wake = useCallback(() => {
setUiVisible(true);
setVisible(true);
if (hideTimer.current) window.clearTimeout(hideTimer.current);
if (!barAutoHideRef.current) return; // per-user setting: never auto-hide the control bar
hideTimer.current = window.setTimeout(() => {
// Keep the controls up while the audio/subtitle menu is open, else it would vanish mid-choice.
if (!videoRef.current?.paused && !menuOpenRef.current && !hovered.current)
setUiVisible(false);
if (!videoRef.current?.paused && !menuOpenRef.current && !hovered.current) setVisible(false);
}, 3000);
}, [barAutoHideRef, menuOpenRef, videoRef]);
}, [barAutoHideRef, menuOpenRef, videoRef, setVisible]);
const show = useCallback(
(visible: boolean) => {
if (visible) return wake();
if (hideTimer.current) window.clearTimeout(hideTimer.current);
// A tap on a picture with a menu open closes nothing and must not strand that menu unseen.
if (!menuOpenRef.current) setVisible(false);
},
[wake, menuOpenRef, setVisible],
);
const toggle = useCallback(() => show(!visibleRef.current), [show]);
const holdOnHover = useCallback(
(over: boolean) => {
hovered.current = over;
@@ -99,7 +124,19 @@ export function useControlsVisibility(opts: {
},
[wake],
);
return { uiVisible, wake, holdOnHover };
useEffect(() => {
const video = videoRef.current;
if (!video) return;
video.addEventListener("play", wake);
return () => video.removeEventListener("play", wake);
}, [videoRef, wake]);
useEffect(
() => () => {
if (hideTimer.current) window.clearTimeout(hideTimer.current);
},
[],
);
return { uiVisible, wake, holdOnHover, toggle, show };
}
// Periodic + on-unmount progress checkpoint. Reads live position/duration through refs (owned by the
+1 -1
View File
@@ -46,7 +46,7 @@ describe("the Plex player's layers", () => {
const popovers = classes(
/\bbottom-full\b.*\bbg-media-chrome\b|\bbg-media-chrome\b.*\bbottom-full\b/,
);
expect(popovers).toHaveLength(2);
expect(popovers).toHaveLength(3); // tracks, the ⋯ menu (C5), the settings panel
for (const c of popovers) expect(c).toMatch(/\bz-menu\b/);
});