import React, { useState, useEffect, useRef, useCallback } from 'react' import { Play, Pause, Square, SkipBack, SkipForward, ChevronLeft, ChevronRight, Repeat, Video, Music, X } from 'lucide-react' import type { Asset } from '../../types/project-model' import { formatTime } from './video-editor-utils' import { Tooltip } from '../../components/ui/tooltip' import { pathToFileUrl } from '../../lib/file-url' import { selectHasSourceAsset } from './editor-selectors' import { useEditorActions, useEditorStore } from './editor-store' export type SourceKeyboardAction = | 'transport.playPause' | 'transport.shuttleReverse' | 'transport.shuttleStop' | 'transport.shuttleForward' | 'transport.stepBackward' | 'transport.stepForward' | 'transport.jumpBackward' | 'transport.jumpForward' | 'mark.setIn' | 'mark.setOut' | 'mark.clearIn' | 'mark.clearOut' | 'mark.clearInOut' | 'edit.insertEdit' | 'edit.overwriteEdit' export interface SourceEditRequest { asset: Asset sourceIn: number | null sourceOut: number | null sourceTime: number } export interface VideoEditorSourceMonitorHandle { openAsset: (asset: Asset, opts?: { initialTime?: number; resetMarks?: boolean }) => void pause: () => void dispatchKeyboardAction: (action: SourceKeyboardAction) => void } type SourceMarker = 'sourceIn' | 'sourceOut' | null const FRAME_DURATION = 1 / 24 const LOOP_EPSILON = 0.01 function getLoopRange(sourceIn: number | null, sourceOut: number | null, duration: number) { const start = sourceIn ?? 0 const end = sourceOut ?? duration return start <= end ? { start, end } : { start: end, end: start } } export const VideoEditorSourceMonitor = React.forwardRef(function VideoEditorSourceMonitor(_props, ref) { const { closeSourceMonitor, insertSourceEdit, overwriteSourceEdit, pause: pauseTimelinePlayback, setHasSourceAsset, stopShuttle, } = useEditorActions() const hasSourceAsset = useEditorStore(selectHasSourceAsset) const [sourceAsset, setSourceAsset] = useState(null) const [sourceTime, setSourceTime] = useState(0) const [sourceIsPlaying, setSourceIsPlaying] = useState(false) const [sourceIn, setSourceIn] = useState(null) const [sourceOut, setSourceOut] = useState(null) const [sourcePlayingInOut, setSourcePlayingInOut] = useState(false) const [sourceReversePlaying, setSourceReversePlaying] = useState(false) const [draggingMarker, setDraggingMarker] = useState(null) const sourceVideoRef = useRef(null) const sourceAnimRef = useRef(0) const reverseRafRef = useRef(null) const reverseLastRef = useRef(null) const pendingSeekRef = useRef(null) const sourceTimeRef = useRef(0) sourceTimeRef.current = sourceTime const sourceInRef = useRef(null) sourceInRef.current = sourceIn const sourceOutRef = useRef(null) sourceOutRef.current = sourceOut const pause = useCallback(() => { sourceVideoRef.current?.pause() setSourceIsPlaying(false) setSourceReversePlaying(false) }, []) const stopPlayback = useCallback(() => { sourceVideoRef.current?.pause() setSourceIsPlaying(false) setSourcePlayingInOut(false) setSourceReversePlaying(false) }, []) const seekTo = useCallback((t: number) => { setSourceTime(t) if (sourceVideoRef.current) sourceVideoRef.current.currentTime = t }, []) const emitInsertRequest = useCallback(() => { if (!sourceAsset) return insertSourceEdit({ asset: sourceAsset, sourceIn, sourceOut, sourceTime }) }, [insertSourceEdit, sourceAsset, sourceIn, sourceOut, sourceTime]) const emitOverwriteRequest = useCallback(() => { if (!sourceAsset) return overwriteSourceEdit({ asset: sourceAsset, sourceIn, sourceOut, sourceTime }) }, [overwriteSourceEdit, sourceAsset, sourceIn, sourceOut, sourceTime]) const dispatchKeyboardAction = useCallback((action: SourceKeyboardAction) => { switch (action) { case 'transport.playPause': setSourceReversePlaying(false) if (sourceIsPlaying) { pause() } else { pauseTimelinePlayback() stopShuttle() if (sourceVideoRef.current) { const duration = sourceAsset?.duration || 5 const { start: loopStart, end: loopEnd } = getLoopRange(sourceIn, sourceOut, duration) const looping = sourcePlayingInOut if (looping) { if (sourceTimeRef.current < loopStart || sourceTimeRef.current >= loopEnd - LOOP_EPSILON) { sourceVideoRef.current.currentTime = loopStart setSourceTime(loopStart) } } else if (sourceIn !== null && sourceTimeRef.current < sourceIn) { sourceVideoRef.current.currentTime = sourceIn setSourceTime(sourceIn) } sourceVideoRef.current.play().catch(() => {}) } setSourceIsPlaying(true) } return case 'transport.shuttleReverse': { setSourceReversePlaying(false) const video = sourceVideoRef.current if (!video) return video.pause() setSourceIsPlaying(false) const t = Math.max(0, video.currentTime - FRAME_DURATION) video.currentTime = t setSourceTime(t) return } case 'transport.shuttleStop': stopPlayback() return case 'transport.shuttleForward': setSourceReversePlaying(false) pauseTimelinePlayback() stopShuttle() if (sourceVideoRef.current) sourceVideoRef.current.play().catch(() => {}) setSourceIsPlaying(true) return case 'transport.stepBackward': { setSourceReversePlaying(false) const t = Math.max(0, sourceTimeRef.current - FRAME_DURATION) seekTo(t) return } case 'transport.stepForward': { setSourceReversePlaying(false) const dur = sourceAsset?.duration || 5 const t = Math.min(dur, sourceTimeRef.current + FRAME_DURATION) seekTo(t) return } case 'transport.jumpBackward': { setSourceReversePlaying(false) const t = Math.max(0, sourceTimeRef.current - 1) seekTo(t) return } case 'transport.jumpForward': { setSourceReversePlaying(false) const dur = sourceAsset?.duration || 5 const t = Math.min(dur, sourceTimeRef.current + 1) seekTo(t) return } case 'mark.setIn': { const st = sourceTimeRef.current setSourceIn(prev => prev !== null && Math.abs(prev - st) < 0.01 ? null : st) return } case 'mark.setOut': { const st = sourceTimeRef.current setSourceOut(prev => prev !== null && Math.abs(prev - st) < 0.01 ? null : st) return } case 'mark.clearIn': setSourceIn(null) return case 'mark.clearOut': setSourceOut(null) return case 'mark.clearInOut': setSourceIn(null) setSourceOut(null) return case 'edit.insertEdit': emitInsertRequest() return case 'edit.overwriteEdit': emitOverwriteRequest() return default: return } }, [ emitInsertRequest, emitOverwriteRequest, pause, pauseTimelinePlayback, seekTo, sourceAsset, sourceIn, sourceIsPlaying, sourceOut, sourcePlayingInOut, stopPlayback, stopShuttle, ]) const openAsset = useCallback((asset: Asset, opts?: { initialTime?: number; resetMarks?: boolean }) => { const nextTime = Math.max(0, opts?.initialTime ?? 0) const shouldResetMarks = opts?.resetMarks ?? true stopPlayback() setSourceAsset(asset) setSourceTime(nextTime) if (shouldResetMarks) { setSourceIn(null) setSourceOut(null) } pendingSeekRef.current = nextTime }, [stopPlayback]) React.useImperativeHandle(ref, () => ({ openAsset, pause, dispatchKeyboardAction, }), [dispatchKeyboardAction, openAsset, pause]) useEffect(() => { const hasAsset = !!sourceAsset if (hasSourceAsset === hasAsset) return setHasSourceAsset(hasAsset) }, [hasSourceAsset, setHasSourceAsset, sourceAsset]) useEffect(() => () => { setHasSourceAsset(false) }, [setHasSourceAsset]) useEffect(() => { if (!sourceAsset || sourceAsset.type !== 'video') return const pending = pendingSeekRef.current if (pending === null || !sourceVideoRef.current) return sourceVideoRef.current.currentTime = pending pendingSeekRef.current = null }, [sourceAsset]) useEffect(() => { if (!sourceIsPlaying || !sourceVideoRef.current) { cancelAnimationFrame(sourceAnimRef.current) return } const video = sourceVideoRef.current const duration = sourceAsset?.duration || 5 const { start: loopStart, end: loopEnd } = getLoopRange(sourceIn, sourceOut, duration) const tick = () => { setSourceTime(video.currentTime) if (sourcePlayingInOut && video.currentTime >= loopEnd - LOOP_EPSILON) { video.currentTime = loopStart setSourceTime(loopStart) video.play().catch(() => {}) } else if (!sourcePlayingInOut && sourceOut !== null && video.currentTime >= sourceOut) { video.pause() setSourceIsPlaying(false) setSourceTime(sourceOut) return } if (sourcePlayingInOut || !video.paused) sourceAnimRef.current = requestAnimationFrame(tick) } video.play().catch(() => {}) sourceAnimRef.current = requestAnimationFrame(tick) return () => cancelAnimationFrame(sourceAnimRef.current) }, [sourceAsset, sourceIn, sourceIsPlaying, sourceOut, sourcePlayingInOut]) useEffect(() => { if (!sourceReversePlaying) { if (reverseRafRef.current) cancelAnimationFrame(reverseRafRef.current) reverseRafRef.current = null reverseLastRef.current = null return } sourceVideoRef.current?.pause() const tick = (ts: number) => { if (!sourceReversePlaying) return if (reverseLastRef.current !== null) { const delta = (ts - reverseLastRef.current) / 1000 const duration = sourceAsset?.duration || 5 const { start: loopStart, end: loopEnd } = getLoopRange(sourceInRef.current, sourceOutRef.current, duration) let next = Math.max(0, (sourceVideoRef.current?.currentTime ?? sourceTimeRef.current) - delta) if (sourcePlayingInOut && next <= loopStart + LOOP_EPSILON) { next = loopEnd } if (sourceVideoRef.current) sourceVideoRef.current.currentTime = next setSourceTime(next) if (!sourcePlayingInOut && next <= 0) { setSourceReversePlaying(false) return } } reverseLastRef.current = ts reverseRafRef.current = requestAnimationFrame(tick) } reverseRafRef.current = requestAnimationFrame(tick) return () => { if (reverseRafRef.current) cancelAnimationFrame(reverseRafRef.current) } }, [sourceAsset, sourcePlayingInOut, sourceReversePlaying]) useEffect(() => { if (!draggingMarker) return const handleMouseMove = (e: MouseEvent) => { const scrubEl = document.getElementById('source-scrub-bar') if (!scrubEl) return const rect = scrubEl.getBoundingClientRect() const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)) const dur = sourceAsset?.duration || 5 let time = pct * dur if (draggingMarker === 'sourceIn' && sourceOutRef.current !== null) { time = Math.min(time, sourceOutRef.current - 0.01) } if (draggingMarker === 'sourceOut' && sourceInRef.current !== null) { time = Math.max(time, sourceInRef.current + 0.01) } time = Math.max(0, Math.min(time, dur)) if (draggingMarker === 'sourceIn') { setSourceIn(time) } else { setSourceOut(time) } } const handleMouseUp = () => setDraggingMarker(null) window.addEventListener('mousemove', handleMouseMove) window.addEventListener('mouseup', handleMouseUp) return () => { window.removeEventListener('mousemove', handleMouseMove) window.removeEventListener('mouseup', handleMouseUp) } }, [draggingMarker, sourceAsset]) return (
Clip Viewer
{sourceAsset ? ( <> {sourceAsset.type === 'video' ? (
{sourceAsset && (sourceAsset.type === 'video' || sourceAsset.type === 'audio') && (
{ const bar = e.currentTarget const rect = bar.getBoundingClientRect() const dur = sourceAsset.duration || 5 const seek = (clientX: number) => { const frac = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)) const t = frac * dur seekTo(t) } seek(e.clientX) const onMove = (ev: MouseEvent) => seek(ev.clientX) const onUp = () => { window.removeEventListener('mousemove', onMove) window.removeEventListener('mouseup', onUp) } window.addEventListener('mousemove', onMove) window.addEventListener('mouseup', onUp) }} >
{sourceIn !== null && (
)} {sourceOut !== null && (
)} {(sourceIn !== null || sourceOut !== null) && (
)} {sourceIn !== null && (
{ e.stopPropagation(); e.preventDefault(); setDraggingMarker('sourceIn') }} >
)} {sourceOut !== null && (
{ e.stopPropagation(); e.preventDefault(); setDraggingMarker('sourceOut') }} >
)}
{(sourceIn !== null || sourceOut !== null) && (
{sourceIn !== null ? `IN ${formatTime(sourceIn)}` : ''} {sourceIn !== null && sourceOut !== null ? `Duration: ${formatTime(sourceOut - sourceIn)}` : ''} {sourceOut !== null ? `OUT ${formatTime(sourceOut)}` : ''}
)}
)}
{formatTime(sourceTime)}
{formatTime(sourceAsset?.duration || 0)}
) })