6da2539c03
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)
134 lines
5.2 KiB
TypeScript
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>
|
|
);
|
|
}
|