import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import type { Video } from "../lib/api"; import VideoCard from "./VideoCard"; // Grid column sizing mirrors the CSS the feed used before virtualization // (grid-cols-[repeat(auto-fill,minmax(260px,1fr))] with a gap-4 / 1rem gutter). const GRID_MIN_COL = 260; const GRID_GAP = 16; const LIST_GAP = 4; // Estimated row heights before measurement; real heights are measured per-row so // these only affect the very first paint and the scrollbar's initial guess. const GRID_ROW_EST = 340; const LIST_ROW_EST = 96; // Start fetching the next page this many rows before the end scrolls into view // (keeps the old IntersectionObserver's generous prefetch feel). const PREFETCH_ROWS = 4; function chunk(arr: T[], size: number): T[][] { if (size <= 1) return arr.map((x) => [x]); const out: T[][] = []; for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size)); return out; } // The feed scrolls inside the app's
, not the window, so the // virtualizer needs that element. Walk up to the nearest scrollable ancestor. function getScrollParent(node: HTMLElement | null): HTMLElement { let el = node?.parentElement ?? null; while (el) { const oy = getComputedStyle(el).overflowY; if (oy === "auto" || oy === "scroll") return el; el = el.parentElement; } return document.documentElement; } export interface VirtualFeedProps { items: Video[]; view: "grid" | "list"; onState: (id: string, status: string) => void; onToggleSave: (id: string, saved: boolean) => void; onOpenChannel: (channelId: string, channelName: string) => void; onResetState: (id: string) => void; onOpen: (v: Video, startAt?: number | null) => void; hasNextPage: boolean; isFetchingNextPage: boolean; fetchNextPage: () => void; } export default function VirtualFeed({ items, view, onState, onToggleSave, onOpenChannel, onResetState, onOpen, hasNextPage, isFetchingNextPage, fetchNextPage, }: VirtualFeedProps) { const listRef = useRef(null); const [scrollEl, setScrollEl] = useState(null); const [colCount, setColCount] = useState(view === "list" ? 1 : 4); // Distance from the scroll element's content top to where this list starts (the // feed toolbar sits above it inside the same scroll area). const [scrollMargin, setScrollMargin] = useState(0); useLayoutEffect(() => { if (listRef.current) setScrollEl(getScrollParent(listRef.current)); }, []); // Re-measure the column count and start offset on container/scroll-area resize. useLayoutEffect(() => { const node = listRef.current; if (!node) return; const measure = () => { if (scrollEl) { const m = node.getBoundingClientRect().top - scrollEl.getBoundingClientRect().top + scrollEl.scrollTop; setScrollMargin(m); } setColCount( view === "list" ? 1 : Math.max(1, Math.floor((node.clientWidth + GRID_GAP) / (GRID_MIN_COL + GRID_GAP))) ); }; measure(); const ro = new ResizeObserver(measure); ro.observe(node); if (scrollEl) ro.observe(scrollEl); return () => ro.disconnect(); }, [scrollEl, view]); const rows = useMemo(() => chunk(items, colCount), [items, colCount]); const virtualizer = useVirtualizer({ count: rows.length, getScrollElement: () => scrollEl, estimateSize: () => (view === "grid" ? GRID_ROW_EST : LIST_ROW_EST), overscan: 4, gap: view === "grid" ? GRID_GAP : LIST_GAP, scrollMargin, }); const virtualRows = virtualizer.getVirtualItems(); // Infinite-scroll trigger: once the last rendered row is within PREFETCH_ROWS of // the end, pull the next keyset page. useEffect(() => { const last = virtualRows[virtualRows.length - 1]; if (!last) return; if (last.index >= rows.length - 1 - PREFETCH_ROWS && hasNextPage && !isFetchingNextPage) { fetchNextPage(); } }, [virtualRows, rows.length, hasNextPage, isFetchingNextPage, fetchNextPage]); return (
{virtualRows.map((vr) => { const row = rows[vr.index]; if (!row) return null; return (
{view === "grid" ? (
{row.map((v) => ( ))}
) : (
)}
); })}
); }