import { memo, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigationActions } from "./NavigationProvider"; import { Bookmark, Check, CheckCheck, Clock, Eye, EyeOff, Play, Radio, RotateCcw, // 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 DownloadButton from "./DownloadButton"; import VideoInfoCard from "./VideoInfoCard"; import clsx from "clsx"; import type { Video, VideoStatus } from "../lib/api"; import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView"; import { formatDate, formatDuration, formatViews, relativeTime } from "../lib/format"; import { isPlainLeftClick } from "../lib/nav"; import ChannelNameLink from "./ChannelNameLink"; function Actions({ video, onState, onResetState, onToggleSave, dense = false, }: { video: Video; onState: (id: string, status: VideoStatus) => void; onResetState?: (id: string) => void; onToggleSave: (id: string, saved: boolean) => void; /** Tighter buttons for the small card, whose column bottoms out at 180px. */ dense?: boolean; }) { const { t } = useTranslation(); const act = (status: VideoStatus) => (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); onState(video.id, video.status === status ? "new" : status); }; // Pristine = never opened: default status and no resume position. The reset clears the // whole state (incl. an in-progress position the un-watch toggle can't touch). const resettable = video.status !== "new" || video.position_seconds > 0; const toggleSave = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); onToggleSave(video.id, !video.saved); }; // One button style for the row — AddToPlaylist and DownloadButton take it as a prop, so all six // stay the same size instead of two of them keeping their own default. // `dense` shrinks 28px buttons to 24 and halves the gap: six of them need 188px, but the small // card's text column bottoms out around 160 (its 180px column less the padding), so at its // narrowest the last button used to hang outside the card. 154 at dense. const btn = clsx("rounded-md hover:bg-surface text-muted hover:text-fg", dense ? "p-1" : "p-1.5"); return ( // flex-wrap is the safety net, not the plan: the sizing above is what makes them fit. If a // future button or a narrower column breaks that arithmetic, the row wraps to a second line // rather than silently rendering a half-clipped control outside the card.
{onResetState && resettable && ( )}
); } // The hover-infobox trigger skins (E8·S3). The ⓘ is hover-revealed and dropped on touch (`reveal-hover` // + the group opacity toggle), and lives OUTSIDE the six-button action budget — as a thumbnail-corner // overlay for the thumbnailed views, and inline beside the actions for the one-line row. const INFO_REVEAL = "reveal-hover opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 transition-opacity"; const INFO_OVERLAY = clsx( INFO_REVEAL, "absolute bottom-1.5 left-1.5 z-10 rounded-md bg-black/70 p-1 text-white hover:bg-black/85", ); const INFO_INLINE = clsx(INFO_REVEAL, "rounded-md p-1.5 text-muted hover:bg-surface hover:text-fg"); // 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(); 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" ? // fixed live-red, not the status token — "live" is a broadcast indicator, not an error { Icon: Radio, cls: "text-red-500", 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 Actions — the label must not tell anyone to click something that doesn't respond. */} {StatusIcon && ( )} {video.saved && ( )}
); } function Thumb({ video, className, onOpen, small = false, }: { 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; }) { 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 ( openInApp(e, video, onOpen)} className={clsx( "block relative rounded-xl overflow-hidden bg-surface border border-border shadow-md group-hover:shadow-2xl transition-shadow", className, )} > {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 ? ( <> ) : ( )}
)} {/* Hover infobox trigger (E8·S3), docked bottom-left so it clears the centered play overlay, the corner badges (saved / stream, top corners) and the duration (bottom-right). Revealed with the rest of the hover chrome, dropped on touch. */} {/* Resume progress bar (sits at the very bottom, under the duration badge). */} {pct > 0 && (
)}
); } // 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 = { actions: 224, // w-48 (six buttons: 6×28 + 5×4 = 188) + gap-1 + the 28px inline info trigger 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_COL.actions + ROW_GAP + ROW_TITLE_MIN; 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, onResetState, onToggleSave, onOpen, menuProps, }: { 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; onResetState?: (id: string) => 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 (E24 S3 — the comfort pin). * 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; }) { 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)} title={titleClamped ? (video.title ?? undefined) : undefined} className={clsx( "font-medium leading-snug hover:text-accent", noThumb ? "block truncate" : "line-clamp-2", )} > {video.title} ); // A real link, like the title above it: a