import { useEffect, useState } from "react"; import { Radio, Check, Bookmark, Scissors, Film, Activity, Loader2, X } from "lucide-react"; import clsx from "clsx"; import Box from "../components/Box.tsx"; import BlueButton from "../components/buttons/BlueButton.tsx"; import { getUser } from "../utils/api/users.ts"; import { getCurrentStream, getStreamHistory } from "../utils/api/stream.ts"; import { getMarkers, createMarker, deleteMarker } from "../utils/api/markers.ts"; import { saveSectionByMarkers, clipSection } from "../utils/api/media.ts"; import { getJob } from "../utils/api/jobs.ts"; import type { User, StreamStatus, StreamHistoryItem, Marker, JobResponse } from "../utils/types.ts"; import { formatTime, formatLocalDate, stringToDate } from "../utils/utils.ts"; const inputClass = "border border-gray-300 bg-white rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors"; const labelClass = "text-xs font-medium text-gray-500 uppercase tracking-wide"; type TrackedJob = { uuid: string; label: string; progress: number; state: JobResponse['state']; errorOutput: string | null; }; const CardHeader = ({ icon: Icon, title, accent = "primary" }: { icon: React.ElementType; title: string; accent?: "primary" | "accent" }) => (

{title}

); const JobStatus = ({ job }: { job: TrackedJob }) => { if (job.state === 'FAILED') { return (

{job.label} failed: {job.errorOutput ?? "Unknown error"}

); } if (job.state === 'SUCCEEDED') { return (

{job.label} saved

); } return (

{job.label}…

); }; const StreamControl = () => { const [user, setUser] = useState(null); const [streamStatus, setStreamStatus] = useState(null); const [streamDetails, setStreamDetails] = 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 [clipDuration, setClipDuration] = useState(30); const [clipTitle, setClipTitle] = useState(""); const [clipDescription, setClipDescription] = useState(""); const [clipError, setClipError] = useState(null); const [startMarkerId, setStartMarkerId] = useState(""); const [endMarkerId, setEndMarkerId] = useState(""); const [markerSaveTitle, setMarkerSaveTitle] = useState(""); const [markerSaveDescription, setMarkerSaveDescription] = useState(""); const [markerSaveError, setMarkerSaveError] = useState(null); const [jobs, setJobs] = useState([]); const isStreaming = streamStatus?.isStreaming ?? false; useEffect(() => { getUser().then(setUser).catch(() => setUser(null)); }, []); useEffect(() => { if (!user) return; const poll = () => getCurrentStream().then(setStreamStatus).catch(() => {}); poll(); const interval = setInterval(poll, 5000); return () => clearInterval(interval); }, [user]); useEffect(() => { if (!user || !streamStatus?.id) { setStreamDetails(null); return; } getStreamHistory(user.id) .then((history) => setStreamDetails(history.find((s) => s.id === streamStatus.id) ?? null)) .catch(() => {}); }, [user, streamStatus?.id]); useEffect(() => { if (!streamDetails?.startDate) return; const interval = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(interval); }, [streamDetails?.startDate]); const refreshMarkers = () => { getMarkers() .then((data) => setMarkers(data .sort((a, b) => stringToDate(a.timestamp).getTime() - stringToDate(b.timestamp).getTime()))) .catch(() => {}); }; useEffect(() => { if (!isStreaming) { setMarkers([]); return; } refreshMarkers(); const interval = setInterval(refreshMarkers, 5000); return () => clearInterval(interval); }, [isStreaming, streamStatus?.id]); useEffect(() => { const pending = jobs.filter((j) => j.state !== 'SUCCEEDED' && j.state !== 'FAILED'); if (pending.length === 0) return; const interval = setInterval(() => { pending.forEach((job) => { getJob(job.uuid) .then((result) => { setJobs((prev) => prev.map((j) => j.uuid === job.uuid ? { ...j, progress: result.progress, state: result.state, errorOutput: result.errorOutput } : j)); }) .catch(() => {}); }); }, 1000); return () => clearInterval(interval); }, [jobs]); const addJob = (uuid: string, label: string) => { setJobs((prev) => [{ uuid, label, progress: 0, state: 'READY', errorOutput: null }, ...prev]); }; const handleAddMarker = async () => { if (!markerMessage.trim() || markerBusy) return; setMarkerBusy(true); setMarkerError(null); try { await createMarker(markerMessage.trim()); setMarkerMessage(""); refreshMarkers(); } catch (err) { setMarkerError(err instanceof Error ? err.message : "Failed to add marker"); } finally { setMarkerBusy(false); } }; const handleClip = async () => { setClipError(null); try { const uuid = await clipSection(clipDuration, clipTitle || undefined, clipDescription || undefined); addJob(uuid, `Clip (last ${clipDuration}s)`); setClipTitle(""); setClipDescription(""); } catch (err) { setClipError(err instanceof Error ? err.message : "Failed to clip section"); } }; const handleSaveByMarkers = async () => { setMarkerSaveError(null); if (!startMarkerId || !endMarkerId) { setMarkerSaveError("Tap two markers above to pick a start and end"); return; } try { const uuid = await saveSectionByMarkers( Number(startMarkerId), Number(endMarkerId), markerSaveTitle || undefined, markerSaveDescription || undefined ); addJob(uuid, "Save (by markers)"); setStartMarkerId(""); setEndMarkerId(""); setMarkerSaveTitle(""); setMarkerSaveDescription(""); } catch (err) { setMarkerSaveError(err instanceof Error ? err.message : "Failed to save section"); } }; const handleDeleteMarker = async (id: number) => { try { await deleteMarker(id); if (startMarkerId === String(id)) setStartMarkerId(""); if (endMarkerId === String(id)) setEndMarkerId(""); refreshMarkers(); } catch (err) { setMarkerError(err instanceof Error ? err.message : "Failed to delete marker"); } }; const handleChipClick = (id: number) => { const idStr = String(id); setMarkerSaveError(null); if (startMarkerId === idStr) { setStartMarkerId(""); return; } if (endMarkerId === idStr) { setEndMarkerId(""); return; } if (!startMarkerId) { setStartMarkerId(idStr); return; } if (!endMarkerId) { setEndMarkerId(idStr); return; } setEndMarkerId(idStr); }; if (!user) { return (
Please log in to control your stream.
); } const elapsed = streamDetails?.startDate ? formatTime((now - stringToDate(streamDetails.startDate).getTime()) / 1000) : null; const startMarker = markers.find((m) => String(m.id) === startMarkerId); const endMarker = markers.find((m) => String(m.id) === endMarkerId); return (

Stream Control

{/* Hero status bar */}
{isStreaming && }

{isStreaming ? "Live" : "Offline"}

{isStreaming && streamDetails?.startDate ? (

Started {formatLocalDate(streamDetails.startDate)}

) : !isStreaming ? (

Point OBS at your stream key (see Profile) to go live.

) : null}
{isStreaming && elapsed ? (

{elapsed}

elapsed

) : ( )}
{isStreaming && ( <> {/* Quick mark toolbar */}
setMarkerMessage(e.target.value)} onKeyDown={(e) => e.key === "Enter" && handleAddMarker()} placeholder="What just happened?" className={inputClass} /> Add Marker
{markerError &&

{markerError}

} {markers.length > 0 && (
{markers.map((m) => { const isStart = startMarkerId === String(m.id); const isEnd = endMarkerId === String(m.id); return (
); })}
)}
{/* Forefront actions */}
setClipDuration(Number(e.target.value))} className={inputClass} />
setClipTitle(e.target.value)} className={inputClass} />