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.
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.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")}
|
|
/>
|
|
);
|
|
}
|