507 lines
18 KiB
TypeScript
507 lines
18 KiB
TypeScript
import React, { useState, useRef, useEffect, useCallback } from 'react'
|
|
import { Film, Play, Pause, Volume2, VolumeX, Loader2, Upload, Trash2, RefreshCw } from 'lucide-react'
|
|
import { logger } from '../lib/logger'
|
|
import { pathToFileUrl } from '../lib/file-url'
|
|
|
|
// Shared video source + preview surface used by RetakePanel and ExtendPanel. Owns the
|
|
// video file (drop/browse/clear), playback, mute, the timecode transport bar, and the
|
|
// filmstrip thumbnail strip with click-to-seek. Mode-specific UI (retake's trim handles,
|
|
// extend's direction/seconds controls) is injected via the render-prop slots.
|
|
|
|
export interface VideoPreviewContext {
|
|
videoPath: string | null
|
|
videoUrl: string | null
|
|
videoDuration: number
|
|
currentTime: number
|
|
isPlaying: boolean
|
|
thumbnails: string[]
|
|
videoRef: React.RefObject<HTMLVideoElement>
|
|
filmstripRef: React.RefObject<HTMLDivElement>
|
|
seek: (seconds: number) => void
|
|
}
|
|
|
|
interface VideoPreviewPanelProps {
|
|
title: string
|
|
initialVideoPath?: string | null
|
|
initialDuration?: number
|
|
resetKey?: number
|
|
isProcessing?: boolean
|
|
processingStatus?: string
|
|
processingDefault?: string
|
|
fillHeight?: boolean
|
|
emptyTitle?: string
|
|
hint?: { title: string; subtitle?: string }
|
|
// Validation error for the current source; when set, shown as a banner.
|
|
errorMessage?: string
|
|
// Owned by a parent when it needs them in its own effects (e.g. retake drag/keyboard).
|
|
videoRef?: React.RefObject<HTMLVideoElement>
|
|
filmstripRef?: React.RefObject<HTMLDivElement>
|
|
onSourceChange?: (data: { videoPath: string | null; videoDuration: number; width: number; height: number }) => void
|
|
// Returns true when the parent is mid-interaction (e.g. dragging a trim handle) and the
|
|
// filmstrip click-to-seek should be ignored — a drag release lands a click on the strip.
|
|
shouldSuppressSeek?: () => boolean
|
|
// Absolutely-positioned overlay rendered on top of the filmstrip thumbnails.
|
|
filmstripOverlay?: (ctx: VideoPreviewContext) => React.ReactNode
|
|
// Content rendered below the filmstrip (labels for retake, controls for extend).
|
|
belowFilmstrip?: (ctx: VideoPreviewContext) => React.ReactNode
|
|
}
|
|
|
|
export function formatTimecode(seconds: number): string {
|
|
const m = Math.floor(seconds / 60)
|
|
const s = seconds % 60
|
|
return `${String(m).padStart(2, '0')}:${s.toFixed(2).padStart(5, '0')}`
|
|
}
|
|
|
|
export function VideoPreviewPanel({
|
|
title,
|
|
initialVideoPath,
|
|
initialDuration,
|
|
resetKey,
|
|
isProcessing = false,
|
|
processingStatus = '',
|
|
processingDefault = 'Processing...',
|
|
fillHeight = false,
|
|
emptyTitle = 'Drop a video',
|
|
hint,
|
|
errorMessage,
|
|
videoRef: videoRefProp,
|
|
filmstripRef: filmstripRefProp,
|
|
onSourceChange,
|
|
shouldSuppressSeek,
|
|
filmstripOverlay,
|
|
belowFilmstrip,
|
|
}: VideoPreviewPanelProps) {
|
|
const internalVideoRef = useRef<HTMLVideoElement>(null)
|
|
const internalFilmstripRef = useRef<HTMLDivElement>(null)
|
|
const videoRef = videoRefProp ?? internalVideoRef
|
|
const filmstripRef = filmstripRefProp ?? internalFilmstripRef
|
|
|
|
const [videoPath, setVideoPath] = useState<string | null>(initialVideoPath || null)
|
|
const videoUrl = videoPath ? pathToFileUrl(videoPath) : null
|
|
const [videoDuration, setVideoDuration] = useState<number>(initialDuration || 0)
|
|
|
|
const [isPlaying, setIsPlaying] = useState(false)
|
|
const [isMuted, setIsMuted] = useState(false)
|
|
const [currentTime, setCurrentTime] = useState(0)
|
|
const [isDragOver, setIsDragOver] = useState(false)
|
|
const [dimensions, setDimensions] = useState<{ width: number; height: number }>({ width: 0, height: 0 })
|
|
|
|
const [thumbnails, setThumbnails] = useState<string[]>([])
|
|
const [thumbCount] = useState(20)
|
|
const extractingRef = useRef(false)
|
|
|
|
useEffect(() => {
|
|
if (resetKey === undefined) return
|
|
setVideoPath(initialVideoPath || null)
|
|
setVideoDuration(initialDuration || 0)
|
|
setIsPlaying(false)
|
|
setCurrentTime(0)
|
|
setThumbnails([])
|
|
setDimensions({ width: 0, height: 0 })
|
|
extractingRef.current = false
|
|
}, [resetKey, initialVideoPath, initialDuration])
|
|
|
|
useEffect(() => {
|
|
if (!videoUrl) {
|
|
setVideoDuration(0)
|
|
setIsPlaying(false)
|
|
setCurrentTime(0)
|
|
setThumbnails([])
|
|
setDimensions({ width: 0, height: 0 })
|
|
extractingRef.current = false
|
|
}
|
|
}, [videoUrl, initialDuration])
|
|
|
|
useEffect(() => {
|
|
onSourceChange?.({ videoPath, videoDuration, width: dimensions.width, height: dimensions.height })
|
|
}, [videoPath, videoDuration, dimensions, onSourceChange])
|
|
|
|
useEffect(() => {
|
|
if (!videoUrl || extractingRef.current || videoDuration <= 0) return
|
|
extractingRef.current = true
|
|
let cancelled = false
|
|
|
|
const extractThumbnails = async () => {
|
|
const video = document.createElement('video')
|
|
video.crossOrigin = 'anonymous'
|
|
video.preload = 'auto'
|
|
video.muted = true
|
|
video.src = videoUrl
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
video.onloadeddata = () => resolve()
|
|
video.onerror = () => reject(new Error('Failed to load video for thumbnails'))
|
|
setTimeout(() => reject(new Error('Timeout loading video')), 10000)
|
|
})
|
|
if (cancelled) return
|
|
|
|
const canvas = document.createElement('canvas')
|
|
const ctx = canvas.getContext('2d')!
|
|
const thumbWidth = 80
|
|
const thumbHeight = Math.round(thumbWidth * (video.videoHeight / video.videoWidth))
|
|
canvas.width = thumbWidth
|
|
canvas.height = thumbHeight
|
|
|
|
const frames: string[] = []
|
|
const count = Math.min(thumbCount, Math.max(5, Math.floor(videoDuration / 0.25)))
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
const seekTime = (i / count) * videoDuration
|
|
video.currentTime = seekTime
|
|
await new Promise<void>(resolve => {
|
|
video.onseeked = () => resolve()
|
|
setTimeout(resolve, 500)
|
|
})
|
|
if (cancelled) { video.src = ''; video.load(); return }
|
|
ctx.drawImage(video, 0, 0, thumbWidth, thumbHeight)
|
|
frames.push(canvas.toDataURL('image/jpeg', 0.6))
|
|
}
|
|
|
|
video.src = ''
|
|
video.load()
|
|
setThumbnails(frames)
|
|
}
|
|
|
|
extractThumbnails().catch(err => {
|
|
logger.warn(`Filmstrip extraction failed: ${err}`)
|
|
})
|
|
|
|
// Abort an in-flight extraction when the source changes or the component unmounts.
|
|
// Reset the re-entry guard too: videoDuration is refined by loadedmetadata after the
|
|
// asset's approximate duration kicks off the first run, and without this the cancelled
|
|
// run leaves the guard stuck true and the restart never happens (permanent spinner).
|
|
return () => {
|
|
cancelled = true
|
|
extractingRef.current = false
|
|
}
|
|
}, [videoUrl, videoDuration, thumbCount])
|
|
|
|
useEffect(() => {
|
|
const video = videoRef.current
|
|
if (!video) return
|
|
const handler = () => setCurrentTime(video.currentTime)
|
|
const onLoaded = () => {
|
|
// The real file duration is authoritative; initialDuration is only a pre-load hint
|
|
// (an asset's stored duration can be stale/approximate and mismatch the actual file).
|
|
if (video.duration && Number.isFinite(video.duration)) {
|
|
setVideoDuration(video.duration)
|
|
}
|
|
if (video.videoWidth && video.videoHeight) {
|
|
setDimensions({ width: video.videoWidth, height: video.videoHeight })
|
|
}
|
|
}
|
|
video.addEventListener('timeupdate', handler)
|
|
video.addEventListener('loadedmetadata', onLoaded)
|
|
return () => {
|
|
video.removeEventListener('timeupdate', handler)
|
|
video.removeEventListener('loadedmetadata', onLoaded)
|
|
}
|
|
}, [videoUrl])
|
|
|
|
const togglePlay = useCallback(() => {
|
|
const video = videoRef.current
|
|
if (!video) return
|
|
if (video.paused) {
|
|
video.play()
|
|
setIsPlaying(true)
|
|
} else {
|
|
video.pause()
|
|
setIsPlaying(false)
|
|
}
|
|
}, [])
|
|
|
|
const toggleMute = useCallback(() => {
|
|
const video = videoRef.current
|
|
if (!video) return
|
|
video.muted = !video.muted
|
|
setIsMuted(video.muted)
|
|
}, [])
|
|
|
|
// Shared transport keyboard: play/pause + frame/scrub. (Retake adds its own i/o keys.)
|
|
useEffect(() => {
|
|
const handler = (e: KeyboardEvent) => {
|
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
|
|
const key = e.key.toLowerCase()
|
|
const video = videoRef.current
|
|
if (key === ' ') {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
togglePlay()
|
|
} else if (key === 'arrowleft') {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
if (video) {
|
|
video.pause()
|
|
setIsPlaying(false)
|
|
video.currentTime = Math.max(0, video.currentTime - 1 / 24)
|
|
}
|
|
} else if (key === 'arrowright') {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
if (video) {
|
|
video.pause()
|
|
setIsPlaying(false)
|
|
video.currentTime = Math.min(videoDuration, video.currentTime + 1 / 24)
|
|
}
|
|
} else if (key === 'k') {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
if (video) { video.pause(); setIsPlaying(false) }
|
|
} else if (key === 'l') {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
if (video) { video.play(); setIsPlaying(true) }
|
|
}
|
|
}
|
|
window.addEventListener('keydown', handler, true)
|
|
return () => window.removeEventListener('keydown', handler, true)
|
|
}, [togglePlay, videoDuration])
|
|
|
|
const seek = useCallback((seconds: number) => {
|
|
const video = videoRef.current
|
|
if (video) video.currentTime = seconds
|
|
}, [])
|
|
|
|
const handleFilmstripClick = useCallback((e: React.MouseEvent) => {
|
|
if (shouldSuppressSeek?.()) return
|
|
const strip = filmstripRef.current
|
|
const video = videoRef.current
|
|
if (!strip || !video) return
|
|
const rect = strip.getBoundingClientRect()
|
|
const fraction = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
|
video.currentTime = fraction * videoDuration
|
|
}, [videoDuration, shouldSuppressSeek])
|
|
|
|
// Swap to a new source. Reset duration/dimensions too (not just thumbnails) so the next
|
|
// onSourceChange doesn't emit the new path with the previous file's metadata until
|
|
// loadedmetadata fires.
|
|
const selectSource = useCallback((path: string) => {
|
|
setVideoPath(path)
|
|
setVideoDuration(0)
|
|
setCurrentTime(0)
|
|
setDimensions({ width: 0, height: 0 })
|
|
setThumbnails([])
|
|
extractingRef.current = false
|
|
}, [])
|
|
|
|
const handleBrowse = useCallback(async () => {
|
|
const paths = await window.electronAPI.showOpenFileDialog({
|
|
title: 'Select Video',
|
|
filters: [{ name: 'Video', extensions: ['mp4', 'mov', 'avi', 'webm', 'mkv'] }],
|
|
})
|
|
if (paths && paths.length > 0) {
|
|
selectSource(paths[0])
|
|
}
|
|
}, [selectSource])
|
|
|
|
const handleClear = useCallback(() => {
|
|
setVideoPath(null)
|
|
setVideoDuration(0)
|
|
setIsPlaying(false)
|
|
setCurrentTime(0)
|
|
setThumbnails([])
|
|
extractingRef.current = false
|
|
}, [])
|
|
|
|
const handleDrop = useCallback((e: React.DragEvent) => {
|
|
e.preventDefault()
|
|
setIsDragOver(false)
|
|
|
|
const assetData = e.dataTransfer.getData('asset')
|
|
if (assetData) {
|
|
try {
|
|
const asset = JSON.parse(assetData) as { type?: string; path?: string }
|
|
if (asset.type === 'video' && asset.path) {
|
|
selectSource(asset.path)
|
|
return
|
|
}
|
|
} catch {
|
|
// fall through to file handling
|
|
}
|
|
}
|
|
|
|
const file = e.dataTransfer.files?.[0]
|
|
if (file) {
|
|
const filePath = window.electronAPI?.getPathForFile(file)
|
|
if (filePath) {
|
|
selectSource(filePath)
|
|
}
|
|
}
|
|
}, [selectSource])
|
|
|
|
const ctx: VideoPreviewContext = {
|
|
videoPath,
|
|
videoUrl,
|
|
videoDuration,
|
|
currentTime,
|
|
isPlaying,
|
|
thumbnails,
|
|
videoRef,
|
|
filmstripRef,
|
|
seek,
|
|
}
|
|
|
|
const playheadFrac = videoDuration > 0 ? currentTime / videoDuration : 0
|
|
|
|
return (
|
|
<div className={`bg-zinc-900 border border-zinc-800 rounded-2xl overflow-hidden flex flex-col ${fillHeight ? 'h-full min-h-0' : ''}`}>
|
|
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-800 flex-shrink-0">
|
|
<div className="flex items-center gap-2">
|
|
<Film className="h-4 w-4 text-blue-400" />
|
|
<span className="text-sm font-semibold text-white">{title}</span>
|
|
{videoPath && (
|
|
<span className="text-xs text-zinc-500 truncate max-w-[240px]">
|
|
{videoPath.split(/[/\\]/).pop()}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{videoUrl && (
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={handleClear}
|
|
className="p-1.5 rounded-md hover:bg-zinc-800 text-zinc-400 hover:text-white transition-colors"
|
|
title="Clear video"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</button>
|
|
<button
|
|
onClick={handleBrowse}
|
|
className="p-1.5 rounded-md hover:bg-zinc-800 text-zinc-400 hover:text-white transition-colors"
|
|
title="Replace video"
|
|
>
|
|
<RefreshCw className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{!videoUrl ? (
|
|
<div
|
|
className={`p-8 flex flex-col items-center justify-center gap-3 border-2 border-dashed rounded-xl m-4 transition-colors ${
|
|
isDragOver ? 'border-blue-500 bg-blue-500/10' : 'border-zinc-700'
|
|
}`}
|
|
onDragOver={(e) => { e.preventDefault(); setIsDragOver(true) }}
|
|
onDragLeave={() => setIsDragOver(false)}
|
|
onDrop={handleDrop}
|
|
>
|
|
<div className="p-3 rounded-full bg-zinc-800">
|
|
<Upload className="h-5 w-5 text-zinc-400" />
|
|
</div>
|
|
<div className="text-center">
|
|
<p className="text-sm text-white">{emptyTitle}</p>
|
|
<p className="text-xs text-zinc-500">mp4, mov, avi, webm, mkv</p>
|
|
</div>
|
|
<button
|
|
onClick={handleBrowse}
|
|
className="px-4 py-1.5 text-xs font-medium rounded-md bg-white text-black hover:bg-zinc-200 transition-colors"
|
|
>
|
|
Browse
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="flex-1 min-h-0 flex flex-col">
|
|
<div className="relative bg-black flex-1 min-h-0">
|
|
<video
|
|
ref={videoRef}
|
|
src={videoUrl}
|
|
className="w-full h-full object-contain"
|
|
onClick={togglePlay}
|
|
onEnded={() => setIsPlaying(false)}
|
|
/>
|
|
<div className="absolute bottom-2 left-2 flex items-center gap-1.5">
|
|
<button
|
|
onClick={toggleMute}
|
|
className="p-1.5 rounded bg-black/60 hover:bg-black/80 text-white/80 hover:text-white transition-colors"
|
|
>
|
|
{isMuted ? <VolumeX className="h-3.5 w-3.5" /> : <Volume2 className="h-3.5 w-3.5" />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-shrink-0">
|
|
<div className="flex items-center justify-center gap-3 px-4 py-2 bg-zinc-900 border-b border-zinc-800">
|
|
<button
|
|
onClick={togglePlay}
|
|
className="p-1 rounded hover:bg-zinc-800 text-white transition-colors"
|
|
>
|
|
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
|
</button>
|
|
<span className="text-xs font-mono text-zinc-400">
|
|
{formatTimecode(currentTime)} / {formatTimecode(videoDuration)}
|
|
</span>
|
|
</div>
|
|
|
|
{hint && (
|
|
<div className="px-4 pt-3 pb-1">
|
|
<p className="text-xs font-semibold text-white">{hint.title}</p>
|
|
{hint.subtitle && <p className="text-[10px] text-zinc-500 mt-0.5">{hint.subtitle}</p>}
|
|
</div>
|
|
)}
|
|
|
|
<div className="px-4 pb-4">
|
|
<div className="relative h-3 mb-0">
|
|
<div
|
|
className="absolute pointer-events-none z-10"
|
|
style={{ left: `${playheadFrac * 100}%`, transform: 'translateX(-50%)' }}
|
|
>
|
|
<svg width="10" height="10" viewBox="0 0 10 10">
|
|
<polygon points="0,0 10,0 5,8" fill="#fff" />
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
<div
|
|
ref={filmstripRef}
|
|
className="relative h-14 rounded-md overflow-hidden cursor-pointer select-none"
|
|
onClick={handleFilmstripClick}
|
|
>
|
|
<div className="absolute inset-0 flex">
|
|
{thumbnails.length > 0 ? (
|
|
thumbnails.map((thumb, i) => (
|
|
<img
|
|
key={i}
|
|
src={thumb}
|
|
alt=""
|
|
className="h-full flex-1 object-cover"
|
|
style={{ minWidth: 0 }}
|
|
draggable={false}
|
|
/>
|
|
))
|
|
) : (
|
|
<div className="w-full h-full bg-zinc-800 flex items-center justify-center">
|
|
<Loader2 className="h-4 w-4 text-zinc-600 animate-spin" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{filmstripOverlay?.(ctx)}
|
|
|
|
<div
|
|
className="absolute top-0 bottom-0 w-0.5 bg-zinc-800 pointer-events-none z-[15]"
|
|
style={{ left: `${playheadFrac * 100}%` }}
|
|
/>
|
|
</div>
|
|
|
|
{belowFilmstrip?.(ctx)}
|
|
</div>
|
|
|
|
{errorMessage ? (
|
|
<div className="px-4 pb-4">
|
|
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-red-600/10 border border-red-500/20">
|
|
<span className="text-xs text-red-300">{errorMessage}</span>
|
|
</div>
|
|
</div>
|
|
) : isProcessing ? (
|
|
<div className="px-4 pb-4">
|
|
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-blue-600/10 border border-blue-500/20">
|
|
<Loader2 className="h-3.5 w-3.5 text-blue-400 animate-spin flex-shrink-0" />
|
|
<span className="text-xs text-blue-300">{processingStatus || processingDefault}</span>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|