diff --git a/bruno/VoD-System/Markers/Delete Marker.bru b/bruno/VoD-System/Markers/Delete Marker.bru new file mode 100644 index 0000000..b2e3459 --- /dev/null +++ b/bruno/VoD-System/Markers/Delete Marker.bru @@ -0,0 +1,16 @@ +meta { + name: Delete Marker + type: http + seq: 4 +} + +delete { + url: {{base_url}}/markers/5 + body: none + auth: inherit +} + +settings { + encodeUrl: true + timeout: 0 +} 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/components/Topbar.tsx b/frontend/src/components/Topbar.tsx index d3bd7dd..ccf897f 100644 --- a/frontend/src/components/Topbar.tsx +++ b/frontend/src/components/Topbar.tsx @@ -1,5 +1,5 @@ import { Menu, X } from 'lucide-react'; -import { login, logout } from "../utils/endpoints.ts"; +import { login, logout } from "../utils/api/users.ts"; import { Dropdown, DropdownItem } from "./Dropdown.tsx"; import { GoogleLogin } from '@react-oauth/google'; diff --git a/frontend/src/components/video/VideoCard.tsx b/frontend/src/components/video/VideoCard.tsx index 1561ceb..5653e7b 100644 --- a/frontend/src/components/video/VideoCard.tsx +++ b/frontend/src/components/video/VideoCard.tsx @@ -2,7 +2,7 @@ import clsx from "clsx"; import { formatTime, stringToDate, dateToTimeAgo } from "../../utils/utils.ts"; import { Link } from "react-router-dom"; import { useEffect, useRef, useState } from "react"; -import { isThumbnailAvailable } from "../../utils/endpoints.ts"; +import { isThumbnailAvailable } from "../../utils/api/client.ts"; import { MoreVertical, Pencil, Trash2 } from "lucide-react"; type VideoCardProps = { diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index d21a82e..6ba6821 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -4,7 +4,8 @@ import Topbar from '../components/Topbar' import { Outlet } from 'react-router-dom'; import {useEffect, useState} from "react"; import type {User} from "../utils/types"; -import { getUser, getStreamStatus } from "../utils/endpoints"; +import { getUser } from "../utils/api/users"; +import { getStreamStatus } from "../utils/api/stream"; const MainLayout = () => { const [sidebarToggled, setSidebarToggled] = useState(false); diff --git a/frontend/src/pages/ClipEdit.tsx b/frontend/src/pages/ClipEdit.tsx index be878ca..cc0f4e4 100644 --- a/frontend/src/pages/ClipEdit.tsx +++ b/frontend/src/pages/ClipEdit.tsx @@ -5,7 +5,8 @@ import PlaybackSlider from "./../components/video/PlaybackSlider"; import ClipRangeSlider from "./../components/video/ClipRangeSlider"; import ConfigBox from "../components/video/ConfigBox.tsx"; import ExportWidget from "../components/video/ExportWidget.tsx"; -import { compress, getJob } from "../utils/endpoints" +import { compress } from "../utils/api/media" +import { getJob } from "../utils/api/jobs" import type { VideoMetadata } from "../utils/types.ts"; import Box from "../components/Box.tsx"; import MetadataBox from "../components/video/MetadataBox.tsx"; diff --git a/frontend/src/pages/EditClip.tsx b/frontend/src/pages/EditClip.tsx index 58d86b1..dc5f8d2 100644 --- a/frontend/src/pages/EditClip.tsx +++ b/frontend/src/pages/EditClip.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { ArrowLeft, Check } from "lucide-react"; -import { getClipById, patchClip } from "../utils/endpoints.ts"; +import { getClipById, patchClip } from "../utils/api/clips.ts"; import Box from "../components/Box.tsx"; import BlueButton from "../components/buttons/BlueButton.tsx"; diff --git a/frontend/src/pages/EditVod.tsx b/frontend/src/pages/EditVod.tsx index 45b895d..89c1cd9 100644 --- a/frontend/src/pages/EditVod.tsx +++ b/frontend/src/pages/EditVod.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { ArrowLeft, Check } from "lucide-react"; -import { getVodById, patchVod } from "../utils/endpoints.ts"; +import { getVodById, patchVod } from "../utils/api/vods.ts"; import Box from "../components/Box.tsx"; import BlueButton from "../components/buttons/BlueButton.tsx"; diff --git a/frontend/src/pages/MyClips.tsx b/frontend/src/pages/MyClips.tsx index bf4f502..5b95f37 100644 --- a/frontend/src/pages/MyClips.tsx +++ b/frontend/src/pages/MyClips.tsx @@ -1,7 +1,7 @@ import VideoCard from "../components/video/VideoCard"; import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { getClips, deleteClip } from "../utils/endpoints"; +import { getClips, deleteClip } from "../utils/api/clips"; import type { Clip } from "../utils/types"; const MyClips = () => { diff --git a/frontend/src/pages/MyVods.tsx b/frontend/src/pages/MyVods.tsx index 8fe1325..54a0a70 100644 --- a/frontend/src/pages/MyVods.tsx +++ b/frontend/src/pages/MyVods.tsx @@ -1,7 +1,7 @@ import VideoCard from "../components/video/VideoCard"; import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { getVods, deleteVod } from "../utils/endpoints"; +import { getVods, deleteVod } from "../utils/api/vods"; import type { Vod } from "../utils/types"; const MyVods = () => { diff --git a/frontend/src/pages/Profile.tsx b/frontend/src/pages/Profile.tsx index 1bb946b..156a560 100644 --- a/frontend/src/pages/Profile.tsx +++ b/frontend/src/pages/Profile.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from "react"; import { Eye, EyeOff, Copy, Check } from "lucide-react"; import Box from "../components/Box.tsx"; -import { getUser } from "../utils/endpoints.ts"; +import { getUser } from "../utils/api/users.ts"; import type { User } from "../utils/types.ts"; import { formatLocalDate } from "../utils/utils.ts"; diff --git a/frontend/src/pages/StreamControl.tsx b/frontend/src/pages/StreamControl.tsx new file mode 100644 index 0000000..480ee20 --- /dev/null +++ b/frontend/src/pages/StreamControl.tsx @@ -0,0 +1,399 @@ +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} /> +