endsTextSelection takes the click event and exempts a keyboard click (detail 0): Enter / Space ends no selection, so a title selected and then tabbed to opened nothing. All three callers (FocusSafeButton, the Plex caption, the video card title) go through it.
697 lines
23 KiB
TypeScript
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) && !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")}
|
|
/>
|
|
);
|
|
}
|