Archilyzer · Source

archilyzer

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

commit da4fc4e12b2774e6fed42674bde42adbead5316e
parent e091d98e9f2e5fbbab18d37130afe52dae2debe8
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon, 18 May 2026 22:10:07 -0400

live chat video UI

Diffstat:
Mcommon/components/PlayerProvider.tsx | 121+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcommon/components/TranscriptModal.tsx | 59+++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mcommon/components/TranscriptSearch.tsx | 0
Mcommon/components/urlState.ts | 14++++++++++++++
4 files changed, 180 insertions(+), 14 deletions(-)

diff --git a/common/components/PlayerProvider.tsx b/common/components/PlayerProvider.tsx @@ -12,7 +12,9 @@ import { } from "react"; import type ReactPlayerType from "react-player"; import { fetchTranscript } from "./transcriptCache"; +import { fetchSubs } from "./subsCache"; import { useUrlParams, writeUrlParams } from "./urlState"; +import type { ModalMode } from "./urlState"; import { formatDate, formatDuration } from "../lib/format"; import type { Platform } from "../lib/transcripts"; import type { RumblePlayerHandle } from "./RumblePlayer"; @@ -54,6 +56,7 @@ export type TranscriptData = { }; type Status = "idle" | "loading" | "ready"; +type ChatStatus = "idle" | "loading" | "ready" | "missing"; export type DisplayMode = "modal" | "mini" | "hidden"; type Detail = { @@ -83,12 +86,21 @@ type PlayerState = { status: Status; displayMode: DisplayMode; modalOpen: boolean; + modalMode: ModalMode; + chatCues: Cue[] | null; + chatStatus: ChatStatus; + chatNotice: string | null; playing: boolean; currentTime: number; clipStart: number | null; clipEnd: number | null; - openTranscript: (slug: string, start?: number) => void; + openTranscript: ( + slug: string, + start?: number, + opts?: { mode?: ModalMode }, + ) => void; setDisplayMode: (mode: DisplayMode) => void; + setModalMode: (mode: ModalMode) => void; closePlayer: () => void; markClipStart: () => void; markClipEnd: () => void; @@ -120,7 +132,7 @@ export function PlayerProvider({ }: { children: React.ReactNode; }) { - const { v: urlSlug, t: urlTime } = useUrlParams(); + const { v: urlSlug, t: urlTime, vm: urlVm } = useUrlParams(); const [detail, setDetail] = useState<Detail | null>(null); const [displayState, setDisplayState] = useState<{ slug: string; @@ -133,11 +145,17 @@ export function PlayerProvider({ }); 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); const activeSlug = urlSlug; + const modalMode: ModalMode = urlVm; const displayMode: DisplayMode = displayState?.slug === activeSlug ? displayState.mode : "modal"; const modalOpen = activeSlug !== null && displayMode === "modal"; @@ -170,7 +188,7 @@ export function PlayerProvider({ const clipEnd = clip.slug && clip.slug === activeSlug ? clip.end : null; const openTranscript = useCallback( - (slug: string, start?: number) => { + (slug: string, start?: number, opts?: { mode?: ModalMode }) => { setDisplayState({ slug, mode: "modal" }); const t = typeof start === "number" ? Math.round(start) : null; if (slug === activeSlug && t !== null && playerRef.current) { @@ -178,11 +196,19 @@ export function PlayerProvider({ setCurrentTime(t); setPlaying(true); } - writeUrlParams({ v: slug, t }); + writeUrlParams({ + v: slug, + t, + vm: opts?.mode === "chat" ? "chat" : "transcript", + }); }, [activeSlug], ); + const setModalMode = useCallback((mode: ModalMode) => { + writeUrlParams({ vm: mode }); + }, []); + const setDisplayMode = useCallback( (mode: DisplayMode) => { if (!activeSlug) return; @@ -193,7 +219,7 @@ export function PlayerProvider({ const closePlayer = useCallback(() => { setDisplayState(null); - writeUrlParams({ v: null, t: null }); + writeUrlParams({ v: null, t: null, vm: "transcript" }); }, []); const markClipStart = useCallback(() => { @@ -241,6 +267,7 @@ export function PlayerProvider({ const params = new URLSearchParams(); params.set("v", data.slug); if (secs > 0) params.set("t", String(secs)); + if (modalMode === "chat") params.set("vm", "chat"); const url = `${window.location.origin}${window.location.pathname}?${params.toString()}`; try { await navigator.clipboard.writeText(url); @@ -248,7 +275,7 @@ export function PlayerProvider({ } catch { return false; } - }, [data, currentTime, urlTime]); + }, [data, currentTime, urlTime, modalMode]); const openInPreservetube = useCallback(() => { if (!data || data.platform !== "youtube") return; @@ -269,6 +296,83 @@ export function PlayerProvider({ setCurrentTime(seconds); }, []); + // Reset chat state whenever the active video changes. Subsequent effect + // decides whether to actually fetch based on modalMode. + useEffect(() => { + setChatCues(null); + setChatStatus("idle"); + setChatSlug(null); + setChatNotice(null); + if (chatNoticeTimerRef.current !== null) { + window.clearTimeout(chatNoticeTimerRef.current); + chatNoticeTimerRef.current = null; + } + }, [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. + 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."); + writeUrlParams({ vm: "transcript" }); + return; + } + if (chatSlug === urlSlug && chatStatus !== "idle") return; + setChatStatus("loading"); + setChatSlug(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"); + } else { + setChatCues(null); + setChatStatus("missing"); + snapBack("Live chat not available for this video — showing transcript."); + } + }) + .catch(() => { + if (cancelled) return; + setChatCues(null); + setChatStatus("missing"); + snapBack("Couldn't load live chat — showing transcript."); + }); + return () => { + cancelled = true; + }; + }, [urlSlug, modalMode, chatSlug, chatStatus]); + + useEffect(() => { + return () => { + if (chatNoticeTimerRef.current !== null) { + window.clearTimeout(chatNoticeTimerRef.current); + } + }; + }, []); + useEffect(() => { if (!urlSlug) return; readyForSlugRef.current = null; @@ -352,12 +456,17 @@ export function PlayerProvider({ 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, diff --git a/common/components/TranscriptModal.tsx b/common/components/TranscriptModal.tsx @@ -12,6 +12,11 @@ export default function TranscriptModal() { activeSlug, data, status, + modalMode, + setModalMode, + chatCues, + chatStatus, + chatNotice, currentTime, clipStart, clipEnd, @@ -32,13 +37,22 @@ export default function TranscriptModal() { const [shareCopied, setShareCopied] = useState(false); const shareResetRef = useRef<number | null>(null); - const cues = data?.cues ?? []; + const isChat = modalMode === "chat"; + const cues = isChat ? (chatCues ?? []) : (data?.cues ?? []); + const cueStatus: "loading" | "ready" = + isChat + ? chatStatus === "loading" || chatStatus === "idle" + ? "loading" + : "ready" + : status === "loading" + ? "loading" + : "ready"; const activeIndex = findActiveIndex(cues, currentTime); useEffect(() => { if (!modalOpen) return; activeRef.current?.scrollIntoView({ block: "nearest", behavior: "smooth" }); - }, [activeIndex, modalOpen]); + }, [activeIndex, modalOpen, modalMode]); useEffect(() => { if (modalOpen) { @@ -124,6 +138,12 @@ export default function TranscriptModal() { /> <div className="flex-1" /> <ControlButton + title={isChat ? "Show transcript" : "Show live chat"} + onClick={() => setModalMode(isChat ? "transcript" : "chat")} + char={isChat ? "📜" : "💬"} + highlight={isChat} + /> + <ControlButton title={ !data ? "Loading…" @@ -175,20 +195,36 @@ export default function TranscriptModal() { )} </div> + {chatNotice && ( + <p + role="status" + className="text-xs text-amber-200 bg-amber-500/15 ring-1 ring-amber-400/40 rounded px-3 py-1.5" + > + {chatNotice} + </p> + )} <div ref={scrollRef} className="flex-1 overflow-y-auto rounded-lg border border-zinc-700 bg-zinc-900" > - {status === "loading" && ( - <p className="text-sm text-zinc-400 p-4">Loading transcript…</p> + {cueStatus === "loading" && ( + <p className="text-sm text-zinc-400 p-4"> + {isChat ? "Loading live chat…" : "Loading transcript…"} + </p> )} - {status === "ready" && cues.length === 0 && ( - <p className="text-sm text-zinc-400 p-4">No cues available.</p> + {cueStatus === "ready" && cues.length === 0 && ( + <p className="text-sm text-zinc-400 p-4"> + {isChat ? "No live chat for this video." : "No cues available."} + </p> )} - {status === "ready" && cues.length > 0 && ( + {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; return ( <li key={i} @@ -205,7 +241,14 @@ export default function TranscriptModal() { <span className="text-xs font-mono text-zinc-400 shrink-0 w-16 pt-0.5"> {formatTimestamp(cue.start)} </span> - <span className="text-sm">{cue.text}</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> ); diff --git a/common/components/TranscriptSearch.tsx b/common/components/TranscriptSearch.tsx Binary files differ. diff --git a/common/components/urlState.ts b/common/components/urlState.ts @@ -4,11 +4,17 @@ import { useMemo, useSyncExternalStore } from "react"; export type SearchMode = "transcripts" | "subs"; +// Per-video modal content mode. Independent from the search page's `mode` so +// the modal can be toggled without disturbing search state. Absence on the +// URL means "transcript" — only `"chat"` is persisted. +export type ModalMode = "transcript" | "chat"; + export type UrlParams = { q: string; re: boolean; v: string | null; t: number | null; + vm: ModalMode; ch: string[]; nov: boolean; nol: boolean; @@ -49,11 +55,14 @@ function parse(search: string): UrlParams { const t = tRaw !== null && tRaw !== "" ? Number(tRaw) : null; const modeRaw = p.get("m"); const mode: SearchMode = modeRaw === "subs" ? "subs" : "transcripts"; + const vmRaw = p.get("vm"); + const vm: ModalMode = vmRaw === "chat" ? "chat" : "transcript"; return { q: p.get("q") ?? "", re: p.get("re") === "1", v: p.get("v"), t: t !== null && Number.isFinite(t) ? t : null, + vm, ch: p.getAll("ch"), nov: p.get("nov") === "1", nol: p.get("nol") === "1", @@ -76,6 +85,7 @@ type Patch = Partial<{ re: boolean; v: string | null; t: number | null; + vm: ModalMode; ch: string[]; nov: boolean; nol: boolean; @@ -105,6 +115,10 @@ export function writeUrlParams(patch: Patch) { if (patch.t !== null) params.set("t", String(patch.t)); else params.delete("t"); } + if (patch.vm !== undefined) { + if (patch.vm === "chat") params.set("vm", "chat"); + else params.delete("vm"); + } if (patch.ch !== undefined) { params.delete("ch"); for (const v of patch.ch) params.append("ch", v);