feat(frontend): streaming controls page
This commit is contained in:
@@ -12,6 +12,7 @@ import VodPlayer from "./pages/VodPlayer.tsx";
|
|||||||
import EditClip from "./pages/EditClip.tsx";
|
import EditClip from "./pages/EditClip.tsx";
|
||||||
import EditVod from "./pages/EditVod.tsx";
|
import EditVod from "./pages/EditVod.tsx";
|
||||||
import Profile from "./pages/Profile.tsx";
|
import Profile from "./pages/Profile.tsx";
|
||||||
|
import StreamControl from "./pages/StreamControl.tsx";
|
||||||
import { GoogleOAuthProvider } from '@react-oauth/google';
|
import { GoogleOAuthProvider } from '@react-oauth/google';
|
||||||
|
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ function App() {
|
|||||||
<Route path="/clips/:id/edit" element={<EditClip />} />
|
<Route path="/clips/:id/edit" element={<EditClip />} />
|
||||||
<Route path="/vods/:id/edit" element={<EditVod />} />
|
<Route path="/vods/:id/edit" element={<EditVod />} />
|
||||||
<Route path="/profile" element={<Profile />} />
|
<Route path="/profile" element={<Profile />} />
|
||||||
|
<Route path="/stream" element={<StreamControl />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</Router>
|
</Router>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import SidebarButton from "./buttons/SidebarButton.tsx";
|
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";
|
import type {User} from "../utils/types.ts";
|
||||||
|
|
||||||
type props = {
|
type props = {
|
||||||
@@ -30,6 +30,7 @@ const Sidebar = ({user, className}: props) => {
|
|||||||
<>
|
<>
|
||||||
<Divider />
|
<Divider />
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<SidebarButton url="/stream" logo={<Radio size={18}/>} label="Stream" />
|
||||||
<SidebarButton url="/my-clips" logo={<Film size={18}/>} label="Clips" />
|
<SidebarButton url="/my-clips" logo={<Film size={18}/>} label="Clips" />
|
||||||
<SidebarButton url="/my-vods" logo={<Video size={18}/>} label="VoDs" />
|
<SidebarButton url="/my-vods" logo={<Video size={18}/>} label="VoDs" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
386
frontend/src/pages/StreamControl.tsx
Normal file
386
frontend/src/pages/StreamControl.tsx
Normal file
@@ -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" }) => (
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className={clsx("w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0", accent === "primary" ? "bg-primary/10" : "bg-accent/10")}>
|
||||||
|
<Icon size={16} className={accent === "primary" ? "text-primary" : "text-accent"} />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-sm font-semibold text-gray-800">{title}</h2>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const JobStatus = ({ job }: { job: TrackedJob }) => {
|
||||||
|
if (job.state === 'FAILED') {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-2 text-sm">
|
||||||
|
<X size={15} className="text-red-500 mt-0.5 flex-shrink-0" />
|
||||||
|
<p className="text-red-500">{job.label} failed: {job.errorOutput ?? "Unknown error"}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (job.state === 'SUCCEEDED') {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Check size={15} className="text-green-600 flex-shrink-0" />
|
||||||
|
<p className="text-gray-700">{job.label} saved</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Loader2 size={15} className="text-primary animate-spin flex-shrink-0" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm text-gray-600 mb-1">{job.label}…</p>
|
||||||
|
<progress value={job.progress} className="w-full h-1.5 rounded bg-gray-200" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const StreamControl = () => {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [streamStatus, setStreamStatus] = useState<StreamStatus | null>(null);
|
||||||
|
const [streamDetails, setStreamDetails] = useState<StreamHistoryItem | null>(null);
|
||||||
|
const [now, setNow] = useState(Date.now());
|
||||||
|
|
||||||
|
const [markers, setMarkers] = useState<Marker[]>([]);
|
||||||
|
const [markerMessage, setMarkerMessage] = useState("");
|
||||||
|
const [markerBusy, setMarkerBusy] = useState(false);
|
||||||
|
const [markerError, setMarkerError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [clipDuration, setClipDuration] = useState(30);
|
||||||
|
const [clipTitle, setClipTitle] = useState("");
|
||||||
|
const [clipDescription, setClipDescription] = useState("");
|
||||||
|
const [clipError, setClipError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [startMarkerId, setStartMarkerId] = useState<string>("");
|
||||||
|
const [endMarkerId, setEndMarkerId] = useState<string>("");
|
||||||
|
const [markerSaveTitle, setMarkerSaveTitle] = useState("");
|
||||||
|
const [markerSaveDescription, setMarkerSaveDescription] = useState("");
|
||||||
|
const [markerSaveError, setMarkerSaveError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [jobs, setJobs] = useState<TrackedJob[]>([]);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="flex justify-center items-center h-full text-gray-400 text-base">
|
||||||
|
Please log in to control your stream.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="px-8 py-10 max-w-5xl mx-auto flex flex-col gap-5">
|
||||||
|
<h1 className="text-2xl font-semibold text-gray-900">Stream Control</h1>
|
||||||
|
|
||||||
|
{/* Hero status bar */}
|
||||||
|
<Box className={clsx(
|
||||||
|
"p-6 flex items-center justify-between gap-6 border-l-4",
|
||||||
|
isStreaming ? "border-l-red-500" : "border-l-gray-200"
|
||||||
|
)}>
|
||||||
|
<div className="flex items-center gap-3.5">
|
||||||
|
<span className="relative flex h-3.5 w-3.5">
|
||||||
|
{isStreaming && <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-500 opacity-75" />}
|
||||||
|
<span className={clsx("relative inline-flex rounded-full h-3.5 w-3.5", isStreaming ? "bg-red-500" : "bg-gray-300")} />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-gray-900 text-lg leading-tight">{isStreaming ? "Live" : "Offline"}</p>
|
||||||
|
{isStreaming && streamDetails?.startDate ? (
|
||||||
|
<p className="text-sm text-gray-500">Started {formatLocalDate(streamDetails.startDate)}</p>
|
||||||
|
) : !isStreaming ? (
|
||||||
|
<p className="text-sm text-gray-500">Point OBS at your stream key (see Profile) to go live.</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isStreaming && elapsed ? (
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-2xl font-mono font-semibold text-gray-900 tabular-nums">{elapsed}</p>
|
||||||
|
<p className="text-xs text-gray-400 uppercase tracking-wide">elapsed</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Radio size={28} className="text-gray-200" />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{isStreaming && (
|
||||||
|
<>
|
||||||
|
{/* Quick mark toolbar */}
|
||||||
|
<Box className="p-4 flex flex-col gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Bookmark size={18} className="text-accent flex-shrink-0" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={markerMessage}
|
||||||
|
onChange={(e) => setMarkerMessage(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleAddMarker()}
|
||||||
|
placeholder="What just happened?"
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
<BlueButton onClick={handleAddMarker} disabled={markerBusy || !markerMessage.trim()} className="whitespace-nowrap">
|
||||||
|
Add Marker
|
||||||
|
</BlueButton>
|
||||||
|
</div>
|
||||||
|
{markerError && <p className="text-sm text-red-500">{markerError}</p>}
|
||||||
|
|
||||||
|
{markers.length > 0 && (
|
||||||
|
<div className="flex gap-2 overflow-x-auto pb-0.5 -mx-1 px-1">
|
||||||
|
{markers.map((m) => {
|
||||||
|
const isStart = startMarkerId === String(m.id);
|
||||||
|
const isEnd = endMarkerId === String(m.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={m.id}
|
||||||
|
onClick={() => handleChipClick(m.id)}
|
||||||
|
className={clsx(
|
||||||
|
"flex-shrink-0 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"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-xs font-medium text-gray-800 max-w-40 truncate">{m.message}</span>
|
||||||
|
<span className="text-[11px] text-gray-400">
|
||||||
|
{formatLocalDate(m.timestamp, { hour: "2-digit", minute: "2-digit", second: "2-digit" })}
|
||||||
|
{isStart && <span className="text-primary font-medium"> · start</span>}
|
||||||
|
{isEnd && <span className="text-accent font-medium"> · end</span>}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Forefront actions */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5 items-stretch">
|
||||||
|
<Box className="p-6 flex flex-col gap-3">
|
||||||
|
<CardHeader icon={Scissors} title="Clip the last few seconds" />
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className={labelClass}>Duration (seconds)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={clipDuration}
|
||||||
|
onChange={(e) => setClipDuration(Number(e.target.value))}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<input type="text" placeholder="Title" value={clipTitle} onChange={(e) => setClipTitle(e.target.value)} className={inputClass} />
|
||||||
|
<textarea placeholder="Description" value={clipDescription} onChange={(e) => setClipDescription(e.target.value)} rows={2} className={inputClass + " resize-none"} />
|
||||||
|
{clipError && <p className="text-sm text-red-500">{clipError}</p>}
|
||||||
|
<BlueButton onClick={handleClip} className="self-start mt-auto">Clip</BlueButton>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box className="p-6 flex flex-col gap-3">
|
||||||
|
<CardHeader icon={Film} title="Save between two markers" accent="accent" />
|
||||||
|
{markers.length < 2 ? (
|
||||||
|
<p className="text-sm text-gray-400">Add at least two markers above to save a section between them.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className={clsx(
|
||||||
|
"text-xs font-medium px-2.5 py-1 rounded-full border",
|
||||||
|
startMarker ? "border-primary text-primary bg-primary/5" : "border-dashed border-gray-300 text-gray-400"
|
||||||
|
)}>
|
||||||
|
{startMarker ? startMarker.message : "Tap a marker to set start"}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-300">→</span>
|
||||||
|
<span className={clsx(
|
||||||
|
"text-xs font-medium px-2.5 py-1 rounded-full border",
|
||||||
|
endMarker ? "border-accent text-accent bg-accent/5" : "border-dashed border-gray-300 text-gray-400"
|
||||||
|
)}>
|
||||||
|
{endMarker ? endMarker.message : "Tap a marker to set end"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input type="text" placeholder="Title" value={markerSaveTitle} onChange={(e) => setMarkerSaveTitle(e.target.value)} className={inputClass} />
|
||||||
|
<textarea placeholder="Description" value={markerSaveDescription} onChange={(e) => setMarkerSaveDescription(e.target.value)} rows={2} className={inputClass + " resize-none"} />
|
||||||
|
{markerSaveError && <p className="text-sm text-red-500">{markerSaveError}</p>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<BlueButton onClick={handleSaveByMarkers} disabled={markers.length < 2} className="self-start mt-auto">Save</BlueButton>
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Box className="p-5 flex flex-col gap-3">
|
||||||
|
<CardHeader icon={Activity} title="Recent actions" />
|
||||||
|
{jobs.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-400">Clip and save actions will show up here.</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{jobs.map((job) => <JobStatus key={job.uuid} job={job} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StreamControl;
|
||||||
@@ -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;
|
const API_URL = import.meta.env.VITE_API_URL;
|
||||||
|
|
||||||
@@ -219,6 +219,92 @@ const isThumbnailAvailable = async (thumbnailUrl: string): Promise<boolean> => {
|
|||||||
return response.ok;
|
return response.ok;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getCurrentStream = async (): Promise<StreamStatus> => {
|
||||||
|
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<StreamHistoryItem[]> => {
|
||||||
|
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<Marker[]> => {
|
||||||
|
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<Marker> => {
|
||||||
|
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<string> => {
|
||||||
|
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<string> => {
|
||||||
|
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<string> => {
|
||||||
|
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 {
|
export {
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
@@ -237,4 +323,11 @@ export {
|
|||||||
deleteVod,
|
deleteVod,
|
||||||
getStreamStatus,
|
getStreamStatus,
|
||||||
isThumbnailAvailable,
|
isThumbnailAvailable,
|
||||||
|
getCurrentStream,
|
||||||
|
getStreamHistory,
|
||||||
|
getMarkers,
|
||||||
|
createMarker,
|
||||||
|
saveSection,
|
||||||
|
saveSectionByMarkers,
|
||||||
|
clipSection,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type APIResponse = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type User = {
|
type User = {
|
||||||
|
id: number,
|
||||||
name: string,
|
name: string,
|
||||||
username: string,
|
username: string,
|
||||||
email: string,
|
email: string,
|
||||||
@@ -51,11 +52,34 @@ type JobResponse = {
|
|||||||
createdAt: string,
|
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 {
|
export type {
|
||||||
APIResponse,
|
APIResponse,
|
||||||
VideoMetadata,
|
VideoMetadata,
|
||||||
User,
|
User,
|
||||||
Clip,
|
Clip,
|
||||||
Vod,
|
Vod,
|
||||||
JobResponse
|
JobResponse,
|
||||||
|
StreamStatus,
|
||||||
|
StreamHistoryItem,
|
||||||
|
Marker
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user