Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

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:
M.gitignore | 7+++++++
Mcommon/components/PlayerProvider.tsx | 408+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Mcommon/components/TranscriptModal.tsx | 180++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Mcommon/package.json | 1+
Aexport/e2e/fixtures/data.ts | 192+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aexport/e2e/helpers.ts | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aexport/e2e/live-chat.spec.ts | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aexport/e2e/modal-close.spec.ts | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Aexport/e2e/modal-open.spec.ts | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aexport/e2e/modal-share.spec.ts | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aexport/e2e/modal-snapback.spec.ts | 41+++++++++++++++++++++++++++++++++++++++++
Aexport/e2e/modal-toggle.spec.ts | 46++++++++++++++++++++++++++++++++++++++++++++++
Aexport/e2e/player-control-bar.spec.ts | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mexport/package.json | 5++++-
Aexport/playwright.config.ts | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpnpm-lock.yaml | 23+++++++++++++++++++++++
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