commit fca8cd098bf185b185eaada4f3e68cb4b8bd36e0
parent da4fc4e12b2774e6fed42674bde42adbead5316e
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 18 May 2026 23:45:26 -0400
export UI for live chats part 2
Diffstat:
16 files changed, 1123 insertions(+), 179 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -62,5 +62,12 @@ yarn-error.log*
/editor/test-results/
/editor/blob-report/
+# export e2e fixtures and ephemeral state
+/export/test-public/
+/export/test-settings.json
+/export/playwright-report/
+/export/test-results/
+/export/blob-report/
+
# site config
/settings.json
diff --git a/common/components/PlayerProvider.tsx b/common/components/PlayerProvider.tsx
@@ -7,6 +7,7 @@ import {
useContext,
useEffect,
useMemo,
+ useReducer,
useRef,
useState,
} from "react";
@@ -90,8 +91,6 @@ type PlayerState = {
chatCues: Cue[] | null;
chatStatus: ChatStatus;
chatNotice: string | null;
- playing: boolean;
- currentTime: number;
clipStart: number | null;
clipEnd: number | null;
openTranscript: (
@@ -111,7 +110,13 @@ type PlayerState = {
seekTo: (seconds: number) => void;
};
+type PlayerTimeState = {
+ currentTime: number;
+ playing: boolean;
+};
+
const Ctx = createContext<PlayerState | null>(null);
+const TimeCtx = createContext<PlayerTimeState | null>(null);
export function usePlayer(): PlayerState {
const v = useContext(Ctx);
@@ -119,6 +124,16 @@ export function usePlayer(): PlayerState {
return v;
}
+// Subscribe to the high-frequency time/playing state separately from the
+// rest of the player state. Components that only need to know "what time
+// is it now" should use this hook so re-renders triggered by 4 Hz progress
+// ticks don't cascade through `usePlayer()` consumers like TranscriptSearch.
+export function usePlayerTime(): PlayerTimeState {
+ const v = useContext(TimeCtx);
+ if (!v) throw new Error("usePlayerTime must be inside PlayerProvider");
+ return v;
+}
+
function toHMS(seconds: number): string {
const s = Math.max(0, Math.floor(seconds));
const h = Math.floor(s / 3600);
@@ -127,32 +142,131 @@ function toHMS(seconds: number): string {
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
}
+// Reducer-managed slice of player state. `currentTime`/`playing` are kept
+// outside the reducer because they update ~4×/sec from YouTube progress
+// events and live on a separate `PlayerTimeContext` to avoid invalidating
+// every `usePlayer()` consumer on each tick.
+type Core = {
+ detail: Detail | null;
+ displayState: { slug: string; mode: DisplayMode } | null;
+ clip: ClipState;
+ chat: {
+ slug: string | null;
+ status: ChatStatus;
+ cues: Cue[] | null;
+ notice: string | null;
+ };
+};
+
+const INITIAL_CORE: Core = {
+ detail: null,
+ displayState: null,
+ clip: { slug: null, start: null, end: null },
+ chat: { slug: null, status: "idle", cues: null, notice: null },
+};
+
+type CoreAction =
+ | { type: "DETAIL_LOADED"; detail: Detail }
+ | { type: "DETAIL_FAILED" }
+ | { type: "DISPLAY_SET"; slug: string; mode: DisplayMode }
+ | { type: "DISPLAY_CLEAR" }
+ | { type: "CLIP_MARK_START"; slug: string; time: number }
+ | { type: "CLIP_MARK_END"; slug: string; time: number }
+ | { type: "CLIP_CLEAR" }
+ | { type: "CHAT_RESET" }
+ | { type: "CHAT_LOADING"; slug: string }
+ | { type: "CHAT_READY"; slug: string; cues: Cue[] }
+ | { type: "CHAT_MISSING"; slug: string; notice: string }
+ | { type: "CHAT_NOTICE_CLEAR" };
+
+function coreReducer(state: Core, action: CoreAction): Core {
+ switch (action.type) {
+ case "DETAIL_LOADED":
+ return { ...state, detail: action.detail };
+ case "DETAIL_FAILED":
+ return { ...state, detail: null };
+ case "DISPLAY_SET":
+ return {
+ ...state,
+ displayState: { slug: action.slug, mode: action.mode },
+ };
+ case "DISPLAY_CLEAR":
+ return { ...state, displayState: null };
+ case "CLIP_MARK_START":
+ return {
+ ...state,
+ clip: {
+ slug: action.slug,
+ start: action.time,
+ end: state.clip.slug === action.slug ? state.clip.end : null,
+ },
+ };
+ case "CLIP_MARK_END":
+ return {
+ ...state,
+ clip: {
+ slug: action.slug,
+ start: state.clip.slug === action.slug ? state.clip.start : null,
+ end: action.time,
+ },
+ };
+ case "CLIP_CLEAR":
+ return { ...state, clip: { slug: null, start: null, end: null } };
+ case "CHAT_RESET":
+ return { ...state, chat: INITIAL_CORE.chat };
+ case "CHAT_LOADING":
+ return {
+ ...state,
+ chat: { slug: action.slug, status: "loading", cues: null, notice: null },
+ };
+ case "CHAT_READY":
+ return {
+ ...state,
+ chat: {
+ slug: action.slug,
+ status: "ready",
+ cues: action.cues,
+ notice: state.chat.notice,
+ },
+ };
+ case "CHAT_MISSING":
+ return {
+ ...state,
+ chat: {
+ slug: action.slug,
+ status: "missing",
+ cues: null,
+ notice: action.notice,
+ },
+ };
+ case "CHAT_NOTICE_CLEAR":
+ return { ...state, chat: { ...state.chat, notice: null } };
+ default:
+ return state;
+ }
+}
+
export function PlayerProvider({
children,
}: {
children: React.ReactNode;
}) {
const { v: urlSlug, t: urlTime, vm: urlVm } = useUrlParams();
- const [detail, setDetail] = useState<Detail | null>(null);
- const [displayState, setDisplayState] = useState<{
- slug: string;
- mode: DisplayMode;
- } | null>(null);
- const [clip, setClip] = useState<ClipState>({
- slug: null,
- start: null,
- end: null,
- });
+ const [core, dispatch] = useReducer(coreReducer, INITIAL_CORE);
+ const { detail, displayState, clip, chat } = core;
const [playing, setPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
- const [chatCues, setChatCues] = useState<Cue[] | null>(null);
- const [chatStatus, setChatStatus] = useState<ChatStatus>("idle");
- const [chatSlug, setChatSlug] = useState<string | null>(null);
- const [chatNotice, setChatNotice] = useState<string | null>(null);
const playerRef = useRef<PlayerHandle | null>(null);
const pendingSeekRef = useRef<number | null>(null);
const readyForSlugRef = useRef<string | null>(null);
- const chatNoticeTimerRef = useRef<number | null>(null);
+ // Tracks the slug we've already kicked off a chat fetch for. Decoupled
+ // from `chat.slug`/`chat.status` so we don't include them in the
+ // chat-fetch effect's deps (which would cause a cleanup-during-fetch race).
+ const chatInFlightForRef = useRef<string | null>(null);
+ // Mirrors `chat.status` for the same-render synchronous lookup the
+ // chat-fetch effect needs to decide "re-toggle on already-missing slug".
+ const chatStatusRef = useRef<ChatStatus>("idle");
+ chatStatusRef.current = chat.status;
const activeSlug = urlSlug;
const modalMode: ModalMode = urlVm;
@@ -189,7 +303,7 @@ export function PlayerProvider({
const openTranscript = useCallback(
(slug: string, start?: number, opts?: { mode?: ModalMode }) => {
- setDisplayState({ slug, mode: "modal" });
+ dispatch({ type: "DISPLAY_SET", slug, mode: "modal" });
const t = typeof start === "number" ? Math.round(start) : null;
if (slug === activeSlug && t !== null && playerRef.current) {
playerRef.current.seekTo(t, "seconds");
@@ -212,36 +326,28 @@ export function PlayerProvider({
const setDisplayMode = useCallback(
(mode: DisplayMode) => {
if (!activeSlug) return;
- setDisplayState({ slug: activeSlug, mode });
+ dispatch({ type: "DISPLAY_SET", slug: activeSlug, mode });
},
[activeSlug],
);
const closePlayer = useCallback(() => {
- setDisplayState(null);
+ dispatch({ type: "DISPLAY_CLEAR" });
writeUrlParams({ v: null, t: null, vm: "transcript" });
}, []);
const markClipStart = useCallback(() => {
if (!activeSlug) return;
- setClip((prev) => ({
- slug: activeSlug,
- start: currentTime,
- end: prev.slug === activeSlug ? prev.end : null,
- }));
+ dispatch({ type: "CLIP_MARK_START", slug: activeSlug, time: currentTime });
}, [activeSlug, currentTime]);
const markClipEnd = useCallback(() => {
if (!activeSlug) return;
- setClip((prev) => ({
- slug: activeSlug,
- start: prev.slug === activeSlug ? prev.start : null,
- end: currentTime,
- }));
+ dispatch({ type: "CLIP_MARK_END", slug: activeSlug, time: currentTime });
}, [activeSlug, currentTime]);
const clearClip = useCallback(() => {
- setClip({ slug: null, start: null, end: null });
+ dispatch({ type: "CLIP_CLEAR" });
}, []);
const copyDownloadCommand = useCallback(async (): Promise<boolean> => {
@@ -296,116 +402,120 @@ export function PlayerProvider({
setCurrentTime(seconds);
}, []);
- // Reset chat state whenever the active video changes. Subsequent effect
- // decides whether to actually fetch based on modalMode.
+ // Active-video change: reset chat state, kick off the transcript fetch.
+ // The chat fetch (if needed) runs in a separate effect keyed on
+ // `modalMode` so we don't fetch chat for the common transcript-only case.
useEffect(() => {
- setChatCues(null);
- setChatStatus("idle");
- setChatSlug(null);
- setChatNotice(null);
- if (chatNoticeTimerRef.current !== null) {
- window.clearTimeout(chatNoticeTimerRef.current);
- chatNoticeTimerRef.current = null;
- }
+ dispatch({ type: "CHAT_RESET" });
+ chatInFlightForRef.current = null;
+ if (!urlSlug) return;
+ readyForSlugRef.current = null;
+ pendingSeekRef.current = urlTime ?? 0;
+ let cancelled = false;
+ fetchTranscript(urlSlug)
+ .then((full) => {
+ if (cancelled) return;
+ dispatch({
+ type: "DETAIL_LOADED",
+ detail: {
+ slug: urlSlug,
+ id: full.id,
+ title: full.title,
+ uploadDate: full.uploadDate,
+ duration: full.duration,
+ channel: full.channel,
+ description: full.description,
+ isLivestream: full.isLivestream,
+ ageRestricted: full.ageRestricted,
+ platform: full.platform,
+ webpageUrl: full.webpageUrl,
+ cues: full.cues,
+ },
+ });
+ })
+ .catch(() => {
+ if (cancelled) return;
+ dispatch({ type: "DETAIL_FAILED" });
+ });
+ return () => {
+ cancelled = true;
+ };
+ // urlTime intentionally omitted — t changes shouldn't refetch
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [urlSlug]);
// Lazy-fetch live chat only when the modal enters chat mode for the
// current video. Snaps back to transcript mode on "missing" or error so
// users never get stuck on an empty chat view.
+ //
+ // Why refs over reducer state in the deps: including `chat.slug`/`chat.status`
+ // in the dep array causes React to run cleanup before the fetch resolves,
+ // and the cancelled-flag in the closure causes the result to be dropped.
+ // chatInFlightForRef tracks the slug we've already initiated a fetch for
+ // (or detected missing for); chatStatusRef gives a same-render mirror of
+ // the reducer's chat.status.
useEffect(() => {
if (!urlSlug) return;
if (modalMode !== "chat") return;
- const showNotice = (notice: string) => {
- setChatNotice(notice);
- if (chatNoticeTimerRef.current !== null) {
- window.clearTimeout(chatNoticeTimerRef.current);
- }
- chatNoticeTimerRef.current = window.setTimeout(() => {
- setChatNotice(null);
- chatNoticeTimerRef.current = null;
- }, 4000);
- };
// Re-toggling chat on a video already known to lack live chat: snap back
- // immediately instead of refetching.
- if (chatSlug === urlSlug && chatStatus === "missing") {
- showNotice("Live chat not available for this video — showing transcript.");
+ // immediately without refetching.
+ if (
+ chatInFlightForRef.current === urlSlug &&
+ chatStatusRef.current === "missing"
+ ) {
+ dispatch({
+ type: "CHAT_MISSING",
+ slug: urlSlug,
+ notice: "Live chat not available for this video — showing transcript.",
+ });
writeUrlParams({ vm: "transcript" });
return;
}
- if (chatSlug === urlSlug && chatStatus !== "idle") return;
- setChatStatus("loading");
- setChatSlug(urlSlug);
+ if (chatInFlightForRef.current === urlSlug) return;
+ chatInFlightForRef.current = urlSlug;
+ dispatch({ type: "CHAT_LOADING", slug: urlSlug });
let cancelled = false;
- const snapBack = (notice: string) => {
- if (cancelled) return;
- showNotice(notice);
- writeUrlParams({ vm: "transcript" });
- };
fetchSubs(urlSlug)
.then((subs) => {
if (cancelled) return;
const cues = subs.tracks?.live_chat;
if (cues && cues.length > 0) {
- setChatCues(cues);
- setChatStatus("ready");
+ dispatch({ type: "CHAT_READY", slug: urlSlug, cues });
} else {
- setChatCues(null);
- setChatStatus("missing");
- snapBack("Live chat not available for this video — showing transcript.");
+ dispatch({
+ type: "CHAT_MISSING",
+ slug: urlSlug,
+ notice: "Live chat not available for this video — showing transcript.",
+ });
+ writeUrlParams({ vm: "transcript" });
}
})
.catch(() => {
if (cancelled) return;
- setChatCues(null);
- setChatStatus("missing");
- snapBack("Couldn't load live chat — showing transcript.");
+ dispatch({
+ type: "CHAT_MISSING",
+ slug: urlSlug,
+ notice: "Couldn't load live chat — showing transcript.",
+ });
+ writeUrlParams({ vm: "transcript" });
});
return () => {
cancelled = true;
};
- }, [urlSlug, modalMode, chatSlug, chatStatus]);
+ }, [urlSlug, modalMode]);
+ // Auto-clear the snap-back notice after a brief window. Replaces the
+ // hand-managed setTimeout/clearTimeout ref dance — the effect's cleanup is
+ // the cancellation.
useEffect(() => {
+ if (!chat.notice) return;
+ const id = window.setTimeout(() => {
+ dispatch({ type: "CHAT_NOTICE_CLEAR" });
+ }, 4000);
return () => {
- if (chatNoticeTimerRef.current !== null) {
- window.clearTimeout(chatNoticeTimerRef.current);
- }
+ window.clearTimeout(id);
};
- }, []);
-
- useEffect(() => {
- if (!urlSlug) return;
- readyForSlugRef.current = null;
- pendingSeekRef.current = urlTime ?? 0;
- let cancelled = false;
- fetchTranscript(urlSlug)
- .then((full) => {
- if (cancelled) return;
- setDetail({
- slug: urlSlug,
- id: full.id,
- title: full.title,
- uploadDate: full.uploadDate,
- duration: full.duration,
- channel: full.channel,
- description: full.description,
- isLivestream: full.isLivestream,
- ageRestricted: full.ageRestricted,
- platform: full.platform,
- webpageUrl: full.webpageUrl,
- cues: full.cues,
- });
- })
- .catch(() => {
- if (cancelled) return;
- setDetail(null);
- });
- return () => {
- cancelled = true;
- };
- // urlTime intentionally omitted — t changes shouldn't refetch
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [urlSlug]);
+ }, [chat.notice]);
useEffect(() => {
if (urlTime === null) return;
@@ -450,32 +560,58 @@ export function PlayerProvider({
}
};
- const value: PlayerState = {
- activeSlug,
- data,
- status,
- displayMode,
- modalOpen,
- modalMode,
- chatCues: chatSlug === activeSlug ? chatCues : null,
- chatStatus: chatSlug === activeSlug ? chatStatus : "idle",
- chatNotice,
- playing,
- currentTime,
- clipStart,
- clipEnd,
- openTranscript,
- setDisplayMode,
- setModalMode,
- closePlayer,
- markClipStart,
- markClipEnd,
- clearClip,
- copyDownloadCommand,
- copyShareUrl,
- openInPreservetube,
- seekTo,
- };
+ const value: PlayerState = useMemo(
+ () => ({
+ activeSlug,
+ data,
+ status,
+ displayMode,
+ modalOpen,
+ modalMode,
+ chatCues: chat.slug === activeSlug ? chat.cues : null,
+ chatStatus: chat.slug === activeSlug ? chat.status : "idle",
+ chatNotice: chat.notice,
+ clipStart,
+ clipEnd,
+ openTranscript,
+ setDisplayMode,
+ setModalMode,
+ closePlayer,
+ markClipStart,
+ markClipEnd,
+ clearClip,
+ copyDownloadCommand,
+ copyShareUrl,
+ openInPreservetube,
+ seekTo,
+ }),
+ [
+ activeSlug,
+ data,
+ status,
+ displayMode,
+ modalOpen,
+ modalMode,
+ chat,
+ clipStart,
+ clipEnd,
+ openTranscript,
+ setDisplayMode,
+ setModalMode,
+ closePlayer,
+ markClipStart,
+ markClipEnd,
+ clearClip,
+ copyDownloadCommand,
+ copyShareUrl,
+ openInPreservetube,
+ seekTo,
+ ],
+ );
+ const timeValue: PlayerTimeState = useMemo(
+ () => ({ currentTime, playing }),
+ [currentTime, playing],
+ );
const showPlayer = activeSlug !== null && data !== null;
@@ -495,6 +631,7 @@ export function PlayerProvider({
return (
<Ctx.Provider value={value}>
+ <TimeCtx.Provider value={timeValue}>
{children}
{showPlayer && (
@@ -557,6 +694,7 @@ export function PlayerProvider({
onClose={closePlayer}
/>
)}
+ </TimeCtx.Provider>
</Ctx.Provider>
);
}
diff --git a/common/components/TranscriptModal.tsx b/common/components/TranscriptModal.tsx
@@ -1,11 +1,25 @@
"use client";
-import { useEffect, useRef, useState } from "react";
-import { ControlButton, toHMS, usePlayer } from "./PlayerProvider";
+import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useVirtualizer } from "@tanstack/react-virtual";
+import {
+ ControlButton,
+ toHMS,
+ usePlayer,
+ usePlayerTime,
+} from "./PlayerProvider";
import { AgeRestrictedBadge, LivestreamBadge } from "./badges";
import { formatTimestamp } from "../lib/vtt";
import { formatDate } from "../lib/format";
+type DisplayCue = {
+ start: number;
+ end: number;
+ text: string;
+ author: string | null;
+ body: string;
+};
+
export default function TranscriptModal() {
const {
modalOpen,
@@ -17,7 +31,6 @@ export default function TranscriptModal() {
chatCues,
chatStatus,
chatNotice,
- currentTime,
clipStart,
clipEnd,
setDisplayMode,
@@ -30,15 +43,39 @@ export default function TranscriptModal() {
openInPreservetube,
seekTo,
} = usePlayer();
- const activeRef = useRef<HTMLLIElement | null>(null);
+ const { currentTime } = usePlayerTime();
const scrollRef = useRef<HTMLDivElement | null>(null);
const [copied, setCopied] = useState(false);
const copyResetRef = useRef<number | null>(null);
const [shareCopied, setShareCopied] = useState(false);
const shareResetRef = useRef<number | null>(null);
+ // Tracks whether the next scrollToIndex should animate. Smooth on
+ // user-initiated changes (cue click, mode toggle, initial open); instant
+ // on natural playhead drift so 4 Hz progress ticks don't keep retriggering
+ // a smooth-scroll animation.
+ const scrollKindRef = useRef<"smooth" | "auto">("smooth");
const isChat = modalMode === "chat";
- const cues = isChat ? (chatCues ?? []) : (data?.cues ?? []);
+
+ // Pre-compute author/body split once per cue array so the render hot path
+ // doesn't redo `indexOf`/`slice` on every progress tick. Memo key is the
+ // identity of the underlying cues array.
+ const displayCues: DisplayCue[] = useMemo(() => {
+ const raw = isChat ? (chatCues ?? []) : (data?.cues ?? []);
+ return raw.map((c) => {
+ const sepIdx = isChat ? c.text.indexOf(": ") : -1;
+ const author = sepIdx > 0 ? c.text.slice(0, sepIdx) : null;
+ const body = sepIdx > 0 ? c.text.slice(sepIdx + 2) : c.text;
+ return {
+ start: c.start,
+ end: c.end ?? c.start,
+ text: c.text,
+ author,
+ body,
+ };
+ });
+ }, [isChat, chatCues, data?.cues]);
+
const cueStatus: "loading" | "ready" =
isChat
? chatStatus === "loading" || chatStatus === "idle"
@@ -47,12 +84,27 @@ export default function TranscriptModal() {
: status === "loading"
? "loading"
: "ready";
- const activeIndex = findActiveIndex(cues, currentTime);
+ const activeIndex = findActiveIndex(displayCues, currentTime);
+
+ const rowVirtualizer = useVirtualizer({
+ count: displayCues.length,
+ getScrollElement: () => scrollRef.current,
+ estimateSize: () => 36,
+ overscan: 12,
+ });
+ // Active row auto-scroll. Smooth on user-initiated jumps; instant during
+ // playback. We let the virtualizer scroll because it handles offsets
+ // correctly even when the target row is not yet rendered.
useEffect(() => {
if (!modalOpen) return;
- activeRef.current?.scrollIntoView({ block: "nearest", behavior: "smooth" });
- }, [activeIndex, modalOpen, modalMode]);
+ if (activeIndex < 0) return;
+ rowVirtualizer.scrollToIndex(activeIndex, {
+ align: "center",
+ behavior: scrollKindRef.current,
+ });
+ scrollKindRef.current = "auto";
+ }, [activeIndex, modalOpen, modalMode, rowVirtualizer]);
useEffect(() => {
if (modalOpen) {
@@ -71,6 +123,20 @@ export default function TranscriptModal() {
};
}, []);
+ // Stable seek callback so memo'd CueRow doesn't invalidate per render.
+ const onSeekRow = useCallback(
+ (start: number) => {
+ scrollKindRef.current = "smooth";
+ seekTo(start);
+ },
+ [seekTo],
+ );
+
+ const onToggleMode = useCallback(() => {
+ scrollKindRef.current = "smooth";
+ setModalMode(isChat ? "transcript" : "chat");
+ }, [isChat, setModalMode]);
+
if (!modalOpen || !activeSlug) return null;
const canDownload = clipStart !== null && clipEnd !== null && clipEnd > clipStart;
@@ -89,6 +155,9 @@ export default function TranscriptModal() {
shareResetRef.current = window.setTimeout(() => setShareCopied(false), 1800);
};
+ const virtualItems = rowVirtualizer.getVirtualItems();
+ const totalSize = rowVirtualizer.getTotalSize();
+
return (
<div className="fixed inset-0 z-50 flex flex-col">
<button
@@ -139,7 +208,7 @@ export default function TranscriptModal() {
<div className="flex-1" />
<ControlButton
title={isChat ? "Show transcript" : "Show live chat"}
- onClick={() => setModalMode(isChat ? "transcript" : "chat")}
+ onClick={onToggleMode}
char={isChat ? "📜" : "💬"}
highlight={isChat}
/>
@@ -212,45 +281,29 @@ export default function TranscriptModal() {
{isChat ? "Loading live chat…" : "Loading transcript…"}
</p>
)}
- {cueStatus === "ready" && cues.length === 0 && (
+ {cueStatus === "ready" && displayCues.length === 0 && (
<p className="text-sm text-zinc-400 p-4">
{isChat ? "No live chat for this video." : "No cues available."}
</p>
)}
- {cueStatus === "ready" && cues.length > 0 && (
- <ol className="divide-y divide-zinc-800 text-zinc-100">
- {cues.map((cue, i) => {
- const isActive = i === activeIndex;
- const sepIdx = isChat ? cue.text.indexOf(": ") : -1;
- const author = sepIdx > 0 ? cue.text.slice(0, sepIdx) : null;
- const body =
- sepIdx > 0 ? cue.text.slice(sepIdx + 2) : cue.text;
+ {cueStatus === "ready" && displayCues.length > 0 && (
+ <ol
+ className="relative divide-y divide-zinc-800 text-zinc-100"
+ style={{ height: totalSize }}
+ >
+ {virtualItems.map((vi) => {
+ const cue = displayCues[vi.index];
+ const isActive = vi.index === activeIndex;
return (
- <li
- key={i}
- ref={isActive ? activeRef : undefined}
- className={
- isActive ? "bg-blue-950/50" : ""
- }
- >
- <button
- type="button"
- onClick={() => seekTo(cue.start)}
- className="w-full text-left flex gap-3 px-3 py-2 hover:bg-zinc-800"
- >
- <span className="text-xs font-mono text-zinc-400 shrink-0 w-16 pt-0.5">
- {formatTimestamp(cue.start)}
- </span>
- <span className="text-sm min-w-0">
- {author && (
- <span className="text-zinc-400 mr-1.5">
- {author}:
- </span>
- )}
- <span>{body}</span>
- </span>
- </button>
- </li>
+ <CueRow
+ key={vi.key}
+ cue={cue}
+ isActive={isActive}
+ onSeek={onSeekRow}
+ measureRef={rowVirtualizer.measureElement}
+ virtualIndex={vi.index}
+ offset={vi.start}
+ />
);
})}
</ol>
@@ -262,6 +315,47 @@ export default function TranscriptModal() {
);
}
+const CueRow = memo(function CueRow({
+ cue,
+ isActive,
+ onSeek,
+ measureRef,
+ virtualIndex,
+ offset,
+}: {
+ cue: DisplayCue;
+ isActive: boolean;
+ onSeek: (start: number) => void;
+ measureRef: (el: Element | null) => void;
+ virtualIndex: number;
+ 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)` }}
+ >
+ <button
+ type="button"
+ onClick={() => onSeek(cue.start)}
+ className="w-full text-left flex gap-3 px-3 py-2 hover:bg-zinc-800"
+ >
+ <span className="text-xs font-mono text-zinc-400 shrink-0 w-16 pt-0.5">
+ {formatTimestamp(cue.start)}
+ </span>
+ <span className="text-sm min-w-0">
+ {cue.author && (
+ <span className="text-zinc-400 mr-1.5">{cue.author}:</span>
+ )}
+ <span>{cue.body}</span>
+ </span>
+ </button>
+ </li>
+ );
+});
+
function ClipReadout({
clipStart,
clipEnd,
diff --git a/common/package.json b/common/package.json
@@ -6,6 +6,7 @@
"dependencies": {
"@sindresorhus/slugify": "^3.0.0",
"@tanstack/react-query": "^5.99.1",
+ "@tanstack/react-virtual": "^3.13.0",
"execa": "^9.6.1",
"fs-extra": "^11.3.4",
"lmdb": "^3.5.4",
diff --git a/export/e2e/fixtures/data.ts b/export/e2e/fixtures/data.ts
@@ -0,0 +1,192 @@
+import { writeFileSync } from "node:fs";
+
+export const CHANNEL = "Test Channel";
+export const CHANNEL_SLUG = "test-channel";
+
+export const VIDEO_TRANSCRIPT_ONLY = "vid-transcript-only";
+export const VIDEO_CHAT_SMALL = "vid-chat-small";
+export const VIDEO_CHAT_LARGE = "vid-chat-large";
+
+type Cue = { start: number; end: number; text: string };
+
+function slug(id: string) {
+ return `${CHANNEL_SLUG}/${id}`;
+}
+
+function makeSummary(id: string, title: string) {
+ return {
+ slug: slug(id),
+ id,
+ channelSlug: CHANNEL_SLUG,
+ title,
+ 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/${id}`,
+ };
+}
+
+function transcriptCues(text: string): Cue[] {
+ return [
+ { start: 5, end: 9, text: `${text} — alpha line` },
+ { start: 50, end: 54, text: `${text} — beta line` },
+ { start: 100, end: 104, text: `${text} — gamma line` },
+ ];
+}
+
+function smallChatCues(): Cue[] {
+ const out: Cue[] = [];
+ for (let i = 0; i < 30; i++) {
+ const t = 5 + i * 2;
+ out.push({
+ start: t,
+ end: t + 2,
+ text: `@user${i % 5}: chat message number ${i}`,
+ });
+ }
+ return out;
+}
+
+function largeChatCues(): Cue[] {
+ const out: Cue[] = [];
+ const TOTAL = 5000;
+ for (let i = 0; i < TOTAL; i++) {
+ const t = (i * 3600) / TOTAL;
+ const author = `user${i % 50}`;
+ const len = 20 + ((i * 7) % 120);
+ const body = "x".repeat(len) + ` (#${i})`;
+ out.push({ start: t, end: t + 5, text: `@${author}: ${body}` });
+ }
+ return out;
+}
+
+export function summaries() {
+ return [
+ makeSummary(VIDEO_TRANSCRIPT_ONLY, "Transcript only — no chat"),
+ makeSummary(VIDEO_CHAT_SMALL, "Small live chat"),
+ makeSummary(VIDEO_CHAT_LARGE, "Large live chat"),
+ ];
+}
+
+export function summariesManifest() {
+ const list = summaries();
+ return {
+ version: 2,
+ totalCount: list.length,
+ pageSize: 1000,
+ pageCount: 1,
+ generatedAt: new Date().toISOString(),
+ channels: [{ name: CHANNEL, count: list.length }],
+ };
+}
+
+export function transcriptPage() {
+ return [
+ {
+ ...makeSummary(VIDEO_TRANSCRIPT_ONLY, "Transcript only — no chat"),
+ duration: 300,
+ description: "",
+ cues: transcriptCues("transcript-only video"),
+ },
+ {
+ ...makeSummary(VIDEO_CHAT_SMALL, "Small live chat"),
+ duration: 300,
+ description: "",
+ cues: transcriptCues("small chat video"),
+ },
+ {
+ ...makeSummary(VIDEO_CHAT_LARGE, "Large live chat"),
+ duration: 3600,
+ description: "",
+ cues: transcriptCues("large chat video"),
+ },
+ ];
+}
+
+export function channelTranscriptsManifest() {
+ return {
+ version: 1,
+ channelSlug: CHANNEL_SLUG,
+ pageCount: 1,
+ maxPageBytes: 8388608,
+ generatedAt: new Date().toISOString(),
+ slugToPage: {
+ [VIDEO_TRANSCRIPT_ONLY]: 0,
+ [VIDEO_CHAT_SMALL]: 0,
+ [VIDEO_CHAT_LARGE]: 0,
+ },
+ };
+}
+
+export function subsManifest() {
+ return {
+ version: 3,
+ channels: [
+ {
+ name: CHANNEL,
+ slug: CHANNEL_SLUG,
+ videoCount: 2,
+ tracks: ["live_chat"],
+ liveChatCount: 2,
+ },
+ ],
+ totalCount: 2,
+ liveChatTotalCount: 2,
+ generatedAt: new Date().toISOString(),
+ };
+}
+
+export function channelSubsManifest() {
+ return {
+ version: 1,
+ channelSlug: CHANNEL_SLUG,
+ pageCount: 1,
+ maxPageBytes: 8388608,
+ generatedAt: new Date().toISOString(),
+ tracks: ["live_chat"],
+ slugToPage: {
+ [VIDEO_CHAT_SMALL]: 0,
+ [VIDEO_CHAT_LARGE]: 0,
+ },
+ };
+}
+
+export function subsPage() {
+ return [
+ {
+ ...makeSummary(VIDEO_CHAT_SMALL, "Small live chat"),
+ isLivestream: true,
+ tracks: { live_chat: smallChatCues() },
+ },
+ {
+ ...makeSummary(VIDEO_CHAT_LARGE, "Large live chat"),
+ isLivestream: true,
+ tracks: { live_chat: largeChatCues() },
+ },
+ ];
+}
+
+export function buildFixtureSettings(filePath: string): void {
+ const settings = {
+ siteTitle: "Test Export",
+ siteDescription: "E2E fixture",
+ headerTitle: "Test Export",
+ homeTagline: "",
+ maxTranscriptPageBytes: 8388608,
+ transcribeBin: "whisper-cli",
+ transcribeModel: "/dev/null",
+ transcribeArgs: [],
+ cookiesFromBrowser: "",
+ sleepBetweenDownloadsSeconds: 0,
+ inlineTranscribeOnFallback: false,
+ groups: [],
+ defaultGroupId: "",
+ socialLinks: [],
+ };
+ writeFileSync(filePath, JSON.stringify(settings, null, 2));
+}
diff --git a/export/e2e/helpers.ts b/export/e2e/helpers.ts
@@ -0,0 +1,64 @@
+import type { Page, Route } from "@playwright/test";
+import {
+ channelSubsManifest,
+ channelTranscriptsManifest,
+ subsManifest,
+ subsPage,
+ summaries,
+ summariesManifest,
+ transcriptPage,
+} from "./fixtures/data";
+
+async function fulfillJson(route: Route, body: unknown) {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(body),
+ });
+}
+
+export async function installRoutes(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());
+ });
+ // Per-channel transcripts manifest — match `/transcripts/<channel>/manifest.json`.
+ 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());
+ });
+ await page.route(/\/subs\/[^/]+\/manifest\.json$/, async (route) => {
+ await fulfillJson(route, channelSubsManifest());
+ });
+ await page.route(/\/subs\/[^/]+\/page-\d+\.json$/, async (route) => {
+ await fulfillJson(route, subsPage());
+ });
+}
+
+export async function urlParams(page: Page): Promise<URLSearchParams> {
+ const search = await page.evaluate(() => window.location.search);
+ return new URLSearchParams(search);
+}
+
+export async function expectModalOpen(page: Page) {
+ await page
+ .getByRole("button", { name: "Mark clip start at current time" })
+ .waitFor();
+}
+
+export async function closeModal(page: Page) {
+ await page.getByRole("button", { name: "Close player" }).click();
+ await page
+ .getByRole("button", { name: "Mark clip start at current time" })
+ .waitFor({ state: "detached" });
+}
diff --git a/export/e2e/live-chat.spec.ts b/export/e2e/live-chat.spec.ts
@@ -0,0 +1,56 @@
+import { expect, test } from "@playwright/test";
+import { CHANNEL_SLUG, VIDEO_CHAT_LARGE } from "./fixtures/data";
+import { expectModalOpen, installRoutes } from "./helpers";
+
+test.describe("live-chat — large list interaction", () => {
+ test.beforeEach(async ({ page }) => {
+ await installRoutes(page);
+ });
+
+ test("opens a 5k-cue live chat within budget", async ({ page }) => {
+ test.setTimeout(20_000);
+ const start = Date.now();
+ await page.goto(
+ `/?v=${CHANNEL_SLUG}/${VIDEO_CHAT_LARGE}&vm=chat`,
+ );
+ await expectModalOpen(page);
+
+ // At least one chat message visible after open.
+ await expect(page.getByText(/\(#0\)/).first()).toBeVisible();
+ const elapsed = Date.now() - start;
+ // Generous budget — before the perf fixes a 5k list still renders, just
+ // slowly. This is a smoke test that we don't blow the test timeout.
+ expect(elapsed).toBeLessThan(15_000);
+ });
+
+ test("opening at ?t= highlights the corresponding chat cue", async ({
+ page,
+ }) => {
+ // The large chat distributes ~5000 cues across an hour (3600s). Cue at
+ // t≈360 should be around index 500.
+ await page.goto(
+ `/?v=${CHANNEL_SLUG}/${VIDEO_CHAT_LARGE}&vm=chat&t=360`,
+ );
+ await expectModalOpen(page);
+
+ // Some cue near index 500 should carry the active highlight class.
+ // We don't pin the exact index — just that *some* row is highlighted.
+ const active = page.locator("li.bg-blue-950\\/50");
+ await expect(active.first()).toBeVisible();
+ });
+
+ test("clicking a chat cue updates the active row", async ({ page }) => {
+ await page.goto(
+ `/?v=${CHANNEL_SLUG}/${VIDEO_CHAT_LARGE}&vm=chat`,
+ );
+ await expectModalOpen(page);
+
+ // Click a row near the start. The corresponding cue becomes active.
+ const target = page.getByText(/\(#5\)/).first();
+ await target.scrollIntoViewIfNeeded();
+ await target.click();
+ // The clicked li should now carry the active background.
+ const clickedLi = page.locator("li", { has: page.getByText(/\(#5\)/) });
+ await expect(clickedLi).toHaveClass(/bg-blue-950/);
+ });
+});
diff --git a/export/e2e/modal-close.spec.ts b/export/e2e/modal-close.spec.ts
@@ -0,0 +1,50 @@
+import { expect, test } from "@playwright/test";
+import { CHANNEL_SLUG, VIDEO_CHAT_SMALL } from "./fixtures/data";
+import {
+ closeModal,
+ expectModalOpen,
+ installRoutes,
+ urlParams,
+} from "./helpers";
+
+test.describe("video modal — close cleans URL state", () => {
+ test.beforeEach(async ({ page }) => {
+ await installRoutes(page);
+ });
+
+ test("close clears v, t, and vm", async ({ page }) => {
+ await page.goto(
+ `/?v=${CHANNEL_SLUG}/${VIDEO_CHAT_SMALL}&vm=chat&t=11`,
+ );
+ await expectModalOpen(page);
+
+ await closeModal(page);
+
+ const after = await urlParams(page);
+ expect(after.get("v")).toBeNull();
+ expect(after.get("t")).toBeNull();
+ expect(after.get("vm")).toBeNull();
+ });
+
+ test("ESC collapses modal to mini player, then reopen restores chat mode", async ({
+ page,
+ }) => {
+ await page.goto(`/?v=${CHANNEL_SLUG}/${VIDEO_CHAT_SMALL}&vm=chat`);
+ await expectModalOpen(page);
+
+ await page.keyboard.press("Escape");
+ // The clip button disappears (modal collapsed); the mini reopen control
+ // appears with the video title.
+ await page
+ .getByRole("button", { name: "Mark clip start at current time" })
+ .waitFor({ state: "detached" });
+ const reopen = page.getByRole("button", { name: "Reopen transcript" });
+ await reopen.click();
+
+ await expectModalOpen(page);
+ // Still in chat mode after reopen.
+ const params = await urlParams(page);
+ expect(params.get("vm")).toBe("chat");
+ await expect(page.getByText(/chat message number 0/).first()).toBeVisible();
+ });
+});
diff --git a/export/e2e/modal-open.spec.ts b/export/e2e/modal-open.spec.ts
@@ -0,0 +1,67 @@
+import { expect, test } from "@playwright/test";
+import {
+ CHANNEL_SLUG,
+ VIDEO_CHAT_SMALL,
+ VIDEO_TRANSCRIPT_ONLY,
+} from "./fixtures/data";
+import { expectModalOpen, installRoutes, urlParams } from "./helpers";
+
+test.describe("video modal — open + mode inheritance", () => {
+ test.beforeEach(async ({ page }) => {
+ await installRoutes(page);
+ });
+
+ test("transcript hit click opens modal in transcript mode (no vm)", async ({
+ page,
+ }) => {
+ await page.goto(`/?v=${CHANNEL_SLUG}/${VIDEO_TRANSCRIPT_ONLY}`);
+ await expectModalOpen(page);
+
+ const params = await urlParams(page);
+ expect(params.get("v")).toBe(`${CHANNEL_SLUG}/${VIDEO_TRANSCRIPT_ONLY}`);
+ expect(params.get("vm")).toBeNull();
+
+ // Transcript cues from the fixture are visible.
+ await expect(page.getByText(/alpha line/)).toBeVisible();
+
+ // Chat toggle button is present, labeled with the target state.
+ await expect(
+ page.getByRole("button", { name: "Show live chat" }),
+ ).toBeVisible();
+ });
+
+ test("chat hit click opens modal in chat mode with vm=chat", async ({
+ page,
+ }) => {
+ await page.goto(
+ `/?v=${CHANNEL_SLUG}/${VIDEO_CHAT_SMALL}&vm=chat`,
+ );
+ await expectModalOpen(page);
+
+ const params = await urlParams(page);
+ expect(params.get("vm")).toBe("chat");
+
+ // First chat cue from the small fixture is rendered.
+ await expect(page.getByText(/chat message number 0/).first()).toBeVisible();
+
+ // Toggle button shows the transcript target state.
+ await expect(
+ page.getByRole("button", { name: "Show transcript" }),
+ ).toBeVisible();
+
+ // Chat author is rendered separately in gray.
+ const authorEl = page.getByText("@user0:").first();
+ await expect(authorEl).toBeVisible();
+ });
+
+ test("opening at ?t= highlights the corresponding cue", async ({ page }) => {
+ await page.goto(
+ `/?v=${CHANNEL_SLUG}/${VIDEO_TRANSCRIPT_ONLY}&t=51`,
+ );
+ await expectModalOpen(page);
+
+ // The beta line starts at 50, gamma at 100 — t=51 picks beta.
+ const beta = page.locator("li", { hasText: "beta line" });
+ await expect(beta).toHaveClass(/bg-blue-950/);
+ });
+});
diff --git a/export/e2e/modal-share.spec.ts b/export/e2e/modal-share.spec.ts
@@ -0,0 +1,54 @@
+import { expect, test } from "@playwright/test";
+import {
+ CHANNEL_SLUG,
+ VIDEO_CHAT_SMALL,
+ VIDEO_TRANSCRIPT_ONLY,
+} from "./fixtures/data";
+import { expectModalOpen, installRoutes } from "./helpers";
+
+test.use({
+ permissions: ["clipboard-read", "clipboard-write"],
+});
+
+test.describe("video modal — share URL", () => {
+ test.beforeEach(async ({ page }) => {
+ await installRoutes(page);
+ });
+
+ test("transcript mode share URL omits vm", async ({ page, context }) => {
+ await context.grantPermissions(["clipboard-read", "clipboard-write"]);
+ await page.goto(
+ `/?v=${CHANNEL_SLUG}/${VIDEO_TRANSCRIPT_ONLY}&t=51`,
+ );
+ await expectModalOpen(page);
+
+ await page
+ .getByRole("button", { name: "Copy share link at current time" })
+ .click();
+ const clip = await page.evaluate(() => navigator.clipboard.readText());
+ const url = new URL(clip);
+ expect(url.searchParams.get("v")).toBe(
+ `${CHANNEL_SLUG}/${VIDEO_TRANSCRIPT_ONLY}`,
+ );
+ expect(url.searchParams.get("vm")).toBeNull();
+ // Rumble share URL uses urlTime, not currentTime, so t=51 round-trips.
+ expect(url.searchParams.get("t")).toBe("51");
+ });
+
+ test("chat mode share URL includes vm=chat", async ({ page, context }) => {
+ await context.grantPermissions(["clipboard-read", "clipboard-write"]);
+ await page.goto(
+ `/?v=${CHANNEL_SLUG}/${VIDEO_CHAT_SMALL}&vm=chat&t=11`,
+ );
+ await expectModalOpen(page);
+ await expect(page.getByText(/chat message number 0/).first()).toBeVisible();
+
+ await page
+ .getByRole("button", { name: "Copy share link at current time" })
+ .click();
+ const clip = await page.evaluate(() => navigator.clipboard.readText());
+ const url = new URL(clip);
+ expect(url.searchParams.get("vm")).toBe("chat");
+ expect(url.searchParams.get("t")).toBe("11");
+ });
+});
diff --git a/export/e2e/modal-snapback.spec.ts b/export/e2e/modal-snapback.spec.ts
@@ -0,0 +1,41 @@
+import { expect, test } from "@playwright/test";
+import { CHANNEL_SLUG, VIDEO_TRANSCRIPT_ONLY } from "./fixtures/data";
+import { expectModalOpen, installRoutes, urlParams } from "./helpers";
+
+test.describe("video modal — snap-back when chat missing", () => {
+ test.beforeEach(async ({ page }) => {
+ await installRoutes(page);
+ });
+
+ test("vm=chat on a transcript-only video reverts to transcript with notice", async ({
+ page,
+ }) => {
+ await page.goto(
+ `/?v=${CHANNEL_SLUG}/${VIDEO_TRANSCRIPT_ONLY}&vm=chat`,
+ );
+ await expectModalOpen(page);
+
+ // Either snap-back branch surfaces a notice — "not available" when the
+ // subs page has the slug but no live_chat, "Couldn't load" when the slug
+ // is missing from the manifest entirely. Both indicate the user-facing
+ // snap-back happened.
+ await expect(
+ page.locator('[role="status"]', {
+ hasText: /(Live chat not available|Couldn't load live chat)/,
+ }),
+ ).toBeVisible();
+
+ // vm clears from the URL once snap-back fires.
+ await expect
+ .poll(async () => (await urlParams(page)).get("vm"))
+ .toBeNull();
+
+ // Transcript content is now visible.
+ await expect(page.getByText(/alpha line/)).toBeVisible();
+
+ // Toggle reads "Show live chat" (target state = chat).
+ await expect(
+ page.getByRole("button", { name: "Show live chat" }),
+ ).toBeVisible();
+ });
+});
diff --git a/export/e2e/modal-toggle.spec.ts b/export/e2e/modal-toggle.spec.ts
@@ -0,0 +1,46 @@
+import { expect, test } from "@playwright/test";
+import { CHANNEL_SLUG, VIDEO_CHAT_SMALL } from "./fixtures/data";
+import { expectModalOpen, installRoutes, urlParams } from "./helpers";
+
+test.describe("video modal — transcript/chat toggle", () => {
+ test.beforeEach(async ({ page }) => {
+ await installRoutes(page);
+ });
+
+ test("toggle flips content and only touches vm (not m, tk)", async ({
+ page,
+ }) => {
+ // Land with search-page mode = subs AND vm = chat, with an extra tk hint
+ // we expect to remain untouched.
+ await page.goto(
+ `/?m=subs&tk=foo&v=${CHANNEL_SLUG}/${VIDEO_CHAT_SMALL}&vm=chat`,
+ );
+ await expectModalOpen(page);
+ await expect(page.getByText(/chat message number 0/).first()).toBeVisible();
+
+ // Flip to transcript mode via the modal toggle.
+ await page.getByRole("button", { name: "Show transcript" }).click();
+ await expect(page.getByText(/alpha line/)).toBeVisible();
+
+ const after = await urlParams(page);
+ expect(after.get("vm")).toBeNull(); // transcript = default = absent
+ // The search-page params survive untouched.
+ expect(after.get("m")).toBe("subs");
+ expect(after.get("tk")).toBe("foo");
+ expect(after.get("v")).toBe(`${CHANNEL_SLUG}/${VIDEO_CHAT_SMALL}`);
+ });
+
+ test("toggle back to chat re-sets vm=chat", async ({ page }) => {
+ await page.goto(`/?v=${CHANNEL_SLUG}/${VIDEO_CHAT_SMALL}&vm=chat`);
+ await expectModalOpen(page);
+
+ await page.getByRole("button", { name: "Show transcript" }).click();
+ await expect(page.getByText(/alpha line/)).toBeVisible();
+
+ await page.getByRole("button", { name: "Show live chat" }).click();
+ await expect(page.getByText(/chat message number 0/).first()).toBeVisible();
+
+ const after = await urlParams(page);
+ expect(after.get("vm")).toBe("chat");
+ });
+});
diff --git a/export/e2e/player-control-bar.spec.ts b/export/e2e/player-control-bar.spec.ts
@@ -0,0 +1,56 @@
+import { expect, test } from "@playwright/test";
+import { CHANNEL_SLUG, VIDEO_TRANSCRIPT_ONLY } from "./fixtures/data";
+import { expectModalOpen, installRoutes } from "./helpers";
+
+test.use({
+ permissions: ["clipboard-read", "clipboard-write"],
+});
+
+test.describe("video modal — control bar", () => {
+ test.beforeEach(async ({ page }) => {
+ await installRoutes(page);
+ });
+
+ test("clip mark + copy download command", async ({ page, context }) => {
+ await context.grantPermissions(["clipboard-read", "clipboard-write"]);
+ await page.goto(
+ `/?v=${CHANNEL_SLUG}/${VIDEO_TRANSCRIPT_ONLY}&t=10`,
+ );
+ await expectModalOpen(page);
+
+ // Mark start at the current playhead. With rumble, currentTime is
+ // seeded from urlTime (10).
+ await page
+ .getByRole("button", { name: "Mark clip start at current time" })
+ .click();
+
+ // Move the playhead via cue click to t=50 (beta line).
+ await page.locator("li", { hasText: "beta line" }).click();
+
+ // Mark end at the new playhead.
+ await page
+ .getByRole("button", { name: "Mark clip end at current time" })
+ .click();
+
+ await page
+ .getByRole("button", { name: /Copy yt-dlp download command/ })
+ .click();
+ const clip = await page.evaluate(() => navigator.clipboard.readText());
+ expect(clip).toMatch(/^yt-dlp --download-sections "\*\d\d:\d\d:\d\d-\d\d:\d\d:\d\d" "https:\/\/example\.com\//);
+ });
+
+ test("collapse to mini then restore to modal", async ({ page }) => {
+ await page.goto(`/?v=${CHANNEL_SLUG}/${VIDEO_TRANSCRIPT_ONLY}`);
+ await expectModalOpen(page);
+
+ // Two buttons match this name — the backdrop (covered by iframe) and the
+ // toolbar control. `.last()` picks the toolbar one.
+ await page
+ .getByRole("button", { name: "Collapse to mini-player" })
+ .last()
+ .click();
+ const reopen = page.getByRole("button", { name: "Reopen transcript" });
+ await reopen.click();
+ await expectModalOpen(page);
+ });
+});
diff --git a/export/package.json b/export/package.json
@@ -9,7 +9,9 @@
"prebuild": "pnpm run build:index",
"build": "next build",
"start": "serve out",
- "lint": "eslint"
+ "lint": "eslint",
+ "e2e": "playwright test",
+ "e2e:ui": "playwright test --ui"
},
"dependencies": {
"@tanstack/react-query": "^5.99.1",
@@ -21,6 +23,7 @@
"yt-dlp-transcript-common": "workspace:*"
},
"devDependencies": {
+ "@playwright/test": "^1.59.1",
"@tailwindcss/postcss": "^4.2.2",
"@types/node": "^20.19.39",
"@types/react": "^19.2.14",
diff --git a/export/playwright.config.ts b/export/playwright.config.ts
@@ -0,0 +1,52 @@
+import path from "node:path";
+import { defineConfig, devices } from "@playwright/test";
+import { buildFixtureSettings } from "./e2e/fixtures/data";
+
+const PORT = Number(process.env.PORT ?? 3020);
+const baseURL = `http://localhost:${PORT}`;
+
+const TEST_SETTINGS_FILE = path.resolve(process.cwd(), "test-settings.json");
+
+// Server-side reads (layout settings, summaries totalCount) hit the real
+// `export/public/` on disk — that's tolerated for these tests, since we only
+// care about modal/cue behavior. Browser fetches are all route-mocked per
+// spec via `installRoutes`.
+buildFixtureSettings(TEST_SETTINGS_FILE);
+
+export default defineConfig({
+ testDir: "./e2e",
+ timeout: 30_000,
+ retries: process.env.CI ? 2 : 0,
+ reporter: process.env.CI ? "github" : "list",
+ outputDir: "test-results/",
+ fullyParallel: false,
+ workers: 1,
+ webServer: {
+ command: `pnpm dev --port ${PORT}`,
+ url: baseURL,
+ timeout: 120_000,
+ reuseExistingServer: !process.env.CI,
+ env: {
+ SETTINGS_FILE: TEST_SETTINGS_FILE,
+ },
+ },
+ use: {
+ baseURL,
+ trace: "on-first-retry",
+ screenshot: "only-on-failure",
+ video: "retain-on-failure",
+ },
+ projects: [
+ {
+ name: "chromium",
+ use: {
+ ...devices["Desktop Chrome"],
+ // The modal lays out its cue list below an aspect-video iframe sized
+ // to ~min(92vw,960px). At 1280×720 the cue area collapses to ~50px,
+ // so most assertions can't see the list. 1440×1200 leaves ~500px of
+ // visible cue list.
+ viewport: { width: 1440, height: 1200 },
+ },
+ },
+ ],
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
@@ -20,6 +20,9 @@ importers:
'@tanstack/react-query':
specifier: ^5.99.1
version: 5.100.5(react@19.2.4)
+ '@tanstack/react-virtual':
+ specifier: ^3.13.0
+ version: 3.13.24(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
execa:
specifier: ^9.6.1
version: 9.6.1
@@ -134,6 +137,9 @@ importers:
specifier: workspace:*
version: link:../common
devDependencies:
+ '@playwright/test':
+ specifier: ^1.59.1
+ version: 1.59.1
'@tailwindcss/postcss':
specifier: ^4.2.2
version: 4.2.4
@@ -898,6 +904,15 @@ packages:
peerDependencies:
react: ^18 || ^19
+ '@tanstack/react-virtual@3.13.24':
+ resolution: {integrity: sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
+ '@tanstack/virtual-core@3.14.0':
+ resolution: {integrity: sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==}
+
'@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
@@ -3302,6 +3317,14 @@ snapshots:
'@tanstack/query-core': 5.100.5
react: 19.2.4
+ '@tanstack/react-virtual@3.13.24(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ dependencies:
+ '@tanstack/virtual-core': 3.14.0
+ react: 19.2.4
+ react-dom: 19.2.4(react@19.2.4)
+
+ '@tanstack/virtual-core@3.14.0': {}
+
'@tybys/wasm-util@0.10.1':
dependencies:
tslib: 2.8.1