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:
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);