Files

127 lines
4.4 KiB
TypeScript

import React, { useState, useRef, useEffect, useCallback } from 'react'
export function VideoThumbnailCard({ videoUrl, thumbnailUrl }: { videoUrl: string; thumbnailUrl?: string }) {
const containerRef = useRef<HTMLDivElement>(null)
const videoRef = useRef<HTMLVideoElement | null>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const [isHovering, setIsHovering] = useState(false)
const [videoReady, setVideoReady] = useState(false)
const [scrubProgress, setScrubProgress] = useState(0)
const [scrubTime, setScrubTime] = useState('')
const rafRef = useRef<number>(0)
const drawFrame = useCallback(() => {
const video = videoRef.current
const canvas = canvasRef.current
if (!video || !canvas || video.readyState < 2) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const rect = canvas.getBoundingClientRect()
if (canvas.width !== rect.width || canvas.height !== rect.height) {
canvas.width = rect.width
canvas.height = rect.height
}
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
}, [])
const handleMouseMove = useCallback((e: React.MouseEvent) => {
const video = videoRef.current
const container = containerRef.current
if (!video || !container || !video.duration || isNaN(video.duration)) return
const rect = container.getBoundingClientRect()
const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width))
const pct = x / rect.width
const targetTime = pct * video.duration
video.currentTime = targetTime
setScrubProgress(pct)
const mins = Math.floor(targetTime / 60)
const secs = Math.floor(targetTime % 60)
const frames = Math.floor((targetTime % 1) * 24)
setScrubTime(`${mins}:${secs.toString().padStart(2, '0')}:${frames.toString().padStart(2, '0')}`)
cancelAnimationFrame(rafRef.current)
rafRef.current = requestAnimationFrame(drawFrame)
}, [drawFrame])
const handleMouseEnter = useCallback(() => {
setIsHovering(true)
}, [])
const handleMouseLeave = useCallback(() => {
setIsHovering(false)
setVideoReady(false)
setScrubProgress(0)
setScrubTime('')
}, [])
useEffect(() => {
if (!isHovering) return
const video = document.createElement('video')
video.muted = true
video.playsInline = true
video.preload = 'auto'
video.src = videoUrl
videoRef.current = video
const onLoaded = () => {
setVideoReady(true)
requestAnimationFrame(drawFrame)
}
const onSeeked = () => requestAnimationFrame(drawFrame)
video.addEventListener('loadeddata', onLoaded)
video.addEventListener('seeked', onSeeked)
video.load()
return () => {
video.removeEventListener('loadeddata', onLoaded)
video.removeEventListener('seeked', onSeeked)
video.pause()
video.removeAttribute('src')
video.load()
if (videoRef.current === video) {
videoRef.current = null
}
}
}, [isHovering, videoUrl, drawFrame])
return (
<div
ref={containerRef}
className="w-full aspect-video relative overflow-hidden bg-zinc-900"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onMouseMove={isHovering ? handleMouseMove : undefined}
>
{thumbnailUrl ? (
<img
src={thumbnailUrl}
alt=""
className={`w-full h-full object-cover absolute inset-0 ${isHovering && videoReady ? 'opacity-0' : 'opacity-100'} transition-opacity duration-100`}
/>
) : (
<div className={`w-full h-full absolute inset-0 ${isHovering && videoReady ? 'opacity-0' : 'opacity-100'} transition-opacity duration-100`} />
)}
<canvas
ref={canvasRef}
className={`w-full h-full object-cover absolute inset-0 ${isHovering && videoReady ? 'opacity-100' : 'opacity-0'} transition-opacity duration-100`}
/>
{isHovering && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-black/40">
<div
className="h-full bg-blue-500 transition-none"
style={{ width: `${scrubProgress * 100}%` }}
/>
</div>
)}
{isHovering && videoReady && scrubTime && (
<div className="absolute bottom-1.5 left-1/2 -translate-x-1/2 px-1.5 py-0.5 rounded bg-black/80 backdrop-blur-sm">
<span className="text-[9px] text-white font-mono tabular-nums">{scrubTime}</span>
</div>
)}
</div>
)
}