feat(streaming): frontend page for viewing and controlling stream #7
16
bruno/VoD-System/Markers/Delete Marker.bru
Normal file
16
bruno/VoD-System/Markers/Delete Marker.bru
Normal file
@@ -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
|
||||
}
|
||||
@@ -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() {
|
||||
<Route path="/clips/:id/edit" element={<EditClip />} />
|
||||
<Route path="/vods/:id/edit" element={<EditVod />} />
|
||||
<Route path="/profile" element={<Profile />} />
|
||||
<Route path="/stream" element={<StreamControl />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</Router>
|
||||
|
||||
@@ -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) => {
|
||||
<>
|
||||
<Divider />
|
||||
<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-vods" logo={<Video size={18}/>} label="VoDs" />
|
||||
</div>
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
399
frontend/src/pages/StreamControl.tsx
Normal file
399
frontend/src/pages/StreamControl.tsx
Normal file
@@ -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" }) => (
|
||||
<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
|
||||
.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 (
|
||||
<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 pt-1.5">
|
||||
{markers.map((m) => {
|
||||
const isStart = startMarkerId === String(m.id);
|
||||
const isEnd = endMarkerId === String(m.id);
|
||||
return (
|
||||
<div key={m.id} className="group relative flex-shrink-0">
|
||||
<button
|
||||
onClick={() => handleChipClick(m.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"
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
<button
|
||||
onClick={(e) => { 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"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</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;
|
||||
@@ -2,7 +2,8 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, Lock, Maximize, Minimize, Pause, Play, Volume1, Volume2, VolumeX } from "lucide-react";
|
||||
import type { Clip } from "../utils/types";
|
||||
import { AuthError, getClipById, getVideoBlob } from "../utils/endpoints.ts";
|
||||
import { AuthError } from "../utils/api/client.ts";
|
||||
import { getClipById, getVideoBlob } from "../utils/api/clips.ts";
|
||||
import Box from "../components/Box.tsx";
|
||||
import { dateToTimeAgo, formatTime, stringToDate } from "../utils/utils.ts";
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, Lock, Maximize, Minimize, Pause, Play, Volume1, Volume2, VolumeX } from "lucide-react";
|
||||
import type { Vod } from "../utils/types";
|
||||
import { AuthError, getVodById, getVodBlob } from "../utils/endpoints.ts";
|
||||
import { AuthError } from "../utils/api/client.ts";
|
||||
import { getVodById, getVodBlob } from "../utils/api/vods.ts";
|
||||
import Box from "../components/Box.tsx";
|
||||
import { dateToTimeAgo, formatTime, stringToDate } from "../utils/utils.ts";
|
||||
|
||||
|
||||
12
frontend/src/utils/api/client.ts
Normal file
12
frontend/src/utils/api/client.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
class AuthError extends Error {
|
||||
constructor() { super("Not authenticated"); this.name = "AuthError"; }
|
||||
}
|
||||
|
||||
const isThumbnailAvailable = async (thumbnailUrl: string): Promise<boolean> => {
|
||||
const response = await fetch(thumbnailUrl, { credentials: 'include' });
|
||||
return response.ok;
|
||||
};
|
||||
|
||||
export { API_URL, AuthError, isThumbnailAvailable };
|
||||
67
frontend/src/utils/api/clips.ts
Normal file
67
frontend/src/utils/api/clips.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { APIResponse, Clip } from "../types.ts";
|
||||
import { API_URL, AuthError } from "./client.ts";
|
||||
|
||||
const getClips = async (): Promise<Clip[]> => {
|
||||
const response = await fetch(API_URL + '/api/v1/clips', { credentials: 'include' });
|
||||
|
||||
if (!response.ok) {
|
||||
const errorResult: APIResponse = await response.json();
|
||||
throw new Error(`Failed to fetch clips: ${errorResult.message}`);
|
||||
}
|
||||
|
||||
const result: APIResponse = await response.json();
|
||||
return result.data;
|
||||
};
|
||||
|
||||
const getClipById = async (id: string): Promise<Clip | null> => {
|
||||
const response = await fetch(API_URL + `/api/v1/clips/${id}`, { credentials: 'include' });
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 403) throw new AuthError();
|
||||
throw new Error(`Failed to fetch clip: ${response.status}`);
|
||||
}
|
||||
|
||||
const result: APIResponse = await response.json();
|
||||
return result.data;
|
||||
};
|
||||
|
||||
const getVideoBlob = async (id: string): Promise<Blob> => {
|
||||
const response = await fetch(API_URL + `/api/v1/clips/${id}/media`, { credentials: 'include' });
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 403) throw new AuthError();
|
||||
throw new Error(`Failed to fetch video ${id}: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
};
|
||||
|
||||
const patchClip = async (id: number, data: { title?: string; description?: string }): Promise<Clip> => {
|
||||
const response = await fetch(API_URL + `/api/v1/clips/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`Failed to update clip: ${response.status}`);
|
||||
|
||||
const result: APIResponse = await response.json();
|
||||
if (result.status === 'error') throw new Error(`Failed to update clip: ${result.message}`);
|
||||
|
||||
return result.data;
|
||||
};
|
||||
|
||||
const deleteClip = async (id: number): Promise<void> => {
|
||||
const response = await fetch(API_URL + `/api/v1/clips/${id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`Failed to delete clip: ${response.status}`);
|
||||
|
||||
const result: APIResponse = await response.json();
|
||||
if (result.status === 'error') throw new Error(`Failed to delete clip: ${result.message}`);
|
||||
};
|
||||
|
||||
export { getClips, getClipById, getVideoBlob, patchClip, deleteClip };
|
||||
15
frontend/src/utils/api/jobs.ts
Normal file
15
frontend/src/utils/api/jobs.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { APIResponse, JobResponse } from "../types.ts";
|
||||
import { API_URL } from "./client.ts";
|
||||
|
||||
const getJob = async (uuid: string): Promise<JobResponse> => {
|
||||
const response = await fetch(API_URL + `/api/v1/jobs/${uuid}`, { credentials: 'include' });
|
||||
|
||||
if (!response.ok) throw new Error(`Failed to fetch job: ${response.status}`);
|
||||
|
||||
const result: APIResponse = await response.json();
|
||||
if (result.status === 'error') throw new Error(`Failed to fetch job: ${result.message}`);
|
||||
|
||||
return result.data;
|
||||
};
|
||||
|
||||
export { getJob };
|
||||
40
frontend/src/utils/api/markers.ts
Normal file
40
frontend/src/utils/api/markers.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { APIResponse, Marker } from "../types.ts";
|
||||
import { API_URL } from "./client.ts";
|
||||
|
||||
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 deleteMarker = async (id: number): Promise<void> => {
|
||||
const response = await fetch(API_URL + `/api/v1/markers/${id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`Failed to delete marker: ${response.status}`);
|
||||
|
||||
const result: APIResponse = await response.json();
|
||||
if (result.status === 'error') throw new Error(`Failed to delete marker: ${result.message}`);
|
||||
};
|
||||
|
||||
export { getMarkers, createMarker, deleteMarker };
|
||||
72
frontend/src/utils/api/media.ts
Normal file
72
frontend/src/utils/api/media.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { APIResponse, VideoMetadata } from "../types.ts";
|
||||
import { API_URL } from "./client.ts";
|
||||
|
||||
const compress = async (file: File, options: VideoMetadata): Promise<string> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('title', options.title);
|
||||
if (options.description) formData.append('description', options.description);
|
||||
formData.append('startPoint', options.startPoint.toString());
|
||||
formData.append('duration', options.duration.toString());
|
||||
if (options.fps) formData.append('fps', options.fps.toString());
|
||||
if (options.width) formData.append('width', options.width.toString());
|
||||
if (options.height) formData.append('height', options.height.toString());
|
||||
if (options.fileSize) formData.append('fileSize', options.fileSize.toString());
|
||||
|
||||
const response = await fetch(API_URL + '/api/v1/media/compress', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`Compression failed: ${response.status}`);
|
||||
|
||||
const result: APIResponse = await response.json();
|
||||
if (result.status === 'error') throw new Error(`Compression failed: ${result.message}`);
|
||||
|
||||
return result.data.uuid;
|
||||
};
|
||||
|
||||
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 { compress, saveSection, saveSectionByMarkers, clipSection };
|
||||
31
frontend/src/utils/api/stream.ts
Normal file
31
frontend/src/utils/api/stream.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { APIResponse, StreamHistoryItem, StreamStatus } from "../types.ts";
|
||||
import { API_URL } from "./client.ts";
|
||||
|
||||
const getStreamStatus = async (): Promise<boolean> => {
|
||||
const response = await fetch(API_URL + '/api/v1/stream/current', { credentials: 'include' });
|
||||
if (!response.ok) return false;
|
||||
const result: APIResponse = await response.json();
|
||||
return result.status === 'success' && result.data?.isStreaming === true;
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
export { getStreamStatus, getCurrentStream, getStreamHistory };
|
||||
42
frontend/src/utils/api/users.ts
Normal file
42
frontend/src/utils/api/users.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { APIResponse, User } from "../types.ts";
|
||||
import { API_URL } from "./client.ts";
|
||||
|
||||
const login = async (googleToken: string): Promise<string> => {
|
||||
const response = await fetch(API_URL + '/api/v1/users/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: googleToken }),
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`Login failed: ${response.status}`);
|
||||
|
||||
const result: APIResponse = await response.json();
|
||||
if (result.status === 'error') throw new Error(`Login failed: ${result.message}`);
|
||||
|
||||
return result.data.token;
|
||||
};
|
||||
|
||||
const logout = async (): Promise<void> => {
|
||||
const response = await fetch(API_URL + '/api/v1/users/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`Logout failed: ${response.status}`);
|
||||
|
||||
const result: APIResponse = await response.json();
|
||||
if (result.status === 'error') throw new Error(`Logout failed: ${result.message}`);
|
||||
};
|
||||
|
||||
const getUser = async (): Promise<User | null> => {
|
||||
const response = await fetch(API_URL + '/api/v1/users/me', { credentials: 'include' });
|
||||
if (!response.ok) return null;
|
||||
|
||||
const result: APIResponse = await response.json();
|
||||
if (result.status === 'error') return null;
|
||||
|
||||
return result.data;
|
||||
};
|
||||
|
||||
export { login, logout, getUser };
|
||||
67
frontend/src/utils/api/vods.ts
Normal file
67
frontend/src/utils/api/vods.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { APIResponse, Vod } from "../types.ts";
|
||||
import { API_URL, AuthError } from "./client.ts";
|
||||
|
||||
const getVods = async (): Promise<Vod[]> => {
|
||||
const response = await fetch(API_URL + '/api/v1/vods', { credentials: 'include' });
|
||||
|
||||
if (!response.ok) {
|
||||
const errorResult: APIResponse = await response.json();
|
||||
throw new Error(`Failed to fetch vods: ${errorResult.message}`);
|
||||
}
|
||||
|
||||
const result: APIResponse = await response.json();
|
||||
return result.data;
|
||||
};
|
||||
|
||||
const getVodById = async (id: string): Promise<Vod | null> => {
|
||||
const response = await fetch(API_URL + `/api/v1/vods/${id}`, { credentials: 'include' });
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 403) throw new AuthError();
|
||||
throw new Error(`Failed to fetch vod: ${response.status}`);
|
||||
}
|
||||
|
||||
const result: APIResponse = await response.json();
|
||||
return result.data;
|
||||
};
|
||||
|
||||
const getVodBlob = async (id: string): Promise<Blob> => {
|
||||
const response = await fetch(API_URL + `/api/v1/vods/${id}/media`, { credentials: 'include' });
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 403) throw new AuthError();
|
||||
throw new Error(`Failed to fetch vod ${id}: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
};
|
||||
|
||||
const patchVod = async (id: number, data: { title?: string; description?: string }): Promise<Vod> => {
|
||||
const response = await fetch(API_URL + `/api/v1/vods/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`Failed to update vod: ${response.status}`);
|
||||
|
||||
const result: APIResponse = await response.json();
|
||||
if (result.status === 'error') throw new Error(`Failed to update vod: ${result.message}`);
|
||||
|
||||
return result.data;
|
||||
};
|
||||
|
||||
const deleteVod = async (id: number): Promise<void> => {
|
||||
const response = await fetch(API_URL + `/api/v1/vods/${id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`Failed to delete vod: ${response.status}`);
|
||||
|
||||
const result: APIResponse = await response.json();
|
||||
if (result.status === 'error') throw new Error(`Failed to delete vod: ${result.message}`);
|
||||
};
|
||||
|
||||
export { getVods, getVodById, getVodBlob, patchVod, deleteVod };
|
||||
@@ -1,240 +0,0 @@
|
||||
import type { VideoMetadata, APIResponse, User, Clip, Vod, JobResponse } from "./types.ts";
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
export class AuthError extends Error {
|
||||
constructor() { super("Not authenticated"); this.name = "AuthError"; }
|
||||
}
|
||||
|
||||
const login = async (googleToken: string): Promise<string> => {
|
||||
const response = await fetch(API_URL + '/api/v1/users/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: googleToken }),
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`Login failed: ${response.status}`);
|
||||
|
||||
const result: APIResponse = await response.json();
|
||||
if (result.status === 'error') throw new Error(`Login failed: ${result.message}`);
|
||||
|
||||
return result.data.token;
|
||||
};
|
||||
|
||||
const logout = async (): Promise<void> => {
|
||||
const response = await fetch(API_URL + '/api/v1/users/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`Logout failed: ${response.status}`);
|
||||
|
||||
const result: APIResponse = await response.json();
|
||||
if (result.status === 'error') throw new Error(`Logout failed: ${result.message}`);
|
||||
};
|
||||
|
||||
const compress = async (file: File, options: VideoMetadata): Promise<string> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('title', options.title);
|
||||
if (options.description) formData.append('description', options.description);
|
||||
formData.append('startPoint', options.startPoint.toString());
|
||||
formData.append('duration', options.duration.toString());
|
||||
if (options.fps) formData.append('fps', options.fps.toString());
|
||||
if (options.width) formData.append('width', options.width.toString());
|
||||
if (options.height) formData.append('height', options.height.toString());
|
||||
if (options.fileSize) formData.append('fileSize', options.fileSize.toString());
|
||||
|
||||
const response = await fetch(API_URL + '/api/v1/media/compress', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`Compression failed: ${response.status}`);
|
||||
|
||||
const result: APIResponse = await response.json();
|
||||
if (result.status === 'error') throw new Error(`Compression failed: ${result.message}`);
|
||||
|
||||
return result.data.uuid;
|
||||
};
|
||||
|
||||
const getJob = async (uuid: string): Promise<JobResponse> => {
|
||||
const response = await fetch(API_URL + `/api/v1/jobs/${uuid}`, { credentials: 'include' });
|
||||
|
||||
if (!response.ok) throw new Error(`Failed to fetch job: ${response.status}`);
|
||||
|
||||
const result: APIResponse = await response.json();
|
||||
if (result.status === 'error') throw new Error(`Failed to fetch job: ${result.message}`);
|
||||
|
||||
return result.data;
|
||||
};
|
||||
|
||||
const getUser = async (): Promise<User | null> => {
|
||||
const response = await fetch(API_URL + '/api/v1/users/me', { credentials: 'include' });
|
||||
if (!response.ok) return null;
|
||||
|
||||
const result: APIResponse = await response.json();
|
||||
if (result.status === 'error') return null;
|
||||
|
||||
return result.data;
|
||||
};
|
||||
|
||||
const getClips = async (): Promise<Clip[]> => {
|
||||
const response = await fetch(API_URL + '/api/v1/clips', { credentials: 'include' });
|
||||
|
||||
if (!response.ok) {
|
||||
const errorResult: APIResponse = await response.json();
|
||||
throw new Error(`Failed to fetch clips: ${errorResult.message}`);
|
||||
}
|
||||
|
||||
const result: APIResponse = await response.json();
|
||||
return result.data;
|
||||
};
|
||||
|
||||
const getClipById = async (id: string): Promise<Clip | null> => {
|
||||
const response = await fetch(API_URL + `/api/v1/clips/${id}`, { credentials: 'include' });
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 403) throw new AuthError();
|
||||
throw new Error(`Failed to fetch clip: ${response.status}`);
|
||||
}
|
||||
|
||||
const result: APIResponse = await response.json();
|
||||
return result.data;
|
||||
};
|
||||
|
||||
const getVideoBlob = async (id: string): Promise<Blob> => {
|
||||
const response = await fetch(API_URL + `/api/v1/clips/${id}/media`, { credentials: 'include' });
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 403) throw new AuthError();
|
||||
throw new Error(`Failed to fetch video ${id}: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
};
|
||||
|
||||
const patchClip = async (id: number, data: { title?: string; description?: string }): Promise<Clip> => {
|
||||
const response = await fetch(API_URL + `/api/v1/clips/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`Failed to update clip: ${response.status}`);
|
||||
|
||||
const result: APIResponse = await response.json();
|
||||
if (result.status === 'error') throw new Error(`Failed to update clip: ${result.message}`);
|
||||
|
||||
return result.data;
|
||||
};
|
||||
|
||||
const deleteClip = async (id: number): Promise<void> => {
|
||||
const response = await fetch(API_URL + `/api/v1/clips/${id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`Failed to delete clip: ${response.status}`);
|
||||
|
||||
const result: APIResponse = await response.json();
|
||||
if (result.status === 'error') throw new Error(`Failed to delete clip: ${result.message}`);
|
||||
};
|
||||
|
||||
const getVods = async (): Promise<Vod[]> => {
|
||||
const response = await fetch(API_URL + '/api/v1/vods', { credentials: 'include' });
|
||||
|
||||
if (!response.ok) {
|
||||
const errorResult: APIResponse = await response.json();
|
||||
throw new Error(`Failed to fetch vods: ${errorResult.message}`);
|
||||
}
|
||||
|
||||
const result: APIResponse = await response.json();
|
||||
return result.data;
|
||||
};
|
||||
|
||||
const getVodById = async (id: string): Promise<Vod | null> => {
|
||||
const response = await fetch(API_URL + `/api/v1/vods/${id}`, { credentials: 'include' });
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 403) throw new AuthError();
|
||||
throw new Error(`Failed to fetch vod: ${response.status}`);
|
||||
}
|
||||
|
||||
const result: APIResponse = await response.json();
|
||||
return result.data;
|
||||
};
|
||||
|
||||
const getVodBlob = async (id: string): Promise<Blob> => {
|
||||
const response = await fetch(API_URL + `/api/v1/vods/${id}/media`, { credentials: 'include' });
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 403) throw new AuthError();
|
||||
throw new Error(`Failed to fetch vod ${id}: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
};
|
||||
|
||||
const patchVod = async (id: number, data: { title?: string; description?: string }): Promise<Vod> => {
|
||||
const response = await fetch(API_URL + `/api/v1/vods/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`Failed to update vod: ${response.status}`);
|
||||
|
||||
const result: APIResponse = await response.json();
|
||||
if (result.status === 'error') throw new Error(`Failed to update vod: ${result.message}`);
|
||||
|
||||
return result.data;
|
||||
};
|
||||
|
||||
const deleteVod = async (id: number): Promise<void> => {
|
||||
const response = await fetch(API_URL + `/api/v1/vods/${id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`Failed to delete vod: ${response.status}`);
|
||||
|
||||
const result: APIResponse = await response.json();
|
||||
if (result.status === 'error') throw new Error(`Failed to delete vod: ${result.message}`);
|
||||
};
|
||||
|
||||
const getStreamStatus = async (): Promise<boolean> => {
|
||||
const response = await fetch(API_URL + '/api/v1/stream/current', { credentials: 'include' });
|
||||
if (!response.ok) return false;
|
||||
const result: APIResponse = await response.json();
|
||||
return result.status === 'success' && result.data?.isStreaming === true;
|
||||
};
|
||||
|
||||
const isThumbnailAvailable = async (thumbnailUrl: string): Promise<boolean> => {
|
||||
const response = await fetch(thumbnailUrl, { credentials: 'include' });
|
||||
return response.ok;
|
||||
};
|
||||
|
||||
export {
|
||||
login,
|
||||
logout,
|
||||
compress,
|
||||
getJob,
|
||||
getUser,
|
||||
getClips,
|
||||
getClipById,
|
||||
getVideoBlob,
|
||||
patchClip,
|
||||
deleteClip,
|
||||
getVods,
|
||||
getVodById,
|
||||
getVodBlob,
|
||||
patchVod,
|
||||
deleteVod,
|
||||
getStreamStatus,
|
||||
isThumbnailAvailable,
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
@@ -51,6 +51,15 @@ public class MarkerController {
|
||||
);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<APIResponse<String>> deleteMarker(@PathVariable Long id) {
|
||||
markerService.deleteMarker(id);
|
||||
|
||||
return ResponseEntity.ok(
|
||||
new APIResponse<>(SUCCESS, "Marker deleted successfully", "Marker " + id + " deleted.")
|
||||
);
|
||||
}
|
||||
|
||||
private MarkerResponse convertToDTO(Marker marker) {
|
||||
return new MarkerResponse(
|
||||
marker.getId(),
|
||||
|
||||
@@ -72,6 +72,11 @@ public class MarkerService {
|
||||
return markerRepository.saveAndFlush(marker);
|
||||
}
|
||||
|
||||
public void deleteMarker(Long id) {
|
||||
Marker marker = getMarkerById(id);
|
||||
markerRepository.delete(marker);
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelay = 360_000)
|
||||
@Transactional
|
||||
public void deleteOldMarkers() {
|
||||
|
||||
Reference in New Issue
Block a user