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:
@@ -27,6 +27,15 @@ import { Button } from "./ui/Button";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
import { MoreButton } from "./ui/MoreButton";
|
||||
import { HOVER_REVEAL, HOVER_YIELD } from "./ui/reveal";
|
||||
import {
|
||||
MEDIA_BADGE,
|
||||
MEDIA_BAR,
|
||||
MEDIA_HOVER_LAYER,
|
||||
MEDIA_IMG,
|
||||
MEDIA_PICTURE,
|
||||
mediaTitleClass,
|
||||
} from "./ui/mediaCardStyles";
|
||||
import { MediaCard, MediaProgress } from "./ui/MediaCard";
|
||||
|
||||
/** The "…" that opens the video's action list, and whether its menu is the open one. */
|
||||
type More = { props: Record<string, unknown>; open: boolean };
|
||||
@@ -35,7 +44,8 @@ type More = { props: Record<string, unknown>; open: boolean };
|
||||
// a "…" for everything else (download, details, copy, hide, reset, pin), which is the list's ONE
|
||||
// menu (useVideoActions). On a mouse the row shows on hover or keyboard focus; on touch the toggles
|
||||
// are gone (the "…" and the long-press are the way in) and only the "…" stays.
|
||||
function Actions({
|
||||
/** The card's hover row (watched, save, add, "…"); Home's hero docks the same row on its picture. */
|
||||
export function VideoHoverActions({
|
||||
video,
|
||||
onState,
|
||||
onToggleSave,
|
||||
@@ -64,8 +74,8 @@ function Actions({
|
||||
onToggleSave(video.id, !video.saved);
|
||||
};
|
||||
const variant = media ? "media" : "ghost";
|
||||
// Four 28px buttons: 4 × 28 + 3 × 4 = 124, which fits the small card's 180px thumbnail and the
|
||||
// one-line row's w-32 column (ROW_COL.actions).
|
||||
// Four 28px buttons: 4 × 28 + 3 × 4 = 124, which fits the small card's 180px thumbnail; the
|
||||
// one-line row floats three of them (no "…": its own stays put) over its date on hover.
|
||||
return (
|
||||
<div className={clsx("flex", dense ? "gap-0.5" : "gap-1")}>
|
||||
<IconButton
|
||||
@@ -171,7 +181,7 @@ function RowMetaCells({ video }: { video: Video }) {
|
||||
{/* role="img" + aria-label names each marker ONCE and reliably; `title` alone wouldn't (a
|
||||
bare span has no role, and screen readers don't dependably surface title on one), while a
|
||||
title plus an sr-only twin gets it announced twice. These are indicators, NOT the controls
|
||||
in Actions — the label must not tell anyone to click something that doesn't respond. */}
|
||||
in VideoHoverActions — the label must not tell anyone to click something that doesn't respond. */}
|
||||
<span>
|
||||
{StatusIcon && (
|
||||
<span role="img" aria-label={status.label} title={status.label}>
|
||||
@@ -220,7 +230,10 @@ function Thumb({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx("relative", className)}>
|
||||
// min-h-0: the caller's aspect box must hold its ratio before the image arrives — an aspect-ratio
|
||||
// box otherwise grows to its content, and an off-screen, not-yet-loaded thumbnail measured 338px
|
||||
// tall, which stretched a whole Home shelf to it (ui/mediaCardStyles `mediaPictureBox`).
|
||||
<div className={clsx("relative min-h-0", className)}>
|
||||
{/* Named by the title: its only text is the duration badge, so a screen reader heard the first
|
||||
Tab stop of every card as "44:10" (E28 S3d4). */}
|
||||
<a
|
||||
@@ -229,7 +242,7 @@ function Thumb({
|
||||
rel="noreferrer"
|
||||
aria-label={video.title ?? undefined}
|
||||
onClick={(e) => openInApp(e, video, onOpen)}
|
||||
className="block relative h-full w-full rounded-xl overflow-hidden bg-surface border border-border shadow-md group-hover:shadow-2xl transition-shadow"
|
||||
className={MEDIA_PICTURE}
|
||||
>
|
||||
{video.thumbnail_url ? (
|
||||
<img
|
||||
@@ -239,7 +252,7 @@ function Thumb({
|
||||
decoding="async"
|
||||
width={1280}
|
||||
height={720}
|
||||
className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105"
|
||||
className={MEDIA_IMG}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full" />
|
||||
@@ -247,7 +260,8 @@ function Thumb({
|
||||
{video.duration_seconds != null ? (
|
||||
<span
|
||||
className={clsx(
|
||||
"absolute bottom-1.5 right-1.5 bg-black/80 text-white text-2xs font-medium px-1.5 py-0.5 rounded-sm",
|
||||
MEDIA_BADGE,
|
||||
"bottom-1.5 right-1.5",
|
||||
// The hover row takes this corner; the duration steps aside while it shows.
|
||||
bar && HOVER_YIELD,
|
||||
)}
|
||||
@@ -278,7 +292,7 @@ function Thumb({
|
||||
`reveal-hover` drops it entirely on touch — the card tap already plays/resumes there, so a
|
||||
permanent dark scrim over every mobile thumbnail (and an invisible tap target) is not wanted. */}
|
||||
{onOpen && (
|
||||
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-black/45 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity reveal-hover">
|
||||
<div className={MEDIA_HOVER_LAYER}>
|
||||
{inProgress ? (
|
||||
<>
|
||||
{small ? (
|
||||
@@ -338,15 +352,9 @@ function Thumb({
|
||||
</div>
|
||||
)}
|
||||
{/* Resume progress bar (sits at the very bottom, under the duration badge). */}
|
||||
{pct > 0 && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-black/40">
|
||||
<div className="h-full bg-accent" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
)}
|
||||
<MediaProgress pct={pct} />
|
||||
</a>
|
||||
{bar && (
|
||||
<div className={clsx(HOVER_REVEAL, "absolute bottom-2.5 right-1.5 z-base")}>{bar}</div>
|
||||
)}
|
||||
{bar && <div className={MEDIA_BAR}>{bar}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -359,7 +367,7 @@ function Thumb({
|
||||
// Keep this table and the classes in step; each number is measured against the worst case in the
|
||||
// LIBRARY and in BOTH languages — see the row's own comment for where each comes from.
|
||||
const ROW_COL = {
|
||||
actions: 128, // w-32 (four buttons: 4×28 + 3×4 = 124; E28 C1 moved the rest into the "…")
|
||||
more: 28, // the "…" at the row's end (size-7); the quick actions float over the date on hover
|
||||
channel: 128, // w-32
|
||||
meta: 104, // RowMetaCells' own grid: 4rem + 1rem + 1rem + two 4px gaps
|
||||
views: 64, // w-16
|
||||
@@ -373,7 +381,7 @@ const ROW_TITLE_MIN = 120; // not a column: the floor below which a title isn't
|
||||
// once everything to its left AND a readable title already fit. Calibrated at the default text
|
||||
// size; at 130% the columns grow ~30% while these don't, so they read slightly eager — the title
|
||||
// gets tighter there, but it can't vanish, since the last column standing down frees ~200px.
|
||||
const ROW_BASE = ROW_PAD + ROW_COL.actions + ROW_GAP + ROW_TITLE_MIN;
|
||||
const ROW_BASE = ROW_PAD + ROW_TITLE_MIN + ROW_GAP + ROW_COL.more;
|
||||
const ROW_FITS = {
|
||||
channel: ROW_BASE + ROW_GAP + ROW_COL.channel,
|
||||
meta: ROW_BASE + ROW_GAP + ROW_COL.channel + ROW_GAP + ROW_COL.meta,
|
||||
@@ -401,6 +409,7 @@ function VideoCard({
|
||||
menuButtonProps,
|
||||
menuOpen = false,
|
||||
navIndex,
|
||||
shelf = false,
|
||||
}: {
|
||||
video: Video;
|
||||
view: FeedView;
|
||||
@@ -422,6 +431,8 @@ function VideoCard({
|
||||
menuOpen?: boolean;
|
||||
/** Its position in a keyboard-navigable list (`lib/listNav`) — omit outside one (Home's rails). */
|
||||
navIndex?: number;
|
||||
/** On a shelf (a horizontal strip): a one-line title over the channel alone (ui/mediaCardStyles). */
|
||||
shelf?: boolean;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { openChannel } = useNavigationActions();
|
||||
@@ -493,8 +504,8 @@ function VideoCard({
|
||||
title={titleClamped ? (video.title ?? undefined) : undefined}
|
||||
className={clsx(
|
||||
// min-h-6: a one-line title is still a 24 target (B1).
|
||||
"min-h-6 select-text font-medium leading-snug hover:text-accent",
|
||||
noThumb ? "block truncate" : "line-clamp-2",
|
||||
"min-h-6 select-text hover:text-accent",
|
||||
noThumb ? "block truncate font-medium leading-snug" : mediaTitleClass(!!spec.dense, shelf),
|
||||
)}
|
||||
>
|
||||
{video.title}
|
||||
@@ -523,7 +534,7 @@ function VideoCard({
|
||||
// the toggles inside it are gone and only the "…" is left.
|
||||
const actions = (
|
||||
<div className="reveal">
|
||||
<Actions
|
||||
<VideoHoverActions
|
||||
video={video}
|
||||
onState={onState}
|
||||
onToggleSave={onToggleSave}
|
||||
@@ -569,29 +580,53 @@ function VideoCard({
|
||||
{...(navIndex !== undefined && navItemAttrs(navIndex))}
|
||||
className={clsx(rowShell, "flex items-center gap-3 px-3 py-2", watched && "opacity-55")}
|
||||
>
|
||||
{/* Actions lead: this is where the eye already is (the title is the thing you read), so
|
||||
they're the shortest pointer trip from it. ROW_COL.actions counts their width. */}
|
||||
<div className="shrink-0 w-32">{actions}</div>
|
||||
{/* Everything else is shrink-0, so the title is the only thing that can give — which means
|
||||
once the fixed columns outgrow the row, it gives ALL of it and vanishes. Columns drop
|
||||
from the right as the row narrows, least important first: a title with no date beats a
|
||||
date with no title. `width` is the ROW's own, so this holds whatever the rail and the
|
||||
filter panel are doing. Until the first measure lands (width 0) show everything: the
|
||||
row is off-screen for that frame anyway, and guessing narrow would flash. */}
|
||||
{/* The title leads (E28 S3h, the owner's pick B, 2026-09-26): the actions used to lead in a
|
||||
128px column that sat EMPTY at rest, a hole at the start of every row. Now the row's
|
||||
end holds a faint "…" (always there: the menu's door on every device), and on hover or
|
||||
keyboard focus the quick toggles float in beside it OVER the date, which steps aside —
|
||||
the Gmail row. On touch the toggles are gone (reveal-hover) and the "…" stays.
|
||||
Everything but the title is shrink-0, so the title is the only thing that can give —
|
||||
which means once the fixed columns outgrow the row, it gives ALL of it and vanishes.
|
||||
Columns drop from the right as the row narrows, least important first: a title with no
|
||||
date beats a date with no title. `width` is the ROW's own, so this holds whatever the
|
||||
rail and the filter panel are doing. Until the first measure lands (width 0) show
|
||||
everything: the row is off-screen for that frame anyway, and guessing narrow would
|
||||
flash. */}
|
||||
<div className="min-w-0 flex-1">{title}</div>
|
||||
{fits(ROW_FITS.channel) && <div className="shrink-0 w-32">{channelLink}</div>}
|
||||
{fits(ROW_FITS.meta) && <RowMetaCells video={video} />}
|
||||
{fits(ROW_FITS.meta) && (
|
||||
<div className={clsx(!fits(ROW_FITS.views) && HOVER_YIELD)}>
|
||||
<RowMetaCells video={video} />
|
||||
</div>
|
||||
)}
|
||||
{/* Right-aligned like the duration: it's a number, so the digits share an edge. 64px
|
||||
holds the widest bare form ("101.7K" at 48) in either language — dropping the word
|
||||
took this from 144 and handed the difference to the title. */}
|
||||
{fits(ROW_FITS.views) && (
|
||||
<div className="shrink-0 w-16 text-sm text-muted text-right tabular-nums truncate">
|
||||
<div
|
||||
className={clsx(
|
||||
"shrink-0 w-16 text-sm text-muted text-right tabular-nums truncate",
|
||||
!fits(ROW_FITS.published) && HOVER_YIELD,
|
||||
)}
|
||||
>
|
||||
{viewsCell(false)}
|
||||
</div>
|
||||
)}
|
||||
{fits(ROW_FITS.published) && (
|
||||
<div className="shrink-0 w-48 text-sm text-muted truncate">{published}</div>
|
||||
<div className={clsx("shrink-0 w-48 text-sm text-muted truncate", HOVER_YIELD)}>
|
||||
{published}
|
||||
</div>
|
||||
)}
|
||||
<div className="relative shrink-0">
|
||||
<div
|
||||
className={clsx(HOVER_REVEAL, "absolute inset-y-0 right-full mr-1 flex items-center")}
|
||||
>
|
||||
<VideoHoverActions video={video} onState={onState} onToggleSave={onToggleSave} />
|
||||
</div>
|
||||
{more && (
|
||||
<MoreButton label={t("card.menu.more")} expanded={more.open} menuProps={more.props} />
|
||||
)}
|
||||
</div>
|
||||
{/* Thumb draws this over the image; with no image it goes on the row itself. */}
|
||||
{pct > 0 && (
|
||||
<div className="absolute bottom-0 left-2 right-2 h-1 rounded-full bg-black/40">
|
||||
@@ -649,56 +684,85 @@ function VideoCard({
|
||||
}
|
||||
|
||||
// The card's action row sits on its picture (the C1 reference: shown on hover, never taking a line
|
||||
// of its own), and on touch its "…" sits beside the title as a 44px target.
|
||||
// of its own), and on touch its "…" sits beside the title as a 44px target. The frame, the pinned
|
||||
// meta line and the picture's pieces are the one media card's (ui/MediaCard), shared with Plex.
|
||||
// NOTE: do NOT add h-full here. A percentage height against the grid's content-derived height is
|
||||
// circular, and VirtualFeed measures each row with a ResizeObserver — the two together oscillate
|
||||
// by a pixel or two forever, which shows up as the whole page juddering.
|
||||
// by a pixel or two forever, which shows up as the whole page juddering. The grid stretches the
|
||||
// card to its row on its own, which is what the pinned meta line needs.
|
||||
return (
|
||||
<div
|
||||
data-testid="video-card"
|
||||
data-video-id={video.id}
|
||||
{...menuProps?.(video)}
|
||||
{...(navIndex !== undefined && navItemAttrs(navIndex))}
|
||||
className={clsx(
|
||||
`cv-card group ${SURFACE.card} glass-hover transition-all duration-150`,
|
||||
"flex flex-col",
|
||||
spec.dense ? "p-2" : "p-2.5",
|
||||
watched && "opacity-55",
|
||||
)}
|
||||
>
|
||||
{/* The small card's image is in the SAME size class as a row's (its column bottoms out at
|
||||
180px), so it needs the icon-only controls just as much — the labelled pair is 194px wide
|
||||
and would spill straight out of it. */}
|
||||
<Thumb
|
||||
video={video}
|
||||
className="aspect-video"
|
||||
onOpen={onOpen}
|
||||
small={spec.dense}
|
||||
bar={
|
||||
<Actions video={video} onState={onState} onToggleSave={onToggleSave} more={more} media />
|
||||
}
|
||||
/>
|
||||
<div className={clsx("flex gap-3 px-0.5 pb-0.5 flex-1", spec.dense ? "mt-2" : "mt-2.5")}>
|
||||
{/* The small card drops the channel avatar: at its ~180px column the 36px avatar plus the
|
||||
gap would leave the title barely half the width. The channel name is still below it. */}
|
||||
{!spec.dense && (
|
||||
<MediaCard
|
||||
rootProps={{
|
||||
"data-testid": "video-card",
|
||||
"data-video-id": video.id,
|
||||
...menuProps?.(video),
|
||||
...(navIndex !== undefined && navItemAttrs(navIndex)),
|
||||
}}
|
||||
className="cv-card"
|
||||
dense={spec.dense}
|
||||
dim={watched}
|
||||
// The small card's image is in the SAME size class as a row's (its column bottoms out at
|
||||
// 180px), so it needs the icon-only controls just as much — the labelled pair is 194px wide
|
||||
// and would spill straight out of it.
|
||||
picture={
|
||||
<Thumb
|
||||
video={video}
|
||||
className="aspect-video"
|
||||
onOpen={onOpen}
|
||||
small={spec.dense}
|
||||
bar={
|
||||
<VideoHoverActions
|
||||
video={video}
|
||||
onState={onState}
|
||||
onToggleSave={onToggleSave}
|
||||
more={more}
|
||||
media
|
||||
/>
|
||||
}
|
||||
/>
|
||||
}
|
||||
// The small card drops the channel avatar: at its ~180px column the 36px avatar plus the gap
|
||||
// would leave the title barely half the width. The channel name is still in the meta line.
|
||||
leading={
|
||||
!spec.dense && (
|
||||
<Avatar
|
||||
src={video.channel_thumbnail}
|
||||
fallback={video.channel_title ?? ""}
|
||||
className="w-9 h-9 rounded-full shrink-0 mt-0.5"
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0 flex-1 flex flex-col">{textBlock}</div>
|
||||
{more && (
|
||||
<MoreButton
|
||||
className="touch-only -mr-1 shrink-0"
|
||||
label={t("card.menu.more")}
|
||||
expanded={more.open}
|
||||
menuProps={more.props}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
title={title}
|
||||
meta={
|
||||
shelf ? (
|
||||
channelLink
|
||||
) : (
|
||||
<>
|
||||
{channelLink}
|
||||
<div className="text-xs text-muted mt-0.5">
|
||||
{views}
|
||||
{views && " · "}
|
||||
{/* The small card keeps the relative time and moves the date into its tooltip: at a
|
||||
shelf's width the full pair wrapped to a third meta line, and the tallest card sets
|
||||
the whole shelf's height (every card fills its slot). */}
|
||||
{spec.dense ? (
|
||||
<span
|
||||
title={
|
||||
video.published_at ? formatDate(video.published_at, i18n.language) : undefined
|
||||
}
|
||||
>
|
||||
{relativeTime(video.published_at)}
|
||||
</span>
|
||||
) : (
|
||||
published
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
more={more || undefined}
|
||||
moreLabel={t("card.menu.more")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user