feat(frontend): implement vods in frontend
This commit is contained in:
108
frontend/src/pages/EditVod.tsx
Normal file
108
frontend/src/pages/EditVod.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
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 Box from "../components/Box.tsx";
|
||||||
|
import BlueButton from "../components/buttons/BlueButton.tsx";
|
||||||
|
|
||||||
|
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 EditVod = () => {
|
||||||
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return;
|
||||||
|
getVodById(id)
|
||||||
|
.then((vod) => {
|
||||||
|
if (vod) {
|
||||||
|
setTitle(vod.title ?? "");
|
||||||
|
setDescription(vod.description ?? "");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => setError("Failed to load vod."))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!id) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await patchVod(Number(id), { title, description });
|
||||||
|
setSaved(true);
|
||||||
|
setTimeout(() => setSaved(false), 2000);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to save.");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-8 py-10 max-w-2xl mx-auto">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 mb-6 transition-colors duration-150"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={16} />
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h1 className="text-2xl font-semibold text-gray-900 mb-6">Edit VoD</h1>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-gray-400 text-sm">Loading...</div>
|
||||||
|
) : (
|
||||||
|
<Box className="p-6 flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-sm font-medium text-gray-700">Title</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Enter title"
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-sm font-medium text-gray-700">Description</label>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Enter description"
|
||||||
|
rows={4}
|
||||||
|
className={inputClass + " resize-none"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 pt-1">
|
||||||
|
<BlueButton onClick={handleSave} disabled={saving} className="w-28">
|
||||||
|
{saving ? "Saving…" : saved
|
||||||
|
? <span className="flex items-center gap-1.5 justify-center"><Check size={14} />Saved</span>
|
||||||
|
: "Save"}
|
||||||
|
</BlueButton>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
className="text-sm text-gray-500 hover:text-gray-900 transition-colors duration-150"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditVod;
|
||||||
60
frontend/src/pages/MyVods.tsx
Normal file
60
frontend/src/pages/MyVods.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import VideoCard from "../components/video/VideoCard";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { getVods, deleteVod } from "../utils/endpoints";
|
||||||
|
import type { Vod } from "../utils/types";
|
||||||
|
|
||||||
|
const MyVods = () => {
|
||||||
|
const [vods, setVods] = useState<Vod[]>([]);
|
||||||
|
const [error, setError] = useState<null | string>(null);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getVods()
|
||||||
|
.then((data) => setVods(data.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())))
|
||||||
|
.catch((err) => setError(err instanceof Error ? err.message : "Failed to load vods"));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
try {
|
||||||
|
await deleteVod(id);
|
||||||
|
setVods(prev => prev.filter(v => v.id !== id));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to delete vod");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<h1 className="text-2xl font-semibold text-gray-900 mb-6">My VoDs</h1>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||||
|
{vods.map((vod) => (
|
||||||
|
<VideoCard
|
||||||
|
key={vod.id}
|
||||||
|
id={vod.id}
|
||||||
|
title={vod.title}
|
||||||
|
duration={vod.duration}
|
||||||
|
createdAt={vod.createdAt}
|
||||||
|
mediaApiPath="/api/v1/vods"
|
||||||
|
playerPath="/vod"
|
||||||
|
itemLabel="vod"
|
||||||
|
onEdit={() => navigate(`/vods/${vod.id}/edit`)}
|
||||||
|
onDelete={() => handleDelete(vod.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{vods.length === 0 && !error && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
|
||||||
|
<p className="text-base">No VoDs yet.</p>
|
||||||
|
<p className="text-sm mt-1">Your stream recordings will appear here.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-red-500 mt-4">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MyVods;
|
||||||
310
frontend/src/pages/VodPlayer.tsx
Normal file
310
frontend/src/pages/VodPlayer.tsx
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
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 Box from "../components/Box.tsx";
|
||||||
|
import { dateToTimeAgo, formatTime, stringToDate } from "../utils/utils.ts";
|
||||||
|
|
||||||
|
const VodPlayer = () => {
|
||||||
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const progressRef = useRef<HTMLDivElement>(null);
|
||||||
|
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const [videoUrl, setVideoUrl] = useState<string | undefined>(undefined);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [notAuthenticated, setNotAuthenticated] = useState(false);
|
||||||
|
const [vod, setVod] = useState<Vod | null>(null);
|
||||||
|
const [timeAgo, setTimeAgo] = useState("");
|
||||||
|
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
const [bufferedPct, setBufferedPct] = useState(0);
|
||||||
|
const [isWaiting, setIsWaiting] = useState(false);
|
||||||
|
|
||||||
|
const [volume, setVolume] = useState(1);
|
||||||
|
const [isMuted, setIsMuted] = useState(false);
|
||||||
|
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
const [controlsVisible, setControlsVisible] = useState(true);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) { setError("VoD ID is required."); return; }
|
||||||
|
|
||||||
|
getVodBlob(id)
|
||||||
|
.then((blob) => setVideoUrl(URL.createObjectURL(blob)))
|
||||||
|
.catch((e) => { if (e instanceof AuthError) setNotAuthenticated(true); });
|
||||||
|
|
||||||
|
getVodById(id)
|
||||||
|
.then(setVod)
|
||||||
|
.catch((e) => {
|
||||||
|
if (e instanceof AuthError) setNotAuthenticated(true);
|
||||||
|
else setError("Failed to load vod details.");
|
||||||
|
});
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!vod?.createdAt) return;
|
||||||
|
const update = () => setTimeAgo(dateToTimeAgo(stringToDate(vod.createdAt)));
|
||||||
|
update();
|
||||||
|
const interval = setInterval(update, 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [vod]);
|
||||||
|
|
||||||
|
const scheduleHide = useCallback(() => {
|
||||||
|
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
|
||||||
|
hideTimerRef.current = setTimeout(() => setControlsVisible(false), 3000);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const showControls = useCallback(() => {
|
||||||
|
setControlsVisible(true);
|
||||||
|
scheduleHide();
|
||||||
|
}, [scheduleHide]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onChange = () => setIsFullscreen(!!document.fullscreenElement);
|
||||||
|
document.addEventListener('fullscreenchange', onChange);
|
||||||
|
return () => document.removeEventListener('fullscreenchange', onChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleFullscreen = useCallback(() => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
document.fullscreenElement
|
||||||
|
? document.exitFullscreen()
|
||||||
|
: containerRef.current.requestFullscreen();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
|
||||||
|
switch (e.code) {
|
||||||
|
case 'Space': case 'KeyK':
|
||||||
|
e.preventDefault();
|
||||||
|
video.paused ? video.play() : video.pause();
|
||||||
|
showControls();
|
||||||
|
break;
|
||||||
|
case 'ArrowLeft':
|
||||||
|
e.preventDefault();
|
||||||
|
video.currentTime = Math.max(0, video.currentTime - 5);
|
||||||
|
showControls();
|
||||||
|
break;
|
||||||
|
case 'ArrowRight':
|
||||||
|
e.preventDefault();
|
||||||
|
video.currentTime = Math.min(video.duration, video.currentTime + 5);
|
||||||
|
showControls();
|
||||||
|
break;
|
||||||
|
case 'KeyM':
|
||||||
|
video.muted = !video.muted;
|
||||||
|
setIsMuted(video.muted);
|
||||||
|
break;
|
||||||
|
case 'KeyF':
|
||||||
|
toggleFullscreen();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
return () => document.removeEventListener('keydown', onKey);
|
||||||
|
}, [showControls, toggleFullscreen]);
|
||||||
|
|
||||||
|
const seekTo = useCallback((clientX: number) => {
|
||||||
|
const rect = progressRef.current?.getBoundingClientRect();
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!rect || !video || !duration) return;
|
||||||
|
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
||||||
|
video.currentTime = ratio * duration;
|
||||||
|
setCurrentTime(ratio * duration);
|
||||||
|
}, [duration]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDragging) return;
|
||||||
|
const onMove = (e: MouseEvent) => seekTo(e.clientX);
|
||||||
|
const onUp = () => setIsDragging(false);
|
||||||
|
document.addEventListener('mousemove', onMove);
|
||||||
|
document.addEventListener('mouseup', onUp);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousemove', onMove);
|
||||||
|
document.removeEventListener('mouseup', onUp);
|
||||||
|
};
|
||||||
|
}, [isDragging, seekTo]);
|
||||||
|
|
||||||
|
const handleTimeUpdate = () => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
setCurrentTime(video.currentTime);
|
||||||
|
if (video.buffered.length > 0) {
|
||||||
|
setBufferedPct((video.buffered.end(video.buffered.length - 1) / video.duration) * 100);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const v = parseFloat(e.target.value);
|
||||||
|
setVolume(v);
|
||||||
|
setIsMuted(v === 0);
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.volume = v;
|
||||||
|
videoRef.current.muted = v === 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMute = () => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
video.muted = !video.muted;
|
||||||
|
setIsMuted(video.muted);
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePlay = () => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
video.paused ? video.play() : video.pause();
|
||||||
|
showControls();
|
||||||
|
};
|
||||||
|
|
||||||
|
const progressPct = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||||||
|
const effectiveVolume = isMuted ? 0 : volume;
|
||||||
|
const VolumeIcon = effectiveVolume === 0 ? VolumeX : effectiveVolume < 0.5 ? Volume1 : Volume2;
|
||||||
|
|
||||||
|
if (notAuthenticated) {
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col items-center justify-center gap-4 text-center p-8">
|
||||||
|
<div className="w-14 h-14 rounded-full bg-gray-100 flex items-center justify-center">
|
||||||
|
<Lock size={24} className="text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-lg font-semibold text-gray-900">Not authenticated</p>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">You don't have access to this VoD.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 transition-colors duration-150"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={16} />
|
||||||
|
Go back
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col p-4 gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 flex-shrink-0 transition-colors duration-150"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={16} />
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="relative bg-black rounded-xl overflow-hidden flex-1 min-h-0 select-none"
|
||||||
|
onMouseMove={showControls}
|
||||||
|
onMouseLeave={() => isPlaying && setControlsVisible(false)}
|
||||||
|
onClick={togglePlay}
|
||||||
|
style={{ cursor: controlsVisible ? 'default' : 'none' }}
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
className="w-full h-full object-contain"
|
||||||
|
src={videoUrl}
|
||||||
|
onPlay={() => { setIsPlaying(true); scheduleHide(); }}
|
||||||
|
onPause={() => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
setControlsVisible(true);
|
||||||
|
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
|
||||||
|
}}
|
||||||
|
onTimeUpdate={handleTimeUpdate}
|
||||||
|
onLoadedMetadata={() => { if (videoRef.current) setDuration(videoRef.current.duration); }}
|
||||||
|
onWaiting={() => setIsWaiting(true)}
|
||||||
|
onCanPlay={() => setIsWaiting(false)}
|
||||||
|
onError={(e) => setError(e.currentTarget.error?.message || "An error occurred.")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isWaiting && videoUrl && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||||
|
<div className="w-11 h-11 rounded-full border-[3px] border-white/20 border-t-white animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={`absolute inset-0 flex flex-col justify-end transition-opacity duration-300 pointer-events-none ${controlsVisible ? 'opacity-100' : 'opacity-0'}`}>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/75 via-black/10 to-transparent" />
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="relative px-4 pb-4 pt-10 pointer-events-auto"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={progressRef}
|
||||||
|
className="relative h-1 rounded-full bg-white/25 cursor-pointer mb-3 group/prog hover:h-1.5 transition-[height] duration-150"
|
||||||
|
onMouseDown={(e) => { setIsDragging(true); seekTo(e.clientX); }}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-y-0 left-0 bg-white/35 rounded-full" style={{ width: `${bufferedPct}%` }} />
|
||||||
|
<div className="absolute inset-y-0 left-0 bg-primary rounded-full" style={{ width: `${progressPct}%` }} />
|
||||||
|
<div
|
||||||
|
className="absolute top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full shadow opacity-0 group-hover/prog:opacity-100 transition-opacity"
|
||||||
|
style={{ left: `calc(${progressPct}% - 6px)` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 text-white">
|
||||||
|
<button onClick={togglePlay} className="hover:text-white/70 transition-colors">
|
||||||
|
{isPlaying
|
||||||
|
? <Pause size={20} fill="currentColor" />
|
||||||
|
: <Play size={20} fill="currentColor" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5 group/vol">
|
||||||
|
<button onClick={toggleMute} className="hover:text-white/70 transition-colors">
|
||||||
|
<VolumeIcon size={18} />
|
||||||
|
</button>
|
||||||
|
<div className="overflow-hidden w-0 group-hover/vol:w-16 transition-[width] duration-200">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0} max={1} step={0.02}
|
||||||
|
value={effectiveVolume}
|
||||||
|
onChange={handleVolumeChange}
|
||||||
|
className="w-16 accent-white cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="text-xs font-mono tabular-nums text-white/80">
|
||||||
|
{formatTime(currentTime)} / {formatTime(duration)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
<button onClick={toggleFullscreen} className="hover:text-white/70 transition-colors">
|
||||||
|
{isFullscreen ? <Minimize size={18} /> : <Maximize size={18} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="text-red-500 text-sm mt-3">{error}</div>}
|
||||||
|
{!videoUrl && !error && (
|
||||||
|
<div className="text-gray-400 text-sm mt-3 flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 rounded-full border-2 border-gray-300 border-t-gray-500 animate-spin" />
|
||||||
|
Loading video...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box className="p-4 flex-shrink-0">
|
||||||
|
<p className="text-xl font-semibold text-gray-900">{vod?.title || "(No Title)"}</p>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">{timeAgo}</p>
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VodPlayer;
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package com.ddf.vodsystem.controllers;
|
||||||
|
|
||||||
|
import com.ddf.vodsystem.controllers.dto.VodResponse;
|
||||||
|
import com.ddf.vodsystem.controllers.dto.VodUpdateRequest;
|
||||||
|
import com.ddf.vodsystem.dto.APIResponse;
|
||||||
|
import com.ddf.vodsystem.entities.Vod;
|
||||||
|
import com.ddf.vodsystem.services.VodService;
|
||||||
|
import org.springframework.core.io.Resource;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.MediaTypeFactory;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/vods")
|
||||||
|
public class VodController {
|
||||||
|
private final VodService vodService;
|
||||||
|
private static final String SUCCESS = "success";
|
||||||
|
private static final String FILENAME_HEADER = "inline; filename=\"%s\"";
|
||||||
|
|
||||||
|
public VodController(VodService vodService) {
|
||||||
|
this.vodService = vodService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("")
|
||||||
|
public ResponseEntity<APIResponse<List<VodResponse>>> getVods() {
|
||||||
|
List<VodResponse> vods = vodService.getUserVods().stream()
|
||||||
|
.map(this::convertToDTO)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Vods retrieved successfully", vods));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ResponseEntity<APIResponse<VodResponse>> getVodById(@PathVariable Long id) {
|
||||||
|
VodResponse vod = convertToDTO(vodService.getVodById(id));
|
||||||
|
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Vod retrieved successfully", vod));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PatchMapping("/{id}")
|
||||||
|
public ResponseEntity<APIResponse<VodResponse>> updateVod(@PathVariable Long id,
|
||||||
|
@RequestBody VodUpdateRequest updateFields) {
|
||||||
|
VodResponse vod = convertToDTO(vodService.updateVod(id, updateFields));
|
||||||
|
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Vod updated successfully", vod));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public ResponseEntity<APIResponse<String>> deleteVod(@PathVariable Long id) {
|
||||||
|
vodService.deleteVod(id);
|
||||||
|
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Vod deleted successfully",
|
||||||
|
"Vod with ID " + id + " has been deleted"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}/media")
|
||||||
|
public ResponseEntity<Resource> downloadVod(@PathVariable Long id) {
|
||||||
|
Resource resource = vodService.downloadVod(id);
|
||||||
|
|
||||||
|
if (resource == null || !resource.exists()) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
|
||||||
|
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
|
||||||
|
.body(resource);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}/thumbnail")
|
||||||
|
public ResponseEntity<Resource> downloadThumbnail(@PathVariable Long id) {
|
||||||
|
Resource resource = vodService.downloadThumbnail(id);
|
||||||
|
|
||||||
|
if (resource == null || !resource.exists()) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
|
||||||
|
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
|
||||||
|
.body(resource);
|
||||||
|
}
|
||||||
|
|
||||||
|
private VodResponse convertToDTO(Vod vod) {
|
||||||
|
return new VodResponse(
|
||||||
|
vod.getId(),
|
||||||
|
vod.getUser().getId(),
|
||||||
|
vod.getTitle(),
|
||||||
|
vod.getDescription(),
|
||||||
|
vod.getDuration(),
|
||||||
|
vod.getCreatedAt()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.ddf.vodsystem.controllers.dto;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
public record VodResponse(
|
||||||
|
Long id,
|
||||||
|
Long userId,
|
||||||
|
String title,
|
||||||
|
String description,
|
||||||
|
Float duration,
|
||||||
|
Instant createdAt
|
||||||
|
) {}
|
||||||
Reference in New Issue
Block a user