Deep-review round 1 on feature/plex-playback-class-filter: - Sticky watermark (medium): _apply_item reset the bit-depth probe watermark only on a codec-string change, so a same-codec re-encode (8-bit->10-bit HEVC) kept a stale video_bit_depth/transcode_hw forever (the backfill only re-probes media_probed_at IS NULL rows). Also reset when the Plex Part key changes (the file was replaced), not just the codec. - i18n parity (medium): the HU playback-class labels were mixed-language (only 'direct' translated). Translated remux/transcode_hw/transcode_sw and the tooltips consistently to Hungarian (Kozvetlen streameles / Atkodolas). - Layering (low): _PLAYBACK_CLASSES was module-private yet imported across into routes.plex.browse; renamed to public PLAYBACK_CLASSES (it is the shared source of truth for facet order + zero-fill). - a11y (low): the browse-card playback-class dot conveyed meaning only via a mouse-hover title; added role=img + aria-label so SR/touch users get the class. - tests: the NULL/unprobed transcode_hw -> SW fold (filter + facet), facet self-exclusion under an active playable filter, the migration codec-set == HW_DECODABLE_CODECS drift guard, the probe-batch bound + oldest-first order, the transient probe-failure retry path, and the same-codec re-encode re-probe. Backend gate green (ruff + full pytest); FE gate green (tsc/lint/knip/prettier/ vitest). NOTE: the facet-vs-runtime disagreement for 10-bit HEVC (C1) is the known merge-order constraint — this branch's runtime is codec-only; the facet becomes truthful once BUG1/tonemap's bit-depth-aware routing lands with it. Left as a sequencing constraint, not code-changed here.
559 lines
22 KiB
TypeScript
559 lines
22 KiB
TypeScript
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<string, string> = {
|
|
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 ? (
|
|
<CheckCircle2 className="w-4 h-4 text-success" />
|
|
) : (
|
|
<Circle className="w-4 h-4 text-white" />
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div
|
|
className="group text-left"
|
|
// content-visibility keeps off-screen posters cheap to render (smoother long-list scroll).
|
|
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 300px" } as CSSProperties}
|
|
>
|
|
<div
|
|
onClick={onOpen}
|
|
role="button"
|
|
tabIndex={0}
|
|
onKeyDown={(e) => {
|
|
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"
|
|
>
|
|
<img
|
|
src={card.thumb}
|
|
alt=""
|
|
loading="lazy"
|
|
decoding="async"
|
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
|
/>
|
|
{/* 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 (
|
|
<span
|
|
role="img"
|
|
aria-label={t(`plex.playable.${cls}`)}
|
|
className={`absolute top-1.5 left-1.5 z-20 w-2 h-2 rounded-full ring-2 ring-black/40 ${PLAYABLE_TINT[cls] ?? "bg-gray-400"}`}
|
|
title={t(`plex.playable.${cls}`)}
|
|
/>
|
|
);
|
|
})()}
|
|
{/* 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" && (
|
|
<span
|
|
className={`pointer-events-none absolute top-1.5 right-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm ${swapBadgeReveal(
|
|
isPlayable,
|
|
)}`}
|
|
>
|
|
{t("plex.watched")}
|
|
</span>
|
|
)}
|
|
{/* Aggregate in-progress badge for a partially-watched show. */}
|
|
{card.type === "show" && card.status === "in_progress" && (
|
|
<span className="pointer-events-none absolute top-1.5 right-1.5 text-3xs bg-black/75 text-accent px-1.5 py-0.5 rounded-sm group-hover:opacity-0">
|
|
{t("plex.inProgress")}
|
|
</span>
|
|
)}
|
|
{/* Rest-state bottom badges — hidden on hover, where the scrim takes over the bottom. */}
|
|
{card.type === "show" && card.season_count != null && (
|
|
<span className="pointer-events-none absolute bottom-1.5 left-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm transition-opacity group-hover:opacity-0">
|
|
{t("plex.seasons", { count: card.season_count })}
|
|
</span>
|
|
)}
|
|
<span
|
|
className="pointer-events-none absolute bottom-1.5 right-1.5 rounded-sm bg-black/65 p-0.5 text-white/90 transition-opacity group-hover:opacity-0"
|
|
title={t(
|
|
card.type === "show" ? "plex.filter.scopeOpt.show" : "plex.filter.scopeOpt.movie",
|
|
)}
|
|
>
|
|
{card.type === "show" ? <Tv2 className="w-3 h-3" /> : <Film className="w-3 h-3" />}
|
|
</span>
|
|
|
|
{/* 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). */}
|
|
<div className="absolute inset-0 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
|
<div className="absolute inset-0 bg-black/25" />
|
|
<div className="absolute inset-x-0 top-[30%] flex justify-center">
|
|
<div
|
|
className="grid h-12 w-12 scale-90 place-items-center rounded-full text-white shadow-xl transition-transform duration-200 group-hover:scale-100"
|
|
style={{
|
|
background:
|
|
"radial-gradient(circle at 38% 32%, color-mix(in srgb, var(--accent) 72%, white), var(--accent))",
|
|
boxShadow:
|
|
"0 10px 22px -6px color-mix(in srgb, var(--accent) 60%, transparent), 0 0 0 4px color-mix(in srgb, var(--accent) 22%, transparent)",
|
|
}}
|
|
>
|
|
<Play className="w-5 h-5 translate-x-px fill-current" />
|
|
</div>
|
|
</div>
|
|
{(card.summary || card.rating != null) && (
|
|
<div className="absolute inset-x-0 bottom-0 translate-y-1.5 bg-gradient-to-t from-black/95 via-black/85 to-transparent p-2.5 transition-transform duration-200 group-hover:translate-y-0">
|
|
<div className="line-clamp-1 text-xs font-semibold leading-snug text-white">
|
|
{card.title}
|
|
</div>
|
|
<div className="mt-0.5 flex items-center gap-1.5 text-2xs text-white/75">
|
|
{(card.year || runtime) && (
|
|
<span>{[card.year, runtime].filter(Boolean).join(" · ")}</span>
|
|
)}
|
|
{card.rating != null && (
|
|
<span className="inline-flex items-center gap-0.5 text-amber-300">
|
|
<Star className="w-3 h-3 fill-current" />
|
|
{card.rating.toFixed(1)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{card.summary && (
|
|
<p className="mt-1 line-clamp-3 text-2xs leading-snug text-white/85">
|
|
{card.summary}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Quick actions (playable cards): Info + watched toggle — revealed on hover, always on touch. */}
|
|
{isPlayable && (
|
|
<div className="reveal absolute right-1.5 top-1.5 z-10 flex gap-1">
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onInfo();
|
|
}}
|
|
title={t("plex.info.title")}
|
|
aria-label={t("plex.info.title")}
|
|
className="rounded-full bg-black/60 p-1 transition hover:bg-black/80"
|
|
>
|
|
<Info className="h-4 w-4 text-white" />
|
|
</button>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onToggleWatched(card);
|
|
}}
|
|
title={watchedLabel}
|
|
aria-label={watchedLabel}
|
|
className="rounded-full bg-black/60 p-1 transition hover:bg-black/80"
|
|
>
|
|
<WatchedIcon watched={card.status === "watched"} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{pct > 0 && (
|
|
<div className="absolute inset-x-0 bottom-0 z-10 h-1 bg-black/40">
|
|
<div className="h-full bg-accent" style={{ width: `${pct}%` }} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div
|
|
onClick={onOpen}
|
|
className="mt-1.5 rounded-lg px-2 py-1.5 cursor-pointer"
|
|
style={{ background: "color-mix(in srgb, var(--card) 60%, transparent)" }}
|
|
>
|
|
<div className="text-sm font-medium line-clamp-2 leading-tight min-h-[2lh] hover:text-accent">
|
|
{card.title}
|
|
</div>
|
|
<div className="text-xs text-muted">
|
|
{[card.year, formatRuntime(card.duration_seconds)].filter(Boolean).join(" · ")}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="group text-left">
|
|
<div
|
|
onClick={onOpen}
|
|
role="button"
|
|
tabIndex={0}
|
|
onKeyDown={(e) => {
|
|
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"
|
|
>
|
|
<img
|
|
src={se.thumb}
|
|
alt=""
|
|
loading="lazy"
|
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
|
/>
|
|
<div className="absolute inset-0 bg-black/45 opacity-0 group-hover:opacity-100 transition grid place-items-center">
|
|
<div className="flex flex-col items-center gap-1 text-white">
|
|
<Play className="w-9 h-9" fill="currentColor" />
|
|
<span className="text-xs font-medium">{t("plex.openShow")}</span>
|
|
</div>
|
|
</div>
|
|
{/* Quick watched toggle (whole season), on hover. */}
|
|
{onToggleWatched && (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onToggleWatched();
|
|
}}
|
|
title={watchedLabel}
|
|
aria-label={watchedLabel}
|
|
className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 reveal hover:bg-black/80 transition"
|
|
>
|
|
<WatchedIcon watched={watched} />
|
|
</button>
|
|
)}
|
|
{/* Add whole season to a playlist, on hover. */}
|
|
{onAdd && (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onAdd();
|
|
}}
|
|
title={t("plex.playlist.addSeason")}
|
|
aria-label={t("plex.playlist.addSeason")}
|
|
className="absolute bottom-1.5 right-1.5 p-1 rounded-full bg-black/60 reveal hover:bg-black/80 transition"
|
|
>
|
|
<ListPlus className="w-4 h-4 text-white" />
|
|
</button>
|
|
)}
|
|
{watched && (
|
|
<span
|
|
className={`pointer-events-none absolute top-1.5 right-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm ${swapBadgeReveal(
|
|
!!onToggleWatched,
|
|
)}`}
|
|
>
|
|
{t("plex.watched")}
|
|
</span>
|
|
)}
|
|
{/* 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" && (
|
|
<span className="pointer-events-none absolute top-1.5 left-1.5 text-3xs bg-black/75 text-accent px-1.5 py-0.5 rounded-sm">
|
|
{t("plex.inProgress")}
|
|
</span>
|
|
)}
|
|
<span className="absolute bottom-1.5 left-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm">
|
|
{t("plex.series.episodeCount", { count: se.episode_count })}
|
|
</span>
|
|
</div>
|
|
<div
|
|
onClick={onOpen}
|
|
className="mt-1.5 rounded-lg px-2 py-1.5 text-sm font-medium line-clamp-2 leading-tight cursor-pointer hover:text-accent"
|
|
style={{ background: "color-mix(in srgb, var(--card) 60%, transparent)" }}
|
|
>
|
|
{se.title}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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="glass-card rounded-2xl 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")}>
|
|
{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 ? (
|
|
<button
|
|
key={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 text-center"
|
|
>
|
|
{inner}
|
|
</button>
|
|
) : (
|
|
<div key={i} className="w-20 shrink-0 text-center">
|
|
{inner}
|
|
</div>
|
|
);
|
|
})}
|
|
</Carousel>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
export function RelatedStrip({
|
|
related,
|
|
onOpen,
|
|
}: {
|
|
related: PlexCard[];
|
|
onOpen: (id: string) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<section
|
|
className="glass-card rounded-2xl 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")}>
|
|
{related.map((r) => (
|
|
<button
|
|
key={r.id}
|
|
onClick={() => onOpen(r.id)}
|
|
className="group w-[130px] shrink-0 text-left"
|
|
>
|
|
<div className="glass-card glass-hover relative aspect-2/3 rounded-xl overflow-hidden transition-all duration-200">
|
|
<img
|
|
src={r.thumb}
|
|
alt=""
|
|
loading="lazy"
|
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
|
/>
|
|
{r.status === "watched" && (
|
|
<span className="absolute top-1.5 right-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm">
|
|
{t("plex.watched")}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="mt-1.5 text-sm font-medium line-clamp-2 leading-tight min-h-[2lh]">
|
|
{r.title}
|
|
</div>
|
|
</button>
|
|
))}
|
|
</Carousel>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="group">
|
|
<div
|
|
onClick={onPlay}
|
|
role="button"
|
|
tabIndex={0}
|
|
onKeyDown={(e) => {
|
|
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"
|
|
>
|
|
<img
|
|
src={ep.thumb}
|
|
alt=""
|
|
loading="lazy"
|
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
|
/>
|
|
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition grid place-items-center">
|
|
<Play className="w-9 h-9 text-white" fill="currentColor" />
|
|
</div>
|
|
{/* Quick watched toggle (single episode), on hover. */}
|
|
{onToggleWatched && (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onToggleWatched();
|
|
}}
|
|
title={watchedLabel}
|
|
aria-label={watchedLabel}
|
|
className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 reveal hover:bg-black/80 transition"
|
|
>
|
|
<WatchedIcon watched={ep.status === "watched"} />
|
|
</button>
|
|
)}
|
|
{ep.status === "watched" && (
|
|
<span
|
|
className={`pointer-events-none absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 ${
|
|
onToggleWatched ? "reveal-idle" : ""
|
|
}`}
|
|
>
|
|
<CheckCircle2 className="w-4 h-4 text-success" />
|
|
</span>
|
|
)}
|
|
{pct > 0 && (
|
|
<div className="absolute bottom-0 inset-x-0 h-1 bg-black/40">
|
|
<div className="h-full bg-accent" style={{ width: `${pct}%` }} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div
|
|
className="mt-1.5 flex items-start gap-1.5 rounded-lg px-2 py-1.5"
|
|
style={{ background: "color-mix(in srgb, var(--card) 60%, transparent)" }}
|
|
>
|
|
<div className="min-w-0 flex-1">
|
|
{withShowTitle && ep.show_title && (
|
|
<div className="text-2xs text-muted truncate">
|
|
{ep.show_title}
|
|
{ep.season_number != null && ep.episode_number != null
|
|
? ` · S${ep.season_number}·E${ep.episode_number}`
|
|
: ""}
|
|
</div>
|
|
)}
|
|
<div className="text-sm font-medium leading-tight">
|
|
{!withShowTitle && <span className="text-muted mr-1">{ep.episode_number}.</span>}
|
|
{ep.title}
|
|
</div>
|
|
<div className="text-2xs text-muted mt-0.5">{formatRuntime(ep.duration_seconds)}</div>
|
|
</div>
|
|
{onAdd && (
|
|
<button
|
|
onClick={onAdd}
|
|
title={t("plex.playlist.addEpisode")}
|
|
aria-label={t("plex.playlist.addEpisode")}
|
|
className="shrink-0 rounded-sm p-1 text-muted transition hover:text-accent"
|
|
>
|
|
<ListPlus className="w-4 h-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|