feat(player): adjust frontend to use raw source

This commit is contained in:
2026-07-25 10:41:14 +01:00
parent 88cf2103f6
commit b0055277f3
4 changed files with 35 additions and 39 deletions

View File

@@ -8,8 +8,8 @@ import {useEffect} from "react";
import MyClips from './pages/MyClips'; import MyClips from './pages/MyClips';
import MyVods from './pages/MyVods'; import MyVods from './pages/MyVods';
import MediaPlayer from "./pages/MediaPlayer.tsx"; import MediaPlayer from "./pages/MediaPlayer.tsx";
import { getVodById, getVodBlob } from "./utils/api/vods.ts"; import { getVodById, getVodMediaUrl } from "./utils/api/vods.ts";
import { getClipById, getVideoBlob } from "./utils/api/clips.ts"; import { getClipById, getClipMediaUrl } from "./utils/api/clips.ts";
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";
@@ -32,8 +32,8 @@ function App() {
<Route path="/create/:id" element={<ClipEdit />} /> <Route path="/create/:id" element={<ClipEdit />} />
<Route path="/my-clips" element={<MyClips />} /> <Route path="/my-clips" element={<MyClips />} />
<Route path="/my-vods" element={<MyVods />} /> <Route path="/my-vods" element={<MyVods />} />
<Route path="/video/:id" element={<MediaPlayer noun="Clip" fetchBlob={getVideoBlob} fetchDetails={getClipById} />} /> <Route path="/video/:id" element={<MediaPlayer noun="Clip" getMediaUrl={getClipMediaUrl} fetchDetails={getClipById} />} />
<Route path="/vod/:id" element={<MediaPlayer noun="VoD" fetchBlob={getVodBlob} fetchDetails={getVodById} />} /> <Route path="/vod/:id" element={<MediaPlayer noun="VoD" getMediaUrl={getVodMediaUrl} fetchDetails={getVodById} />} />
<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 />} />

View File

