Files
siftlode/frontend/src/components/plexCards.tsx
T
peter efa4aeeeda fix(plex): a card offers Restart by the menu's own "started" rule
The picture read "started" off the rounded progress %, so a resume point under 1 % showed Play
while the card's menu offered "from the start". Both use `plexStarted` now.
2026-09-27 14:38:27 +02:00

697 lines
23 KiB
TypeScript

import { useTranslation } from "react-i18next";
import { CheckCircle2, Circle, Info, ListPlus, Star } from "lucide-react";
import clsx from "clsx";
import { type ReactNode, useRef } from "react";
import { formatRuntime } from "../lib/format";
import { Carousel } from "./ui/Carousel";
import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api";
import { navItemAttrs } from "../lib/listNav";
import { endsTextSelection, movedPastSlop } from "../lib/textSelection";
import { plexAddLabelKey, plexStarted } from "./usePlexCardActions";
import { IconButton } from "./ui/IconButton";
import { MoreButton } from "./ui/MoreButton";
import { Pressable } from "./ui/Pressable";
import { HOVER_REVEAL, HOVER_YIELD } from "./ui/reveal";
import { MediaResumeButtons } from "./MediaResumeButtons";
import {
MEDIA_BADGE,
MEDIA_BAR,
MEDIA_HOVER_LAYER,
MEDIA_IMG,
MEDIA_PICTURE,
mediaPictureBox,
mediaTitleClass,
} from "./ui/mediaCardStyles";
import { MediaCard, MediaProgress, MediaPlayGlyph } from "./ui/MediaCard";
import { MediaPreview } from "./ui/MediaPreview";
import { useMediaPreview } from "./ui/useMediaPreview";
import { SURFACE } from "./ui/surfaces";
// Presentational Plex cards + strips, split out of PlexBrowse (all purely prop-driven, no shared
// state). Rendered by the browse grid, the person page, and the series show/season views.
//
// E28 C1 (docs/DESIGN.md "Action placement"): a card shows its state (watched, progress) at rest;
// on hover its picture carries the everyday toggles (watched, add to playlist) and the "…" that
// opens the title's one action list (`usePlexCardActions`); on touch those toggles are gone and a 44px
// "…" sits beside the title. The list's menu is the one door every device has.
/** A card's "…": this card's menu-button props (`useContextMenu.buttonProps`) and whether its menu
* is the open one. */
type More = { props: Record<string, unknown>; open: boolean } | undefined;
const more = <T,>(
row: T,
buttonProps: ((row: T) => Record<string, unknown>) | undefined,
open: boolean,
): More => (buttonProps ? { props: buttonProps(row), open } : undefined);
// Watched-toggle icon. Distinguishes state by SHAPE (a filled check vs an empty circle), not color
// alone (WCAG 1.4.1). Watched = CheckCircle2 (green), not-watched = Circle (empty, white).
function WatchedIcon({ watched }: { watched: boolean }) {
return watched ? (
<CheckCircle2 className="w-4 h-4 text-success" />
) : (
<Circle className="w-4 h-4 text-white" />
);
}
/** The hover row on a card's picture (bottom-right, as on the YouTube card). A sibling of the picture's own button, never
* inside it, so no control nests in another. */
function HoverBar({
watched,
watchedLabel,
onToggleWatched,
addLabel,
onAdd,
onInfo,
more: m,
}: {
watched: boolean;
watchedLabel: string;
onToggleWatched?: () => void;
addLabel: string;
onAdd?: () => void;
/** The item's own page, when the click does something else (a show on Home plays its next episode). */
onInfo?: () => void;
more: More;
}) {
const { t } = useTranslation();
if (!onToggleWatched && !onAdd && !onInfo && !m) return null;
return (
<div className={clsx(MEDIA_BAR, "flex gap-1")}>
{onToggleWatched && (
<IconButton variant="media" onClick={onToggleWatched} label={watchedLabel}>
<WatchedIcon watched={watched} />
</IconButton>
)}
{onAdd && (
<IconButton variant="media" onClick={onAdd} label={addLabel}>
<ListPlus />
</IconButton>
)}
{onInfo && (
<IconButton variant="media" onClick={onInfo} label={t("plex.openShow")}>
<Info />
</IconButton>
)}
{m && (
<MoreButton
variant="media"
label={t("card.menu.more")}
expanded={m.open}
menuProps={m.props}
/>
)}
</div>
);
}
/** The hover row on a picture that is not a card's (Home's hero): the card's own watched and add
* toggles and its "…", acting on a movie or an episode. */
export function PlexHoverBar({
card,
onToggleWatched,
onAdd,
moreProps,
moreOpen = false,
}: {
card: PlexCard;
onToggleWatched?: (c: PlexCard) => void;
onAdd?: (c: PlexCard) => void;
moreProps?: Record<string, unknown>;
moreOpen?: boolean;
}) {
const { t } = useTranslation();
const plays = card.type !== "show";
const watched = card.status === "watched";
return (
<HoverBar
watched={watched}
watchedLabel={watched ? t("plex.markUnwatched") : t("plex.markWatched")}
onToggleWatched={plays && onToggleWatched ? () => onToggleWatched(card) : undefined}
addLabel={t(plexAddLabelKey(card))}
onAdd={plays && onAdd ? () => onAdd(card) : undefined}
more={moreProps ? { props: moreProps, open: moreOpen } : undefined}
/>
);
}
/** A Plex card's title: it opens the card like its picture does, except for the click that ends
* selecting its words, or a press dragged past the slop (DESIGN "Text stays selectable"); a div's
* text selects on its own, so only the click needs the guard. */
function CardTitle({
onClick,
shelf = false,
children,
}: {
onClick?: () => void;
/** One line, cut with an ellipsis (a shelf). The whole title is in the card's preview. */
shelf?: boolean;
children: ReactNode;
}) {
const pressedAt = useRef<{ x: number; y: number } | null>(null);
return (
<div
onClick={
onClick &&
((e) => {
if (!endsTextSelection(e.currentTarget) && !movedPastSlop(pressedAt.current, e))
onClick();
})
}
onMouseDown={(e) => {
pressedAt.current = { x: e.clientX, y: e.clientY };
}}
className={clsx(
"min-h-6",
mediaTitleClass(true, shelf),
onClick && "cursor-pointer hover:text-accent",
)}
>
{children}
</div>
);
}
/** 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. 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,
started = false,
bar,
children,
}: {
aspect: "poster" | "wide";
thumb: string;
/** 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. */
small?: boolean;
pct?: number;
/** Has a resume point (`plexStarted`, the card menu's own rule): Continue + Restart, not Play. */
started?: boolean;
bar: ReactNode;
/** The badges printed on the picture. */
children?: ReactNode;
}) {
// Not read off `pct`: that is rounded, so 20 s into a 3 h film was 0 % and "not started" here while
// the menu offered "from the start" (E28 review D4). One rule, `plexStarted`, for both.
const resumable = plays && started && !!onRestart;
return (
<div className={mediaPictureBox(aspect)}>
<Pressable onClick={onOpen} aria-label={label} data-nav-open="" className={MEDIA_PICTURE}>
<img src={thumb} alt="" loading="lazy" decoding="async" className={MEDIA_IMG} />
{children}
<div className={MEDIA_HOVER_LAYER}>
{plays && !resumable && <MediaPlayGlyph small={small} />}
</div>
<MediaProgress pct={pct} />
</Pressable>
{resumable && (
<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>
);
}
const progressPct = (c: { position_seconds?: number; duration_seconds?: number | null }) =>
(c.position_seconds ?? 0) > 0 && c.duration_seconds
? Math.round(((c.position_seconds ?? 0) / c.duration_seconds) * 100)
: 0;
export function PlexPosterCard({
card,
onOpen,
onToggleWatched,
onAdd,
menuProps,
menuButtonProps,
menuOpen = false,
navIndex,
wide = false,
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;
/** Right-click / long-press props for the list's ONE context menu. A function of the card rather
* than a ready-made object, so every card surface takes `menuProps={menu.triggerProps}`. */
menuProps?: (c: PlexCard) => Record<string, unknown>;
/** The same menu's "…" (`useContextMenu.buttonProps`), on the picture on hover and beside the
* title on touch. */
menuButtonProps?: (c: PlexCard) => Record<string, unknown>;
/** This card's menu is the open one (the "…" says so with `aria-expanded`). */
menuOpen?: boolean;
/** Position in a keyboard-walkable grid or rail (lib/listNav); the poster is what Enter opens. */
navIndex?: number;
/** Landscape, beside YouTube cards (Home's shelves): the item's wide art instead of its poster
* (the poster, cropped, when it has no art). An episode's picture is landscape already. */
wide?: boolean;
/** On a shelf (a horizontal strip): a one-line title (ui/mediaCardStyles). */
shelf?: boolean;
/** A SHOW whose click plays this episode (Home's shelves: a pinned or started show plays on). The
* picture then draws its Play, the watched toggle and the add act on the episode, the meta line
* names it, and an ⓘ (`onInfo`) opens the show. */
next?: PlexCard | null;
/** The show's page, beside a `next` episode. */
onInfo?: () => void;
}) {
const { t } = useTranslation();
const preview = useMediaPreview();
const landscape = wide || card.type === "episode";
const ep = card.type === "show" ? (next ?? null) : null;
// movies/episodes play; a show opens its drill-down — unless it plays on (`next`)
const isPlayable = card.type !== "show" || !!ep;
// What the hover row acts on: the episode a show would play, else the card itself.
const acted = ep ?? card;
const watched = card.status === "watched";
const actedWatched = acted.status === "watched";
const runtime = formatRuntime(card.duration_seconds);
const pct = ep ? progressPct(ep) : watched ? 0 : progressPct(card);
const m = more(card, menuButtonProps, menuOpen);
const toggle = isPlayable && onToggleWatched ? () => onToggleWatched(acted) : undefined;
const add = isPlayable && onAdd ? () => onAdd(acted) : undefined;
const epCode = ep && { season: ep.season_number ?? "?", episode: ep.episode_number ?? "?" };
// A show has no length: its corner says how many seasons it has instead, so the corner alone tells
// a movie from a show (the film/TV glyph it replaces said the same thing a second time).
const corner =
card.type === "show"
? card.season_count != null && t("plex.seasons", { count: card.season_count })
: runtime;
const rating = card.rating != null && (
<span className="inline-flex items-center gap-0.5">
<Star className="w-3 h-3 fill-current text-rating" aria-hidden />
{card.rating.toFixed(1)}
</span>
);
return (
<>
<MediaCard
dense
rootProps={preview.rootProps({
...menuProps?.(card),
...(navIndex !== undefined && navItemAttrs(navIndex, card.title)),
})}
// content-visibility keeps off-screen posters cheap to render (smoother long-list scroll);
// a carousel turns it off (index.css `.media-rail`).
className="cv-poster"
picture={
<PlexPicture
aspect={landscape ? "wide" : "poster"}
thumb={(wide && card.type !== "episode" && card.art) || card.thumb}
label={card.title}
onOpen={onOpen}
onRestart={onRestart}
plays={isPlayable}
small={shelf && landscape}
pct={pct}
started={plexStarted(acted)}
bar={
<HoverBar
watched={actedWatched}
watchedLabel={
epCode
? t(actedWatched ? "plex.markNextUnwatched" : "plex.markNextWatched", epCode)
: actedWatched
? t("plex.markUnwatched")
: t("plex.markWatched")
}
onToggleWatched={toggle}
addLabel={t(plexAddLabelKey(acted))}
onAdd={add}
onInfo={ep ? onInfo : undefined}
more={m}
/>
}
>
{watched && <WatchedBadge />}
{/* Aggregate in-progress badge for a partially-watched show. */}
{card.type === "show" && card.status === "in_progress" && <InProgressBadge />}
{corner && <CornerBadge yields={!!(toggle || add || m)}>{corner}</CornerBadge>}
</PlexPicture>
}
title={
<CardTitle onClick={onOpen} shelf={shelf}>
{card.title}
</CardTitle>
}
meta={
epCode ? (
<div className="truncate text-xs leading-6 text-muted">{t("plex.nextUp", epCode)}</div>
) : (
(!!card.year || card.rating != null) && (
<div className="flex items-center gap-1.5 text-xs leading-6 text-muted">
{!!card.year && <span>{card.year}</span>}
{!!card.year && rating && <span aria-hidden>·</span>}
{rating}
</div>
)
)
}
more={m}
moreLabel={t("card.menu.more")}
/>
<MediaPreview
preview={preview}
title={card.title}
facts={
<span className="flex flex-wrap items-center gap-x-1.5">
{[card.year, corner].filter(Boolean).join(" · ")}
{rating && (!!card.year || !!corner) && <span aria-hidden>·</span>}
{rating}
</span>
}
summary={card.summary}
/>
</>
);
}
/** The picture's rest-state marks, in the corners the hover row leaves free (it docks bottom-right). */
function WatchedBadge() {
const { t } = useTranslation();
return <span className={clsx(MEDIA_BADGE, "top-1.5 right-1.5")}>{t("plex.watched")}</span>;
}
function InProgressBadge() {
const { t } = useTranslation();
return (
<span className={clsx(MEDIA_BADGE, "top-1.5 right-1.5 text-accent")}>
{t("plex.inProgress")}
</span>
);
}
/** The bottom-right fact (a length, a count): it steps aside while the hover row covers its corner;
* at rest, and on touch, it stays. */
function CornerBadge({ yields, children }: { yields: boolean; children: ReactNode }) {
return (
<span className={clsx(MEDIA_BADGE, "bottom-1.5 right-1.5", yields && HOVER_YIELD)}>
{children}
</span>
);
}
export function SeasonCard({
se,
onOpen,
onToggleWatched,
onAdd,
menuProps,
menuButtonProps,
menuOpen = false,
navIndex,
}: {
se: PlexSeasonDetail;
onOpen: () => void;
onToggleWatched?: () => void;
onAdd?: () => void;
/** The show page's season menu (see `PlexPosterCard`). */
menuProps?: (se: PlexSeasonDetail) => Record<string, unknown>;
menuButtonProps?: (se: PlexSeasonDetail) => Record<string, unknown>;
menuOpen?: boolean;
navIndex?: number;
}) {
const { t } = useTranslation();
const watched = se.status === "watched";
const m = more(se, menuButtonProps, menuOpen);
return (
<MediaCard
dense
rootProps={{
...menuProps?.(se),
...(navIndex !== undefined && navItemAttrs(navIndex, se.title)),
}}
picture={
// A season opens its episode list: no Play on hover, since the click does not play.
<PlexPicture
aspect="poster"
thumb={se.thumb}
label={se.title}
onOpen={onOpen}
plays={false}
bar={
<HoverBar
watched={watched}
watchedLabel={
watched ? t("plex.series.markSeasonUnwatched") : t("plex.series.markSeasonWatched")
}
onToggleWatched={onToggleWatched}
addLabel={t("plex.playlist.addSeason")}
onAdd={onAdd}
more={m}
/>
}
>
{watched && <WatchedBadge />}
{/* A season has no progress bar, so this badge is its sole in-progress cue. */}
{se.status === "in_progress" && <InProgressBadge />}
<CornerBadge yields={!!(onToggleWatched || onAdd || m)}>
{t("plex.series.episodeCount", { count: se.episode_count })}
</CornerBadge>
</PlexPicture>
}
title={<CardTitle onClick={onOpen}>{se.title}</CardTitle>}
more={m}
moreLabel={t("card.menu.more")}
/>
);
}
export function CastStrip({
cast,
onFilter,
onOpenPerson,
}: {
cast: PlexCastMember[];
onFilter?: (patch: Partial<PlexFilters>) => void;
onOpenPerson?: (name: string) => void;
}) {
const { t } = useTranslation();
const clickable = !!(onOpenPerson || onFilter);
return (
<section
className={`${SURFACE.panel} p-4 sm:p-5 mb-6`}
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
>
<h2 className="text-sm font-semibold mb-3">{t("plex.info.cast")}</h2>
<Carousel gap="gap-4" ariaLabel={t("plex.info.cast")} keyboardNav={clickable}>
{cast.map((c, i) => {
const inner = (
<>
<div
className={`mx-auto h-20 w-20 overflow-hidden rounded-full border bg-surface ${clickable ? "border-border group-hover/cast:border-accent" : "border-border"}`}
>
{c.thumb ? (
<img src={c.thumb} alt="" loading="lazy" className="h-full w-full object-cover" />
) : (
<div className="grid h-full w-full place-items-center opacity-40">
{c.name.charAt(0)}
</div>
)}
</div>
{/* Name + role are each capped to ONE line (truncate → ellipsis) so every tile has the
same text height and the row of circles sits on one baseline regardless of name length. */}
<div
className={`mt-1.5 truncate text-xs font-medium leading-tight ${clickable ? "group-hover/cast:text-accent" : ""}`}
title={c.name}
>
{c.name}
</div>
{c.role && (
<div className="truncate text-2xs text-muted" title={c.role}>
{c.role}
</div>
)}
</>
);
return clickable ? (
<Pressable
selectable
key={i}
align="center"
{...navItemAttrs(i)}
onClick={() =>
onOpenPerson ? onOpenPerson(c.name) : onFilter?.({ actors: [c.name] })
}
title={
onOpenPerson
? t("plex.info.viewPerson", { name: c.name })
: t("plex.info.filterActor", { name: c.name })
}
className="group/cast w-20 shrink-0"
>
{inner}
</Pressable>
) : (
<div key={i} className="w-20 shrink-0 text-center">
{inner}
</div>
);
})}
</Carousel>
</section>
);
}
export function RelatedStrip({
related,
onOpen,
menuProps,
menuButtonProps,
openId = null,
}: {
related: PlexCard[];
onOpen: (c: PlexCard) => void;
/** The page's Plex menu (a related show is a Plex card like any other: E28 C1). */
menuProps?: (c: PlexCard) => Record<string, unknown>;
menuButtonProps?: (c: PlexCard) => Record<string, unknown>;
/** The id whose menu is open. */
openId?: string | null;
}) {
const { t } = useTranslation();
return (
<section
className={`${SURFACE.panel} p-4 sm:p-5 mb-6`}
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
>
<h2 className="text-sm font-semibold mb-3">{t("plex.series.related")}</h2>
<Carousel gap="gap-3" ariaLabel={t("plex.series.related")} keyboardNav>
{related.map((r, i) => (
<div key={r.id} className="grid w-37.5 shrink-0 grid-cols-1">
<PlexPosterCard
card={r}
shelf
navIndex={i}
onOpen={() => onOpen(r)}
menuProps={menuProps}
menuButtonProps={menuButtonProps}
menuOpen={openId === r.id}
/>
</div>
))}
</Carousel>
</section>
);
}
export function EpisodeCard({
ep,
onPlay,
onRestart,
onAdd,
onToggleWatched,
withShowTitle,
menuProps,
menuButtonProps,
menuOpen = false,
navIndex,
}: {
ep: PlexCard;
onPlay: () => void;
/** Play the episode from its start, not its resume point (C5). */
onRestart?: () => void;
onAdd?: () => void;
onToggleWatched?: () => void;
withShowTitle?: boolean;
/** The episode list's menu (see `PlexPosterCard`). */
menuProps?: (c: PlexCard) => Record<string, unknown>;
menuButtonProps?: (c: PlexCard) => Record<string, unknown>;
menuOpen?: boolean;
navIndex?: number;
}) {
const { t } = useTranslation();
const watched = ep.status === "watched";
const pct = watched ? 0 : progressPct(ep);
const m = more(ep, menuButtonProps, menuOpen);
const runtime = formatRuntime(ep.duration_seconds);
return (
<MediaCard
dense
rootProps={{
...menuProps?.(ep),
...(navIndex !== undefined && navItemAttrs(navIndex, ep.title)),
}}
picture={
<PlexPicture
aspect="wide"
thumb={ep.thumb}
label={ep.title}
onOpen={onPlay}
onRestart={onRestart}
plays
pct={pct}
started={plexStarted(ep)}
bar={
<HoverBar
watched={watched}
watchedLabel={watched ? t("plex.markUnwatched") : t("plex.markWatched")}
onToggleWatched={onToggleWatched}
addLabel={t("plex.playlist.addEpisode")}
onAdd={onAdd}
more={m}
/>
}
>
{watched && <WatchedBadge />}
{runtime && (
<CornerBadge yields={!!(onToggleWatched || onAdd || m)}>{runtime}</CornerBadge>
)}
</PlexPicture>
}
title={
<CardTitle onClick={onPlay}>
{!withShowTitle && ep.episode_number != null && (
<span className="text-muted mr-1">{ep.episode_number}.</span>
)}
{ep.title}
</CardTitle>
}
meta={
withShowTitle &&
ep.show_title && (
<div className="truncate text-xs text-muted">
{ep.show_title}
{ep.season_number != null && ep.episode_number != null
? ` · S${ep.season_number}·E${ep.episode_number}`
: ""}
</div>
)
}
more={m}
moreLabel={t("card.menu.more")}
/>
);
}