import { memo, useEffect, useRef, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { useNavigationActions } from "./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 "./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, formatViews, relativeTime } from "../lib/format"; import { isPlainLeftClick } from "../lib/nav"; import { endsTextSelection } from "../lib/textSelection"; import ChannelNameLink from "./ChannelNameLink"; import { IconButton } from "./ui/IconButton"; 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"; import { MediaResumeButtons } from "./MediaResumeButtons"; import { RelativeTime } from "./ui/RelativeTime"; /** The "…" that opens the video's action list, and whether its menu is the open one. */ type More = { props: Record; open: boolean }; // E28 C1: 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 (
{video.status === "watched" ? : } {more && ( )}
); } // 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 (
{/* 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). */} {video.duration_seconds != null ? formatDuration(video.duration_seconds) : ""} {/* 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. */} {StatusIcon && ( )} {video.saved && ( )}
); } 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`).
{/* 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). */} openInApp(e, video, onOpen)} className={MEDIA_PICTURE} > {video.thumbnail_url ? ( ) : (
)} {video.duration_seconds != null ? ( {formatDuration(video.duration_seconds)} ) : video.live_status === "live" || video.live_status === "upcoming" ? ( {t(`card.${video.live_status}`)} ) : null} {video.live_status === "was_live" && ( {t("card.stream")} )} {video.saved && ( )} {/* Hover overlay: Play everywhere; Continue + Restart once the video is in progress. Mouse-only: reveal on hover AND on keyboard focus-within (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 && (
{inProgress ? ( ) : ( )}
)} {/* Resume progress bar (sits at the very bottom, under the duration badge). */}
{bar &&
{bar}
}
); } // 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, }: { 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 (E28 C1: 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; /** 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; /** 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; }) { const { t, i18n } = useTranslation(); 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 && ( {formatViews(video.view_count)} {withWord && ` ${t("card.views")}`} ); const published = ( <> {relativeTime(video.published_at)} {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(null); const channelRef = useRef(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 = ( 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 the S3e1 UAT. 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 real link, like the title above it: a