Files
eranos/src/components/VideoPlayer.tsx
T
Alex Gleason eabaaa3fd5 Fix VideoPlayer letterboxing when dim is known
When the container has an aspect-ratio from the dim tag, switch the
video to absolute inset-0 h-full to fill it cleanly. Fall back to
max-h-[70vh] object-cover for videos without dim data.
2026-02-23 23:08:32 -06:00

277 lines
9.3 KiB
TypeScript

import { useRef, useState, useEffect, useCallback } from 'react';
import { Play, Pause, Volume2, VolumeX, Expand } from 'lucide-react';
import { Blurhash } from 'react-blurhash';
import { cn } from '@/lib/utils';
import { useBlossomFallback } from '@/hooks/useBlossomFallback';
interface VideoPlayerProps {
src: string;
poster?: string;
className?: string;
/** NIP-94 `dim` tag value, e.g. "1280x720". Sets the aspect ratio before metadata loads. */
dim?: string;
/** NIP-94 `blurhash` tag value. Shown as a placeholder before the video poster/frame loads. */
blurhash?: string;
}
/** Parses a NIP-94 `dim` string like "1280x720" into `{ width, height }`. */
function parseDim(dim: string | undefined): { width: number; height: number } | undefined {
if (!dim) return undefined;
const [w, h] = dim.split('x').map(Number);
if (!w || !h || isNaN(w) || isNaN(h)) return undefined;
return { width: w, height: h };
}
/** Format seconds to m:ss or h:mm:ss. */
function formatTime(seconds: number): string {
if (!isFinite(seconds) || seconds < 0) return '0:00';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
return `${m}:${s.toString().padStart(2, '0')}`;
}
export function VideoPlayer({ src: originalSrc, poster, className, dim, blurhash }: VideoPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const progressRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const hideTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
const { src, onError: onBlossomError } = useBlossomFallback(originalSrc);
const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [showControls, setShowControls] = useState(true);
const [hasStarted, setHasStarted] = useState(false);
// True once the video has enough data to display a frame (or has a poster)
const [videoReady, setVideoReady] = useState(!!poster);
const dimensions = parseDim(dim);
const aspectRatio = dimensions ? `${dimensions.width} / ${dimensions.height}` : undefined;
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
const scheduleHide = useCallback(() => {
if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current);
if (isPlaying) {
hideTimeoutRef.current = setTimeout(() => setShowControls(false), 2500);
}
}, [isPlaying]);
const revealControls = useCallback(() => {
setShowControls(true);
scheduleHide();
}, [scheduleHide]);
useEffect(() => {
if (isPlaying) {
scheduleHide();
} else {
setShowControls(true);
if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current);
}
return () => {
if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current);
};
}, [isPlaying, scheduleHide]);
// Pause video when scrolled out of view
useEffect(() => {
const video = videoRef.current;
const container = containerRef.current;
if (!video || !container) return;
const observer = new IntersectionObserver(
([entry]) => {
if (!entry.isIntersecting && !video.paused) {
video.pause();
}
},
{ threshold: 0.25 },
);
observer.observe(container);
return () => observer.disconnect();
}, []);
const togglePlay = (e: React.MouseEvent) => {
e.stopPropagation();
const video = videoRef.current;
if (!video) return;
if (video.paused) {
video.play();
} else {
video.pause();
}
};
const toggleMute = (e: React.MouseEvent) => {
e.stopPropagation();
const video = videoRef.current;
if (!video) return;
video.muted = !video.muted;
setIsMuted(video.muted);
};
const handleFullscreen = (e: React.MouseEvent) => {
e.stopPropagation();
const video = videoRef.current;
if (!video) return;
if (video.requestFullscreen) {
video.requestFullscreen();
}
};
const handleSeek = (e: React.MouseEvent) => {
e.stopPropagation();
const video = videoRef.current;
const bar = progressRef.current;
if (!video || !bar || !duration) return;
const rect = bar.getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
video.currentTime = ratio * duration;
};
const handleVideoClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (!hasStarted) {
const video = videoRef.current;
if (video) video.play();
return;
}
togglePlay(e);
revealControls();
};
return (
<div
ref={containerRef}
className={cn(
'relative mt-3 rounded-2xl overflow-hidden border border-border bg-black group',
className,
)}
style={aspectRatio ? { aspectRatio } : undefined}
onMouseMove={revealControls}
onMouseLeave={() => { if (isPlaying) scheduleHide(); }}
onClick={(e) => e.stopPropagation()}
>
{/* Blurhash placeholder — shown until the video has a displayable frame */}
{blurhash && !videoReady && (
<Blurhash
hash={blurhash}
width="100%"
height="100%"
resolutionX={32}
resolutionY={32}
punch={1}
style={{ position: 'absolute', inset: 0 }}
/>
)}
<video
ref={videoRef}
src={src}
poster={poster}
className={cn(
'w-full cursor-pointer',
// When dim is known the container already has the correct aspect ratio,
// so the video just needs to fill it (absolute inset-0). Without dim we
// fall back to the original constrained height with object-cover so the
// player doesn't grow to an unmanageable size.
aspectRatio
? 'absolute inset-0 h-full object-cover'
: 'max-h-[70vh] object-cover',
)}
playsInline
preload="metadata"
{...({ 'webkit-playsinline': 'true' } as React.HTMLAttributes<HTMLVideoElement>)}
{...({ 'x-webkit-airplay': 'allow' } as React.HTMLAttributes<HTMLVideoElement>)}
onClick={handleVideoClick}
onPlay={() => { setIsPlaying(true); setHasStarted(true); }}
onPause={() => setIsPlaying(false)}
onTimeUpdate={() => setCurrentTime(videoRef.current?.currentTime ?? 0)}
onLoadedMetadata={() => setDuration(videoRef.current?.duration ?? 0)}
onDurationChange={() => setDuration(videoRef.current?.duration ?? 0)}
onLoadedData={() => setVideoReady(true)}
onError={onBlossomError}
/>
{/* Big centered play button before first play */}
{!hasStarted && (
<div
className="absolute inset-0 flex items-center justify-center bg-black/30 cursor-pointer"
onClick={handleVideoClick}
>
<div className="size-16 rounded-full bg-black/60 flex items-center justify-center backdrop-blur-sm">
<Play className="size-8 text-white ml-1" fill="white" />
</div>
</div>
)}
{/* Bottom control bar */}
{hasStarted && (
<div
className={cn(
'absolute bottom-0 left-0 right-0 transition-opacity duration-200',
'bg-gradient-to-t from-black/80 via-black/40 to-transparent pt-8 pb-2 px-3',
showControls ? 'opacity-100' : 'opacity-0 pointer-events-none',
)}
>
{/* Progress bar */}
<div
ref={progressRef}
className="w-full h-1 bg-white/30 rounded-full cursor-pointer mb-2 group/progress"
onClick={handleSeek}
>
<div
className="h-full bg-primary rounded-full relative"
style={{ width: `${progress}%` }}
>
<div className="absolute right-0 top-1/2 -translate-y-1/2 size-3 bg-primary rounded-full opacity-0 group-hover/progress:opacity-100 transition-opacity" />
</div>
</div>
{/* Controls row */}
<div className="flex items-center gap-3">
{/* Play/Pause */}
<button
onClick={togglePlay}
className="text-white hover:text-white/80 transition-colors"
aria-label={isPlaying ? 'Pause' : 'Play'}
>
{isPlaying ? <Pause className="size-5" fill="white" /> : <Play className="size-5 ml-0.5" fill="white" />}
</button>
{/* Volume */}
<button
onClick={toggleMute}
className="text-white hover:text-white/80 transition-colors"
aria-label={isMuted ? 'Unmute' : 'Mute'}
>
{isMuted ? <VolumeX className="size-5" /> : <Volume2 className="size-5" />}
</button>
{/* Time */}
<span className="text-white text-xs tabular-nums min-w-0">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
<div className="flex-1" />
{/* Fullscreen */}
<button
onClick={handleFullscreen}
className="text-white hover:text-white/80 transition-colors"
aria-label="Fullscreen"
>
<Expand className="size-[18px]" />
</button>
</div>
</div>
)}
</div>
);
}