chore(prod): sync with main #4
@@ -1,80 +1,296 @@
|
||||
import {useEffect, useState} from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
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, stringToDate} from "../utils/utils.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<HTMLVideoElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const progressRef = useRef<HTMLDivElement>(null);
|
||||
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Data
|
||||
const [videoUrl, setVideoUrl] = useState<string | undefined>(undefined);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [clip, setClip] = useState<Clip | null>(null);
|
||||
const [timeAgo, setTimeAgo] = useState<String>("");
|
||||
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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div className="p-6 max-w-5xl mx-auto">
|
||||
<div className="h-full flex flex-col p-4 gap-3">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 mb-4 transition-colors duration-150"
|
||||
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 flex-shrink-0 transition-colors duration-150"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
Back
|
||||
</button>
|
||||
|
||||
<video
|
||||
className="w-full rounded-xl shadow-md bg-black"
|
||||
controls
|
||||
autoPlay
|
||||
src={videoUrl}
|
||||
onError={(e) => {
|
||||
setError(e.currentTarget.error?.message || "An error occurred while playing the video.");
|
||||
}}
|
||||
{/* Player */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative bg-black rounded-xl overflow-hidden flex-1 min-h-0 select-none"
|
||||
onMouseMove={showControls}
|
||||
onMouseLeave={() => isPlaying && setControlsVisible(false)}
|
||||
onClick={togglePlay}
|
||||
style={{ cursor: controlsVisible ? 'default' : 'none' }}
|
||||
>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="w-full h-full object-contain"
|
||||
src={videoUrl}
|
||||
onPlay={() => { setIsPlaying(true); scheduleHide(); }}
|
||||
onPause={() => {
|
||||
setIsPlaying(false);
|
||||
setControlsVisible(true);
|
||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
|
||||
}}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onLoadedMetadata={() => { if (videoRef.current) setDuration(videoRef.current.duration); }}
|
||||
onWaiting={() => setIsWaiting(true)}
|
||||
onCanPlay={() => setIsWaiting(false)}
|
||||
onError={(e) => setError(e.currentTarget.error?.message || "An error occurred.")}
|
||||
/>
|
||||
|
||||
{/* Buffering spinner */}
|
||||
{isWaiting && videoUrl && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div className="w-11 h-11 rounded-full border-[3px] border-white/20 border-t-white animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Controls overlay — pointer-events-none so video clicks pass through */}
|
||||
<div className={`absolute inset-0 flex flex-col justify-end transition-opacity duration-300 pointer-events-none ${controlsVisible ? 'opacity-100' : 'opacity-0'}`}>
|
||||
{/* Gradient */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/75 via-black/10 to-transparent" />
|
||||
|
||||
{/* Controls bar — pointer-events-auto only here */}
|
||||
<div
|
||||
className="relative px-4 pb-4 pt-10 pointer-events-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Scrub bar */}
|
||||
<div
|
||||
ref={progressRef}
|
||||
className="relative h-1 rounded-full bg-white/25 cursor-pointer mb-3 group/prog hover:h-1.5 transition-[height] duration-150"
|
||||
onMouseDown={(e) => { setIsDragging(true); seekTo(e.clientX); }}
|
||||
>
|
||||
<div className="absolute inset-y-0 left-0 bg-white/35 rounded-full" style={{ width: `${bufferedPct}%` }} />
|
||||
<div className="absolute inset-y-0 left-0 bg-primary rounded-full" style={{ width: `${progressPct}%` }} />
|
||||
<div
|
||||
className="absolute top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full shadow opacity-0 group-hover/prog:opacity-100 transition-opacity"
|
||||
style={{ left: `calc(${progressPct}% - 6px)` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Buttons row */}
|
||||
<div className="flex items-center gap-3 text-white">
|
||||
<button onClick={togglePlay} className="hover:text-white/70 transition-colors">
|
||||
{isPlaying
|
||||
? <Pause size={20} fill="currentColor" />
|
||||
: <Play size={20} fill="currentColor" />}
|
||||
</button>
|
||||
|
||||
{/* Volume — slider expands on hover */}
|
||||
<div className="flex items-center gap-1.5 group/vol">
|
||||
<button onClick={toggleMute} className="hover:text-white/70 transition-colors">
|
||||
<VolumeIcon size={18} />
|
||||
</button>
|
||||
<div className="overflow-hidden w-0 group-hover/vol:w-16 transition-[width] duration-200">
|
||||
<input
|
||||
type="range"
|
||||
min={0} max={1} step={0.02}
|
||||
value={effectiveVolume}
|
||||
onChange={handleVolumeChange}
|
||||
className="w-16 accent-white cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="text-xs font-mono tabular-nums text-white/80">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</span>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<button onClick={toggleFullscreen} className="hover:text-white/70 transition-colors">
|
||||
{isFullscreen ? <Minimize size={18} /> : <Maximize size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-red-500 text-sm mt-3">{error}</div>}
|
||||
{!videoUrl && !error && <div className="text-gray-400 text-sm mt-3">Loading video...</div>}
|
||||
{!videoUrl && !error && (
|
||||
<div className="text-gray-400 text-sm mt-3 flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full border-2 border-gray-300 border-t-gray-500 animate-spin" />
|
||||
Loading video...
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Box className="p-4 mt-4">
|
||||
<Box className="p-4 flex-shrink-0">
|
||||
<p className="text-xl font-semibold text-gray-900">{clip?.title || "(No Title)"}</p>
|
||||
<p className="text-sm text-gray-500 mt-1">{timeAgo}</p>
|
||||
</Box>
|
||||
|
||||
Reference in New Issue
Block a user