Files
surfnathanrip/frontend/src/pages/VideoDetail.tsx
T
CallMeVerity 6da2539c03 Stream video through backend with Range request support
Previously videos were served via direct RustFS URLs, which meant:
- No HTTP Range support (browsers had to download the entire file)
- Large videos couldn't play at all
- Video player rendered broken

Now videos stream through GET /api/videos/:id/stream which:
- Proxies video data from RustFS to the browser
- Supports Range requests (HTTP 206 Partial Content) for seeking
- Sets proper headers (Accept-Ranges, Content-Range, Content-Type)
- Caches for 24 hours

Frontend changes:
- VideoPlayer: added playsInline, preload=metadata, object-contain, error state
- VideoDetail: removed duplicate inline video, now uses VideoPlayer component
- index.css: style WebKit video controls (dark panel, no border-radius)
2026-06-03 03:56:39 +01:00

134 lines
5.2 KiB
TypeScript

import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import VideoPlayer from "../components/VideoPlayer";
import VideoSidebar from "../components/VideoSidebar";
import { fetchVideo, formatRunTime } from "../api/client";
import type { VideoDetail } from "../types/video";
function getMapDisplayName(mapName: string): string {
return mapName.replace(/_/g, " ").toUpperCase();
}
function getMomUrl(mapName: string): string {
return `https://dashboard.momentum-mod.org/maps/${mapName}`;
}
function SkeletonBlock({ className }: { className?: string }) {
return (
<div
className={`rounded bg-white/5 animate-pulse ${className ?? ""}`}
/>
);
}
function PageSkeleton() {
return (
<div className="py-6">
<div className="flex items-baseline gap-3 mb-3">
<SkeletonBlock className="h-8 w-64" />
</div>
<div className="flex flex-col lg:flex-row gap-4 lg:items-start">
<div className="lg:w-1/2 min-w-0">
<SkeletonBlock className="aspect-video w-full rounded-lg" />
</div>
<div className="lg:w-1/2 flex flex-col gap-4">
<div className="rounded border border-white/5 bg-white/5 p-4">
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
<div>
<SkeletonBlock className="h-2.5 w-8 mb-1.5" />
<SkeletonBlock className="h-4 w-28" />
</div>
<div>
<SkeletonBlock className="h-2.5 w-8 mb-1.5" />
<SkeletonBlock className="h-4 w-20" />
</div>
<div>
<SkeletonBlock className="h-2.5 w-10 mb-1.5" />
<SkeletonBlock className="h-4 w-24" />
</div>
<div>
<SkeletonBlock className="h-2.5 w-8 mb-1.5" />
<SkeletonBlock className="h-7 w-16" />
</div>
</div>
</div>
</div>
</div>
</div>
);
}
export default function VideoDetail() {
const { id } = useParams<{ id: string }>();
const [video, setVideo] = useState<VideoDetail | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!id) return;
fetchVideo(id)
.then(setVideo)
.catch((err) => setError(err.message));
}, [id]);
if (error) {
return (
<div className="py-24 text-center">
<p className="text-red-400/60 mb-4">{error}</p>
</div>
);
}
if (!video) {
return <PageSkeleton />;
}
return (
<div className="py-6">
<div className="rounded-lg border border-white/5 bg-white/5 px-4 py-2 inline-block mb-3">
<h1 className="text-2xl font-bold tracking-tight text-white/90">
<a
href={getMomUrl(video.mapName)}
target="_blank"
rel="noopener noreferrer"
className="no-underline text-white/90 hover:text-mom-accent transition-colors"
>
{getMapDisplayName(video.mapName)}
</a>
</h1>
</div>
<div className="flex flex-col lg:flex-row gap-4 lg:items-start">
<div className="lg:w-1/2 min-w-0">
<VideoPlayer video={video} />
</div>
<div className="lg:w-1/2">
<VideoSidebar video={video} />
{video.previousPbs && video.previousPbs.length > 0 && (
<div className="rounded border border-white/5 bg-white/5 p-4 mt-4">
<div className="text-[10px] uppercase tracking-wider text-gray-500 mb-2">
Previous PBs
</div>
<ul className="space-y-1">
{video.previousPbs.map((pb) => (
<li
key={pb.createdAt}
className="text-sm text-white/60"
>
<span className="font-mono text-white/90">
{formatRunTime(pb.runTime)}
</span>
{" · "}
{new Date(
pb.createdAt,
).toLocaleDateString()}
</li>
))}
</ul>
</div>
)}
</div>
</div>
</div>
);
}