@@ -14,13 +14,14 @@ type MediaItem = {
type MediaPlayerProps = { type MediaPlayerProps = {
/** Human-readable name for the media, e.g. "VoD" or "clip". Used in messages. */ /** Human-readable name for the media, e.g. "VoD" or "clip". Used in messages. */
noun: string, noun: string,
/** Fetches the raw video blob for the given id. */ /** Builds the raw media stream URL for the given id. Loaded directly by the
fetchBlob: (id: string) => Promise<Blob>, * <video> element so the browser can issue HTTP Range requests. */
getMediaUrl: (id: string) => string,
/** Fetches the media metadata for the given id. */ /** Fetches the media metadata for the given id. */
fetchDetails: (id: string) => Promise<MediaItem | null>, fetchDetails: (id: string) => Promise<MediaItem | null>,
}; };
const MediaPlayer = ({ noun, fetchBlob, fetchDetails }: MediaPlayerProps) => { const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
const { id } = useParams(); const { id } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -30,7 +31,8 @@ const MediaPlayer = ({ noun, fetchBlob, fetchDetails }: MediaPlayerProps) => {
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Data // Data
const [videoUrl, setVideoUrl] = useState<string | undefined>(undefined); const mediaUrl = id ? getMediaUrl(id) : undefined;
const [mediaLoaded, setMediaLoaded] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [notAuthenticated, setNotAuthenticated] = useState(false); const [notAuthenticated, setNotAuthenticated] = useState(false);
const [item, setItem] = useState<MediaItem | null>(null); const [item, setItem] = useState<MediaItem | null>(null);
@@ -53,20 +55,20 @@ const MediaPlayer = ({ noun, fetchBlob, fetchDetails }: MediaPlayerProps) => {
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
// ── Data loading ──────────────────────────────────────────────────────── // ── Data loading ────────────────────────────────────────────────────────
// The video stream itself is loaded by the <video> element from `mediaUrl`,
// which lets the browser fetch it incrementally via Range requests. We only
// need to fetch the metadata here; it shares the media endpoint's auth, so a
// 401/403 here also stands in for the stream being inaccessible.
useEffect(() => { useEffect(() => {
if (!id) { setError(`${noun} ID is required.`); return; } if (!id) { setError(`${noun} ID is required.`); return; }
fetchBlob(id)
.then((blob) => setVideoUrl(URL.createObjectURL(blob)))
.catch((e) => { if (e instanceof AuthError) setNotAuthenticated(true); });
fetchDetails(id) fetchDetails(id)
.then(setItem) .then(setItem)
.catch((e) => { .catch((e) => {
if (e instanceof AuthError) setNotAuthenticated(true); if (e instanceof AuthError) setNotAuthenticated(true);
else setError(`Failed to load ${noun.toLowerCase()} details.`); else setError(`Failed to load ${noun.toLowerCase()} details.`);
}); });
}, [id, noun, fetchBlob, fetchDetails]); }, [id, noun, fetchDetails]);
useEffect(() => { useEffect(() => {
if (!item?.createdAt) return; if (!item?.createdAt) return;
@@ -240,7 +242,10 @@ const MediaPlayer = ({ noun, fetchBlob, fetchDetails }: MediaPlayerProps) => {
<video <video
ref={videoRef} ref={videoRef}
className="w-full h-full object-contain" className="w-full h-full object-contain"
src={videoUrl} src={mediaUrl}
// Send auth cookies with the cross-origin Range requests.
crossOrigin="use-credentials"
preload="metadata"
onPlay={() => { setIsPlaying(true); scheduleHide(); }} onPlay={() => { setIsPlaying(true); scheduleHide(); }}
onPause={() => { onPause={() => {
setIsPlaying(false); setIsPlaying(false);
@@ -248,14 +253,17 @@ const MediaPlayer = ({ noun, fetchBlob, fetchDetails }: MediaPlayerProps) => {
if (hideTimerRef.current) clearTimeout(hideTimerRef.current); if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
}} }}
onTimeUpdate={handleTimeUpdate} onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={() => { if (videoRef.current) setDuration(videoRef.current.duration); }} onLoadedMetadata={() => {
if (videoRef.current) setDuration(videoRef.current.duration);
setMediaLoaded(true);
}}
onWaiting={() => setIsWaiting(true)} onWaiting={() => setIsWaiting(true)}
onCanPlay={() => setIsWaiting(false)} onCanPlay={() => setIsWaiting(false)}
onError={(e) => setError(e.currentTarget.error?.message || "An error occurred.")} onError={(e) => setError(e.currentTarget.error?.message || "An error occurred.")}
/> />
{/* Buffering spinner */} {/* Buffering spinner */}
{isWaiting && videoUrl && ( {isWaiting && mediaUrl && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none"> <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 className="w-11 h-11 rounded-full border-[3px] border-white/20 border-t-white animate-spin" />
</div> </div>
@@ -324,7 +332,7 @@ const MediaPlayer = ({ noun, fetchBlob, fetchDetails }: MediaPlayerProps) => {
</div> </div>
{error && <div className="text-red-500 text-sm mt-3">{error}</div>} {error && <div className="text-red-500 text-sm mt-3">{error}</div>}
{!videoUrl && !error && ( {!mediaLoaded && !error && (
<div className="text-gray-400 text-sm mt-3 flex items-center gap-2"> <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" /> <div className="w-3 h-3 rounded-full border-2 border-gray-300 border-t-gray-500 animate-spin" />
Loading video... Loading video...

View File

@@ -25,16 +25,10 @@ const getClipById = async (id: string): Promise<Clip | null> => {
return result.data; return result.data;
}; };
const getVideoBlob = async (id: string): Promise<Blob> => { // URL for the raw media stream. The <video> element loads this directly so the
const response = await fetch(API_URL + `/api/v1/clips/${id}/media`, { credentials: 'include' }); // browser can issue HTTP Range requests (progressive loading + seeking) instead
// of downloading the whole file up front.
if (!response.ok) { const getClipMediaUrl = (id: string): string => API_URL + `/api/v1/clips/${id}/media`;
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 patchClip = async (id: number, data: { title?: string; description?: string }): Promise<Clip> => {
const response = await fetch(API_URL + `/api/v1/clips/${id}`, { const response = await fetch(API_URL + `/api/v1/clips/${id}`, {
@@ -64,4 +58,4 @@ const deleteClip = async (id: number): Promise<void> => {
if (result.status === 'error') throw new Error(`Failed to delete clip: ${result.message}`); if (result.status === 'error') throw new Error(`Failed to delete clip: ${result.message}`);
}; };
export { getClips, getClipById, getVideoBlob, patchClip, deleteClip }; export { getClips, getClipById, getClipMediaUrl, patchClip, deleteClip };

View File

@@ -25,16 +25,10 @@ const getVodById = async (id: string): Promise<Vod | null> => {
return result.data; return result.data;
}; };
const getVodBlob = async (id: string): Promise<Blob> => { // URL for the raw media stream. The <video> element loads this directly so the
const response = await fetch(API_URL + `/api/v1/vods/${id}/media`, { credentials: 'include' }); // browser can issue HTTP Range requests (progressive loading + seeking) instead
// of downloading the whole file up front.
if (!response.ok) { const getVodMediaUrl = (id: string): string => API_URL + `/api/v1/vods/${id}/media`;
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 patchVod = async (id: number, data: { title?: string; description?: string }): Promise<Vod> => {
const response = await fetch(API_URL + `/api/v1/vods/${id}`, { const response = await fetch(API_URL + `/api/v1/vods/${id}`, {
@@ -64,4 +58,4 @@ const deleteVod = async (id: number): Promise<void> => {
if (result.status === 'error') throw new Error(`Failed to delete vod: ${result.message}`); if (result.status === 'error') throw new Error(`Failed to delete vod: ${result.message}`);
}; };
export { getVods, getVodById, getVodBlob, patchVod, deleteVod }; export { getVods, getVodById, getVodMediaUrl, patchVod, deleteVod };