Replaced Frame.io link with embedded local video player for the studio reel. ## Changes - Created ReelPlayer component with custom HTML5 video controls - Play/pause, volume, fullscreen, progress bar with scrubbing - Loading and error states with user-friendly messages - Dark theme styling with orange (#ff4d00) accents and sharp corners - Responsive design for mobile/tablet/desktop - Integrated ReelPlayer into Temp-Placeholder (Work section) - Replaced external Frame.io link with local /reel.mp4 - Maintains minimal aesthetic with proper animations - Fixed middleware whitelist issue - Added /reel.mp4 to middleware allowlist (src/middleware.ts:8) - Prevents 307 redirect that was causing "text/html" Content-Type error - Added video file headers to next.config.ts - Ensures proper video/mp4 MIME type for all .mp4 files - Updated CLAUDE.md documentation - Added critical warning about middleware whitelist in "Common pitfalls" - Added rule #9 to "Agents operating rules" for public/ file additions - Future-proofs against this issue happening again ## Technical Details - Video: 146MB, H.264 codec, 4K resolution (3840x2160) - Player handles large file buffering gracefully - ReadyState check prevents loading overlay persistence - All controls accessible and keyboard-friendly 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
259 lines
7.7 KiB
TypeScript
259 lines
7.7 KiB
TypeScript
"use client";
|
|
|
|
import { useRef, useState, useEffect } from "react";
|
|
import { Play, Pause, Volume2, VolumeX, Maximize, AlertCircle } from "lucide-react";
|
|
|
|
interface ReelPlayerProps {
|
|
src: string;
|
|
className?: string;
|
|
}
|
|
|
|
export function ReelPlayer({ src, className = "" }: ReelPlayerProps) {
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
const progressBarRef = useRef<HTMLDivElement>(null);
|
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
const [isMuted, setIsMuted] = useState(false);
|
|
const [currentTime, setCurrentTime] = useState(0);
|
|
const [duration, setDuration] = useState(0);
|
|
const [volume, setVolume] = useState(1);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
|
|
const handleLoadedMetadata = () => {
|
|
setDuration(video.duration);
|
|
};
|
|
|
|
const handleTimeUpdate = () => {
|
|
setCurrentTime(video.currentTime);
|
|
};
|
|
|
|
const handleEnded = () => {
|
|
setIsPlaying(false);
|
|
};
|
|
|
|
const handleError = (e: Event) => {
|
|
setIsLoading(false);
|
|
const videoEl = e.target as HTMLVideoElement;
|
|
const errorCode = videoEl.error?.code;
|
|
const errorMessage = videoEl.error?.message;
|
|
|
|
let userMessage = "Failed to load video. ";
|
|
switch (errorCode) {
|
|
case 1:
|
|
userMessage += "Video loading was aborted.";
|
|
break;
|
|
case 2:
|
|
userMessage += "Network error occurred.";
|
|
break;
|
|
case 3:
|
|
userMessage += "Video format not supported by your browser.";
|
|
break;
|
|
case 4:
|
|
userMessage += "Video source not found.";
|
|
break;
|
|
default:
|
|
userMessage += errorMessage || "Unknown error.";
|
|
}
|
|
|
|
setError(userMessage);
|
|
console.error("Video error:", errorCode, errorMessage);
|
|
};
|
|
|
|
const handleCanPlay = () => {
|
|
console.log("Video canplay event fired");
|
|
setIsLoading(false);
|
|
setError(null);
|
|
};
|
|
|
|
const handleLoadedData = () => {
|
|
console.log("Video loadeddata event fired");
|
|
setIsLoading(false);
|
|
};
|
|
|
|
video.addEventListener("loadedmetadata", handleLoadedMetadata);
|
|
video.addEventListener("timeupdate", handleTimeUpdate);
|
|
video.addEventListener("ended", handleEnded);
|
|
video.addEventListener("error", handleError);
|
|
video.addEventListener("canplay", handleCanPlay);
|
|
video.addEventListener("loadeddata", handleLoadedData);
|
|
|
|
// Check if video is already loaded (in case events fired before listeners attached)
|
|
if (video.readyState >= 3) {
|
|
// HAVE_FUTURE_DATA or HAVE_ENOUGH_DATA
|
|
console.log("Video already loaded, readyState:", video.readyState);
|
|
setIsLoading(false);
|
|
if (video.duration) {
|
|
setDuration(video.duration);
|
|
}
|
|
}
|
|
|
|
return () => {
|
|
video.removeEventListener("loadedmetadata", handleLoadedMetadata);
|
|
video.removeEventListener("timeupdate", handleTimeUpdate);
|
|
video.removeEventListener("ended", handleEnded);
|
|
video.removeEventListener("error", handleError);
|
|
video.removeEventListener("canplay", handleCanPlay);
|
|
video.removeEventListener("loadeddata", handleLoadedData);
|
|
};
|
|
}, []);
|
|
|
|
const togglePlay = async () => {
|
|
const video = videoRef.current;
|
|
if (!video || error) return;
|
|
|
|
try {
|
|
if (isPlaying) {
|
|
video.pause();
|
|
setIsPlaying(false);
|
|
} else {
|
|
await video.play();
|
|
setIsPlaying(true);
|
|
}
|
|
} catch (err) {
|
|
console.error("Play error:", err);
|
|
setError("Unable to play video. " + (err as Error).message);
|
|
setIsPlaying(false);
|
|
}
|
|
};
|
|
|
|
const toggleMute = () => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
|
|
video.muted = !isMuted;
|
|
setIsMuted(!isMuted);
|
|
};
|
|
|
|
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
const video = videoRef.current;
|
|
const progressBar = progressBarRef.current;
|
|
if (!video || !progressBar) return;
|
|
|
|
const rect = progressBar.getBoundingClientRect();
|
|
const clickX = e.clientX - rect.left;
|
|
const percentage = clickX / rect.width;
|
|
video.currentTime = percentage * video.duration;
|
|
};
|
|
|
|
const toggleFullscreen = () => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
|
|
if (!document.fullscreenElement) {
|
|
video.requestFullscreen();
|
|
} else {
|
|
document.exitFullscreen();
|
|
}
|
|
};
|
|
|
|
const formatTime = (seconds: number) => {
|
|
const mins = Math.floor(seconds / 60);
|
|
const secs = Math.floor(seconds % 60);
|
|
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
|
};
|
|
|
|
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
|
|
|
|
return (
|
|
<div className={`relative bg-black border border-white/10 ${className}`}>
|
|
{/* Video Element */}
|
|
<video
|
|
ref={videoRef}
|
|
className="w-full aspect-video bg-black"
|
|
onClick={togglePlay}
|
|
preload="auto"
|
|
playsInline
|
|
>
|
|
<source src={src} type="video/mp4" />
|
|
Your browser does not support the video tag.
|
|
</video>
|
|
|
|
{/* Loading State */}
|
|
{isLoading && !error && (
|
|
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
|
|
<div className="text-white text-sm">Loading video...</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Error State */}
|
|
{error && (
|
|
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/80 p-4">
|
|
<AlertCircle className="w-12 h-12 text-[#ff4d00] mb-3" />
|
|
<div className="text-white text-sm text-center max-w-md">
|
|
{error}
|
|
</div>
|
|
<div className="text-gray-400 text-xs mt-2">
|
|
Try refreshing the page or using a different browser.
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Custom Controls */}
|
|
{!error && (
|
|
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 via-black/70 to-transparent p-4">
|
|
{/* Progress Bar */}
|
|
<div
|
|
ref={progressBarRef}
|
|
className="w-full h-1 bg-white/20 cursor-pointer mb-3 relative"
|
|
onClick={handleProgressClick}
|
|
>
|
|
<div
|
|
className="h-full bg-[#ff4d00] transition-all duration-100"
|
|
style={{ width: `${progress}%` }}
|
|
/>
|
|
</div>
|
|
|
|
{/* Controls Row */}
|
|
<div className="flex items-center justify-between gap-4">
|
|
<div className="flex items-center gap-3">
|
|
{/* Play/Pause Button */}
|
|
<button
|
|
onClick={togglePlay}
|
|
className="text-white hover:text-[#ff4d00] transition-colors"
|
|
aria-label={isPlaying ? "Pause" : "Play"}
|
|
>
|
|
{isPlaying ? (
|
|
<Pause className="w-5 h-5" />
|
|
) : (
|
|
<Play className="w-5 h-5" />
|
|
)}
|
|
</button>
|
|
|
|
{/* Volume Button */}
|
|
<button
|
|
onClick={toggleMute}
|
|
className="text-white hover:text-[#ff4d00] transition-colors"
|
|
aria-label={isMuted ? "Unmute" : "Mute"}
|
|
>
|
|
{isMuted ? (
|
|
<VolumeX className="w-5 h-5" />
|
|
) : (
|
|
<Volume2 className="w-5 h-5" />
|
|
)}
|
|
</button>
|
|
|
|
{/* Time Display */}
|
|
<span className="text-white text-sm font-mono">
|
|
{formatTime(currentTime)} / {formatTime(duration)}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Fullscreen Button */}
|
|
<button
|
|
onClick={toggleFullscreen}
|
|
className="text-white hover:text-[#ff4d00] transition-colors"
|
|
aria-label="Fullscreen"
|
|
>
|
|
<Maximize className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|