136 lines
4.5 KiB
TypeScript
136 lines
4.5 KiB
TypeScript
import { useState, useEffect, lazy, Suspense } from 'react';
|
|
import { useParams, Link } from 'react-router-dom';
|
|
import { apiFetch } from '../lib/api';
|
|
|
|
// Lazy — HLS/DASH chunks ne chargent que sur /video/:id, pas sur la homepage
|
|
const ReactPlayer = lazy(() => import('react-player'));
|
|
|
|
interface Video {
|
|
id: number;
|
|
title: string;
|
|
description: string;
|
|
duration?: number;
|
|
storageType: 'youtube' | 's3' | 'local';
|
|
storageKey: string;
|
|
thumbnailUrl?: string;
|
|
requiredLevel: number;
|
|
locked: boolean;
|
|
}
|
|
|
|
interface VideoResponse {
|
|
success: boolean;
|
|
data: { video: Video };
|
|
}
|
|
|
|
function buildPlayerUrl(storageType: Video['storageType'], storageKey: string): string {
|
|
switch (storageType) {
|
|
case 'youtube': return `https://www.youtube.com/watch?v=${storageKey}`;
|
|
case 's3': return storageKey; // URL S3 complète attendue dans storageKey
|
|
case 'local': return `${import.meta.env.VITE_API_URL}/stream/${storageKey}`;
|
|
}
|
|
}
|
|
|
|
export default function VideoPage() {
|
|
const { id } = useParams<{ id: string }>();
|
|
const [video, setVideo] = useState<Video | null>(null);
|
|
const [error, setError] = useState<'not_found' | 'forbidden' | 'unknown' | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
if (!id) return;
|
|
|
|
apiFetch<VideoResponse>(`/videos/${id}`)
|
|
.then((res) => setVideo(res.data.video))
|
|
.catch((err: Error) => {
|
|
if (err.message.includes('403')) setError('forbidden');
|
|
else if (err.message.includes('404')) setError('not_found');
|
|
else setError('unknown');
|
|
})
|
|
.finally(() => setLoading(false));
|
|
}, [id]);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex flex-col gap-4">
|
|
<div className="aspect-video w-full rounded bg-od-surface-hi animate-pulse" />
|
|
<div className="h-5 w-1/2 rounded bg-od-surface-hi animate-pulse" />
|
|
<div className="h-3 w-3/4 rounded bg-od-surface-hi animate-pulse" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error === 'forbidden') {
|
|
return (
|
|
<div className="flex flex-col items-center gap-4 pt-16 text-center">
|
|
<span className="font-mono text-3xl text-od-accent">⊘</span>
|
|
<p className="text-od-text font-medium">Contenu premium</p>
|
|
<p className="text-sm text-od-muted">Cette vidéo nécessite un abonnement supérieur.</p>
|
|
<Link to="/" className="font-mono text-xs text-od-muted hover:text-od-text transition-colors">
|
|
← Retour
|
|
</Link>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error || !video) {
|
|
return (
|
|
<div className="flex flex-col items-center gap-4 pt-16">
|
|
<p className="text-sm text-od-muted">Vidéo introuvable.</p>
|
|
<Link to="/" className="font-mono text-xs text-od-muted hover:text-od-text transition-colors">
|
|
← Retour
|
|
</Link>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const playerUrl = buildPlayerUrl(video.storageType, video.storageKey);
|
|
|
|
return (
|
|
<div className="flex flex-col gap-6">
|
|
|
|
{/* Player */}
|
|
<div className="overflow-hidden rounded border border-od-border bg-od-surface">
|
|
<div className="aspect-video w-full">
|
|
<Suspense fallback={<div className="h-full w-full bg-od-surface-hi animate-pulse" />}>
|
|
<ReactPlayer
|
|
src={playerUrl}
|
|
width="100%"
|
|
height="100%"
|
|
controls
|
|
/>
|
|
</Suspense>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Meta */}
|
|
<div className="flex flex-col gap-2">
|
|
<h1 className="text-xl font-semibold text-od-text">{video.title}</h1>
|
|
{video.description && (
|
|
<p className="text-sm text-od-muted leading-relaxed">{video.description}</p>
|
|
)}
|
|
<div className="flex items-center gap-3 pt-1">
|
|
{video.requiredLevel === 0 ? (
|
|
<span className="rounded border border-od-border px-2 py-0.5 font-mono text-xs text-od-muted">
|
|
Libre
|
|
</span>
|
|
) : (
|
|
<span className="rounded border border-od-accent px-2 py-0.5 font-mono text-xs text-od-accent">
|
|
Premium · niveau {video.requiredLevel}
|
|
</span>
|
|
)}
|
|
{video.duration && (
|
|
<span className="font-mono text-xs text-od-muted">
|
|
{Math.floor(video.duration / 60)}:{String(video.duration % 60).padStart(2, '0')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<Link to="/" className="self-start font-mono text-xs text-od-muted hover:text-od-text transition-colors">
|
|
← Retour aux vidéos
|
|
</Link>
|
|
|
|
</div>
|
|
);
|
|
}
|