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; open: boolean } | undefined; const more = ( row: T, buttonProps: ((row: T) => Record) | 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 ? ( ) : ( ); } /** 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 (
{onToggleWatched && ( )} {onAdd && ( )} {onInfo && ( )} {m && ( )}
); } /** 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; moreOpen?: boolean; }) { const { t } = useTranslation(); const plays = card.type !== "show"; const watched = card.status === "watched"; return ( 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 (
{ 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}
); } /** 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 (
{children}
{plays && !resumable && }
{resumable && (
)} {bar}
); } 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; /** The same menu's "…" (`useContextMenu.buttonProps`), on the picture on hover and beside the * title on touch. */ menuButtonProps?: (c: PlexCard) => Record; /** 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 && ( {card.rating.toFixed(1)} ); return ( <> } > {watched && } {/* Aggregate in-progress badge for a partially-watched show. */} {card.type === "show" && card.status === "in_progress" && } {corner && {corner}} } title={ {card.title} } meta={ epCode ? (
{t("plex.nextUp", epCode)}
) : ( (!!card.year || card.rating != null) && (
{!!card.year && {card.year}} {!!card.year && rating && ·} {rating}
) ) } more={m} moreLabel={t("card.menu.more")} /> {[card.year, corner].filter(Boolean).join(" · ")} {rating && (!!card.year || !!corner) && ·} {rating} } 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 {t("plex.watched")}; } function InProgressBadge() { const { t } = useTranslation(); return ( {t("plex.inProgress")} ); } /** 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 ( {children} ); } 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; menuButtonProps?: (se: PlexSeasonDetail) => Record; menuOpen?: boolean; navIndex?: number; }) { const { t } = useTranslation(); const watched = se.status === "watched"; const m = more(se, menuButtonProps, menuOpen); return ( } > {watched && } {/* A season has no progress bar, so this badge is its sole in-progress cue. */} {se.status === "in_progress" && } {t("plex.series.episodeCount", { count: se.episode_count })} } title={{se.title}} more={m} moreLabel={t("card.menu.more")} /> ); } export function CastStrip({ cast, onFilter, onOpenPerson, }: { cast: PlexCastMember[]; onFilter?: (patch: Partial) => void; onOpenPerson?: (name: string) => void; }) { const { t } = useTranslation(); const clickable = !!(onOpenPerson || onFilter); return (

{t("plex.info.cast")}

{cast.map((c, i) => { const inner = ( <>
{c.thumb ? ( ) : (
{c.name.charAt(0)}
)}
{/* 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. */}
{c.name}
{c.role && (
{c.role}
)} ); return clickable ? ( 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} ) : (
{inner}
); })}
); } 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; menuButtonProps?: (c: PlexCard) => Record; /** The id whose menu is open. */ openId?: string | null; }) { const { t } = useTranslation(); return (

{t("plex.series.related")}

{related.map((r, i) => (
onOpen(r)} menuProps={menuProps} menuButtonProps={menuButtonProps} menuOpen={openId === r.id} />
))}
); } 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; menuButtonProps?: (c: PlexCard) => Record; 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 ( } > {watched && } {runtime && ( {runtime} )} } title={ {!withShowTitle && ep.episode_number != null && ( {ep.episode_number}. )} {ep.title} } meta={ withShowTitle && ep.show_title && (
{ep.show_title} {ep.season_number != null && ep.episode_number != null ? ` · S${ep.season_number}·E${ep.episode_number}` : ""}
) } more={m} moreLabel={t("card.menu.more")} /> ); }