diff --git a/frontend/src/pages/VideoPlayer.tsx b/frontend/src/pages/VideoPlayer.tsx index c09d8b1..ff6186e 100644 --- a/frontend/src/pages/VideoPlayer.tsx +++ b/frontend/src/pages/VideoPlayer.tsx @@ -1,80 +1,296 @@ -import {useEffect, useState} from "react"; -import {useNavigate, useParams} from "react-router-dom"; -import { ArrowLeft } from "lucide-react"; -import type {Clip} from "../utils/types"; -import {getClipById, getVideoBlob} from "../utils/endpoints.ts"; -import Box from "../components/Box.tsx" -import {dateToTimeAgo, stringToDate} from "../utils/utils.ts"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { ArrowLeft, Maximize, Minimize, Pause, Play, Volume1, Volume2, VolumeX } from "lucide-react"; +import type { Clip } from "../utils/types"; +import { 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 [clip, setClip] = useState(null); - const [timeAgo, setTimeAgo] = useState(""); + 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; - } + if (!id) { setError("Clip ID is required."); return; } - getVideoBlob(id) - .then((blob) => { - const url = URL.createObjectURL(blob); - setVideoUrl(url); - }) + getVideoBlob(id).then((blob) => setVideoUrl(URL.createObjectURL(blob))); getClipById(id) - .then((fetchedClip) => {setClip(fetchedClip)}) - .catch((err) => { - console.error("Error fetching clip:", err); - setError("Failed to load clip details. Please try again later."); - }); - + .then(setClip) + .catch(() => setError("Failed to load clip details.")); }, [id]); useEffect(() => { - if (!clip || !clip.createdAt) return; - - const update = () => { - const date = stringToDate(clip.createdAt); - setTimeAgo(dateToTimeAgo(date)); - }; - + 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; + return ( -
+
-