import { useEffect, useMemo, useRef, useState } from "react"; import { Check, X } from "lucide-react"; import clsx from "clsx"; import Card from "../components/Card.tsx"; import Button from "../components/buttons/Button.tsx"; import PageHeader from "../components/PageHeader.tsx"; import IsLivePulse from "../components/IsLivePulse.tsx"; import NoAccessPage from "../components/NoAccessPage.tsx"; import JobProgress, { isSettled, type TrackedJob } from "../components/JobProgress.tsx"; import { useAuth } from "../utils/AuthContext.tsx"; import { getCurrentStream, getIngestUrl } from "../utils/api/stream.ts"; import { getMarkers, createMarker, deleteMarker } from "../utils/api/markers.ts"; import { saveSection, clipSection } from "../utils/api/media.ts"; import { getJob } from "../utils/api/jobs.ts"; import type { Marker, StreamStatus } from "../utils/types.ts"; import { formatTime, formatTimecode, parseTimecode, toRelativeOffset, stringToDate } from "../utils/utils.ts"; import { inputClass, monoInputClass, labelClass, kickerClass, chipButtonClass } from "../utils/styles.ts"; /** Ten-minute gridlines behind the marker ticks. */ const GRID_INTERVAL_SECONDS = 600; const useCopy = () => { const [copied, setCopied] = useState(null); const copy = (key: string, text: string) => { navigator.clipboard.writeText(text); setCopied(key); setTimeout(() => setCopied((current) => (current === key ? null : current)), 1600); }; return { copied, copy }; }; const StreamControl = () => { const { user, loading } = useAuth(); const { copied, copy } = useCopy(); const [streamStatus, setStreamStatus] = useState(null); const [now, setNow] = useState(Date.now()); const [markers, setMarkers] = useState([]); const [markerMessage, setMarkerMessage] = useState(""); const [markerBusy, setMarkerBusy] = useState(false); const [markerError, setMarkerError] = useState(null); const markerInputRef = useRef(null); const [selected, setSelected] = useState([]); const [inValue, setInValue] = useState(""); const [outValue, setOutValue] = useState(""); const [rangeName, setRangeName] = useState(""); const [rangeError, setRangeError] = useState(null); const [rangeBusy, setRangeBusy] = useState(false); const [clipDuration, setClipDuration] = useState(30); const [clipName, setClipName] = useState(""); const [clipError, setClipError] = useState(null); const [keyVisible, setKeyVisible] = useState(false); const [ingestUrl, setIngestUrl] = useState(null); const [jobs, setJobs] = useState([]); const isStreaming = streamStatus?.isStreaming ?? false; const startMs = streamStatus?.startTime ? stringToDate(streamStatus.startTime).getTime() : null; const elapsedSeconds = startMs === null ? 0 : Math.max(0, (now - startMs) / 1000); const elapsed = formatTimecode(elapsedSeconds); // ── Polling ───────────────────────────────────────────────────────────── useEffect(() => { if (!user) return; const poll = () => getCurrentStream().then(setStreamStatus).catch(() => {}); poll(); const interval = setInterval(poll, 5000); return () => clearInterval(interval); }, [user]); useEffect(() => { const interval = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(interval); }, []); useEffect(() => { if (!user) return; getIngestUrl().then(setIngestUrl).catch(() => setIngestUrl(null)); }, [user]); const refreshMarkers = () => { getMarkers() .then((data) => setMarkers([...data] .sort((a, b) => stringToDate(a.timestamp).getTime() - stringToDate(b.timestamp).getTime()))) .catch(() => {}); }; useEffect(() => { if (!isStreaming) { setMarkers([]); setSelected([]); return; } refreshMarkers(); const interval = setInterval(refreshMarkers, 5000); return () => clearInterval(interval); }, [isStreaming, streamStatus?.id]); // One interval for the whole pending set, rebuilt only when that set changes. const pendingKey = jobs.filter((job) => !isSettled(job)).map((job) => job.uuid).join(","); useEffect(() => { if (!pendingKey) return; const uuids = pendingKey.split(","); const interval = setInterval(() => { uuids.forEach((uuid) => { getJob(uuid) .then((result) => setJobs((prev) => prev.map((job) => job.uuid === uuid ? { ...job, progress: result.progress, state: result.state, errorOutput: result.errorOutput } : job))) .catch(() => {}); }); }, 1000); return () => clearInterval(interval); }, [pendingKey]); // ── Marker helpers ────────────────────────────────────────────────────── const offsetOf = (marker: Marker) => startMs === null ? 0 : (stringToDate(marker.timestamp).getTime() - startMs) / 1000; const toggleMarker = (id: number) => { const next = selected.includes(id) ? selected.filter((current) => current !== id) : selected.length < 2 ? [...selected, id] : [selected[1], id]; setSelected(next); setRangeError(null); if (next.length !== 2 || startMs === null) return; const offsets = next .map((markerId) => markers.find((marker) => marker.id === markerId)) .filter((marker): marker is Marker => marker !== undefined) .map(offsetOf) .sort((a, b) => a - b); if (offsets.length !== 2) return; setInValue(formatTimecode(offsets[0])); setOutValue(formatTimecode(offsets[1])); }; // 'M' anywhere on the page jumps to the marker field. useEffect(() => { const onKey = (event: KeyboardEvent) => { if (event.key !== "m" && event.key !== "M") return; if (event.metaKey || event.ctrlKey || event.altKey) return; if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) return; event.preventDefault(); markerInputRef.current?.focus(); }; document.addEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey); }, []); // ── Actions ───────────────────────────────────────────────────────────── const addJob = (uuid: string, label: string) => setJobs((prev) => [{ uuid, label, progress: 0, state: 'READY', errorOutput: null }, ...prev]); const handleAddMarker = async () => { if (markerBusy) return; setMarkerBusy(true); setMarkerError(null); try { await createMarker(markerMessage.trim() || `Marker at ${elapsed}`); setMarkerMessage(""); refreshMarkers(); } catch (err) { setMarkerError(err instanceof Error ? err.message : "Failed to drop the marker"); } finally { setMarkerBusy(false); } }; const handleDeleteMarker = async (id: number) => { try { await deleteMarker(id); setSelected((prev) => prev.filter((current) => current !== id)); refreshMarkers(); } catch (err) { setMarkerError(err instanceof Error ? err.message : "Failed to remove the marker"); } }; const inSeconds = parseTimecode(inValue); const outSeconds = parseTimecode(outValue); // In/Out are timecodes (hh:mm:ss); the length between them is a duration (h:mm:ss). const rangeLength = inSeconds !== null && outSeconds !== null && outSeconds > inSeconds ? formatTime(outSeconds - inSeconds) : null; const handleSaveRange = async () => { setRangeError(null); if (startMs === null) { setRangeError("No stream is running to take a range from."); return; } if (inSeconds === null || outSeconds === null) { setRangeError("Timecodes read as hh:mm:ss."); return; } if (outSeconds <= inSeconds) { setRangeError("The out point has to come after the in point."); return; } if (outSeconds > elapsedSeconds) { setRangeError("That out point hasn't been streamed yet."); return; } setRangeBusy(true); try { const uuid = await saveSection( new Date(startMs + inSeconds * 1000).toISOString(), new Date(startMs + outSeconds * 1000).toISOString(), rangeName || undefined ); addJob(uuid, rangeName || `Saving ${inValue} – ${outValue}`); clearSelection(); } catch (err) { setRangeError(err instanceof Error ? err.message : "Failed to save the range"); } finally { setRangeBusy(false); } }; const clearSelection = () => { setSelected([]); setInValue(""); setOutValue(""); setRangeName(""); setRangeError(null); }; const handleClip = async () => { setClipError(null); try { const uuid = await clipSection(clipDuration, clipName || undefined); addJob(uuid, clipName || `Cutting the last ${clipDuration}s`); setClipName(""); } catch (err) { setClipError(err instanceof Error ? err.message : "Failed to cut the clip"); } }; // ── Timeline ──────────────────────────────────────────────────────────── const gridlines = useMemo(() => { if (elapsedSeconds < GRID_INTERVAL_SECONDS) return []; return Array.from( { length: Math.floor(elapsedSeconds / GRID_INTERVAL_SECONDS) }, (_, index) => ((index + 1) * GRID_INTERVAL_SECONDS) / elapsedSeconds * 100 ); }, [elapsedSeconds]); const runningJobs = jobs.filter((job) => !isSettled(job)).length; if (loading) return null; if (!user) return ; return (
{/* ── Main column ───────────────────────────────────────── */}

Server credentials

{isStreaming ? (

Live now

) : (

Off air

)}

Ingest URL

Stream key

{keyVisible ? user.streamKey : "•".repeat(Math.min(user.streamKey.length, 28))}

Keep this private — anyone holding the key can stream to your account.

{isStreaming ? (

Live markers

t = {elapsed}

setMarkerMessage(event.target.value)} onKeyDown={(event) => event.key === "Enter" && handleAddMarker()} placeholder="What just happened? (optional)" className={clsx(inputClass, "flex-1")} />

or press{" "} M {" "}to jump here

{markerError &&

{markerError}

} {/* Timeline overview */}
{gridlines.map((left, index) => ( ))} {markers.map((marker) => { const left = elapsedSeconds > 0 ? Math.min(100, Math.max(0, (offsetOf(marker) / elapsedSeconds) * 100)) : 0; return ( ); })} 00:00:00 {elapsed}
{/* Marker list */}
{markers.length === 0 && (

No markers yet. Drop one the moment something happens — you can cut around it later.

)} {markers.map((marker) => { const isSelected = selected.includes(marker.id); const offset = offsetOf(marker); return (
); })}
) : (

Off air

Nothing is coming in right now.

Point OBS at the ingest URL above with your stream key, and this panel fills in — elapsed time, markers, and the range cutter.

)}
{/* ── Side panel ────────────────────────────────────────── */}
); }; export default StreamControl;