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:
+407
-161
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user