commit ac60add03bb335b8380e5ec6648d6188f40c38f8
parent 6d4a06d76f4382ad3d56d372b284a84cb94f98fe
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 22 May 2026 11:04:10 -0400
virtualize big lists
Diffstat:
8 files changed, 614 insertions(+), 156 deletions(-)
diff --git a/common/components/TranscriptModal.tsx b/common/components/TranscriptModal.tsx
@@ -9,6 +9,7 @@ import {
usePlayerTime,
} from "./PlayerProvider";
import { AgeRestrictedBadge, LivestreamBadge } from "./badges";
+import { VirtualRow } from "./VirtualRow";
import { formatTimestamp } from "../lib/vtt";
import { formatDate } from "../lib/format";
@@ -331,11 +332,12 @@ const CueRow = memo(function CueRow({
offset: number;
}) {
return (
- <li
- ref={measureRef}
- data-index={virtualIndex}
- className={`absolute left-0 right-0 ${isActive ? "bg-blue-950/50" : ""}`}
- style={{ transform: `translateY(${offset}px)` }}
+ <VirtualRow
+ as="li"
+ measureRef={measureRef}
+ index={virtualIndex}
+ offset={offset}
+ className={isActive ? "bg-blue-950/50" : ""}
>
<button
type="button"
@@ -352,7 +354,7 @@ const CueRow = memo(function CueRow({
<span>{cue.body}</span>
</span>
</button>
- </li>
+ </VirtualRow>
);
});
diff --git a/common/components/TranscriptSearch.tsx b/common/components/TranscriptSearch.tsx
@@ -1,7 +1,9 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useWindowVirtualizer } from "@tanstack/react-virtual";
import { usePlayer } from "./PlayerProvider";
+import { VirtualRow } from "./VirtualRow";
import { useSummaries } from "./summariesCache";
import { useChannelSubsManifests, useSubsManifest } from "./subsCache";
import type { LayerHit } from "./searchPipeline";
@@ -71,7 +73,10 @@ type ResultGroup = {
const DEFAULT_MAX_HITS = 500;
const DEFAULT_FETCH_CONCURRENCY = 6;
const DEFAULT_FLUSH_INTERVAL_MS = 120;
-const DEFAULT_GROUPS_PAGE_SIZE = 50;
+// Average rendered height of a result row (card-header / leaf-header / hit).
+// Used as the virtualizer's initial estimate; the real heights are measured
+// per row by `rowVirtualizer.measureElement` after mount.
+const RESULT_ROW_ESTIMATE_PX = 48;
// Legacy URL keys still parsed for hydration (old share-links), but stripped
// on the first commit so the modern `qt=` is canonical going forward.
@@ -278,10 +283,6 @@ export default function TranscriptSearch() {
// Advanced options — configurable from the UI, pre-filled with defaults.
const [hitBatchValue, setHitBatchValue] = useState(DEFAULT_MAX_HITS);
const [hitBatchUnlimited, setHitBatchUnlimited] = useState(false);
- const [groupsBatchValue, setGroupsBatchValue] = useState(
- DEFAULT_GROUPS_PAGE_SIZE,
- );
- const [groupsBatchUnlimited, setGroupsBatchUnlimited] = useState(false);
const [fetchConcurrency, setFetchConcurrency] = useState(
DEFAULT_FETCH_CONCURRENCY,
);
@@ -289,9 +290,6 @@ export default function TranscriptSearch() {
DEFAULT_FLUSH_INTERVAL_MS,
);
const hitBatchSize = hitBatchUnlimited ? Infinity : hitBatchValue;
- const groupsBatchSize = groupsBatchUnlimited
- ? Infinity
- : groupsBatchValue;
const [advancedHydrated, setAdvancedHydrated] = useState(false);
useEffect(() => {
@@ -300,8 +298,6 @@ export default function TranscriptSearch() {
if (snap) {
setHitBatchValue(snap.hitBatchValue);
setHitBatchUnlimited(snap.hitBatchUnlimited);
- setGroupsBatchValue(snap.groupsBatchValue);
- setGroupsBatchUnlimited(snap.groupsBatchUnlimited);
setFetchConcurrency(snap.fetchConcurrency);
setFlushIntervalMs(snap.flushIntervalMs);
}
@@ -313,8 +309,6 @@ export default function TranscriptSearch() {
saveAdvanced({
hitBatchValue,
hitBatchUnlimited,
- groupsBatchValue,
- groupsBatchUnlimited,
fetchConcurrency,
flushIntervalMs,
});
@@ -322,23 +316,17 @@ export default function TranscriptSearch() {
advancedHydrated,
hitBatchValue,
hitBatchUnlimited,
- groupsBatchValue,
- groupsBatchUnlimited,
fetchConcurrency,
flushIntervalMs,
]);
const [hitLimit, setHitLimit] = useState(DEFAULT_MAX_HITS);
- const [groupsShown, setGroupsShown] = useState<number>(
- DEFAULT_GROUPS_PAGE_SIZE,
- );
const pipelineRef = useRef<TreeController | null>(null);
const { openTranscript } = usePlayer();
- // Reset the live caps on each new committed query.
+ // Reset the live hit cap on each new committed query.
useEffect(() => {
setHitLimit(hitBatchSize);
- setGroupsShown(groupsBatchSize);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [committedHash]);
@@ -1380,15 +1368,6 @@ export default function TranscriptSearch() {
onValueChange={setHitBatchValue}
onUnlimitedChange={setHitBatchUnlimited}
/>
- <LimitField
- label="Videos per page"
- hint="How many matching videos to show at a time."
- value={groupsBatchValue}
- unlimited={groupsBatchUnlimited}
- defaultValue={DEFAULT_GROUPS_PAGE_SIZE}
- onValueChange={setGroupsBatchValue}
- onUnlimitedChange={setGroupsBatchUnlimited}
- />
<NumberField
label="Fetch concurrency"
hint="Parallel transcript downloads while searching. Applies on next search."
@@ -1412,9 +1391,7 @@ export default function TranscriptSearch() {
type="button"
onClick={() => {
setHitBatchUnlimited(true);
- setGroupsBatchUnlimited(true);
setHitLimit(Infinity);
- setGroupsShown(Infinity);
}}
className="px-3 py-1.5 rounded-md border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 hover:bg-zinc-50 dark:hover:bg-zinc-800 text-xs"
>
@@ -1425,8 +1402,6 @@ export default function TranscriptSearch() {
onClick={() => {
setHitBatchValue(DEFAULT_MAX_HITS);
setHitBatchUnlimited(false);
- setGroupsBatchValue(DEFAULT_GROUPS_PAGE_SIZE);
- setGroupsBatchUnlimited(false);
setFetchConcurrency(DEFAULT_FETCH_CONCURRENCY);
setFlushIntervalMs(DEFAULT_FLUSH_INTERVAL_MS);
clearAdvanced();
@@ -1473,33 +1448,23 @@ export default function TranscriptSearch() {
<p className="text-sm text-zinc-500">No matching videos.</p>
)}
{resultGroups.length > 0 && (
- <div className="flex flex-col gap-3">
- {resultGroups.slice(0, groupsShown).map((g) => (
- <ResultCard
- key={g.slug}
- group={g}
- leavesById={leavesById}
- orderedLeafIds={orderedLeafIds}
- activeVideo={activeVideo}
- activeTime={activeTime}
- openWithMode={openWithMode}
- />
- ))}
- </div>
+ <VirtualResultList
+ resultGroups={resultGroups}
+ leavesById={leavesById}
+ orderedLeafIds={orderedLeafIds}
+ activeVideo={activeVideo}
+ activeTime={activeTime}
+ openWithMode={openWithMode}
+ />
)}
- {(resultGroups.length > groupsShown || capped) && (
+ {capped && (
<div className="mt-3 flex justify-center">
<button
type="button"
- onClick={() => {
- setGroupsShown((n) => n + groupsBatchSize);
- if (capped) setHitLimit((l) => l + hitBatchSize);
- }}
+ onClick={() => setHitLimit((l) => l + hitBatchSize)}
className="px-4 py-2 rounded-md border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 hover:bg-zinc-50 dark:hover:bg-zinc-800 text-sm"
>
- {resultGroups.length > groupsShown
- ? `Show more videos (${resultGroups.length - groupsShown} loaded${capped ? ", more available" : ""})`
- : "Show more videos"}
+ Load more results
</button>
</div>
)}
@@ -1511,116 +1476,294 @@ export default function TranscriptSearch() {
// ─── Result rendering ────────────────────────────────────────────────────────
-function ResultCard({
- group,
+type LeafInfo = {
+ query: string;
+ useRegex: boolean;
+ scope: "transcripts" | "chat" | "metadata";
+};
+
+type ResultRow =
+ | {
+ kind: "card-header";
+ cardIndex: number;
+ group: ResultGroup;
+ isFirstInCard: true;
+ isLastInCard: boolean;
+ }
+ | {
+ kind: "leaf-header";
+ cardIndex: number;
+ group: ResultGroup;
+ leafId: string;
+ hitCount: number;
+ isFirstInCard: false;
+ isLastInCard: boolean;
+ }
+ | {
+ kind: "hit";
+ cardIndex: number;
+ group: ResultGroup;
+ leafId: string;
+ hit: LayerHit;
+ hitIndex: number;
+ isFirstInCard: false;
+ isLastInCard: boolean;
+ };
+
+function buildResultRows(
+ resultGroups: ReadonlyArray<ResultGroup>,
+ orderedLeafIds: ReadonlyArray<string>,
+): ResultRow[] {
+ const out: ResultRow[] = [];
+ resultGroups.forEach((g, cardIndex) => {
+ const buckets = new Map<string, LayerHit[]>();
+ for (const id of orderedLeafIds) buckets.set(id, []);
+ for (const h of g.hits) {
+ const arr = buckets.get(h.leafId);
+ if (arr) arr.push(h);
+ }
+ const cardStart = out.length;
+ out.push({
+ kind: "card-header",
+ cardIndex,
+ group: g,
+ isFirstInCard: true,
+ isLastInCard: false,
+ });
+ for (const [leafId, hits] of buckets) {
+ if (hits.length === 0) continue;
+ out.push({
+ kind: "leaf-header",
+ cardIndex,
+ group: g,
+ leafId,
+ hitCount: hits.length,
+ isFirstInCard: false,
+ isLastInCard: false,
+ });
+ hits.forEach((hit, hitIndex) => {
+ out.push({
+ kind: "hit",
+ cardIndex,
+ group: g,
+ leafId,
+ hit,
+ hitIndex,
+ isFirstInCard: false,
+ isLastInCard: false,
+ });
+ });
+ }
+ // Flag the last row of this card so the renderer can close its visual
+ // grouping (rounded-b, border-b, margin to the next card).
+ const last = out[out.length - 1];
+ if (last && last.cardIndex === cardIndex && out.length - 1 > cardStart) {
+ (last as { isLastInCard: boolean }).isLastInCard = true;
+ } else if (last && out.length - 1 === cardStart) {
+ // Card with no contributing hits — header is also the last row.
+ (last as { isLastInCard: boolean }).isLastInCard = true;
+ }
+ });
+ return out;
+}
+
+function rowKey(row: ResultRow): string {
+ switch (row.kind) {
+ case "card-header":
+ return `c${row.cardIndex}:h`;
+ case "leaf-header":
+ return `c${row.cardIndex}:l:${row.leafId}`;
+ case "hit":
+ return `c${row.cardIndex}:h:${row.leafId}:${row.hitIndex}`;
+ }
+}
+
+function VirtualResultList({
+ resultGroups,
leavesById,
orderedLeafIds,
activeVideo,
activeTime,
openWithMode,
}: {
- group: ResultGroup;
- leavesById: ReadonlyMap<
- string,
- { query: string; useRegex: boolean; scope: "transcripts" | "chat" | "metadata" }
- >;
+ resultGroups: ReadonlyArray<ResultGroup>;
+ leavesById: ReadonlyMap<string, LeafInfo>;
orderedLeafIds: ReadonlyArray<string>;
activeVideo: string | null;
activeTime: number | null;
openWithMode: (slug: string, hit?: LayerHit) => void;
}) {
- // Bucket hits per contributing leaf so the user sees one section per
- // layer rather than an interleaved mishmash.
- const buckets = useMemo(() => {
- const m = new Map<string, LayerHit[]>();
- for (const id of orderedLeafIds) m.set(id, []);
- for (const h of group.hits) {
- const arr = m.get(h.leafId);
- if (arr) arr.push(h);
- }
- return m;
- }, [group.hits, orderedLeafIds]);
+ const flatRows = useMemo(
+ () => buildResultRows(resultGroups, orderedLeafIds),
+ [resultGroups, orderedLeafIds],
+ );
+
+ const listRef = useRef<HTMLDivElement | null>(null);
+ const [scrollMargin, setScrollMargin] = useState(0);
+
+ useEffect(() => {
+ if (typeof window === "undefined") return;
+ const update = () => {
+ const el = listRef.current;
+ if (!el) return;
+ setScrollMargin(el.getBoundingClientRect().top + window.scrollY);
+ };
+ update();
+ const ro = new ResizeObserver(update);
+ if (listRef.current) ro.observe(listRef.current);
+ ro.observe(document.body);
+ window.addEventListener("resize", update);
+ return () => {
+ ro.disconnect();
+ window.removeEventListener("resize", update);
+ };
+ }, []);
+
+ const rowVirtualizer = useWindowVirtualizer({
+ count: flatRows.length,
+ estimateSize: () => RESULT_ROW_ESTIMATE_PX,
+ overscan: 12,
+ scrollMargin,
+ });
+
+ const virtualItems = rowVirtualizer.getVirtualItems();
+ const totalSize = rowVirtualizer.getTotalSize();
return (
<div
- className="border border-zinc-200 dark:border-zinc-800 rounded-lg overflow-hidden bg-white dark:bg-zinc-900"
- data-result-slug={group.slug}
+ ref={listRef}
+ className="relative"
+ style={{ height: totalSize }}
>
+ {virtualItems.map((vi) => {
+ const row = flatRows[vi.index];
+ return (
+ <VirtualRow
+ key={rowKey(row)}
+ measureRef={rowVirtualizer.measureElement}
+ index={vi.index}
+ offset={vi.start - scrollMargin}
+ className={rowFrameClass(row)}
+ >
+ <ResultRowView
+ row={row}
+ leavesById={leavesById}
+ activeVideo={activeVideo}
+ activeTime={activeTime}
+ openWithMode={openWithMode}
+ />
+ </VirtualRow>
+ );
+ })}
+ </div>
+ );
+}
+
+// Tailwind classes that recreate the original card's rounded border + the
+// gap between cards. Side borders + a top border on every non-first row
+// give the divide-y effect without a separate wrapper element.
+function rowFrameClass(row: ResultRow): string {
+ const base =
+ "border-l border-r border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900";
+ const top = row.isFirstInCard
+ ? "rounded-t-lg border-t"
+ : "border-t border-zinc-200/60 dark:border-zinc-800/60";
+ const bottom = row.isLastInCard ? "rounded-b-lg border-b mb-3" : "";
+ return `${base} ${top} ${bottom}`;
+}
+
+function ResultRowView({
+ row,
+ leavesById,
+ activeVideo,
+ activeTime,
+ openWithMode,
+}: {
+ row: ResultRow;
+ leavesById: ReadonlyMap<string, LeafInfo>;
+ activeVideo: string | null;
+ activeTime: number | null;
+ openWithMode: (slug: string, hit?: LayerHit) => void;
+}) {
+ if (row.kind === "card-header") {
+ const g = row.group;
+ return (
+ <div data-result-slug={g.slug} data-card-header="">
+ <button
+ type="button"
+ onClick={() => openWithMode(g.slug)}
+ className="w-full text-left px-4 py-2 bg-zinc-50 dark:bg-zinc-900/60 hover:bg-zinc-100 dark:hover:bg-zinc-800 flex items-baseline gap-2"
+ >
+ <span className="font-medium truncate flex-1 min-w-0">
+ {g.title}
+ </span>
+ {g.isLivestream && <LivestreamBadge />}
+ {g.ageRestricted && <AgeRestrictedBadge />}
+ <span className="text-xs text-zinc-500 shrink-0">
+ {g.channel && `${g.channel} · `}
+ {g.date}
+ {` · ${g.hits.length} hit${g.hits.length === 1 ? "" : "s"}`}
+ </span>
+ </button>
+ </div>
+ );
+ }
+
+ if (row.kind === "leaf-header") {
+ const leafInfo = leavesById.get(row.leafId);
+ if (!leafInfo) return null;
+ // data-leaf-section is on a child so the existing test selector
+ // `[data-result-slug="X"] [data-leaf-section]` (descendant combinator)
+ // still matches across the flat-row structure.
+ return (
+ <div data-result-slug={row.group.slug}>
+ <div
+ className="flex items-center gap-2 px-4 py-1.5 bg-zinc-50/60 dark:bg-zinc-900/40"
+ data-leaf-section={row.leafId}
+ >
+ <LayerSwatch leafId={row.leafId} size="xs" />
+ <span className="text-[10px] uppercase tracking-wide text-zinc-500">
+ {leafInfo.scope === "metadata"
+ ? "Title / channel"
+ : leafInfo.scope === "chat"
+ ? "Live chat"
+ : "Transcripts"}
+ </span>
+ <span className="font-mono text-xs text-zinc-600 dark:text-zinc-400 truncate">
+ {leafInfo.query}
+ </span>
+ <span className="ml-auto text-[10px] text-zinc-500">
+ {row.hitCount} hit{row.hitCount === 1 ? "" : "s"}
+ </span>
+ </div>
+ </div>
+ );
+ }
+
+ const leafInfo = leavesById.get(row.leafId);
+ if (!leafInfo) return null;
+ const h = row.hit;
+ const isActive =
+ activeVideo === row.group.slug && activeTime === h.start;
+ return (
+ <div data-result-slug={row.group.slug}>
<button
type="button"
- onClick={() => openWithMode(group.slug)}
- className="w-full text-left px-4 py-2 bg-zinc-50 dark:bg-zinc-900/60 border-b border-zinc-200 dark:border-zinc-800 hover:bg-zinc-100 dark:hover:bg-zinc-800 flex items-baseline gap-2"
+ onClick={() => openWithMode(row.group.slug, h)}
+ className={`w-full text-left flex gap-3 px-4 py-2 hover:bg-zinc-50 dark:hover:bg-zinc-800 ${
+ isActive
+ ? "bg-amber-100/70 dark:bg-amber-500/10 ring-1 ring-inset ring-amber-400/60"
+ : ""
+ }`}
>
- <span className="font-medium truncate flex-1 min-w-0">
- {group.title}
+ <span className="text-xs font-mono text-zinc-500 shrink-0 w-16">
+ {h.scope === "metadata" ? "—" : formatSeconds(h.start)}
</span>
- {group.isLivestream && <LivestreamBadge />}
- {group.ageRestricted && <AgeRestrictedBadge />}
- <span className="text-xs text-zinc-500 shrink-0">
- {group.channel && `${group.channel} · `}
- {group.date}
- {` · ${group.hits.length} hit${group.hits.length === 1 ? "" : "s"}`}
+ {h.track && h.track !== "live_chat" && <TrackBadge track={h.track} />}
+ <span className="flex-1 min-w-0 text-sm">
+ {highlight(h.text, leafInfo.query, leafInfo.useRegex)}
</span>
</button>
- <ul className="flex flex-col divide-y divide-zinc-200 dark:divide-zinc-800">
- {Array.from(buckets.entries()).map(([leafId, hits]) => {
- if (hits.length === 0) return null;
- const leafInfo = leavesById.get(leafId);
- if (!leafInfo) return null;
- return (
- <li key={leafId}>
- <div
- className="flex items-center gap-2 px-4 py-1.5 bg-zinc-50/60 dark:bg-zinc-900/40 border-b border-zinc-200 dark:border-zinc-800"
- data-leaf-section={leafId}
- >
- <LayerSwatch leafId={leafId} size="xs" />
- <span className="text-[10px] uppercase tracking-wide text-zinc-500">
- {leafInfo.scope === "metadata"
- ? "Title / channel"
- : leafInfo.scope === "chat"
- ? "Live chat"
- : "Transcripts"}
- </span>
- <span className="font-mono text-xs text-zinc-600 dark:text-zinc-400 truncate">
- {leafInfo.query}
- </span>
- <span className="ml-auto text-[10px] text-zinc-500">
- {hits.length} hit{hits.length === 1 ? "" : "s"}
- </span>
- </div>
- <ul className="flex flex-col divide-y divide-zinc-200/60 dark:divide-zinc-800/60">
- {hits.map((h, i) => {
- const isActive =
- activeVideo === group.slug && activeTime === h.start;
- return (
- <li key={i}>
- <button
- type="button"
- onClick={() => openWithMode(group.slug, h)}
- className={`w-full text-left flex gap-3 px-4 py-2 hover:bg-zinc-50 dark:hover:bg-zinc-800 ${
- isActive
- ? "bg-amber-100/70 dark:bg-amber-500/10 ring-1 ring-inset ring-amber-400/60"
- : ""
- }`}
- >
- <span className="text-xs font-mono text-zinc-500 shrink-0 w-16">
- {h.scope === "metadata" ? "—" : formatSeconds(h.start)}
- </span>
- {h.track && h.track !== "live_chat" && (
- <TrackBadge track={h.track} />
- )}
- <span className="flex-1 min-w-0 text-sm">
- {highlight(h.text, leafInfo.query, leafInfo.useRegex)}
- </span>
- </button>
- </li>
- );
- })}
- </ul>
- </li>
- );
- })}
- </ul>
</div>
);
}
diff --git a/common/components/VirtualRow.tsx b/common/components/VirtualRow.tsx
@@ -0,0 +1,35 @@
+import type { ReactNode } from "react";
+
+type VirtualRowElement = "div" | "li";
+
+export function VirtualRow({
+ as = "div",
+ measureRef,
+ index,
+ offset,
+ className,
+ children,
+}: {
+ as?: VirtualRowElement;
+ measureRef: (el: Element | null) => void;
+ index: number;
+ offset: number;
+ className?: string;
+ children: ReactNode;
+}) {
+ const ref = measureRef as (el: HTMLElement | null) => void;
+ const style = { transform: `translateY(${offset}px)` };
+ const cls = `absolute left-0 right-0 ${className ?? ""}`;
+ if (as === "li") {
+ return (
+ <li ref={ref} data-index={index} style={style} className={cls}>
+ {children}
+ </li>
+ );
+ }
+ return (
+ <div ref={ref} data-index={index} style={style} className={cls}>
+ {children}
+ </div>
+ );
+}
diff --git a/common/components/exportAdvancedStorage.ts b/common/components/exportAdvancedStorage.ts
@@ -9,8 +9,6 @@ const VERSION = 1;
export type AdvancedSnapshot = {
hitBatchValue: number;
hitBatchUnlimited: boolean;
- groupsBatchValue: number;
- groupsBatchUnlimited: boolean;
fetchConcurrency: number;
flushIntervalMs: number;
};
@@ -33,16 +31,15 @@ function clampInt(
return n;
}
+// Tolerates v1 records that still carry the now-removed `groupsBatch*`
+// fields — they're silently ignored. Only `hitBatchUnlimited` is required.
function parseSnapshot(raw: unknown): AdvancedSnapshot | null {
if (!raw || typeof raw !== "object") return null;
const r = raw as Record<string, unknown>;
if (typeof r.hitBatchUnlimited !== "boolean") return null;
- if (typeof r.groupsBatchUnlimited !== "boolean") return null;
return {
hitBatchValue: clampInt(r.hitBatchValue, 1, 1_000_000, 500),
hitBatchUnlimited: r.hitBatchUnlimited,
- groupsBatchValue: clampInt(r.groupsBatchValue, 1, 1_000_000, 50),
- groupsBatchUnlimited: r.groupsBatchUnlimited,
fetchConcurrency: clampInt(r.fetchConcurrency, 1, 32, 6),
flushIntervalMs: clampInt(r.flushIntervalMs, 16, 2000, 120),
};
diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md
@@ -2,6 +2,9 @@
## [Unreleased]
+### Changed
+- Search results now render through a single virtualised list (`@tanstack/react-virtual`) covering every video card, leaf-section header, and individual hit row. Scrolling stays smooth across searches with hundreds of matching videos or a single video with hundreds of hits — only the rows currently in view are mounted. The manual "Show more videos" pagination button and the "Videos per page" advanced option are gone; all matching videos render in one continuous list. When the search pipeline still caps at its per-batch hit limit, a "Load more results" button surfaces below the list (the existing `hitLimit` flow, just relabeled now that pagination of cards is no longer needed).
+
### Added
- Composable layered search. The single search bar is now a query builder: any number of layers can be combined with AND / OR / NOT and arbitrary nesting. Each layer targets a scope (transcripts, live chat, or title/channel), and matches from every contributing layer are surfaced in the result list with a per-layer colour swatch. Per-layer results are memoised in IndexedDB so editing a deeper leaf only re-runs that layer against its already-narrowed scope. Simple one-keyword search still looks like a single input — the builder collapses to compact mode when there's only one layer. The composite query serialises into a new `qt=` URL parameter; legacy `?q=&m=&re=` links auto-migrate to a one-layer tree.
- "Unwrap" button on every non-root group in the query builder. Flattens the group's children into the parent at the group's position, giving users a fast way back out of a sub-group they no longer want. If the group has NOT set, each promoted child inherits a flipped `negate` so the meaning of the subtree doesn't silently flip.
diff --git a/export/e2e/fixtures/many-results.ts b/export/e2e/fixtures/many-results.ts
@@ -0,0 +1,105 @@
+// Standalone fixture for the search-results-virtualization spec. Lives in
+// its own channel so it can co-exist with the standard `data.ts` fixture
+// without changing the route surface other specs depend on.
+
+export const CHANNEL = "Bulk Channel";
+export const CHANNEL_SLUG = "bulk-channel";
+
+export const VIDEO_COUNT = 120;
+// One video carries a large number of hits so we can verify the inner-list
+// (hits within a single card) is also virtualized — it lives at the
+// front of the result list so its rows are guaranteed to be on-screen.
+// 350 + 119 = 469 hits total, well under the DEFAULT_MAX_HITS=500 cap so
+// the pipeline doesn't mark the result `capped`.
+export const DENSE_VIDEO_INDEX = 0;
+export const DENSE_HIT_COUNT = 350;
+
+type Cue = { start: number; end: number; text: string };
+
+function videoId(i: number): string {
+ return `bulk-vid-${String(i).padStart(4, "0")}`;
+}
+
+export function slug(i: number): string {
+ return `${CHANNEL_SLUG}/${videoId(i)}`;
+}
+
+function makeSummary(i: number) {
+ return {
+ slug: slug(i),
+ id: videoId(i),
+ channelSlug: CHANNEL_SLUG,
+ title: `Bulk video #${i}`,
+ uploadDate: "20260101",
+ date: "2026-01-01",
+ duration: "5:00",
+ channel: CHANNEL,
+ isLivestream: false,
+ ageRestricted: false,
+ isDeleted: false,
+ platform: "rumble" as const,
+ webpageUrl: `https://example.com/${videoId(i)}`,
+ };
+}
+
+function cuesFor(i: number): Cue[] {
+ if (i === DENSE_VIDEO_INDEX) {
+ const out: Cue[] = [];
+ for (let k = 0; k < DENSE_HIT_COUNT; k++) {
+ const t = 1 + k * 2;
+ out.push({ start: t, end: t + 2, text: `alpha hit ${k} in dense video` });
+ }
+ return out;
+ }
+ // Other videos: a single matching line so each shows up as one hit.
+ return [{ start: 5, end: 9, text: `alpha line for video ${i}` }];
+}
+
+export function summaries() {
+ const out = [];
+ for (let i = 0; i < VIDEO_COUNT; i++) out.push(makeSummary(i));
+ return out;
+}
+
+export function summariesManifest() {
+ return {
+ version: 2,
+ totalCount: VIDEO_COUNT,
+ pageSize: VIDEO_COUNT,
+ pageCount: 1,
+ generatedAt: new Date().toISOString(),
+ channels: [{ name: CHANNEL, count: VIDEO_COUNT }],
+ };
+}
+
+export function transcriptPage() {
+ return summaries().map((s, i) => ({
+ ...s,
+ duration: 300,
+ description: "",
+ cues: cuesFor(i),
+ }));
+}
+
+export function channelTranscriptsManifest() {
+ const slugToPage: Record<string, number> = {};
+ for (let i = 0; i < VIDEO_COUNT; i++) slugToPage[videoId(i)] = 0;
+ return {
+ version: 1,
+ channelSlug: CHANNEL_SLUG,
+ pageCount: 1,
+ maxPageBytes: 8388608,
+ generatedAt: new Date().toISOString(),
+ slugToPage,
+ };
+}
+
+export function subsManifest() {
+ return {
+ version: 3,
+ channels: [],
+ totalCount: 0,
+ liveChatTotalCount: 0,
+ generatedAt: new Date().toISOString(),
+ };
+}
diff --git a/export/e2e/query-tree.spec.ts b/export/e2e/query-tree.spec.ts
@@ -40,9 +40,10 @@ const CHAT_SMALL_SLUG = `${CHANNEL_SLUG}/${VIDEO_CHAT_SMALL}`;
const CHAT_LARGE_SLUG = `${CHANNEL_SLUG}/${VIDEO_CHAT_LARGE}`;
async function expectResultSlugs(page: Page, slugs: string[]) {
- // Wait until the search has settled by waiting for the summary line to
- // report at least the expected count.
- const cards = page.locator("[data-result-slug]");
+ // Wait until the search has settled. We count unique cards by selecting
+ // card-header rows only — leaf-header and hit rows also carry
+ // `data-result-slug` (for compound selectors) but would over-count here.
+ const cards = page.locator("[data-card-header]");
await expect(async () => {
const got = await cards.evaluateAll((els) =>
els.map((e) => e.getAttribute("data-result-slug") ?? ""),
diff --git a/export/e2e/search-results-virtualization.spec.ts b/export/e2e/search-results-virtualization.spec.ts
@@ -0,0 +1,172 @@
+import { expect, test, type Page, type Route } from "@playwright/test";
+import {
+ DENSE_HIT_COUNT,
+ DENSE_VIDEO_INDEX,
+ VIDEO_COUNT,
+ channelTranscriptsManifest,
+ slug,
+ subsManifest,
+ summaries,
+ summariesManifest,
+ transcriptPage,
+} from "./fixtures/many-results";
+
+async function fulfillJson(route: Route, body: unknown) {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(body),
+ });
+}
+
+async function installBulkRoutes(page: Page) {
+ await page.route("**/summaries/manifest.json", async (route) => {
+ await fulfillJson(route, summariesManifest());
+ });
+ await page.route("**/summaries/page-*.json", async (route) => {
+ await fulfillJson(route, summaries());
+ });
+ await page.route(
+ /\/transcripts\/[^/]+\/manifest\.json$/,
+ async (route) => {
+ await fulfillJson(route, channelTranscriptsManifest());
+ },
+ );
+ await page.route(/\/transcripts\/[^/]+\/page-\d+\.json$/, async (route) => {
+ await fulfillJson(route, transcriptPage());
+ });
+ await page.route("**/subs/manifest.json", async (route) => {
+ await fulfillJson(route, subsManifest());
+ });
+}
+
+type QtTree = {
+ k: "g";
+ o: "AND";
+ c: Array<{
+ k: "l";
+ q: string;
+ s: "transcripts" | "chat" | "metadata";
+ }>;
+};
+
+function qt(root: QtTree): string {
+ return encodeURIComponent(JSON.stringify(root));
+}
+
+const DENSE_SLUG = slug(DENSE_VIDEO_INDEX);
+const LAST_SLUG = slug(VIDEO_COUNT - 1);
+
+async function gotoBulkSearch(page: Page) {
+ const tree: QtTree = {
+ k: "g",
+ o: "AND",
+ c: [{ k: "l", q: "alpha", s: "transcripts" }],
+ };
+ await page.goto(`/?qt=${qt(tree)}`);
+}
+
+test.describe("search results virtualization", () => {
+ test.beforeEach(async ({ page }) => {
+ await installBulkRoutes(page);
+ });
+
+ test("renders only a virtualized window of cards for a large result set", async ({
+ page,
+ }) => {
+ await gotoBulkSearch(page);
+ // Wait for the results summary to show all 120 videos.
+ await expect(page.locator('[data-testid="results-summary"]')).toContainText(
+ `${VIDEO_COUNT} videos`,
+ );
+
+ // First card is on-screen; last card is not yet rendered.
+ await expect(
+ page.locator(`[data-card-header][data-result-slug="${DENSE_SLUG}"]`),
+ ).toBeVisible();
+ await expect(
+ page.locator(`[data-card-header][data-result-slug="${LAST_SLUG}"]`),
+ ).toHaveCount(0);
+
+ // Virtualization should keep the rendered card count far below the total
+ // (120 cards plus their leaf headers + hits would otherwise be in the
+ // thousands of nodes; we just need any non-trivial reduction).
+ const renderedCardCount = await page
+ .locator("[data-card-header]")
+ .count();
+ expect(renderedCardCount).toBeGreaterThan(0);
+ expect(renderedCardCount).toBeLessThan(VIDEO_COUNT);
+ });
+
+ test("scrolling reveals later cards and recycles earlier ones", async ({
+ page,
+ }) => {
+ await gotoBulkSearch(page);
+ await expect(page.locator('[data-testid="results-summary"]')).toContainText(
+ `${VIDEO_COUNT} videos`,
+ );
+
+ // Initially the dense (first) video is rendered.
+ await expect(
+ page.locator(`[data-card-header][data-result-slug="${DENSE_SLUG}"]`),
+ ).toBeVisible();
+
+ // Scroll to the bottom; the last slug must appear, and the dense first
+ // slug should drop out of the DOM (rows recycled).
+ await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
+ await expect(
+ page.locator(`[data-card-header][data-result-slug="${LAST_SLUG}"]`),
+ ).toBeVisible();
+ await expect(
+ page.locator(`[data-card-header][data-result-slug="${DENSE_SLUG}"]`),
+ ).toHaveCount(0);
+ });
+
+ test("dense per-card hit list is also virtualized", async ({ page }) => {
+ await gotoBulkSearch(page);
+ await expect(page.locator('[data-testid="results-summary"]')).toContainText(
+ `${VIDEO_COUNT} videos`,
+ );
+
+ // The dense video has DENSE_HIT_COUNT hits — without virtualization they
+ // would all be in the DOM at once. With it, only a small slice exists.
+ const denseHitButtons = page.locator(
+ `[data-result-slug="${DENSE_SLUG}"]:not([data-card-header]) button`,
+ );
+ const renderedHitCount = await denseHitButtons.count();
+ expect(renderedHitCount).toBeGreaterThan(0);
+ expect(renderedHitCount).toBeLessThan(DENSE_HIT_COUNT);
+ });
+
+ test("the legacy Show more videos button is gone", async ({ page }) => {
+ await gotoBulkSearch(page);
+ await expect(page.locator('[data-testid="results-summary"]')).toContainText(
+ `${VIDEO_COUNT} videos`,
+ );
+ await expect(
+ page.getByRole("button", { name: /Show more videos/ }),
+ ).toHaveCount(0);
+ });
+
+ test("clicking a card header opens the modal for that slug", async ({
+ page,
+ }) => {
+ await gotoBulkSearch(page);
+ await expect(page.locator('[data-testid="results-summary"]')).toContainText(
+ `${VIDEO_COUNT} videos`,
+ );
+
+ // The dense card is the first one rendered; clicking its header (not a
+ // hit row) opens the modal for it.
+ await page
+ .locator(`[data-card-header][data-result-slug="${DENSE_SLUG}"] button`)
+ .click();
+ await page
+ .getByRole("button", { name: "Mark clip start at current time" })
+ .waitFor();
+ const params = new URLSearchParams(
+ await page.evaluate(() => window.location.search),
+ );
+ expect(params.get("v")).toBe(DENSE_SLUG);
+ });
+});