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
+407 -161
View File
@@ -6,9 +6,11 @@ import {
ChevronRight,
ChevronsLeft,
ChevronsRight,
ArrowUpRight,
PinOff,
Play,
RotateCcw,
Star,
} from "lucide-react";
import { qk } from "../lib/queryKeys";
import {
@@ -26,10 +28,9 @@ import {
} from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { takeSurprise, useSurpriseRequest } from "../lib/homeIntent";
import { formatDuration } from "../lib/format";
import { formatDuration, formatRuntime, formatViews, relativeTime } from "../lib/format";
import { toggleWatchState } from "../lib/plexWatch";
import { useComfortPin, pinTargetForCard, shelfMoveTarget, type ShelfMove } from "./comfortPin";
import { ComfortPinMenu } from "./ComfortPinMenu";
import { type ActionMenuItem } from "./ui/ActionMenu";
import { Button } from "./ui/Button";
import { Carousel } from "./ui/Carousel";
@@ -37,15 +38,29 @@ import { ContextMenu } from "./ui/ContextMenu";
import { useContextMenu, useItemTriggers } from "./ui/useContextMenu";
import SegmentedToggle from "./ui/SegmentedToggle";
import { LoadingState, StateMessage } from "./ui/QueryState";
import VideoCard from "./VideoCard";
import VideoCard, { VideoHoverActions } from "./VideoCard";
import { useVideoActions } from "./useVideoActions";
import { usePlexCardActions } from "./usePlexCardActions";
import { PlexPosterCard } from "./plexCards";
import { PlexHoverBar, PlexPosterCard } from "./plexCards";
import { usePlaylistsActions } from "./playlistsContext";
import { useNavigationActions } from "./navigationContext";
import { usePlexActions } from "./plexContext";
import { navItemAttrs } from "../lib/listNav";
import { Pressable } from "./ui/Pressable";
import { SURFACE } from "./ui/surfaces";
import clsx from "clsx";
import { IconButton } from "./ui/IconButton";
import { MoreButton } from "./ui/MoreButton";
import { HOVER_YIELD } from "./ui/reveal";
import { MediaCard, MediaPlayGlyph } from "./ui/MediaCard";
import {
MEDIA_BAR,
MEDIA_HOVER_LAYER,
MEDIA_IMG,
MEDIA_PICTURE,
mediaPictureBox,
mediaTitleClass,
} from "./ui/mediaCardStyles";
// The Home surface (E24) — "Mit nézzek most?".
//
@@ -68,11 +83,12 @@ const PlayerModal = lazy(() => import("./PlayerModal"));
// SUBSET, so a band added server-side and to `DISCOVER_BUDGETS` typechecked green while this row
// silently kept offering four.
// A rail tile's slot width. `cardsSmall` is the feed's own dense card (its column bottoms out at
// 180px), so a 200px slot is the same object the feed shows — not a Home-only variant that would
// drift from it. Px on purpose, like that column (`VirtualGrid`'s `minCol` is a layout-engine px value,
// A rail tile's slot width. `cardsSmall` is the feed's own dense card, so the slot holds the same
// object the feed shows — not a Home-only variant that would drift from it. 232, not the 200 it was:
// at 200 the picture was 184×103 and its centred Play overlapped the hover row docked under it by
// 2px (the owner saw them run together, 2026-09-27); at 232 they stand ~7px apart. Px on purpose, like that column (`VirtualGrid`'s `minCol` is a layout-engine px value,
// see ui/README): the card also takes it as its MEASURED width, which must be real pixels.
const YT_SLOT = 200;
const YT_SLOT = 232;
// How many already-offered cards the reroll remembers. Matched to the server's own cap
// (`routes/discover.MAX_EXCLUDE`) rather than to a smaller number of our own: at 8 the window rolled
@@ -89,6 +105,151 @@ const SHELF_MOVES: readonly [ShelfMove, string, typeof ChevronLeft][] = [
["last", "home.shelf.moveLast", ChevronsRight],
];
/** A Home card's ONE action list — a shelf tile's and the hero's alike: Play (it resolves `next`),
* the card's own rows (a video's or a Plex title's, as the feed and the library show them; a
* channel's or a playlist's open row), then the shelf's rows. */
function useHomeCardMenu({
cards,
onPlay,
onInfo,
onState,
onToggleSave,
onToggleWatched,
onMove,
moveSaving = false,
}: {
cards: DiscoverCard[];
onPlay: (card: DiscoverCard, startAt?: number | null) => void;
onInfo: (card: DiscoverCard) => void;
onState: (id: string, status: VideoStatus) => void;
onToggleSave: (id: string, saved: boolean) => void;
onToggleWatched: (card: PlexCard) => void;
onMove?: (card: DiscoverCard, to: ShelfMove, rendered: DiscoverCard[]) => void;
moveSaving?: boolean;
}) {
const { t } = useTranslation();
// ONE right-click menu for the rail (see ui/ContextMenu). It is the shelf's whole editor: menu
// rows rather than drag & drop, because dragging inside a horizontal, scrolling Carousel fights
// the scroll, and because these rows are keyboard-reachable (Shift+F10) for free.
const menu = useContextMenu<DiscoverCard>();
const pin = useComfortPin(menu.open);
// A video tile's own action list (E28 C1), the same one the feed's menu shows; the rail keeps its
// Play row (it resolves `next`) and its shelf rows around it.
const videoActions = useVideoActions({ onState, onToggleSave }, menu.open);
// A Plex tile's, likewise (its "Media info" row goes where the tile's `onInfo` does).
const plexActions = usePlexCardActions(
{
onOpen: (p) => {
const c = cards.find((x) => x.plex === p);
if (c) onPlay(c);
},
onInfo: (p) => {
const c = cards.find((x) => x.plex === p);
if (c) onInfo(c);
},
onToggleWatched,
},
menu.open,
);
const openPage = useOpenShelfPage();
const target = menu.target;
// What the pin row acts on: a comfort hero's pin is the shelf entry it came from — a pinned show's
// next episode is not itself pinned, and looking the episode up offered "Pin" under "From your
// shelf", a second pin (the owner's call, 2026-09-19). Any other card is its own.
const pinned = target ? (target.pinned_from ?? target) : null;
const shelfIdx = target?.pin_id != null ? cards.findIndex((c) => c.pin_id === target.pin_id) : -1;
const items: ActionMenuItem[] = !target
? []
: [
{
id: "play",
label: target.position_seconds > 0 ? t("home.hero.continue") : t("home.hero.play"),
icon: <Play className="w-4 h-4" />,
// A pinned channel with nothing unwatched left, or an empty playlist, resolves to no
// `next` — the same condition that stands its tile down. Kept visible and disabled rather
// than dropped, so the menu does not change shape from tile to tile.
disabled: !(target.next ?? target).video && !(target.next ?? target).plex,
onSelect: () => onPlay(target),
},
...(target.video
? videoActions
.items(target.video, menu.anchor, { pin: false })
.map((it, i) => (i === 0 ? { ...it, separatorBefore: true } : it))
: target.plex
? plexActions
.items(target.plex, { play: false, pin: false })
.map((it, i) => (i === 0 ? { ...it, separatorBefore: true } : it))
: target.kind === "channel" || target.kind === "playlist"
? [{ ...openPage.item(target), separatorBefore: true }]
: []),
...(onMove && target.pin_id != null
? [
...SHELF_MOVES.map(([to, key, Icon], i) => ({
id: `shelf-${to}`,
label: t(key),
icon: <Icon className="w-4 h-4" />,
separatorBefore: i === 0,
// The same rule the move itself runs, so a row is never enabled onto a no-op.
disabled: moveSaving || shelfMoveTarget(shelfIdx, to, cards.length) < 0,
note: moveSaving && i === 0 ? t("home.shelf.moveSaving") : undefined,
onSelect: () => onMove(target, to, cards),
})),
{
id: "shelf-unpin",
label: t("home.pin.remove"),
icon: <PinOff className="w-4 h-4" />,
separatorBefore: true,
// The only row here that removes a curated entry, and it sat in the same colour as
// the four harmless reorders. `danger` matches the shared unpin row in
// `comfortPin.item()`, which is what every OTHER surface (including the hero and the
// rail below) composes for the same command — the two must not disagree.
danger: true,
// The pin id travels on the shelf card, so this needs no lookup — and unlike every
// other surface it can never be in the "not known yet" state.
onSelect: () => pin.remove(target.pin_id as number, target.title),
},
]
: pin.item(pinTargetForCard(pinned ?? target), (pinned ?? target).title)),
];
return {
menu,
items,
panels: (
<>
{videoActions.panel}
{plexActions.panel}
</>
),
plexAdd: plexActions.add,
};
}
/** A pinned channel's or playlist's own page: the shelf tile's ↗ and its menu's open row. */
function useOpenShelfPage() {
const { t } = useTranslation();
const { openChannel, setPage } = useNavigationActions();
const { setSelectedId } = usePlaylistsActions();
const open = (card: DiscoverCard) => {
if (card.kind === "channel") openChannel(card.id, card.title);
else {
setSelectedId(Number(card.id));
setPage("playlists");
}
};
const label = (card: DiscoverCard) =>
card.kind === "channel" ? t("card.menu.openChannel") : t("home.shelf.openPlaylist");
return {
open,
label,
item: (card: DiscoverCard): ActionMenuItem => ({
id: "open",
label: label(card),
icon: <ArrowUpRight className="w-4 h-4" />,
onSelect: () => open(card),
}),
};
}
function Rail({
title,
ariaLabel,
@@ -118,83 +279,16 @@ function Rail({
/** A reorder is being written: the move rows wait for it (one write at a time). */
moveSaving?: boolean;
}) {
const { t } = useTranslation();
// ONE right-click menu for the rail (see ui/ContextMenu). It is the shelf's whole editor: menu
// rows rather than drag & drop, because dragging inside a horizontal, scrolling Carousel fights
// the scroll, and because these rows are keyboard-reachable (Shift+F10) for free.
const menu = useContextMenu<DiscoverCard>();
const pin = useComfortPin(menu.open);
// A video tile's own action list (E28 C1), the same one the feed's menu shows; the rail keeps its
// Play row (it resolves `next`) and its shelf rows around it.
const videoActions = useVideoActions({ onState, onToggleSave }, menu.open);
// A Plex tile's, likewise (its "Media info" row goes where the tile's `onInfo` does).
const plexActions = usePlexCardActions(
{
onOpen: (p) => {
const c = cards.find((x) => x.plex === p);
if (c) onPlay(c);
},
onInfo: (p) => {
const c = cards.find((x) => x.plex === p);
if (c) onInfo(c);
},
onToggleWatched,
},
menu.open,
);
const target = menu.target;
const shelfIdx = target?.pin_id != null ? cards.findIndex((c) => c.pin_id === target.pin_id) : -1;
const items: ActionMenuItem[] = !target
? []
: [
{
id: "play",
label: target.position_seconds > 0 ? t("home.hero.continue") : t("home.hero.play"),
icon: <Play className="w-4 h-4" />,
// A pinned channel with nothing unwatched left, or an empty playlist, resolves to no
// `next` — the same condition that stands its tile down. Kept visible and disabled rather
// than dropped, so the menu does not change shape from tile to tile.
disabled: !(target.next ?? target).video && !(target.next ?? target).plex,
onSelect: () => onPlay(target),
},
...(target.video
? videoActions
.items(target.video, menu.anchor, { pin: false })
.map((it, i) => (i === 0 ? { ...it, separatorBefore: true } : it))
: target.plex
? plexActions
.items(target.plex, { play: false, pin: false })
.map((it, i) => (i === 0 ? { ...it, separatorBefore: true } : it))
: []),
...(onMove && target.pin_id != null
? [
...SHELF_MOVES.map(([to, key, Icon], i) => ({
id: `shelf-${to}`,
label: t(key),
icon: <Icon className="w-4 h-4" />,
separatorBefore: i === 0,
// The same rule the move itself runs, so a row is never enabled onto a no-op.
disabled: moveSaving || shelfMoveTarget(shelfIdx, to, cards.length) < 0,
note: moveSaving && i === 0 ? t("home.shelf.moveSaving") : undefined,
onSelect: () => onMove(target, to, cards),
})),
{
id: "shelf-unpin",
label: t("home.pin.remove"),
icon: <PinOff className="w-4 h-4" />,
separatorBefore: true,
// The only row here that removes a curated entry, and it sat in the same colour as
// the four harmless reorders. `danger` matches the shared unpin row in
// `comfortPin.item()`, which is what every OTHER surface (including the hero and the
// rail below) composes for the same command — the two must not disagree.
danger: true,
// The pin id travels on the shelf card, so this needs no lookup — and unlike every
// other surface it can never be in the "not known yet" state.
onSelect: () => pin.remove(target.pin_id as number, target.title),
},
]
: pin.item(pinTargetForCard(target), target.title)),
];
const { menu, items, panels, plexAdd } = useHomeCardMenu({
cards,
onPlay,
onInfo,
onState,
onToggleSave,
onToggleWatched,
onMove,
moveSaving,
});
// One stable trigger per card (the menu acts on the unified card, not the `Video`/`PlexCard` the
// card component holds) — a fresh lambda per render defeated `memo(VideoCard)` on every tile.
@@ -221,13 +315,19 @@ function Rail({
{cards.map((c, i) => (
<div
key={`${c.source}:${c.kind}:${c.id}`}
className={c.aspect === "poster" ? "w-37.5 shrink-0" : "shrink-0"}
style={c.aspect === "poster" ? undefined : { width: YT_SLOT }}
// Every shelf card is landscape, a Plex movie or show too (its wide art): a shelf mixes
// YouTube and Plex, and a poster beside a 16:9 card stretched the whole row to the
// poster's height, leaving a hole in every video card (the owner's pick A, 2026-09-26 —
// Plex's and Netflix's own "Continue watching" rows do the same). A grid cell, so the card
// fills the slot: every card's meta line (pinned to its frame's bottom) sits on one line.
className="grid shrink-0 grid-cols-1"
style={{ width: YT_SLOT }}
>
{c.video ? (
<VideoCard
video={c.video}
view="cardsSmall"
shelf
width={YT_SLOT}
navIndex={i}
onState={onState}
@@ -240,23 +340,33 @@ function Rail({
) : c.plex ? (
<PlexPosterCard
card={c.plex}
wide
shelf
navIndex={i}
onOpen={() => onPlay(c)}
onToggleWatched={onToggleWatched}
onAdd={plexActions.add}
onAdd={plexAdd}
next={c.next?.plex}
onInfo={() => onInfo(c)}
menuProps={menuFor[i]}
menuButtonProps={buttonFor[i]}
menuOpen={menu.open && menu.target === c}
/>
) : (
<ShelfTile card={c} onPlay={onPlay} menuProps={menu.triggerProps} navIndex={i} />
<ShelfTile
card={c}
onPlay={onPlay}
menuProps={menuFor[i]?.()}
menuButtonProps={buttonFor[i]?.()}
menuOpen={menu.open && menu.target === c}
navIndex={i}
/>
)}
</div>
))}
</Carousel>
<ContextMenu menu={menu} ariaLabel={ariaLabel} items={items} />
{videoActions.panel}
{plexActions.panel}
{panels}
</section>
);
}
@@ -268,45 +378,90 @@ function ShelfTile({
card,
onPlay,
menuProps,
menuButtonProps,
menuOpen = false,
navIndex,
}: {
card: DiscoverCard;
onPlay: (card: DiscoverCard) => void;
menuProps?: (c: DiscoverCard) => Record<string, unknown>;
menuProps?: Record<string, unknown>;
/** The rail menu's "…" for this tile. */
menuButtonProps?: Record<string, unknown>;
menuOpen?: boolean;
navIndex?: number;
}) {
const { t } = useTranslation();
const openPage = useOpenShelfPage();
const count = card.kind === "channel" ? card.unwatched_count : card.item_count;
const countLabel =
card.kind === "channel"
? t("home.shelf.unwatchedCount", { count: count ?? 0 })
: t("home.shelf.itemCount", { count: count ?? 0 });
// The one media card (ui/MediaCard), like the videos and posters beside it on the shelf: the
// picture is the play button, the count the meta line pinned to the frame's bottom.
return (
<Pressable
selectable
onClick={() => card.next && onPlay(card)}
{...menuProps?.(card)}
{...(navIndex !== undefined && navItemAttrs(navIndex))}
// `aria-disabled`, not `disabled`: a pinned channel with nothing unwatched left is not
// playable, but it IS the tile you most want to take off the shelf, and the only way to do
// that is its context menu. MEASURED in Chromium (2026-09-06), because the reason is narrower
// than it looks: a real `disabled` button DOES still receive `contextmenu` and `pointerdown`,
// so mouse and long-press would both survive it — what it loses is FOCUS, and with it
// Shift+F10, the only keyboard route to the menu. Inert to the click (the handler guards),
// alive to all three input paths.
aria-disabled={!card.next}
className={`${SURFACE.card} glass-hover block w-full overflow-hidden transition-colors aria-disabled:opacity-60 [&:not([aria-disabled='true'])]:hover:border-accent`}
>
<span className="block aspect-video bg-card overflow-hidden">
{card.thumb ? (
<img src={card.thumb} alt="" loading="lazy" className="w-full h-full object-cover" />
) : null}
</span>
<span className="block p-2">
<span className="block text-xs font-medium truncate">{card.title}</span>
<span className="block text-2xs text-muted">{countLabel}</span>
</span>
</Pressable>
<MediaCard
dense
rootProps={{
...menuProps,
...(navIndex !== undefined && navItemAttrs(navIndex)),
}}
className={!card.next ? "opacity-60" : undefined}
picture={
// The ratio lives on a wrapper, as in the other cards: MEDIA_PICTURE fills its parent, and
// on the stretched frame itself that would be the whole card's height.
<div className={mediaPictureBox("wide")}>
<Pressable
onClick={() => card.next && onPlay(card)}
aria-label={card.title}
// `aria-disabled`, not `disabled`: a pinned channel with nothing unwatched left is not
// playable, but it IS the tile you most want to take off the shelf, and the only way to
// do that is its context menu. MEASURED in Chromium (2026-09-06), because the reason is
// narrower than it looks: a real `disabled` button DOES still receive `contextmenu` and
// `pointerdown`, so mouse and long-press would both survive it — what it loses is FOCUS,
// and with it Shift+F10, the only keyboard route to the menu. Inert to the click (the
// handler guards), alive to all three input paths.
aria-disabled={!card.next}
data-nav-open=""
className={MEDIA_PICTURE}
>
{card.thumb ? (
<img src={card.thumb} alt="" loading="lazy" className={MEDIA_IMG} />
) : null}
{/* The Play only when there is something to play: the click plays the channel's newest
unwatched video, the playlist's first. */}
<div className={MEDIA_HOVER_LAYER}>{card.next && <MediaPlayGlyph small />}</div>
</Pressable>
{/* The rest of what a tile like this offers, as the video and Plex cards beside it do:
its own page, and the "…" to the whole list (a sibling of the picture's button). */}
<div className={clsx(MEDIA_BAR, "flex gap-1")}>
<IconButton
variant="media"
onClick={() => openPage.open(card)}
label={openPage.label(card)}
>
<ArrowUpRight />
</IconButton>
{menuButtonProps && (
<MoreButton
variant="media"
label={t("card.menu.more")}
expanded={menuOpen}
menuProps={menuButtonProps}
/>
)}
</div>
</div>
}
title={
<span className={`min-h-6 ${mediaTitleClass(true, true)} select-text`} title={card.title}>
{card.title}
</span>
}
meta={<span className="block text-xs leading-6 text-muted">{countLabel}</span>}
more={menuButtonProps && { props: menuButtonProps, open: menuOpen }}
moreLabel={t("card.menu.more")}
/>
);
}
@@ -314,19 +469,34 @@ function Hero({
tier,
card,
onPlay,
onInfo,
onState,
onToggleSave,
onToggleWatched,
onReroll,
rerolling,
}: {
tier: HeroTier;
card: DiscoverCard;
onPlay: (card: DiscoverCard) => void;
onPlay: (card: DiscoverCard, startAt?: number | null) => void;
onInfo: (card: DiscoverCard) => void;
onState: (id: string, status: VideoStatus) => void;
onToggleSave: (id: string, saved: boolean) => void;
onToggleWatched: (card: PlexCard) => void;
onReroll: () => void;
rerolling: boolean;
}) {
const { t } = useTranslation();
// The hero is the likeliest thing on the page to be comfort media — it is what the surface just
// answered with — so it answers the same right-click every card in the app does.
const pinMenu = useContextMenu<DiscoverCard>();
// The hero is a card like any other (the owner, 2026-09-27): the same hover row on its picture and
// the same whole action list on its "…", right-click and long-press — it was a pin row alone.
const { menu, items, panels, plexAdd } = useHomeCardMenu({
cards: [card],
onPlay,
onInfo,
onState,
onToggleSave,
onToggleWatched,
});
const started = card.position_seconds > 0;
const left = card.remaining_seconds;
// The kicker is driven by the tier the SERVER chose, never guessed from the card's shape: "where
@@ -340,41 +510,111 @@ function Hero({
? t("home.hero.comfort")
: t("home.hero.surprise");
return (
<section
{...pinMenu.triggerProps(card)}
className={`${SURFACE.panel} mb-6 p-4 grid gap-4 sm:grid-cols-[minmax(0,18rem)_1fr]`}
>
<Pressable
onClick={() => onPlay(card)}
className={`relative block w-full overflow-hidden rounded-lg border border-border bg-card ${
card.aspect === "poster" ? "aspect-[2/3] max-w-[12rem]" : "aspect-video"
}`}
aria-label={`${started ? t("home.hero.continue") : t("home.hero.play")}: ${card.title}`}
>
{card.thumb ? <img src={card.thumb} alt="" className="w-full h-full object-cover" /> : null}
{card.duration_seconds != null && (
<span className="absolute bottom-1.5 right-1.5 rounded-sm bg-black/80 px-1.5 py-0.5 text-2xs font-medium text-white tabular-nums">
{formatDuration(card.duration_seconds)}
</span>
)}
{started && card.duration_seconds ? (
<span className="absolute inset-x-0 bottom-0 h-1 bg-black/60">
<span
className="block h-full bg-accent"
style={{
width: `${Math.min(100, (card.position_seconds / card.duration_seconds) * 100)}%`,
}}
/>
</span>
) : null}
</Pressable>
// One line of facts under the title: a film's year, length and score; a video's views and age.
// The picture's column is exactly the picture's width (it was 18rem around a 12rem poster: a 6rem
// hole), and the text stands centred beside it, so a video — shorter picture, fewer facts — fills
// its hero as well as a film does (the owner's pick, 2026-09-26).
const plex = card.plex;
const video = card.video;
const facts = plex
? [plex.year, formatRuntime(card.duration_seconds)]
: video
? // The channel is the subtitle already.
[
video.view_count != null && `${formatViews(video.view_count)} ${t("card.views")}`,
relativeTime(video.published_at),
]
: [];
const factLine = facts.filter(Boolean).join(" · ");
// A feed video carries no description, so only a film or an episode has a summary line.
const summary = plex?.summary;
// A film's subtitle is its year, which the fact line already says.
const subtitle = card.subtitle !== String(plex?.year ?? "") ? card.subtitle : null;
<div className="flex min-w-0 flex-col">
return (
// Three quarters of a wide page, centred, so the one answer stands in focus (the owner, 2026-09-27);
// a narrower window gives it the whole width back. The padding is even on every side.
<section
{...menu.triggerProps(card)}
className={`${SURFACE.panel} mb-6 flex flex-col gap-5 p-5 sm:flex-row sm:items-center lg:mx-auto lg:w-3/4`}
>
<div
className={`group relative shrink-0 ${
card.aspect === "poster" ? "aspect-[2/3] w-40 sm:w-48" : "aspect-video w-full sm:w-72"
}`}
>
<Pressable
onClick={() => onPlay(card)}
className="relative block h-full w-full overflow-hidden rounded-lg border border-border bg-card"
aria-label={`${started ? t("home.hero.continue") : t("home.hero.play")}: ${card.title}`}
>
{card.thumb ? (
<img src={card.thumb} alt="" className="w-full h-full object-cover" />
) : null}
{card.duration_seconds != null && (
// It steps aside while the hover row covers its corner, as a card's length does.
<span
className={`absolute bottom-1.5 right-1.5 rounded-sm bg-black/80 px-1.5 py-0.5 text-2xs font-medium text-white tabular-nums ${HOVER_YIELD}`}
>
{formatDuration(card.duration_seconds)}
</span>
)}
{started && card.duration_seconds ? (
<span className="absolute inset-x-0 bottom-0 h-1 bg-black/60">
<span
className="block h-full bg-accent"
style={{
width: `${Math.min(100, (card.position_seconds / card.duration_seconds) * 100)}%`,
}}
/>
</span>
) : null}
<div className={MEDIA_HOVER_LAYER}>
<MediaPlayGlyph />
</div>
</Pressable>
{/* The card's own hover row, as on every card: a sibling of the picture's button. */}
{card.video ? (
<div className={MEDIA_BAR}>
<VideoHoverActions
video={card.video}
onState={onState}
onToggleSave={onToggleSave}
media
more={{ props: menu.buttonProps(card), open: menu.open }}
/>
</div>
) : card.plex ? (
<PlexHoverBar
card={card.plex}
onToggleWatched={onToggleWatched}
onAdd={plexAdd}
moreProps={menu.buttonProps(card)}
moreOpen={menu.open}
/>
) : null}
</div>
<div className="flex min-w-0 flex-1 flex-col">
<p className="text-2xs font-semibold uppercase tracking-wider text-accent">{kicker}</p>
<h2 className="mt-1 text-lg font-semibold leading-tight">{card.title}</h2>
{card.subtitle && <p className="mt-0.5 text-xs text-muted truncate">{card.subtitle}</p>}
<div className="mt-auto flex flex-wrap items-center gap-2 pt-3">
{subtitle && <p className="mt-0.5 text-xs text-muted truncate">{subtitle}</p>}
{(factLine || plex?.rating != null) && (
<p className="mt-1 flex flex-wrap items-center gap-x-1.5 text-xs text-muted">
{factLine && <span>{factLine}</span>}
{plex?.rating != null && (
<span className="inline-flex items-center gap-0.5">
{factLine && <span aria-hidden>·</span>}
<Star className="h-3 w-3 fill-current text-rating" aria-hidden />
{plex.rating.toFixed(1)}
</span>
)}
</p>
)}
{/* Only when there is one (a missing summary leaves no gap, the stack is centred), at the
reading measure: the eye finds the next line's start again (it ran ~130 characters). */}
{summary && <p className="mt-2 measure line-clamp-2 text-sm text-muted">{summary}</p>}
<div className="flex flex-wrap items-center gap-2 pt-4">
<Button icon={<Play className="h-4 w-4" />} onClick={() => onPlay(card)}>
{started ? t("home.hero.continue") : t("home.hero.play")}
</Button>
@@ -386,16 +626,18 @@ function Hero({
>
{t("home.hero.reroll")}
</Button>
{/* On touch the picture's hover row is gone; the "…" stands here instead, as it sits
beside a card's title. */}
<MoreButton
className="touch-only"
label={t("card.menu.more")}
expanded={menu.open}
menuProps={menu.buttonProps(card)}
/>
</div>
</div>
{/* A comfort hero acts on the pin it came from: a pinned show's next episode is not itself
pinned, and looking the episode up offered "Pin" under "From your shelf" — a second pin.
The owner's call (2026-09-19): the row unpins the show. */}
<ComfortPinMenu
menu={pinMenu}
target={(c) => pinTargetForCard(c.pinned_from ?? c)}
title={(c) => (c.pinned_from ?? c).title}
/>
<ContextMenu menu={menu} ariaLabel={card.title} items={items} />
{panels}
</section>
);
}
@@ -776,6 +1018,10 @@ export default function Home() {
tier={hero.tier}
card={hero.card}
onPlay={onPlay}
onInfo={onInfo}
onState={onState}
onToggleSave={onToggleSave}
onToggleWatched={onToggleWatched}
onReroll={() => reroll()}
rerolling={rerolling}
/>