diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index c9bdf58c..cf271ac0 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -2066,10 +2066,19 @@ export default function VideoEditor() { if (!video.paused && !video.ended) { playback.pause(); } else { + // Selection awareness: if playing with a selection active, jump to start if out of bounds + if (timeSelection) { + const currentTimeMs = Math.round(currentTime * 1000); + const bufferMs = 50; // Small buffer for end boundary + if (currentTimeMs < timeSelection.startMs || currentTimeMs >= timeSelection.endMs - bufferMs) { + handleSeek(timeSelection.startMs / 1000); + } + } playback.play().catch((err) => console.error("Video play failed:", err)); } } + function handleSeek(time: number) { const video = videoPlaybackRef.current?.video; if (!video) return; @@ -3653,7 +3662,9 @@ export default function VideoEditor() { cursorClickBounce={cursorClickBounce} cursorClickBounceDuration={cursorClickBounceDuration} cursorSway={cursorSway} + timeSelection={timeSelection} /> + diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 42b642fc..a1fdf222 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -202,8 +202,10 @@ interface VideoPlaybackProps { cursorClickBounceDuration?: number; cursorSway?: number; volume?: number; + timeSelection?: import("./types").TimeSelection | null; } + export interface VideoPlaybackRef { video: HTMLVideoElement | null; app: Application | null; @@ -269,7 +271,9 @@ const VideoPlayback = forwardRef( cursorClickBounceDuration = DEFAULT_CURSOR_CLICK_BOUNCE_DURATION, cursorSway = DEFAULT_CURSOR_SWAY, volume = 1, + timeSelection = null, }, + ref, ) => { const videoRef = useRef(null); @@ -347,6 +351,8 @@ const VideoPlayback = forwardRef( const cursorClickBounceRef = useRef(cursorClickBounce); const cursorClickBounceDurationRef = useRef(cursorClickBounceDuration); const cursorSwayRef = useRef(cursorSway); + const timeSelectionRef = useRef(timeSelection); + const activeCaptionLayout = useMemo(() => { if (!autoCaptionSettings?.enabled || autoCaptions.length === 0 || typeof document === "undefined") { @@ -841,6 +847,11 @@ const VideoPlayback = forwardRef( cursorSwayRef.current = cursorSway; }, [cursorSway]); + useEffect(() => { + timeSelectionRef.current = timeSelection; + }, [timeSelection]); + + useEffect(() => { currentTimeRef.current = currentTime * 1000; }, [currentTime]); @@ -1183,8 +1194,10 @@ const VideoPlayback = forwardRef( onTimeUpdate, trimRegionsRef, speedRegionsRef, + timeSelectionRef, }); + video.addEventListener("play", handlePlay); video.addEventListener("pause", handlePause); video.addEventListener("ended", handlePause); diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index ff11ab45..c3edf44d 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -238,13 +238,17 @@ function PlaybackCursor({ onSeek, timelineRef, keyframes = [], + timeSelection = null, }: { + currentTimeMs: number; videoDurationMs: number; onSeek?: (time: number) => void; timelineRef: React.RefObject; keyframes?: { id: string; time: number }[]; + timeSelection?: import('../types').TimeSelection | null; }) { + const { sidebarWidth = 0, direction, range, valueToPixels, pixelsToValue } = useTimelineContext(); const sideProperty = direction === "rtl" ? "right" : "left"; const [isDragging, setIsDragging] = useState(false); @@ -278,11 +282,25 @@ function PlaybackCursor({ onSeek(absoluteMs / 1000); }; - const handleMouseUp = () => { + const handleMouseUp = (e: MouseEvent) => { + if (isDragging && timeSelection && onSeek) { + const rect = timelineRef.current?.getBoundingClientRect(); + if (rect) { + const clickX = e.clientX - rect.left - sidebarWidth; + const relativeMs = pixelsToValue(clickX); + const absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs)); + + // If released outside selection, jump back to selection start + if (absoluteMs < timeSelection.startMs || absoluteMs > timeSelection.endMs) { + onSeek(timeSelection.startMs / 1000); + } + } + } setIsDragging(false); document.body.style.cursor = ""; }; + window.addEventListener("mousemove", handleMouseMove); window.addEventListener("mouseup", handleMouseUp); document.body.style.cursor = "ew-resize"; @@ -564,8 +582,10 @@ function Timeline({ const isDraggingSelectionRef = useRef(false); const selectionAnchorMsRef = useRef(null); + const selectionCurrentMsRef = useRef(null); const initialMouseDownPosRef = useRef<{ x: number; y: number } | null>(null); + const handleMouseDown = useCallback( (e: React.MouseEvent) => { // In Move mode, the timeline background never starts a selection drag. @@ -604,9 +624,11 @@ function Timeline({ } else { // Plain drag: anchor starts at the click point itself selectionAnchorMsRef.current = absoluteMs; + selectionCurrentMsRef.current = absoluteMs; onTimeSelectionChange?.({ startMs: absoluteMs, endMs: absoluteMs }); } + const handleGlobalMouseMove = (moveEvent: MouseEvent) => { if (selectionAnchorMsRef.current === null || initialMouseDownPosRef.current === null) return; @@ -621,6 +643,8 @@ function Timeline({ const moveX = moveEvent.clientX - capturedRect.left - sidebarWidth; const moveRelativeMs = pixelsToValue(moveX); const moveAbsoluteMs = Math.max(0, Math.min(range.start + moveRelativeMs, videoDurationMs)); + selectionCurrentMsRef.current = moveAbsoluteMs; + const start = Math.min(selectionAnchorMsRef.current, moveAbsoluteMs); const end = Math.max(selectionAnchorMsRef.current, moveAbsoluteMs); @@ -629,12 +653,21 @@ function Timeline({ }; const handleGlobalMouseUp = () => { + if (isDraggingSelectionRef.current && selectionAnchorMsRef.current !== null && selectionCurrentMsRef.current !== null) { + // When finished dragging a selection, jump playhead to the start of selection + const start = Math.min(selectionAnchorMsRef.current, selectionCurrentMsRef.current); + onSeek?.(start / 1000); + } + selectionAnchorMsRef.current = null; + selectionCurrentMsRef.current = null; initialMouseDownPosRef.current = null; window.removeEventListener("mousemove", handleGlobalMouseMove); window.removeEventListener("mouseup", handleGlobalMouseUp); }; + + window.addEventListener("mousemove", handleGlobalMouseMove); window.addEventListener("mouseup", handleGlobalMouseUp); }, @@ -655,11 +688,9 @@ function Timeline({ // In Select mode, shift+click updates the selection (already done in mousedown) — don't seek if (timelineMode === 'select' && e.shiftKey) return; - // Plain click: deselect all blocks, then seek - // Only clear timeSelection in Select mode; in Move mode leave it as-is - if (timelineMode === 'select') { - onTimeSelectionChange?.(null); - } + // Plain click: deselect all blocks, then seek and clear time selection + onTimeSelectionChange?.(null); + onSelectZoom?.(null); onSelectTrim?.(null); onSelectAnnotation?.(null); @@ -834,8 +865,10 @@ function Timeline({ onSeek={onSeek} timelineRef={localTimelineRef} keyframes={keyframes} + timeSelection={timeSelection} /> +
{zoomItems.map((item) => ( diff --git a/src/components/video-editor/videoPlayback/videoEventHandlers.ts b/src/components/video-editor/videoPlayback/videoEventHandlers.ts index 9ee7ae54..25b5db61 100644 --- a/src/components/video-editor/videoPlayback/videoEventHandlers.ts +++ b/src/components/video-editor/videoPlayback/videoEventHandlers.ts @@ -12,8 +12,10 @@ interface VideoEventHandlersParams { onTimeUpdate: (time: number) => void; trimRegionsRef: React.MutableRefObject; speedRegionsRef: React.MutableRefObject; + timeSelectionRef: React.MutableRefObject; } + export function createVideoEventHandlers(params: VideoEventHandlersParams) { const { video, @@ -26,8 +28,10 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) { onTimeUpdate, trimRegionsRef, speedRegionsRef, + timeSelectionRef, } = params; + const emitTime = (timeValue: number) => { currentTimeRef.current = timeValue * 1000; onTimeUpdate(timeValue); @@ -52,8 +56,21 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) { if (!video) return; const currentTimeMs = video.currentTime * 1000; + + // Selection awareness: stop playback at the end of selection range + const selection = timeSelectionRef.current; + if (selection && !video.paused && !isSeekingRef.current) { + if (currentTimeMs >= selection.endMs) { + video.pause(); + video.currentTime = selection.startMs / 1000; + emitTime(selection.startMs / 1000); + return; // Selection boundary reached, stop update loop + } + } + const activeTrimRegion = findActiveTrimRegion(currentTimeMs); + // If we're in a trim region during playback, skip to the end of it if (activeTrimRegion && !video.paused && !video.ended) { const skipToTime = activeTrimRegion.endMs / 1000;