feat(design): one media card, and Home's hero and shelves as cards (E28 S3h)

- ui/MediaCard + mediaCardStyles: one frame for every video, Plex title and
  Home tile; the meta line pinned to the bottom edge, the length a badge on
  the picture, the media Play only where the click plays, the hover row at
  the picture's bottom-right (the owner's pick C).
- Shelf density: on any strip a one-line title over one meta line; mixed
  Home shelves are landscape; rails turn content-visibility off (a card's
  reserved height had become the strip's). Home slot 232px, so the Play
  and the hover row no longer touch; a landscape shelf takes the small Play.
- Home hero is a card (hover row, the whole menu, a touch "…"), three
  quarters wide and centred; channel / playlist tiles get Play, open and
  "…"; a show tile acts on its next episode, ⓘ opens the show.
- Plex preview: a ~0.9 s mouse rest opens a passive panel with the whole
  title, facts and synopsis (popover `pointerOnly`); the synopsis and title
  tooltips are gone.
- Compact list row B: the title leads, "…" at the end, toggles over the
  date on hover.
- Media-info layout B (collections, crew, cast beside the facts sidebar;
  an empty column is not drawn); filtering values are chips there and on
  the show page; "Cast" over the cast-only strips.
- Browse tiles hover like a card; prose capped by the `measure` utility.
This commit is contained in:
2026-09-27 00:54:19 +02:00
parent 6653d81a80
commit a453b77f04
30 changed files with 1882 additions and 769 deletions
+351 -305
View File
@@ -1,17 +1,29 @@
import { useTranslation } from "react-i18next";
import { CheckCircle2, Circle, Film, ListPlus, Play, Star, Tv2 } from "lucide-react";
import { type CSSProperties, type ReactNode, useRef } from "react";
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 { PLAYABLE_TINT, playableClass } from "./plexPlayable";
import { plexAddLabelKey } 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 { HOVER_YIELD } from "./ui/reveal";
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
@@ -42,7 +54,7 @@ function WatchedIcon({ watched }: { watched: boolean }) {
);
}
/** The hover row on a card's picture (top-right). A sibling of the picture's own button, never
/** 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,
@@ -50,6 +62,7 @@ function HoverBar({
onToggleWatched,
addLabel,
onAdd,
onInfo,
more: m,
}: {
watched: boolean;
@@ -57,12 +70,14 @@ function HoverBar({
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 && !m) return null;
if (!onToggleWatched && !onAdd && !onInfo && !m) return null;
return (
<div className={`${HOVER_REVEAL} absolute right-1.5 top-1.5 z-base flex gap-1`}>
<div className={clsx(MEDIA_BAR, "flex gap-1")}>
{onToggleWatched && (
<IconButton variant="media" onClick={onToggleWatched} label={watchedLabel}>
<WatchedIcon watched={watched} />
@@ -73,6 +88,11 @@ function HoverBar({
<ListPlus />
</IconButton>
)}
{onInfo && (
<IconButton variant="media" onClick={onInfo} label={t("plex.openShow")}>
<Info />
</IconButton>
)}
{m && (
<MoreButton
variant="media"
@@ -85,25 +105,52 @@ function HoverBar({
);
}
/** The caption under a card's picture, with the touch "…" at its end (the hover row's twin). */
function Caption({
/** 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,
more: m,
className = "",
shelf = false,
children,
}: {
onClick?: () => void;
more: More;
className?: string;
/** One line, cut with an ellipsis (a shelf). The whole title is in the card's preview. */
shelf?: boolean;
children: ReactNode;
}) {
const { t } = useTranslation();
const pressedAt = useRef<{ x: number; y: number } | null>(null);
return (
<div
// The click that ends selecting the title's words, or a press dragged past the slop, does not
// open it (DESIGN "Text stays selectable"); a div's text selects on its own, so only the click
// needs the guard.
onClick={
onClick &&
((e) => {
@@ -114,22 +161,75 @@ function Caption({
onMouseDown={(e) => {
pressedAt.current = { x: e.clientX, y: e.clientY };
}}
className={`mt-1.5 flex items-start gap-1.5 rounded-lg px-2 py-1.5 ${onClick ? "cursor-pointer" : ""} ${className}`}
style={{ background: "color-mix(in srgb, var(--card) 60%, transparent)" }}
>
<div className="min-w-0 flex-1">{children}</div>
{m && (
<MoreButton
className="touch-only -mr-1 -my-0.5 shrink-0"
label={t("card.menu.more")}
expanded={m.open}
menuProps={m.props}
/>
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. */
function PlexPicture({
aspect,
thumb,
label,
onOpen,
plays,
small = false,
pct = 0,
bar,
children,
}: {
aspect: "poster" | "wide";
thumb: string;
/** The button's name: the picture has no text of its own. */
label: string;
onOpen: () => 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;
bar: ReactNode;
/** The badges printed on the picture. */
children?: ReactNode;
}) {
return (
<div className={mediaPictureBox(aspect)}>
<div
onClick={onOpen}
role="button"
tabIndex={0}
aria-label={label}
data-nav-open=""
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpen();
}
}}
className={clsx(MEDIA_PICTURE, "cursor-pointer")}
>
<img src={thumb} alt="" loading="lazy" decoding="async" className={MEDIA_IMG} />
{children}
<div className={MEDIA_HOVER_LAYER}>{plays && <MediaPlayGlyph small={small} />}</div>
<MediaProgress pct={pct} />
</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,
@@ -139,6 +239,10 @@ export function PlexPosterCard({
menuButtonProps,
menuOpen = false,
navIndex,
wide = false,
shelf = false,
next,
onInfo,
}: {
card: PlexCard;
onOpen: () => void;
@@ -155,163 +259,149 @@ export function PlexPosterCard({
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 inProgress = (card.position_seconds ?? 0) > 0 && card.status !== "watched";
const isPlayable = card.type !== "show"; // movies/episodes play; shows open the drill-down
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 =
inProgress && card.duration_seconds
? Math.min(100, Math.round(((card.position_seconds ?? 0) / card.duration_seconds) * 100))
: 0;
const pct = ep ? progressPct(ep) : watched ? 0 : progressPct(card);
const m = more(card, menuButtonProps, menuOpen);
const toggle = isPlayable && onToggleWatched ? () => onToggleWatched(card) : undefined;
const add = isPlayable && onAdd ? () => onAdd(card) : undefined;
// A corner pill steps aside while the hover row covers its corner; at rest, and on touch, it stays.
const yieldTop = toggle || add || m ? HOVER_YIELD : "";
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 (
<div
className="group text-left"
{...menuProps?.(card)}
{...(navIndex !== undefined && navItemAttrs(navIndex, card.title))}
// content-visibility keeps off-screen posters cheap to render (smoother long-list scroll).
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 300px" } as CSSProperties}
>
<div className="relative">
<div
onClick={onOpen}
role="button"
tabIndex={0}
data-nav-open=""
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpen();
<>
<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}
plays={isPlayable}
small={shelf && landscape}
pct={pct}
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}
/>
}
}}
className={`${SURFACE.card} glass-hover relative aspect-2/3 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-menu 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. */}
{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-menu w-2 h-2 rounded-full ring-2 ring-black/40 ${PLAYABLE_TINT[cls] ?? "bg-muted"}`}
title={t(`plex.playable.${cls}`)}
/>
);
})()}
{/* Rest-state watched badge (top-right). */}
{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 ${yieldTop}`}
>
{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 ${yieldTop}`}
>
{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" />}
{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}
/>
</>
);
}
{/* 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-accent-fg 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-rating-media">
<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>
/** 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>;
}
{pct > 0 && (
<div className="absolute inset-x-0 bottom-0 z-base h-1 bg-black/40">
<div className="h-full bg-accent" style={{ width: `${pct}%` }} />
</div>
)}
</div>
<HoverBar
watched={watched}
watchedLabel={watched ? t("plex.markUnwatched") : t("plex.markWatched")}
onToggleWatched={toggle}
addLabel={t(plexAddLabelKey(card))}
onAdd={add}
more={m}
/>
</div>
<Caption onClick={onOpen} more={m}>
<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>
</Caption>
</div>
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>
);
}
@@ -339,72 +429,45 @@ export function SeasonCard({
const watched = se.status === "watched";
const m = more(se, menuButtonProps, menuOpen);
return (
<div
className="group text-left"
{...menuProps?.(se)}
{...(navIndex !== undefined && navItemAttrs(navIndex, se.title))}
>
<div className="relative">
<div
onClick={onOpen}
role="button"
tabIndex={0}
data-nav-open=""
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpen();
}
}}
className={`${SURFACE.card} glass-hover relative aspect-2/3 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.series.openSeason")}</span>
</div>
</div>
{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 ${
onToggleWatched || onAdd || m ? HOVER_YIELD : ""
}`}
>
{t("plex.watched")}
</span>
)}
{/* In-progress lives at the top-LEFT (the hover row owns the top-right) and stays visible:
a season has no progress bar, so this badge is its sole in-progress cue. */}
{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>
<HoverBar
watched={watched}
watchedLabel={
watched ? t("plex.series.markSeasonUnwatched") : t("plex.series.markSeasonWatched")
<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}
/>
}
onToggleWatched={onToggleWatched}
addLabel={t("plex.playlist.addSeason")}
onAdd={onAdd}
more={m}
/>
</div>
<Caption onClick={onOpen} more={m} className="text-sm font-medium leading-tight">
<div className="line-clamp-2 hover:text-accent">{se.title}</div>
</Caption>
</div>
>
{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")}
/>
);
}
@@ -508,9 +571,10 @@ export function RelatedStrip({
<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="w-37.5 shrink-0">
<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}
@@ -548,80 +612,62 @@ export function EpisodeCard({
}) {
const { t } = useTranslation();
const watched = ep.status === "watched";
const inProgress = (ep.position_seconds ?? 0) > 0 && !watched;
const pct =
inProgress && ep.duration_seconds
? Math.min(100, Math.round(((ep.position_seconds ?? 0) / ep.duration_seconds) * 100))
: 0;
const pct = watched ? 0 : progressPct(ep);
const m = more(ep, menuButtonProps, menuOpen);
const runtime = formatRuntime(ep.duration_seconds);
return (
<div
className="group"
{...menuProps?.(ep)}
{...(navIndex !== undefined && navItemAttrs(navIndex, ep.title))}
>
<div className="relative">
<div
onClick={onPlay}
role="button"
tabIndex={0}
data-nav-open=""
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"
<MediaCard
dense
rootProps={{
...menuProps?.(ep),
...(navIndex !== undefined && navItemAttrs(navIndex, ep.title)),
}}
picture={
<PlexPicture
aspect="wide"
thumb={ep.thumb}
label={ep.title}
onOpen={onPlay}
plays
pct={pct}
bar={
<HoverBar
watched={watched}
watchedLabel={watched ? t("plex.markUnwatched") : t("plex.markWatched")}
onToggleWatched={onToggleWatched}
addLabel={t("plex.playlist.addEpisode")}
onAdd={onAdd}
more={m}
/>
}
>
<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>
{watched && (
<span
className={`pointer-events-none absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 ${
onToggleWatched || onAdd || m ? HOVER_YIELD : ""
}`}
>
<CheckCircle2 className="w-4 h-4 text-success" />
</span>
{watched && <WatchedBadge />}
{runtime && (
<CornerBadge yields={!!(onToggleWatched || onAdd || m)}>{runtime}</CornerBadge>
)}
{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>
</PlexPicture>
}
title={
<CardTitle onClick={onPlay}>
{!withShowTitle && ep.episode_number != null && (
<span className="text-muted mr-1">{ep.episode_number}.</span>
)}
</div>
<HoverBar
watched={watched}
watchedLabel={watched ? t("plex.markUnwatched") : t("plex.markWatched")}
onToggleWatched={onToggleWatched}
addLabel={t("plex.playlist.addEpisode")}
onAdd={onAdd}
more={m}
/>
</div>
<Caption more={m}>
{withShowTitle && ep.show_title && (
<div className="text-2xs text-muted truncate">
{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>
)}
<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>
</Caption>
</div>
)
}
more={m}
moreLabel={t("card.menu.more")}
/>
);
}