Nicholai a06b2607c7 feat(video): add custom reel player with local mp4 support
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>
2025-10-23 03:05:27 -06:00

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>
);
}