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 { Clip } from "../utils/types"; import { AuthError, getClipById, getVideoBlob } from "../utils/endpoints.ts"; import Box from "../components/Box.tsx"; import { dateToTimeAgo, formatTime, stringToDate } from "../utils/utils.ts"; const VideoPlayer = () => { const { id } = useParams(); const navigate = useNavigate(); const videoRef = useRef(null); const containerRef = useRef(null); const progressRef = useRef(null); const hideTimerRef = useRef | null>(null); // Data const [videoUrl, setVideoUrl] = useState(undefined); const [error, setError] = useState(null); const [notAuthenticated, setNotAuthenticated] = useState(false); const [clip, setClip] = useState(null); const [timeAgo, setTimeAgo] = useState(""); // Playback 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); // Volume const [volume, setVolume] = useState(1); const [isMuted, setIsMuted] = useState(false); // UI const [isFullscreen, setIsFullscreen] = useState(false); const [controlsVisible, setControlsVisible] = useState(true); const [isDragging, setIsDragging] = useState(false); // ── Data loading ──────────────────────────────────────────────────────── useEffect(() => { if (!id) { setError("Clip ID is required."); return; } getVideoBlob(id) .then((blob) => setVideoUrl(URL.createObjectURL(blob))) .catch((e) => { if (e instanceof AuthError) setNotAuthenticated(true); }); getClipById(id) .then(setClip) .catch((e) => { if (e instanceof AuthError) setNotAuthenticated(true); else setError("Failed to load clip details."); }); }, [id]); useEffect(() => { if (!clip?.createdAt) return; const update = () => setTimeAgo(dateToTimeAgo(stringToDate(clip.createdAt))); update(); const interval = setInterval(update, 1000); return () => clearInterval(interval); }, [clip]); // ── Controls visibility ───────────────────────────────────────────────── const scheduleHide = useCallback(() => { if (hideTimerRef.current) clearTimeout(hideTimerRef.current); hideTimerRef.current = setTimeout(() => setControlsVisible(false), 3000); }, []); const showControls = useCallback(() => { setControlsVisible(true); scheduleHide(); }, [scheduleHide]); // ── Fullscreen ────────────────────────────────────────────────────────── 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(); }, []); // ── Keyboard shortcuts ────────────────────────────────────────────────── 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]); // ── Progress bar seeking ──────────────────────────────────────────────── 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]); // ── Video element event handlers ──────────────────────────────────────── 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) => { 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 (

Not authenticated

You don't have access to this clip.

); } return (
{/* Player */}
isPlaying && setControlsVisible(false)} onClick={togglePlay} style={{ cursor: controlsVisible ? 'default' : 'none' }} >