- Member since {formatLocalDate(user.createdAt)}
+
- Stream Key
-
- Use this key in OBS or any RTMP-compatible software to start streaming.
- Keep it private — anyone with this key can stream to your account.
+
+ Stream key
+
+ Point OBS — or anything else that speaks RTMP — at the ingest URL with this key.
+ Keep it private: anyone holding it can stream to your account.
-
-
- {keyVisible ? user.streamKey : "•".repeat(user.streamKey.length)}
+
Key
+
+
+ {keyVisible ? user.streamKey : "•".repeat(Math.min(user.streamKey.length, 28))}
-
setKeyVisible(v => !v)}
- title={keyVisible ? "Hide key" : "Show key"}
- className="p-2 rounded-md bg-gray-100 hover:bg-gray-200 transition-colors duration-150 text-gray-600"
- >
- {keyVisible ? : }
+ setKeyVisible(visible => !visible)}>
+ {keyVisible ? "Hide" : "Reveal"}
-
- {copied ? : }
+
+ {copied ? "Copied" : "Copy"}
-
+
);
};
diff --git a/frontend/src/pages/StreamControl.tsx b/frontend/src/pages/StreamControl.tsx
index 480ee20..4b3a266 100644
--- a/frontend/src/pages/StreamControl.tsx
+++ b/frontend/src/pages/StreamControl.tsx
@@ -1,94 +1,72 @@
-import { useEffect, useState } from "react";
-import { Radio, Check, Bookmark, Scissors, Film, Activity, Loader2, X } from "lucide-react";
+import { useEffect, useMemo, useRef, useState } from "react";
+import { Check, 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 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 { saveSectionByMarkers, clipSection } from "../utils/api/media.ts";
+import { saveSection, 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";
+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";
-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";
+/** Ten-minute gridlines behind the marker ticks. */
+const GRID_INTERVAL_SECONDS = 600;
-type TrackedJob = {
- uuid: string;
- label: string;
- progress: number;
- state: JobResponse['state'];
- errorOutput: string | null;
-};
+const useCopy = () => {
+ const [copied, setCopied] = useState
(null);
-const CardHeader = ({ icon: Icon, title, accent = "primary" }: { icon: React.ElementType; title: string; accent?: "primary" | "accent" }) => (
-
-);
+ const copy = (key: string, text: string) => {
+ navigator.clipboard.writeText(text);
+ setCopied(key);
+ setTimeout(() => setCopied((current) => (current === key ? null : current)), 1600);
+ };
-const JobStatus = ({ job }: { job: TrackedJob }) => {
- if (job.state === 'FAILED') {
- return (
-
-
-
{job.label} failed: {job.errorOutput ?? "Unknown error"}
-
- );
- }
- if (job.state === 'SUCCEEDED') {
- return (
-
- );
- }
- return (
-
- );
+ return { copied, copy };
};
const StreamControl = () => {
- const [user, setUser] = useState(null);
+ const { user, loading } = useAuth();
+ const { copied, copy } = useCopy();
+
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 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 [clipTitle, setClipTitle] = useState("");
- const [clipDescription, setClipDescription] = useState("");
+ const [clipName, setClipName] = 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 [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);
- useEffect(() => {
- getUser().then(setUser).catch(() => setUser(null));
- }, []);
-
+ // ── Polling ─────────────────────────────────────────────────────────────
useEffect(() => {
if (!user) return;
const poll = () => getCurrentStream().then(setStreamStatus).catch(() => {});
@@ -98,24 +76,18 @@ const StreamControl = () => {
}, [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]);
+ }, []);
+
+ useEffect(() => {
+ if (!user) return;
+ getIngestUrl().then(setIngestUrl).catch(() => setIngestUrl(null));
+ }, [user]);
const refreshMarkers = () => {
getMarkers()
- .then((data) => setMarkers(data
+ .then((data) => setMarkers([...data]
.sort((a, b) => stringToDate(a.timestamp).getTime() - stringToDate(b.timestamp).getTime())))
.catch(() => {});
};
@@ -123,6 +95,7 @@ const StreamControl = () => {
useEffect(() => {
if (!isStreaming) {
setMarkers([]);
+ setSelected([]);
return;
}
refreshMarkers();
@@ -130,268 +103,495 @@ const StreamControl = () => {
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(() => {
- const pending = jobs.filter((j) => j.state !== 'SUCCEEDED' && j.state !== 'FAILED');
- if (pending.length === 0) return;
+ if (!pendingKey) return;
+ const uuids = pendingKey.split(",");
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));
- })
+ 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);
- }, [jobs]);
+ }, [pendingKey]);
- const addJob = (uuid: string, label: string) => {
- setJobs((prev) => [{ uuid, label, progress: 0, state: 'READY', errorOutput: null }, ...prev]);
+ // ── 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 (!markerMessage.trim() || markerBusy) return;
+ if (markerBusy) return;
setMarkerBusy(true);
setMarkerError(null);
try {
- await createMarker(markerMessage.trim());
+ await createMarker(markerMessage.trim() || `Marker at ${elapsed}`);
setMarkerMessage("");
refreshMarkers();
} catch (err) {
- setMarkerError(err instanceof Error ? err.message : "Failed to add marker");
+ setMarkerError(err instanceof Error ? err.message : "Failed to drop the 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("");
+ setSelected((prev) => prev.filter((current) => current !== id));
refreshMarkers();
} catch (err) {
- setMarkerError(err instanceof Error ? err.message : "Failed to delete marker");
+ setMarkerError(err instanceof Error ? err.message : "Failed to remove the 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)
+ 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 startMarker = markers.find((m) => String(m.id) === startMarkerId);
- const endMarker = markers.find((m) => String(m.id) === endMarkerId);
+ 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 (
-
-
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}
-
-
+
+ {/* ── Main column ───────────────────────────────────────── */}
+
+
+
+
Server credentials
+ {isStreaming ? (
+
+ ) : (
+
Off air
+ )}
+
- {isStreaming && elapsed ? (
-
- ) : (
-
- )}
-
-
- {isStreaming && (
- <>
- {/* Quick mark toolbar */}
-
-
-
+
Ingest URL
+
setMarkerMessage(e.target.value)}
- onKeyDown={(e) => e.key === "Enter" && handleAddMarker()}
- placeholder="What just happened?"
- className={inputClass}
+ readOnly
+ value={ingestUrl ?? ""}
+ placeholder="asking the server…"
+ className={clsx(monoInputClass, "flex-1")}
/>
-
- Add Marker
-
+ ingestUrl && copy("url", ingestUrl)}
+ >
+ {copied === "url" ? "Copied" : "Copy"}
+
- {markerError &&
{markerError}
}
- {markers.length > 0 && (
-
- {markers.map((m) => {
- const isStart = startMarkerId === String(m.id);
- const isEnd = endMarkerId === String(m.id);
+
Stream key
+
+
+ {keyVisible ? user.streamKey : "•".repeat(Math.min(user.streamKey.length, 28))}
+
+
setKeyVisible((visible) => !visible)}>
+ {keyVisible ? "Hide" : "Reveal"}
+
+
copy("key", user.streamKey)}>
+ {copied === "key" ? "Copied" : "Copy"}
+
+
+
+
+ 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")}
+ />
+
+ + Drop marker at {elapsed}
+
+
+
+
+ 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 (
+
handleChipClick(m.id)}
+ onClick={() => toggleMarker(marker.id)}
className={clsx(
- "flex flex-col items-start gap-0.5 rounded-lg border px-3 py-1.5 text-left transition-colors duration-150",
- isStart && "border-primary bg-primary/5 ring-1 ring-primary",
- isEnd && "border-accent bg-accent/5 ring-1 ring-accent",
- !isStart && !isEnd && "border-gray-200 hover:border-gray-300 hover:bg-gray-50"
+ "flex flex-1 items-center gap-4 rounded-tile px-4 py-3 text-left transition-colors duration-150 min-w-0",
+ isSelected
+ ? "bg-armed inset-shadow-armed"
+ : "bg-card inset-shadow-hairline hover:bg-fields"
)}
>
- {m.message}
-
- {formatLocalDate(m.timestamp, { hour: "2-digit", minute: "2-digit", second: "2-digit" })}
- {isStart && · start }
- {isEnd && · end }
+
+ {isSelected && }
+
+
+
+ {formatTimecode(offset)}
+
+
+
+ {marker.message}
+
+
+
+ {toRelativeOffset(elapsedSeconds - offset)}
+
{ e.stopPropagation(); handleDeleteMarker(m.id); }}
- title="Delete marker"
- className="hidden group-hover:flex items-center justify-center absolute -top-1.5 -right-1.5 w-4 h-4 rounded-full bg-gray-400 hover:bg-red-500 text-white transition-colors duration-150"
+ onClick={() => handleDeleteMarker(marker.id)}
+ title="Remove marker"
+ className="flex h-7 w-7 items-center justify-center rounded-chip text-faint-mono opacity-0 group-hover:opacity-100 hover:bg-chip hover:text-live-red transition-all duration-150"
>
-
+
);
})}
- )}
-
+
+ ) : (
+
+ 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.
+
+
+ )}
+
- {/* Forefront actions */}
-
-
-
-
- Duration (seconds)
- setClipDuration(Number(e.target.value))}
- className={inputClass}
- />
-
- setClipTitle(e.target.value)} className={inputClass} />
-
+ {/* ── Side panel ────────────────────────────────────────── */}
+
+ {isStreaming && (
+ <>
+
+
+
Make something from a range
+
+ {selected.length === 2 && rangeLength
+ ? `Two markers selected — ${inValue} to ${outValue}.`
+ : "Tick two markers, or type a timecode by hand."}
+
+
-
-
- {markers.length < 2 ? (
- Add at least two markers above to save a section between them.
- ) : (
- <>
-
-
- {startMarker ? startMarker.message : "Tap a marker to set start"}
-
-
→
-
- {endMarker ? endMarker.message : "Tap a marker to set end"}
-
+
+
+ Out
+ setOutValue(event.target.value)}
+ placeholder="00:00:00"
+ className={clsx(monoInputClass, "bg-card")}
+ />
+
+
-
-
- {jobs.length === 0 ? (
- Clip and save actions will show up here.
- ) : (
-
- {jobs.map((job) =>
)}
+
+ Range length
+
+ {rangeLength ?? "—"}
+
+
+
+
+ Name
+ setRangeName(event.target.value)}
+ placeholder="Give it a name"
+ className={clsx(inputClass, "bg-card")}
+ />
+
+
+ {rangeError &&
{rangeError}
}
+
+
+
+ {rangeBusy ? "Queueing…" : "Save range as VOD"}
+
+
+ Clear selection
+
+
- )}
-
- >
- )}
+
+
+
+
Cut a clip from just now
+
+ Takes the last stretch of stream, counted back from this moment.
+
+
+
+
+
+ {clipError &&
{clipError}
}
+
+
180}
+ className="w-full py-3"
+ >
+ Cut the last {clipDuration}s as a clip
+
+
+ >
+ )}
+
+ {jobs.length > 0 && (
+
+
+
Background jobs
+
{runningJobs} running
+
+ {jobs.map((job) =>
)}
+
+ )}
+
+
+
How it works
+
+ Tick two markers to fill the range automatically, or type a timecode by hand.
+ Saving queues a background job — you can keep streaming while it runs.
+
+
+
+
);
};
diff --git a/frontend/src/utils/AuthContext.tsx b/frontend/src/utils/AuthContext.tsx
new file mode 100644
index 0000000..a9c7ecf
--- /dev/null
+++ b/frontend/src/utils/AuthContext.tsx
@@ -0,0 +1,42 @@
+import React, {createContext, useContext, useState, useEffect, type ReactNode} from 'react';
+import { getUser } from "./api/users.ts";
+import type {User} from "./types.ts";
+
+// for what the Auth object will look like
+interface AuthContextValue {
+ user: User | null;
+ setUser: React.Dispatch>;
+ loading: boolean;
+}
+
+interface AuthProviderProps {
+ children: ReactNode;
+}
+
+const AuthContext = createContext(undefined);
+
+export function AuthProvider({children}: AuthProviderProps) {
+ const [user, setUser] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ getUser()
+ .then(setUser)
+ .catch(() => setUser(null))
+ .finally(() => setLoading(false));
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useAuth(): AuthContextValue {
+ const context = useContext(AuthContext);
+ if (context === undefined) {
+ throw new Error('useAuth must be used within an AuthProvider');
+ }
+ return context;
+}
\ No newline at end of file
diff --git a/frontend/src/utils/api/stream.ts b/frontend/src/utils/api/stream.ts
index 7cf5fc0..fbbc9c4 100644
--- a/frontend/src/utils/api/stream.ts
+++ b/frontend/src/utils/api/stream.ts
@@ -18,6 +18,18 @@ const getCurrentStream = async (): Promise => {
return result.data;
};
+// The RTMP endpoint to point OBS at. Comes from the server so it stays right
+// across environments rather than being baked into the bundle.
+const getIngestUrl = async (): Promise => {
+ const response = await fetch(API_URL + '/api/v1/stream/metadata', { credentials: 'include' });
+ if (!response.ok) throw new Error(`Failed to fetch ingest URL: ${response.status}`);
+
+ const result: APIResponse = await response.json();
+ if (result.status === 'error') throw new Error(`Failed to fetch ingest URL: ${result.message}`);
+
+ return result.data.url;
+};
+
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}`);
@@ -28,4 +40,4 @@ const getStreamHistory = async (userId: number): Promise =>
return result.data;
};
-export { getStreamStatus, getCurrentStream, getStreamHistory };
+export { getStreamStatus, getCurrentStream, getIngestUrl, getStreamHistory };
diff --git a/frontend/src/utils/styles.ts b/frontend/src/utils/styles.ts
new file mode 100644
index 0000000..9399a6a
--- /dev/null
+++ b/frontend/src/utils/styles.ts
@@ -0,0 +1,27 @@
+// Shared class strings for the recurring bits of chrome. Anything used on more
+// than one page lives here so the pages stay readable and stay in step.
+
+/** Field well — no border, a soft terracotta ring on focus. */
+const inputClass =
+ "bg-fields rounded-field w-full px-3 py-2.5 text-sm font-body text-text-strong " +
+ "placeholder:text-faint-mono focus:outline-none focus:inset-shadow-focus transition-shadow duration-150";
+
+/** Same well, for timecodes and other machine facts. */
+const monoInputClass = inputClass.replace("font-body", "font-data");
+
+/** Field label — sits 6–10px above its field. */
+const labelClass = "text-[11.5px] font-body text-muted";
+
+/** Kicker / eyebrow above a page title or inside a muted panel. */
+const kickerClass = "font-data text-[11px] uppercase tracking-[0.12em] text-muted";
+
+/** Small chip button: copy, reveal, toggles. */
+const chipButtonClass =
+ "bg-chip hover:bg-chip-hover text-text-control font-body text-[13px] rounded-chip " +
+ "px-3.5 py-2.5 transition-colors duration-150 whitespace-nowrap";
+
+/** Quiet text link, used for back links and cancels. */
+const quietLinkClass =
+ "font-body text-sm text-muted hover:text-text-strong transition-colors duration-150";
+
+export { inputClass, monoInputClass, labelClass, kickerClass, chipButtonClass, quietLinkClass };
diff --git a/frontend/src/utils/types.ts b/frontend/src/utils/types.ts
index 07287e4..71f22f5 100644
--- a/frontend/src/utils/types.ts
+++ b/frontend/src/utils/types.ts
@@ -32,6 +32,11 @@ type Clip = {
description: string,
duration: number,
createdAt: string,
+ width: number | null,
+ height: number | null,
+ fps: number | null,
+ // Bytes, as stored on disk.
+ fileSize: number | null,
}
type Vod = {
@@ -41,6 +46,11 @@ type Vod = {
description: string,
duration: number,
createdAt: string,
+ width: number | null,
+ height: number | null,
+ fps: number | null,
+ // Bytes, as stored on disk.
+ fileSize: number | null,
}
type JobResponse = {
@@ -55,6 +65,7 @@ type JobResponse = {
type StreamStatus = {
isStreaming: boolean,
id: number | null,
+ startTime: string | null
}
type StreamHistoryItem = {
@@ -72,6 +83,11 @@ type Marker = {
timestamp: string,
}
+type StreamMetadata = {
+ url: string,
+ length: number
+}
+
export type {
APIResponse,
VideoMetadata,
@@ -81,5 +97,6 @@ export type {
JobResponse,
StreamStatus,
StreamHistoryItem,
- Marker
+ Marker,
+ StreamMetadata
}
\ No newline at end of file
diff --git a/frontend/src/utils/utils.ts b/frontend/src/utils/utils.ts
index 7885f8d..3304f44 100644
--- a/frontend/src/utils/utils.ts
+++ b/frontend/src/utils/utils.ts
@@ -7,6 +7,45 @@ function formatTime(seconds: number): string {
return `${m}:${pad(s)}`;
}
+// Sizes in MB/GB, as the design calls for. Input is bytes.
+function formatFileSize(bytes: number): string {
+ const mb = bytes / 1_000_000;
+ if (mb >= 1000) return `${(mb / 1000).toFixed(1)} GB`;
+ if (mb >= 10) return `${Math.round(mb)} MB`;
+ return `${mb.toFixed(1)} MB`;
+}
+
+// Always hh:mm:ss, zero-padded — the shape used for stream timecodes.
+function formatTimecode(seconds: number): string {
+ const safe = Math.max(0, Math.floor(seconds));
+ const pad = (n: number) => n.toString().padStart(2, '0');
+ return `${pad(Math.floor(safe / 3600))}:${pad(Math.floor((safe % 3600) / 60))}:${pad(safe % 60)}`;
+}
+
+// Reads a typed timecode back into seconds. Accepts hh:mm:ss, mm:ss or ss, so a
+// half-typed field doesn't reject the user mid-keystroke.
+function parseTimecode(value: string): number | null {
+ const trimmed = value.trim();
+ if (!/^\d{1,2}(:\d{1,2}){0,2}$/.test(trimmed)) return null;
+
+ const parts = trimmed.split(':').map(Number);
+ if (parts.some(isNaN)) return null;
+
+ // Pad on the left so a bare "45" reads as seconds and "5:00" as minutes.
+ const [h, m, s] = [0, 0, 0].concat(parts).slice(-3);
+ if (m > 59 || s > 59) return null;
+
+ return h * 3600 + m * 60 + s;
+}
+
+// "83m ago" as the compact "−83m" used beside markers. Stays in minutes for the
+// first few hours — that's the scale you actually think in mid-stream.
+function toRelativeOffset(secondsAgo: number): string {
+ const m = Math.round(Math.max(0, secondsAgo) / 60);
+ if (m < 180) return `−${m}m`;
+ return `−${Math.floor(m / 60)}h${(m % 60).toString().padStart(2, '0')}`;
+}
+
// Parses a UTC date string, appending Z if no timezone offset is present so
// the browser always interprets the value as UTC rather than local time.
function stringToDate(dateString: string): Date {
@@ -40,4 +79,4 @@ function dateToTimeAgo(date: Date): string {
return `${s} second${s !== 1 ? 's' : ''} ago`;
}
-export { formatTime, stringToDate, dateToTimeAgo, formatLocalDate };
+export { formatTime, formatTimecode, formatFileSize, parseTimecode, toRelativeOffset, stringToDate, dateToTimeAgo, formatLocalDate };
diff --git a/src/main/java/com/ddf/vodsystem/controllers/ClipController.java b/src/main/java/com/ddf/vodsystem/controllers/ClipController.java
index 88de8cb..c471953 100644
--- a/src/main/java/com/ddf/vodsystem/controllers/ClipController.java
+++ b/src/main/java/com/ddf/vodsystem/controllers/ClipController.java
@@ -116,7 +116,11 @@ public class ClipController {
clip.getTitle(),
clip.getDescription(),
clip.getDuration(),
- clip.getCreatedAt()
+ clip.getCreatedAt(),
+ clip.getWidth(),
+ clip.getHeight(),
+ clip.getFps(),
+ clip.getFileSize()
);
}
}
diff --git a/src/main/java/com/ddf/vodsystem/controllers/StreamController.java b/src/main/java/com/ddf/vodsystem/controllers/StreamController.java
index 09ce332..17b6127 100644
--- a/src/main/java/com/ddf/vodsystem/controllers/StreamController.java
+++ b/src/main/java/com/ddf/vodsystem/controllers/StreamController.java
@@ -1,14 +1,17 @@
package com.ddf.vodsystem.controllers;
+import com.ddf.vodsystem.controllers.dto.StreamMetadataResponse;
import com.ddf.vodsystem.controllers.dto.StreamResponse;
import com.ddf.vodsystem.dto.APIResponse;
import com.ddf.vodsystem.entities.Stream;
import com.ddf.vodsystem.services.StreamService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
+import java.net.URI;
import java.util.List;
import java.util.Optional;
@@ -18,8 +21,21 @@ public class StreamController {
private static final Logger logger = LoggerFactory.getLogger(StreamController.class);
private static final String SUCCESS = "success";
+ /** Matches the port and application name in nginx/default.conf.template. */
+ private static final int RTMP_PORT = 1935;
+ private static final String RTMP_APPLICATION = "live";
+
private final StreamService streamService;
+ @Value("${stream.ingest.url:}")
+ private String configuredIngestUrl;
+
+ @Value("${frontend.url:}")
+ private String frontendUrl;
+
+ @Value("${stream.max-length}")
+ private Float streamLength;
+
public StreamController(StreamService streamService) {
this.streamService = streamService;
}
@@ -53,13 +69,56 @@ public class StreamController {
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Stream history retrieved", history));
}
+ @GetMapping("/metadata")
+ public ResponseEntity> getIngestEndpoint() {
+ return ResponseEntity.ok(new APIResponse<>(SUCCESS,
+ "Stream information endpoint retrieved",
+ new StreamMetadataResponse(resolveIngestUrl(), streamLength)));
+ }
+
+ /**
+ * The RTMP endpoint to publish to. Set {@code STREAM_INGEST_URL} to state it outright —
+ * needed when the RTMP port is reached through a different host or a non-default port.
+ * Otherwise it is derived from the frontend's host, which is where docker-compose
+ * publishes the nginx-rtmp container.
+ */
+ private String resolveIngestUrl() {
+ if (configuredIngestUrl != null && !configuredIngestUrl.isBlank()) {
+ return configuredIngestUrl.trim();
+ }
+
+ String host = hostOf(frontendUrl);
+ return "rtmp://" + (host == null ? "localhost" : host) + ":" + RTMP_PORT + "/" + RTMP_APPLICATION;
+ }
+
+ private static String hostOf(String url) {
+ if (url == null || url.isBlank()) {
+ return null;
+ }
+
+ String trimmed = url.trim();
+
+ try {
+ String host = URI.create(trimmed).getHost();
+ if (host != null && !host.isBlank()) {
+ return host;
+ }
+ } catch (IllegalArgumentException e) {
+ logger.warn("Could not parse frontend.url ({})", trimmed);
+ }
+
+ // A bare "example.net" or "example.net:8080" parses as a path, not a host.
+ String bare = trimmed.split("/", 2)[0].split(":", 2)[0];
+ return bare.isBlank() ? null : bare;
+ }
+
@GetMapping("/current")
public ResponseEntity> getCurrentStream() {
Optional stream = streamService.getActiveStream();
return stream.map(value -> ResponseEntity.ok(new APIResponse<>(SUCCESS,
"Streaming status retrieved",
- new StreamResponse(true, value.getId())))).orElseGet(() -> ResponseEntity.ok(new APIResponse<>(SUCCESS,
+ new StreamResponse(true, value.getId(), value.getStartDate())))).orElseGet(() -> ResponseEntity.ok(new APIResponse<>(SUCCESS,
"Streaming status retrieved",
- new StreamResponse(false, null))));
+ new StreamResponse(false, null, null))));
}
}
diff --git a/src/main/java/com/ddf/vodsystem/controllers/VodController.java b/src/main/java/com/ddf/vodsystem/controllers/VodController.java
index 60981fa..3b7a7b9 100644
--- a/src/main/java/com/ddf/vodsystem/controllers/VodController.java
+++ b/src/main/java/com/ddf/vodsystem/controllers/VodController.java
@@ -93,7 +93,11 @@ public class VodController {
vod.getTitle(),
vod.getDescription(),
vod.getDuration(),
- vod.getCreatedAt()
+ vod.getCreatedAt(),
+ vod.getWidth(),
+ vod.getHeight(),
+ vod.getFps(),
+ vod.getFileSize()
);
}
}
diff --git a/src/main/java/com/ddf/vodsystem/controllers/dto/ClipResponse.java b/src/main/java/com/ddf/vodsystem/controllers/dto/ClipResponse.java
index da2bb92..ea17317 100644
--- a/src/main/java/com/ddf/vodsystem/controllers/dto/ClipResponse.java
+++ b/src/main/java/com/ddf/vodsystem/controllers/dto/ClipResponse.java
@@ -8,5 +8,9 @@ public record ClipResponse(
String title,
String description,
Float duration,
- Instant createdAt
+ Instant createdAt,
+ Integer width,
+ Integer height,
+ Float fps,
+ Long fileSize
) {}
\ No newline at end of file
diff --git a/src/main/java/com/ddf/vodsystem/controllers/dto/StreamMetadataResponse.java b/src/main/java/com/ddf/vodsystem/controllers/dto/StreamMetadataResponse.java
new file mode 100644
index 0000000..900303b
--- /dev/null
+++ b/src/main/java/com/ddf/vodsystem/controllers/dto/StreamMetadataResponse.java
@@ -0,0 +1,4 @@
+package com.ddf.vodsystem.controllers.dto;
+
+/** Where OBS (or anything else speaking RTMP) should publish to. */
+public record StreamMetadataResponse(String url, Float streamMaxLength) {}
diff --git a/src/main/java/com/ddf/vodsystem/controllers/dto/StreamResponse.java b/src/main/java/com/ddf/vodsystem/controllers/dto/StreamResponse.java
index a7c8695..28b7ecc 100644
--- a/src/main/java/com/ddf/vodsystem/controllers/dto/StreamResponse.java
+++ b/src/main/java/com/ddf/vodsystem/controllers/dto/StreamResponse.java
@@ -1,7 +1,10 @@
package com.ddf.vodsystem.controllers.dto;
+import java.time.Instant;
+
public record StreamResponse(
boolean isStreaming,
- Long id
+ Long id,
+ Instant startTime
) {
}
diff --git a/src/main/java/com/ddf/vodsystem/controllers/dto/VodResponse.java b/src/main/java/com/ddf/vodsystem/controllers/dto/VodResponse.java
index 94f1fcb..e8e44bc 100644
--- a/src/main/java/com/ddf/vodsystem/controllers/dto/VodResponse.java
+++ b/src/main/java/com/ddf/vodsystem/controllers/dto/VodResponse.java
@@ -8,5 +8,9 @@ public record VodResponse(
String title,
String description,
Float duration,
- Instant createdAt
+ Instant createdAt,
+ Integer width,
+ Integer height,
+ Float fps,
+ Long fileSize
) {}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index 2d57ae2..de59e91 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -4,6 +4,7 @@ spring.application.name=vodSystem
spring.servlet.multipart.max-file-size=2GB
spring.servlet.multipart.max-request-size=2GB
stream.max-length=${STREAM_RING_LENGTH_SECOND}
+stream.ingest.url=${STREAM_INGEST_URL:}
# Database
spring.datasource.url=${POSTGRES_URL}