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" }) => (
+
+);
+
+const JobStatus = ({ job }: { job: TrackedJob }) => {
+ if (job.state === 'FAILED') {
+ return (
+
+
+
{job.label} failed: {job.errorOutput ?? "Unknown error"}
+
+ );
+ }
+ if (job.state === 'SUCCEEDED') {
+ return (
+
+ );
+ }
+ return (
+
+ );
+};
+
+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 ? (
+
+ ) : (
+
+ )}
+
+
+ {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 */}
+
+
+
+
+ {jobs.length === 0 ? (
+ Clip and save actions will show up here.
+ ) : (
+
+ {jobs.map((job) => )}
+
+ )}
+
+ >
+ )}
+
+ );
+};
+
+export default StreamControl;
diff --git a/frontend/src/utils/endpoints.ts b/frontend/src/utils/endpoints.ts
index fc6fd67..55c41fc 100644
--- a/frontend/src/utils/endpoints.ts
+++ b/frontend/src/utils/endpoints.ts
@@ -1,4 +1,4 @@
-import type { VideoMetadata, APIResponse, User, Clip, Vod, JobResponse } from "./types.ts";
+import type { VideoMetadata, APIResponse, User, Clip, Vod, JobResponse, StreamStatus, StreamHistoryItem, Marker } from "./types.ts";
const API_URL = import.meta.env.VITE_API_URL;
@@ -219,6 +219,92 @@ const isThumbnailAvailable = async (thumbnailUrl: string): Promise => {
return response.ok;
};
+const getCurrentStream = async (): Promise => {
+ const response = await fetch(API_URL + '/api/v1/stream/current', { credentials: 'include' });
+ if (!response.ok) throw new Error(`Failed to fetch stream status: ${response.status}`);
+
+ const result: APIResponse = await response.json();
+ if (result.status === 'error') throw new Error(`Failed to fetch stream status: ${result.message}`);
+
+ return result.data;
+};
+
+const getStreamHistory = async (userId: number): Promise => {
+ const response = await fetch(API_URL + `/api/v1/stream/history/${userId}`, { credentials: 'include' });
+ if (!response.ok) throw new Error(`Failed to fetch stream history: ${response.status}`);
+
+ const result: APIResponse = await response.json();
+ if (result.status === 'error') throw new Error(`Failed to fetch stream history: ${result.message}`);
+
+ return result.data;
+};
+
+const getMarkers = async (): Promise => {
+ const response = await fetch(API_URL + '/api/v1/markers', { credentials: 'include' });
+ if (!response.ok) throw new Error(`Failed to fetch markers: ${response.status}`);
+
+ const result: APIResponse = await response.json();
+ if (result.status === 'error') throw new Error(`Failed to fetch markers: ${result.message}`);
+
+ return result.data;
+};
+
+const createMarker = async (message: string): Promise => {
+ const response = await fetch(API_URL + '/api/v1/markers', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({ message }),
+ });
+
+ const result: APIResponse = await response.json();
+ if (!response.ok || result.status === 'error') throw new Error(`Failed to add marker: ${result.message}`);
+
+ return result.data;
+};
+
+const saveSection = async (startTime: string, endTime: string, title?: string, description?: string): Promise => {
+ const response = await fetch(API_URL + '/api/v1/media/save', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({ startTime, endTime, title, description }),
+ });
+
+ const result: APIResponse = await response.json();
+ if (!response.ok || result.status === 'error') throw new Error(`Failed to save section: ${result.message}`);
+
+ return result.data.uuid;
+};
+
+const saveSectionByMarkers = async (startMarkerId: number, endMarkerId: number, title?: string, description?: string): Promise => {
+ const response = await fetch(API_URL + '/api/v1/media/save/markers', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({ startMarkerId, endMarkerId, title, description }),
+ });
+
+ const result: APIResponse = await response.json();
+ if (!response.ok || result.status === 'error') throw new Error(`Failed to save section: ${result.message}`);
+
+ return result.data.uuid;
+};
+
+const clipSection = async (duration: number, title?: string, description?: string): Promise => {
+ const response = await fetch(API_URL + '/api/v1/media/clip', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({ duration, title, description }),
+ });
+
+ const result: APIResponse = await response.json();
+ if (!response.ok || result.status === 'error') throw new Error(`Failed to clip section: ${result.message}`);
+
+ return result.data.uuid;
+};
+
export {
login,
logout,
@@ -237,4 +323,11 @@ export {
deleteVod,
getStreamStatus,
isThumbnailAvailable,
+ getCurrentStream,
+ getStreamHistory,
+ getMarkers,
+ createMarker,
+ saveSection,
+ saveSectionByMarkers,
+ clipSection,
};
diff --git a/frontend/src/utils/types.ts b/frontend/src/utils/types.ts
index cfba334..07287e4 100644
--- a/frontend/src/utils/types.ts
+++ b/frontend/src/utils/types.ts
@@ -16,6 +16,7 @@ type APIResponse = {
}
type User = {
+ id: number,
name: string,
username: string,
email: string,
@@ -51,11 +52,34 @@ type JobResponse = {
createdAt: string,
};
+type StreamStatus = {
+ isStreaming: boolean,
+ id: number | null,
+}
+
+type StreamHistoryItem = {
+ id: number,
+ startDate: string | null,
+ endDate: string | null,
+ lastSeen: string | null,
+}
+
+type Marker = {
+ id: number,
+ userId: number,
+ streamId: number,
+ message: string,
+ timestamp: string,
+}
+
export type {
APIResponse,
VideoMetadata,
User,
Clip,
Vod,
- JobResponse
+ JobResponse,
+ StreamStatus,
+ StreamHistoryItem,
+ Marker
}
\ No newline at end of file