import { useTranslation } from "react-i18next"; import { CheckCircle2, Circle, Film, Info, ListPlus, Play, Star, Tv2 } from "lucide-react"; import { type CSSProperties } from "react"; import { formatRuntime } from "../lib/format"; import { Carousel } from "./Carousel"; import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api"; // 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. // Playback cost, escalating good→heavy: Direct Play (green) → Direct Stream / remux, a light // repackage (blue) → Transcode · HW, a hardware re-encode (amber) → Transcode · SW, a software // re-encode (red). transcode splits HW vs SW by card.transcode_hw. Reuses the semantic tokens as a // gradient; all four stay visually distinct in light + dark. export const PLAYABLE_TINT: Record = { direct: "bg-success", remux: "bg-info", transcode_hw: "bg-warn", transcode_sw: "bg-danger", }; // Collapse the wire `playable` (direct|remux|transcode) + transcode_hw flag into one of the 4 display // classes, so the corner dot's colour + tooltip can't drift from the sidebar filter's option keys. // Shared with the info-page playback chip (PlexInfo) so both surfaces derive the class identically. export function playableClass(playable: string, transcodeHw?: boolean | null): string { if (playable === "transcode") return transcodeHw ? "transcode_hw" : "transcode_sw"; return playable; // direct | remux } // The reveal class for a status pill (watched / in-progress) that shares a corner with a hover-reveal // control (the watched-toggle). When that control is present it takes over on touch, so the pill yields // via `.reveal-idle` (hidden on touch, faded on hover); when there's no such control the pill is the // sole indicator and just fades on hover. (The single-episode card, where the pill is itself the // full-size watched marker with no fade, keeps its own inline rule.) const swapBadgeReveal = (hasControl: boolean): string => hasControl ? "reveal-idle" : "group-hover:opacity-0"; // Watched-toggle icon. Distinguishes state by SHAPE (a filled check vs an empty circle), not color // alone — on touch the toggle is the only surviving watched indicator (the text pill is hidden), so a // green-vs-white CheckCircle2 would be a use-of-color (WCAG 1.4.1) gap. Watched = CheckCircle2 (green), // not-watched = Circle (empty, white). function WatchedIcon({ watched }: { watched: boolean }) { return watched ? ( ) : ( ); } export function PlexPosterCard({ card, onOpen, onInfo, onToggleWatched, }: { card: PlexCard; onOpen: () => void; onInfo: () => void; onToggleWatched: (c: PlexCard) => void; }) { const { t } = useTranslation(); const inProgress = (card.position_seconds ?? 0) > 0 && card.status !== "watched"; const isPlayable = card.type !== "show"; // movies/episodes play; shows open the drill-down const watchedLabel = card.status === "watched" ? t("plex.markUnwatched") : t("plex.markWatched"); const runtime = formatRuntime(card.duration_seconds); const pct = inProgress && card.duration_seconds ? Math.min(100, Math.round(((card.position_seconds ?? 0) / card.duration_seconds) * 100)) : 0; return (
{ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onOpen(); } }} className="glass-card glass-hover relative aspect-2/3 rounded-xl overflow-hidden transition-all duration-200 cursor-pointer" > {/* Playable-cost tint (always shown — a quick "will it direct-play" cue). z-20 keeps the dot ABOVE the full-card hover-reveal overlay below (absolute inset-0, no pointer-events-none): without it the overlay sits on top and swallows the cursor, so the dot's native `title` tooltip never fired on hover (a regression once the reveal overlay was added). */} {card.playable && (() => { const cls = playableClass(card.playable, card.transcode_hw); return ( ); })()} {/* Rest-state watched badge (top-right). Yields to the hover quick-actions on a playable card (reveal-idle: gone on touch where the toggle is always shown); a watched show keeps it. */} {card.status === "watched" && ( {t("plex.watched")} )} {/* Aggregate in-progress badge for a partially-watched show. */} {card.type === "show" && card.status === "in_progress" && ( {t("plex.inProgress")} )} {/* Rest-state bottom badges — hidden on hover, where the scrim takes over the bottom. */} {card.type === "show" && card.season_count != null && ( {t("plex.seasons", { count: card.season_count })} )} {card.type === "show" ? : } {/* Hover reveal: a soft dim, a less-flat play ring, and a bottom scrim that slides up with the synopsis + rating (the info you'd otherwise click through for). */}
{(card.summary || card.rating != null) && (
{card.title}
{(card.year || runtime) && ( {[card.year, runtime].filter(Boolean).join(" · ")} )} {card.rating != null && ( {card.rating.toFixed(1)} )}
{card.summary && (

{card.summary}

)}
)}
{/* Quick actions (playable cards): Info + watched toggle — revealed on hover, always on touch. */} {isPlayable && (
)} {pct > 0 && (
)}
{card.title}
{[card.year, formatRuntime(card.duration_seconds)].filter(Boolean).join(" · ")}
); } export function SeasonCard({ se, onOpen, onToggleWatched, onAdd, }: { se: PlexSeasonDetail; onOpen: () => void; onToggleWatched?: () => void; onAdd?: () => void; }) { const { t } = useTranslation(); const watched = se.status === "watched"; const watchedLabel = watched ? t("plex.series.markSeasonUnwatched") : t("plex.series.markSeasonWatched"); return (
{ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onOpen(); } }} className="glass-card glass-hover relative aspect-2/3 rounded-xl overflow-hidden transition-all duration-200 cursor-pointer" >
{t("plex.openShow")}
{/* Quick watched toggle (whole season), on hover. */} {onToggleWatched && ( )} {/* Add whole season to a playlist, on hover. */} {onAdd && ( )} {watched && ( {t("plex.watched")} )} {/* In-progress lives at the top-LEFT (the watched-toggle owns the top-right) and stays visible on touch: unlike movie/episode cards, a season has no progress bar, and the always-shown toggle reads as unwatched for a partial season — so this badge is the sole in-progress cue and must not be a reveal-idle swap-badge (which display:none-s on touch). */} {se.status === "in_progress" && ( {t("plex.inProgress")} )} {t("plex.series.episodeCount", { count: se.episode_count })}
{se.title}
); } 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 ? ( ) : (
{inner}
); })}
); } export function RelatedStrip({ related, onOpen, }: { related: PlexCard[]; onOpen: (id: string) => void; }) { const { t } = useTranslation(); return (

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

{related.map((r) => ( ))}
); } export function EpisodeCard({ ep, onPlay, onAdd, onToggleWatched, withShowTitle, }: { ep: PlexCard; onPlay: () => void; onAdd?: () => void; onToggleWatched?: () => void; withShowTitle?: boolean; }) { const { t } = useTranslation(); const inProgress = (ep.position_seconds ?? 0) > 0 && ep.status !== "watched"; const watchedLabel = ep.status === "watched" ? t("plex.markUnwatched") : t("plex.markWatched"); const pct = inProgress && ep.duration_seconds ? Math.min(100, Math.round(((ep.position_seconds ?? 0) / ep.duration_seconds) * 100)) : 0; return (
{ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onPlay(); } }} className="relative block w-full aspect-video rounded-xl overflow-hidden bg-card border border-border text-left cursor-pointer" >
{/* Quick watched toggle (single episode), on hover. */} {onToggleWatched && ( )} {ep.status === "watched" && ( )} {pct > 0 && (
)}
{withShowTitle && ep.show_title && (
{ep.show_title} {ep.season_number != null && ep.episode_number != null ? ` · S${ep.season_number}·E${ep.episode_number}` : ""}
)}
{!withShowTitle && {ep.episode_number}.} {ep.title}
{formatRuntime(ep.duration_seconds)}
{onAdd && ( )}
); }