From b8b4d3b05ce519ba3ca69596f4217e7d9331a656 Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Thu, 2 Jul 2026 19:27:23 +0100 Subject: [PATCH 1/3] feat(frontend): streaming controls page --- frontend/src/App.tsx | 2 + frontend/src/components/Sidebar.tsx | 3 +- frontend/src/pages/StreamControl.tsx | 386 +++++++++++++++++++++++++++ frontend/src/utils/endpoints.ts | 95 ++++++- frontend/src/utils/types.ts | 26 +- 5 files changed, 509 insertions(+), 3 deletions(-) create mode 100644 frontend/src/pages/StreamControl.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5ffc1d3..34ae6cc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,6 +12,7 @@ import VodPlayer from "./pages/VodPlayer.tsx"; import EditClip from "./pages/EditClip.tsx"; import EditVod from "./pages/EditVod.tsx"; import Profile from "./pages/Profile.tsx"; +import StreamControl from "./pages/StreamControl.tsx"; import { GoogleOAuthProvider } from '@react-oauth/google'; @@ -35,6 +36,7 @@ function App() { } /> } /> } /> + } /> diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index bd3ecd2..bb33d56 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1,6 +1,6 @@ import clsx from "clsx"; import SidebarButton from "./buttons/SidebarButton.tsx"; -import { Plus, Film, Home, User as UserIcon, Video } from 'lucide-react'; +import { Plus, Film, Home, User as UserIcon, Video, Radio } from 'lucide-react'; import type {User} from "../utils/types.ts"; type props = { @@ -30,6 +30,7 @@ const Sidebar = ({user, className}: props) => { <>
+ } label="Stream" /> } label="Clips" /> } label="VoDs" />
diff --git a/frontend/src/pages/StreamControl.tsx b/frontend/src/pages/StreamControl.tsx new file mode 100644 index 0000000..be95763 --- /dev/null +++ b/frontend/src/pages/StreamControl.tsx @@ -0,0 +1,386 @@ +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, + getCurrentStream, + getStreamHistory, + getMarkers, + createMarker, + saveSectionByMarkers, + clipSection, + getJob, +} from "../utils/endpoints.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.filter((m) => m.streamId === streamStatus?.id) + .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); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [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 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} /> +