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.
767 lines
34 KiB
TypeScript
767 lines
34 KiB
TypeScript
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);
|