Merge pull request 'Frontend page for controlling streaming actions' (#7) from feat/frontend-streaming-controls into master
Reviewed-on: https://gitea.vodsystem.ddns.net/dylan/vod-system/pulls/7
This commit was merged in pull request #7.
This commit is contained in:
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 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>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Menu, X } from 'lucide-react';
|
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 { Dropdown, DropdownItem } from "./Dropdown.tsx";
|
||||||
import { GoogleLogin } from '@react-oauth/google';
|
import { GoogleLogin } from '@react-oauth/google';
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import clsx from "clsx";
|
|||||||
import { formatTime, stringToDate, dateToTimeAgo } from "../../utils/utils.ts";
|
import { formatTime, stringToDate, dateToTimeAgo } from "../../utils/utils.ts";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useEffect, useRef, useState } from "react";
|
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";
|
import { MoreVertical, Pencil, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
type VideoCardProps = {
|
type VideoCardProps = {
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import Topbar from '../components/Topbar'
|
|||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet } from 'react-router-dom';
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import type {User} from "../utils/types";
|
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 MainLayout = () => {
|
||||||
const [sidebarToggled, setSidebarToggled] = useState(false);
|
const [sidebarToggled, setSidebarToggled] = useState(false);
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import PlaybackSlider from "./../components/video/PlaybackSlider";
|
|||||||
import ClipRangeSlider from "./../components/video/ClipRangeSlider";
|
import ClipRangeSlider from "./../components/video/ClipRangeSlider";
|
||||||
import ConfigBox from "../components/video/ConfigBox.tsx";
|
import ConfigBox from "../components/video/ConfigBox.tsx";
|
||||||
import ExportWidget from "../components/video/ExportWidget.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 type { VideoMetadata } from "../utils/types.ts";
|
||||||
import Box from "../components/Box.tsx";
|
import Box from "../components/Box.tsx";
|
||||||
import MetadataBox from "../components/video/MetadataBox.tsx";
|
import MetadataBox from "../components/video/MetadataBox.tsx";
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { ArrowLeft, Check } from "lucide-react";
|
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 Box from "../components/Box.tsx";
|
||||||
import BlueButton from "../components/buttons/BlueButton.tsx";
|
import BlueButton from "../components/buttons/BlueButton.tsx";
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { ArrowLeft, Check } from "lucide-react";
|
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 Box from "../components/Box.tsx";
|
||||||
import BlueButton from "../components/buttons/BlueButton.tsx";
|
import BlueButton from "../components/buttons/BlueButton.tsx";
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import VideoCard from "../components/video/VideoCard";
|
import VideoCard from "../components/video/VideoCard";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
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";
|
import type { Clip } from "../utils/types";
|
||||||
|
|
||||||
const MyClips = () => {
|
const MyClips = () => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import VideoCard from "../components/video/VideoCard";
|
import VideoCard from "../components/video/VideoCard";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
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";
|
import type { Vod } from "../utils/types";
|
||||||
|
|
||||||
const MyVods = () => {
|
const MyVods = () => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Eye, EyeOff, Copy, Check } from "lucide-react";
|
import { Eye, EyeOff, Copy, Check } from "lucide-react";
|
||||||
import Box from "../components/Box.tsx";
|
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 type { User } from "../utils/types.ts";
|
||||||
import { formatLocalDate } from "../utils/utils.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 { useNavigate, useParams } from "react-router-dom";
|
||||||
import { ArrowLeft, Lock, Maximize, Minimize, Pause, Play, Volume1, Volume2, VolumeX } from "lucide-react";
|
import { ArrowLeft, Lock, Maximize, Minimize, Pause, Play, Volume1, Volume2, VolumeX } from "lucide-react";
|
||||||
import type { Clip } from "../utils/types";
|
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 Box from "../components/Box.tsx";
|
||||||
import { dateToTimeAgo, formatTime, stringToDate } from "../utils/utils.ts";
|
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 { useNavigate, useParams } from "react-router-dom";
|
||||||
import { ArrowLeft, Lock, Maximize, Minimize, Pause, Play, Volume1, Volume2, VolumeX } from "lucide-react";
|
import { ArrowLeft, Lock, Maximize, Minimize, Pause, Play, Volume1, Volume2, VolumeX } from "lucide-react";
|
||||||
import type { Vod } from "../utils/types";
|
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 Box from "../components/Box.tsx";
|
||||||
import { dateToTimeAgo, formatTime, stringToDate } from "../utils/utils.ts";
|
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 = {
|
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
|
||||||
}
|
}
|
||||||
@@ -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) {
|
private MarkerResponse convertToDTO(Marker marker) {
|
||||||
return new MarkerResponse(
|
return new MarkerResponse(
|
||||||
marker.getId(),
|
marker.getId(),
|
||||||
|
|||||||
@@ -72,6 +72,11 @@ public class MarkerService {
|
|||||||
return markerRepository.saveAndFlush(marker);
|
return markerRepository.saveAndFlush(marker);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void deleteMarker(Long id) {
|
||||||
|
Marker marker = getMarkerById(id);
|
||||||
|
markerRepository.delete(marker);
|
||||||
|
}
|
||||||
|
|
||||||
@Scheduled(fixedDelay = 360_000)
|
@Scheduled(fixedDelay = 360_000)
|
||||||
@Transactional
|
@Transactional
|
||||||
public void deleteOldMarkers() {
|
public void deleteOldMarkers() {
|
||||||
|
|||||||
Reference in New Issue
Block a user