Files
siftlode/frontend/src/features/video/VideoCard.tsx
T
peter 686c673fb2 feat(player): a video's related videos, on demand beside it
The player shows what YouTube lists beside the video, without what the
user has watched or hidden. Wide, a Related tab beside the description;
stacked, a button beside the info opens them in a drawer. A row plays in
the same player (Back returns) and the list stays the opened video's.
A switch picks on-topic or Hungarian-trending lists, kept on the account.

Each related row says whether the user follows its channel; the row is
the video card's narrow form with its whole menu. VideoMenu moves to
features/video, where the player can reach it.
2026-10-24 16:57:52 +02:00

767 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { memo, useEffect, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useNavigationActions } from "../navigation/navigationContext";
import {
Bookmark,
Check,
CheckCheck,
Clock,
Play,
Radio,
// Aliased: `Video` is this app's own domain type (lib/api), and it wins the name.
Video as VideoIcon,
} from "lucide-react";
import Avatar from "../../components/ui/Avatar";
import AddToPlaylist from "./AddToPlaylist";
import { navItemAttrs } from "../../lib/listNav";
import clsx from "clsx";
import type { Video, VideoStatus } from "../../lib/api";
import { FEED_VIEW_SPEC, type FeedView } from "../../lib/feedView";
import {
formatDate,
formatDuration,
formatNumber,
formatCompact,
relativeTime,
} from "../../lib/format";
import { isPlainLeftClick } from "../../lib/nav";
import { endsTextSelection } from "../../lib/textSelection";
import ChannelNameLink from "./ChannelNameLink";
import { IconButton } from "../../components/ui/IconButton";
import { SURFACE } from "../../components/ui/surfaces";
import { MoreButton } from "../../components/ui/MoreButton";
import { HOVER_REVEAL, HOVER_YIELD } from "../../components/ui/reveal";
import {
MEDIA_BADGE,
MEDIA_BAR,
MEDIA_HOVER_LAYER,
MEDIA_PICTURE_CONTAINER,
MEDIA_IMG,
MEDIA_PICTURE,
mediaTitleClass,
} from "../../components/ui/mediaCardStyles";
import { MediaCard, MediaProgress } from "../../components/ui/MediaCard";
import { MediaResumeButtons } from "./MediaResumeButtons";
import { RelativeTime } from "../../components/ui/RelativeTime";
import RemoteImage from "../../components/ui/RemoteImage";
/** The "…" that opens the video's action list, and whether its menu is the open one. */
type More = { props: Record<string, unknown>; open: boolean };
// The card keeps its state and the everyday toggles — watched, save, add to playlist — and
// 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.
/** The card's hover row (watched, save, add, "…"); Home's hero docks the same row on its picture. */
export function VideoHoverActions({
video,
onState,
onToggleSave,
more,
media = false,
dense = false,
}: {
video: Video;
onState: (id: string, status: VideoStatus) => void;
onToggleSave: (id: string, saved: boolean) => void;
more?: More;
/** On the thumbnail: the round, dark `media` face (A2). */
media?: boolean;
/** Tighter gap for the small card, whose column bottoms out at 180px. */
dense?: boolean;
}) {
const { t } = useTranslation();
const toggleWatched = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onState(video.id, video.status === "watched" ? "new" : "watched");
};
const toggleSave = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
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; 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
variant={variant}
onClick={toggleWatched}
pressed={video.status === "watched"}
label={video.status === "watched" ? t("card.watchedUnmark") : t("card.markWatched")}
className="reveal-hover"
>
{video.status === "watched" ? <CheckCheck /> : <Check />}
</IconButton>
<IconButton
variant={variant}
onClick={toggleSave}
pressed={!!video.saved}
label={video.saved ? t("card.savedRemove") : t("card.saveForLater")}
className="reveal-hover"
>
<Bookmark className={clsx(video.saved && "fill-current")} />
</IconButton>
<span className="reveal-hover flex">
<AddToPlaylist videoId={video.id} variant={variant} />
</span>
{more && (
<MoreButton
variant={variant}
label={t("card.menu.more")}
expanded={more.open}
menuProps={more.props}
/>
)}
</div>
);
}
// Plain left-click opens the in-app player (the experiment); Ctrl/Cmd/middle-click
// keep the native "open youtube.com in a new tab" behavior via the underlying href.
function openInApp(
e: React.MouseEvent,
video: Video,
onOpen?: (v: Video, startAt?: number | null) => void,
): void {
if (!onOpen || !isPlainLeftClick(e)) return;
e.preventDefault();
// The click that ends selecting the title's words (to copy them) neither plays nor follows the
// link (docs/DESIGN.md "Text stays selectable").
if (endsTextSelection(e)) return;
onOpen(video);
}
// A non-zero saved position on an unfinished video = "in progress": offer Continue / Restart
// instead of a plain Play. Note this is TRUE even with no known duration — in which case there is
// no percentage to draw, so the two are deliberately separate predicates.
function isInProgress(video: Video): boolean {
return video.position_seconds > 0 && video.status !== "watched";
}
/** Resume position as 0-100, or 0 when there's nothing to draw. Shared by Thumb and the
* thumbnail-less row, which has no image to draw the bar on but still owes you the state. */
function resumePct(video: Video): number {
return isInProgress(video) && video.duration_seconds
? Math.min(99, Math.max(2, (video.position_seconds / video.duration_seconds) * 100))
: 0;
}
// Thumb overlays duration / live state / saved ON the image. The one-line row has no image, so it
// renders the same FACTS as columns of its own — deliberately not shared with Thumb's badges:
// floating chips over artwork and a table cell are different presentations, and forcing one
// component to do both would be a variant-flag knot for no reuse.
// The cost is real though: the rules below (duration, live/upcoming, was_live, saved) are ALSO in
// Thumb, so a change to them must land in both. If they ever do change, share the decision — a
// videoBadges(video) -> Badge[] — and keep only the markup separate.
//
// A fixed GRID, not a flex row: each datum has to hold its own x across rows or the column stops
// reading as a column. The flex row this replaces let a "36:36" and a "3:31:09" push the marker
// beside them to different places, and pulled it left again whenever the duration was absent
// (live/upcoming) — visible as a ragged edge down the page.
function RowMetaCells({ video }: { video: Video }) {
const { t } = useTranslation();
// Icons, not the labelled chips the thumbnail uses: at a glance this is the least important thing
// in the row, so it gets about a letter's worth of width. Icons rather than a coloured dot
// because the three states must stay apart by SHAPE — in the youtube scheme --accent is red, so a
// "live" dot and a "stream" dot would be the same dot.
const status =
video.live_status === "live"
? // the broadcast red, not --danger: "live" is not an error
{ Icon: Radio, cls: "text-live", label: t("card.live") }
: video.live_status === "upcoming"
? { Icon: Clock, cls: "", label: t("card.upcoming") }
: video.live_status === "was_live"
? { Icon: VideoIcon, cls: "text-accent", label: t("card.stream") }
: null;
const StatusIcon = status?.Icon;
// 4rem + 1rem + 1rem + two 4px gaps = 104px, which the one-line row's width tally counts on —
// widen a slot and the title column silently pays for it. rem, not px, so the whole cell tracks
// the text-size setting the same way its contents do.
return (
<div className="shrink-0 grid grid-cols-[4rem_1rem_1rem] gap-1 items-center text-sm text-muted">
{/* Right-aligned: a duration is a number, so the digits line up on their own edge (30px for
"1:52" up to 59px for an 11-hour stream — 4rem holds the longest). */}
<span className="text-right tabular-nums">
{video.duration_seconds != null ? formatDuration(video.duration_seconds) : ""}
</span>
{/* 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 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}>
<StatusIcon className={clsx("w-3.5 h-3.5", status.cls)} />
</span>
)}
</span>
<span>
{video.saved && (
<span role="img" aria-label={t("card.savedMark")} title={t("card.savedMark")}>
<Bookmark className="w-3.5 h-3.5 text-accent fill-current" />
</span>
)}
</span>
</div>
);
}
function Thumb({
video,
className,
onOpen,
small = false,
bar,
}: {
video: Video;
className?: string;
onOpen?: (v: Video, startAt?: number | null) => void;
/** A row/tile thumbnail (~160-176px): the hover controls go icon-only, since the labelled
* Continue / Restart buttons are sized for the card's full-width image and get clipped here. */
small?: boolean;
/** The card's hover action row, docked on the picture's bottom-right (the C1 reference). A
* SIBLING of the link, not inside it: a click in the playlists panel it opens would otherwise
* bubble (through the portal, along the React tree) into the link and start playback. */
bar?: ReactNode;
}) {
const { t } = useTranslation();
const inProgress = isInProgress(video);
const pct = resumePct(video);
// Open in the in-app player at an explicit position (null = resume from saved).
const open = (startAt: number | null) => (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onOpen?.(video, startAt);
};
return (
// 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", MEDIA_PICTURE_CONTAINER, 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". */}
<a
href={video.watch_url}
target="_blank"
rel="noreferrer"
aria-label={video.title ?? undefined}
onClick={(e) => openInApp(e, video, onOpen)}
className={MEDIA_PICTURE}
>
{video.thumbnail_url ? (
<RemoteImage
src={video.thumbnail_url}
alt=""
loading="lazy"
decoding="async"
width={1280}
height={720}
className={MEDIA_IMG}
/>
) : (
<div className="w-full h-full" />
)}
{video.duration_seconds != null ? (
<span
className={clsx(
MEDIA_BADGE,
"bottom-1.5 right-1.5",
// The hover row takes this corner; the duration steps aside while it shows.
bar && HOVER_YIELD,
)}
>
{formatDuration(video.duration_seconds)}
</span>
) : video.live_status === "live" || video.live_status === "upcoming" ? (
<span
className={`absolute bottom-1.5 right-1.5 text-3xs font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded ${
video.live_status === "live" ? "bg-live text-live-fg" : "bg-black/80 text-white"
}`}
>
{t(`card.${video.live_status}`)}
</span>
) : null}
{video.live_status === "was_live" && (
<span className="absolute top-1.5 left-1.5 bg-accent text-accent-fg text-3xs font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded-sm">
{t("card.stream")}
</span>
)}
{video.saved && (
<span className="absolute top-1.5 right-1.5 bg-accent text-accent-fg rounded-full p-1">
<Bookmark className="w-3.5 h-3.5" />
</span>
)}
{/* Hover overlay: Play everywhere; Continue + Restart once the video is in progress. Mouse-only:
reveal on hover AND on keyboard focus (else a Tab-focused button is invisible), and
`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={MEDIA_HOVER_LAYER}>
{inProgress ? (
<MediaResumeButtons small={small} onContinue={open(null)} onRestart={open(0)} />
) : (
<IconButton
variant="media"
size={small ? "md" : "lg"}
onClick={open(null)}
label={t("card.play")}
>
<Play className="translate-x-px fill-current" />
</IconButton>
)}
</div>
)}
{/* Resume progress bar (sits at the very bottom, under the duration badge). */}
<MediaProgress pct={pct} />
</a>
{bar && <div className={MEDIA_BAR}>{bar}</div>}
</div>
);
}
// The one-line row's column widths at the DEFAULT text size, mirroring the Tailwind classes the
// boxes actually use. They have to be mirrored by hand — Tailwind can't take a runtime value, and
// the classes have to stay, because they're rem: Settings' text-size slider drives the root font
// (`html { font-size: calc(16px * var(--font-scale)) }`, 0.9–1.3), so the boxes must grow with
// their contents. Inline pixels froze them and a channel name at 130% wanted 152px in a 128px box.
// 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 = {
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
published: 192, // w-48
} as const;
const ROW_GAP = 12; // gap-3
const ROW_PAD = 26; // px-3 both sides, plus a rounding allowance
const ROW_TITLE_MIN = 120; // not a column: the floor below which a title isn't worth showing
// What the row must measure before each column earns its place — cumulative, so each appears only
// 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_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,
views: ROW_BASE + ROW_GAP + ROW_COL.channel + ROW_GAP + ROW_COL.meta + ROW_GAP + ROW_COL.views,
published:
ROW_BASE +
ROW_GAP +
ROW_COL.channel +
ROW_GAP +
ROW_COL.meta +
ROW_GAP +
ROW_COL.views +
ROW_GAP +
ROW_COL.published,
};
function VideoCard({
video,
view,
width,
onState,
onToggleSave,
onOpen,
menuProps,
menuButtonProps,
menuOpen = false,
navIndex,
shelf = false,
narrow = false,
channelMark,
}: {
video: Video;
view: FeedView;
/** Measured width of this card/row, from VirtualFeed. 0 until the first measure. */
width: number;
onState: (id: string, status: VideoStatus) => void;
onToggleSave: (id: string, saved: boolean) => void;
onOpen?: (v: Video, startAt?: number | null) => void;
/** Right-click / long-press props for the LIST's one context menu (the video's actions).
* A FUNCTION, not the already-built props object: `useContextMenu.triggerProps` is stable across
* renders while its return value is a fresh object every call, and a fresh object prop would
* blow past this component's memo for every card in the virtualized feed on every parent
* render. The card calls it with the video it already has. */
menuProps?: (v: Video) => Record<string, unknown>;
/** The same menu's "…" button props (`useContextMenu.buttonProps`) — a function, for the same memo
* reason. Omitted, the card has no "…" (the menu is still a right-click away). */
menuButtonProps?: (v: Video) => Record<string, unknown>;
/** This card's menu is the open one (the "…" button's `aria-expanded`). */
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;
/** The thumbnailed row in a side panel's ~20rem column: a smaller picture, and the "…" alone at the
* row's end — the hover toggles leave no room for the title there, and every one of them is a row
* of the menu. */
narrow?: boolean;
/** Something the list says about the channel (a "followed" mark), set before its name. */
channelMark?: ReactNode;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const { openChannel } = useNavigationActions();
const spec = FEED_VIEW_SPEC[view];
const watched = video.status === "watched";
// The thumbnail-less row drops Thumb, and with it the duration / live state / saved marker that
// Thumb overlays on the image. Those are metadata, not decoration, so fold them into the meta
// line here rather than let the mode silently lose them.
const noThumb = !spec.thumb;
// Each datum on its own, so the one-line row can give each a column of its own while the stacked
// layouts still run them together as one sentence. The count comes in two shapes from one span —
// one span so the exact-count tooltip covers all of whatever is rendered: prose needs the word
// ("1.5K views · 2 min ago"), the row's column doesn't, because a table doesn't repeat its unit
// on every line and the tooltip carries the figure regardless.
const viewsCell = (withWord: boolean) =>
video.view_count != null && (
<span title={`${formatNumber(video.view_count, lang)} ${t("card.views")}`}>
{formatCompact(video.view_count, lang)}
{withWord && ` ${t("card.views")}`}
</span>
);
const published = (
<>
{relativeTime(video.published_at, lang, t)}
{video.published_at && (
<>
{" · "}
{formatDate(video.published_at, i18n.language)}
</>
)}
</>
);
// Show the full text in a native tooltip only when it's actually cut off — vertically where the
// title clamps to 2 lines, horizontally where a fixed column truncates it. Shared by the title
// and the channel name: both truncate in some views and fit in others, and a tooltip that just
// repeats what's already legible is noise.
const titleRef = useRef<HTMLAnchorElement>(null);
const channelRef = useRef<HTMLAnchorElement>(null);
const [titleClamped, setTitleClamped] = useState(false);
const [channelClamped, setChannelClamped] = useState(false);
useEffect(() => {
const cut = (el: HTMLElement) =>
el.scrollHeight > el.clientHeight + 1 || el.scrollWidth > el.clientWidth + 1;
const els: [HTMLElement | null, (v: boolean) => void][] = [
[titleRef.current, setTitleClamped],
[channelRef.current, setChannelClamped],
];
const check = () => els.forEach(([el, set]) => el && set(cut(el)));
check();
const ro = new ResizeObserver(check);
els.forEach(([el]) => el && ro.observe(el));
return () => ro.disconnect();
// `view` matters as much as the text: switching views returns a different branch, so React can
// remount these nodes — without it the observer would be left watching detached ones.
}, [video.title, video.channel_title, view]);
const title = (
<a
ref={titleRef}
href={video.watch_url}
target="_blank"
rel="noreferrer"
onClick={(e) => openInApp(e, video, onOpen)}
// A link is dragged, not selected: Chrome starts no selection on one even with the drag off,
// until it is also `user-select: text` (select-text below) — measured in a real browser.
draggable={false}
// Enter on a keyboard-selected card clicks THIS link (lib/listNav), so it opens exactly like a
// click: the in-app player, resuming where the reader left off.
data-nav-open=""
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 hover:text-accent",
noThumb ? "block truncate font-medium leading-snug" : mediaTitleClass(!!spec.dense, shelf),
)}
>
{video.title}
</a>
);
// A real link, like the title above it: a <button> here ignored middle-click and Ctrl/Cmd-click,
// so the same card honoured the browser's gestures on its title and silently dropped them on its
// channel name.
const channelLink = (
<ChannelNameLink
ref={channelRef}
data-testid="video-card-channel"
channelId={video.channel_id}
title={video.channel_title}
onOpenInApp={() =>
openChannel(video.channel_id, video.channel_title ?? t("card.thisChannel"))
}
stopPropagation
tooltip={channelClamped ? (video.channel_title ?? undefined) : undefined}
// leading-6: the one-line name is a 24 target (B1), not its 20px line of text.
className="text-sm leading-6 text-muted truncate block w-fit max-w-full text-left hover:text-fg"
/>
);
const more = menuButtonProps && { props: menuButtonProps(video), open: menuOpen };
// The rows' action block: shown on hover / focus on a mouse (`.reveal`), always on touch — where
// the toggles inside it are gone and only the "…" is left.
const actions = (
<div className="reveal">
<VideoHoverActions
video={video}
onState={onState}
onToggleSave={onToggleSave}
more={more}
dense={spec.dense}
/>
</div>
);
// A permanent container, not a hover-only fill. Rows used to be fully transparent at rest and
// paint an opaque bg-card on hover — so running the pointer down the list kept covering and
// re-revealing the ambient backdrop behind them, which reads as the background flickering. The
// cards never did this because glass-card always paints. No hover lift here: a 900px-wide row
// jumping as the pointer crosses it is a lot more restless than a card doing it.
const rowShell = `cv-row group ${SURFACE.card} glass-hover relative transition`;
if (spec.family === "row") {
// A table without being one. Every column is a FIXED width, which is what makes the same datum
// land on the same vertical line in every row. A grid can't align ACROSS rows here — VirtualFeed
// renders each as its own subtree, so there's no shared parent to be the grid — but a grid is
// still right WITHIN a cell (see RowMetaCells): fixed slots, so they line up across rows too.
// Widths are measured, not chosen by eye — against the worst case in the LIBRARY and in BOTH
// languages, since both have caught me out here:
// actions 188 (6 x 28 + 5 x 4) · meta cells 104 (see RowMetaCells) · views 48 (the bare
// "101.7K"; language-proof now the word is gone — with it, Hungarian's "megtekintés" needed
// 139) · published 183 ("11 months ago · Feb 28, 2026"; HU is shorter) · channel 128,
// which is a deliberate cut rather than a fit: names run to 342px, the median is 87, and 12
// of 102 truncate here vs 5 at 160 — worth 32px to the title, since a clipped channel name
// still reads while a clipped view count loses the number.
// Three ways this went wrong before: sizing off a PAGE of data (the badges came from 60 videos
// whose longest was 2:00:58, then wrapped on the 11-hour one); measuring with a hand-built probe
// instead of the real element (it read "101.7K megtekintés" as 122px where the live cell renders
// 139); and checking that the COLUMNS aligned without checking the data inside them (the meta
// cell was a flex row, so its contents slid about — see RowMetaCells).
// The title takes what's left, which is constant per container width — so it aligns too.
if (noThumb) {
const pct = resumePct(video);
const fits = (needs: number) => width === 0 || width >= needs;
return (
<div
data-testid="video-card"
data-video-id={video.id}
{...menuProps?.(video)}
{...(navIndex !== undefined && navItemAttrs(navIndex))}
className={clsx(rowShell, "flex items-center gap-3 px-3 py-2", watched && "opacity-55")}
>
{/* The title leads (the owner's pick, 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) && (
<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={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={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">
<div className="h-full rounded-full bg-accent" style={{ width: `${pct}%` }} />
</div>
)}
</div>
);
}
}
// Everything below is a STACKED layout — the card and the thumbnailed row. Only they run the
// data together as one sentence; the one-line row above gives each datum its own column and has
// already returned, so building this for it would be waste dressed up as shared code.
const views = viewsCell(true);
const textBlock = (
<>
{title}
<div className="mt-0.5">
{channelMark ? (
<div className="flex items-center gap-1 min-w-0">
{channelMark}
<div className="min-w-0">{channelLink}</div>
</div>
) : (
channelLink
)}
</div>
<div className="text-xs text-muted mt-0.5">
{views}
{views && " · "}
{/* The narrow row keeps the relative time and moves the date into its tooltip, as the small
card does: in a 20rem panel the full pair wrapped to a line of its own. */}
{narrow ? <RelativeTime at={video.published_at} /> : published}
</div>
</>
);
if (spec.family === "row" && narrow) {
return (
<div
data-testid="video-card"
data-video-id={video.id}
{...menuProps?.(video)}
{...(navIndex !== undefined && navItemAttrs(navIndex))}
className={clsx(rowShell, "flex gap-2 p-1.5", watched && "opacity-55")}
>
<Thumb video={video} className="aspect-video shrink-0 w-24" onOpen={onOpen} small />
<div className="min-w-0 flex-1 flex flex-col">{textBlock}</div>
{more && (
<div className="shrink-0 self-start">
<MoreButton label={t("card.menu.more")} expanded={more.open} menuProps={more.props} />
</div>
)}
</div>
);
}
if (spec.family === "row") {
return (
<div
data-testid="video-card"
data-video-id={video.id}
{...menuProps?.(video)}
{...(navIndex !== undefined && navItemAttrs(navIndex))}
className={clsx(rowShell, "flex gap-3 p-2", watched && "opacity-55")}
>
<Thumb
video={video}
className={clsx("aspect-video shrink-0", spec.actionsBelow ? "w-40" : "w-44")}
onOpen={onOpen}
small
/>
<div className="min-w-0 flex-1 flex flex-col">
{textBlock}
{/* Tiles put the actions under the meta, in vertical space the thumbnail already
reserves — which frees the ~168px the docked-right block used, so the block still
works in a narrow column. mt-auto pins them to the tile's bottom edge (the flex row
already stretches this column to full height), so they land in the same place
whether the title above them ran to one line or three. */}
{spec.actionsBelow && <div className="mt-auto pt-1">{actions}</div>}
</div>
{!spec.actionsBelow && actions}
</div>
);
}
// 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. 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. The grid stretches the
// card to its row on its own, which is what the pinned meta line needs.
return (
<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"
/>
)
}
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 ? <RelativeTime at={video.published_at} /> : published}
</div>
</>
)
}
more={more || undefined}
moreLabel={t("card.menu.more")}
/>
);
}
// Memoized so appending a feed page only renders the new cards, not every existing
// one (the callbacks from Feed are stable; non-overridden video objects keep their
// identity across renders).
export default memo(VideoCard);