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:
@@ -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")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user