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 filmstripRef: React.RefObject 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 filmstripRef?: React.RefObject 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(null) const internalFilmstripRef = useRef(null) const videoRef = videoRefProp ?? internalVideoRef const filmstripRef = filmstripRefProp ?? internalFilmstripRef const [videoPath, setVideoPath] = useState(initialVideoPath || null) const videoUrl = videoPath ? pathToFileUrl(videoPath) : null const [videoDuration, setVideoDuration] = useState(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([]) 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((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(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 (
{title} {videoPath && ( {videoPath.split(/[/\\]/).pop()} )}
{videoUrl && (
)}
{!videoUrl ? (
{ e.preventDefault(); setIsDragOver(true) }} onDragLeave={() => setIsDragOver(false)} onDrop={handleDrop} >

{emptyTitle}

mp4, mov, avi, webm, mkv

) : (
{formatTimecode(currentTime)} / {formatTimecode(videoDuration)}
{hint && (

{hint.title}

{hint.subtitle &&

{hint.subtitle}

}
)}
{thumbnails.length > 0 ? ( thumbnails.map((thumb, i) => ( )) ) : (
)}
{filmstripOverlay?.(ctx)}
{belowFilmstrip?.(ctx)}
{errorMessage ? (
{errorMessage}
) : isProcessing ? (
{processingStatus || processingDefault}
) : null}
)}
) }