diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 775f0185..164c6e81 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -105,6 +105,7 @@ import { type ZoomFocus, type ZoomRegion, type ZoomTransitionEasing, + type TimeSelection, } from "./types"; import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback"; import { @@ -418,6 +419,8 @@ export default function VideoEditor() { const [previewVersion, setPreviewVersion] = useState(0); const [isPreviewReady, setIsPreviewReady] = useState(false); const [autoSuggestZoomsTrigger, setAutoSuggestZoomsTrigger] = useState(0); + const [timelineMode, setTimelineMode] = useState<'move' | 'select'>('move'); + const [timeSelection, setTimeSelection] = useState(null); const headerLeftControlsPaddingClass = appPlatform === "darwin" ? "pl-[76px]" : ""; const videoPlaybackRef = useRef(null); @@ -1879,6 +1882,14 @@ 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)); } } @@ -2315,14 +2326,13 @@ export default function VideoEditor() { return; } e.preventDefault(); - - const playback = videoPlaybackRef.current; - if (playback?.video) { - if (playback.video.paused) { - playback.play().catch(console.error); - } else { - playback.pause(); - } + togglePlayPause(); + } + if (!isEditableTarget) { + if (key === "v" || matchesShortcut(e, shortcuts.moveMode, isMac)) { + setTimelineMode("move"); + } else if (key === "e" || matchesShortcut(e, shortcuts.selectMode, isMac)) { + setTimelineMode("select"); } } }; @@ -3300,6 +3310,7 @@ export default function VideoEditor() { cursorClickBounce={cursorClickBounce} cursorClickBounceDuration={cursorClickBounceDuration} cursorSway={cursorSway} + timeSelection={timeSelection} volume={previewVolume} /> @@ -3341,6 +3352,10 @@ export default function VideoEditor() { videoDuration={duration} currentTime={currentTime} onSeek={handleSeek} + timelineMode={timelineMode} + onTimelineModeChange={setTimelineMode} + timeSelection={timeSelection} + onTimeSelectionChange={setTimeSelection} cursorTelemetry={normalizedCursorTelemetry} autoSuggestZoomsTrigger={autoSuggestZoomsTrigger} zoomRegions={effectiveZoomRegions} diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 55431df1..76c2fdc8 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -38,6 +38,7 @@ import { type CursorStyle, type WebcamOverlaySettings, type ZoomTransitionEasing, + type TimeSelection, } from "./types"; import { DEFAULT_FOCUS, @@ -202,6 +203,7 @@ interface VideoPlaybackProps { cursorClickBounce?: number; cursorClickBounceDuration?: number; cursorSway?: number; + timeSelection?: TimeSelection | null; volume?: number; } @@ -269,6 +271,7 @@ const VideoPlayback = forwardRef( cursorClickBounce = DEFAULT_CURSOR_CLICK_BOUNCE, cursorClickBounceDuration = DEFAULT_CURSOR_CLICK_BOUNCE_DURATION, cursorSway = DEFAULT_CURSOR_SWAY, + timeSelection = null, volume = 1, }, ref, @@ -323,6 +326,8 @@ const VideoPlayback = forwardRef( width: number; height: number; } | null>(null); + const timeSelectionRef = useRef(null); + timeSelectionRef.current = timeSelection; const layoutVideoContentRef = useRef<(() => void) | null>(null); const trimRegionsRef = useRef([]); const speedRegionsRef = useRef([]); @@ -1183,6 +1188,7 @@ const VideoPlayback = forwardRef( onTimeUpdate, trimRegionsRef, speedRegionsRef, + timeSelectionRef, }); video.addEventListener("play", handlePlay); diff --git a/src/components/video-editor/timeline/Item.tsx b/src/components/video-editor/timeline/Item.tsx index cc387ea9..61226381 100644 --- a/src/components/video-editor/timeline/Item.tsx +++ b/src/components/video-editor/timeline/Item.tsx @@ -15,6 +15,7 @@ interface ItemProps { zoomDepth?: number; speedValue?: number; variant?: 'zoom' | 'trim' | 'annotation' | 'speed' | 'audio'; + timelineMode?: 'move' | 'select'; } // Map zoom depth to multiplier labels @@ -46,11 +47,13 @@ export default function Item({ zoomDepth = 1, speedValue, variant = "zoom", + timelineMode = "move", children, }: ItemProps) { const { setNodeRef, attributes, listeners, itemStyle, itemContentStyle } = useItem({ id, span, + disabled: timelineMode !== 'move', data: { rowId }, }); @@ -91,33 +94,40 @@ export default function Item({
onSelect?.()} + onMouseDown={(e) => { + if (timelineMode === 'select') { + e.stopPropagation(); + } + }} className="group h-full" >
{ + onClick={(event) => { + // Prevent the timeline background's onClick (seeking) from firing + // when we click on a track item. onSelect is already fired on pointer-down capture. event.stopPropagation(); - onSelect?.(); }} >
{/* Content */}
diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 386d2496..70961fd5 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -7,7 +7,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { Plus, Scissors, ZoomIn, MessageSquare, ChevronDown, Check, Gauge, WandSparkles, Music, Crop } from "lucide-react"; +import { Plus, Scissors, ZoomIn, MessageSquare, ChevronDown, Check, Gauge, WandSparkles, Music, Crop, MousePointer2, BoxSelect } from "lucide-react"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; import { v4 as uuidv4 } from 'uuid'; @@ -23,7 +23,7 @@ import Row from "./Row"; import Item from "./Item"; import KeyframeMarkers from "./KeyframeMarkers"; import type { Range, Span } from "dnd-timeline"; -import type { ZoomRegion, TrimRegion, AnnotationRegion, SpeedRegion, AudioRegion, CursorTelemetryPoint, ZoomFocus } from "../types"; +import type { ZoomRegion, TrimRegion, AnnotationRegion, SpeedRegion, AudioRegion, CursorTelemetryPoint, ZoomFocus, TimeSelection } from "../types"; import { toFileUrl } from "../projectPersistence"; import { detectInteractionCandidates, normalizeCursorTelemetry } from "./zoomSuggestionUtils"; @@ -78,6 +78,10 @@ interface TimelineEditorProps { onAspectRatioChange: (aspectRatio: AspectRatio) => void; onOpenCropEditor?: () => void; isCropped?: boolean; + timeSelection?: TimeSelection | null; + onTimeSelectionChange?: (selection: TimeSelection | null) => void; + timelineMode?: 'move' | 'select'; + onTimelineModeChange?: (mode: 'move' | 'select') => void; } interface TimelineScaleConfig { @@ -208,12 +212,14 @@ function PlaybackCursor({ onSeek, timelineRef, keyframes = [], + timeSelection = null, }: { currentTimeMs: number; videoDurationMs: number; onSeek?: (time: number) => void; timelineRef: React.RefObject; keyframes?: { id: string; time: number }[]; + timeSelection?: TimeSelection | null; }) { const { sidebarWidth, direction, range, valueToPixels, pixelsToValue } = useTimelineContext(); const sideProperty = direction === "rtl" ? "right" : "left"; @@ -248,7 +254,20 @@ 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 = ""; }; @@ -390,10 +409,16 @@ function TimelineAxis({ return (
{ + (e.currentTarget.parentElement as any)?.__handleMouseDown?.(e); + }} + onClick={(e) => { + (e.currentTarget.parentElement as any)?.__handleTimelineClick?.(e); + }} > {/* Minor Ticks */} {markers.minorTicks.map((time) => { @@ -458,6 +483,9 @@ function Timeline({ selectAllBlocksActive = false, onClearBlockSelection, keyframes = [], + timelineMode = 'move', + timeSelection = null, + onTimeSelectionChange, }: { items: TimelineRenderItem[]; videoDurationMs: number; @@ -476,9 +504,15 @@ function Timeline({ selectAllBlocksActive?: boolean; onClearBlockSelection?: () => void; keyframes?: { id: string; time: number }[]; + timelineMode?: 'move' | 'select'; + timeSelection?: { startMs: number; endMs: number } | null; + onTimeSelectionChange?: (selection: { startMs: number; endMs: number } | null) => void; }) { - const { setTimelineRef, style, sidebarWidth, range, pixelsToValue } = useTimelineContext(); + const { setTimelineRef, style, sidebarWidth, range, pixelsToValue, valueToPixels } = useTimelineContext(); const localTimelineRef = useRef(null); + const selectionAnchorRef = useRef(null); + const isDraggingSelectionRef = useRef(false); + const DRAG_THRESHOLD_PX = 5; const setRefs = useCallback( (node: HTMLDivElement | null) => { @@ -488,18 +522,131 @@ function Timeline({ [setTimelineRef], ); + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + if (timelineMode !== "select" || !onTimeSelectionChange) return; + + const rect = e.currentTarget.getBoundingClientRect(); + const clickX = e.clientX - rect.left - sidebarWidth; + if (clickX < 0) return; + + isDraggingSelectionRef.current = false; + + const startX = e.clientX; + const startY = e.clientY; + let hasPassedThreshold = false; + + const relativeMs = pixelsToValue(clickX); + const absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs)); + + + + if (e.shiftKey) { + let anchor = selectionAnchorRef.current ?? currentTimeMs; + if (timeSelection) { + const distToStart = Math.abs(timeSelection.startMs - absoluteMs); + const distToEnd = Math.abs(timeSelection.endMs - absoluteMs); + const anchorMs = distToStart > distToEnd ? timeSelection.startMs : timeSelection.endMs; + anchor = anchorMs; + } + const start = Math.min(anchor, absoluteMs); + const end = Math.max(anchor, absoluteMs); + onTimeSelectionChange({ startMs: start, endMs: end }); + } else { + selectionAnchorRef.current = absoluteMs; + onTimeSelectionChange({ startMs: absoluteMs, endMs: absoluteMs }); + + // Clear track selection when starting manual time selection + onSelectZoom?.(null); + onSelectTrim?.(null); + onSelectAnnotation?.(null); + onSelectSpeed?.(null); + onSelectAudio?.(null); + onClearBlockSelection?.(); + } + + const handleMouseMove = (moveEvent: MouseEvent) => { + if (selectionAnchorRef.current === null) return; + + if (!hasPassedThreshold) { + const dx = Math.abs(moveEvent.clientX - startX); + const dy = Math.abs(moveEvent.clientY - startY); + if (dx > DRAG_THRESHOLD_PX || dy > DRAG_THRESHOLD_PX) { + hasPassedThreshold = true; + isDraggingSelectionRef.current = true; + } else { + return; + } + } + + const moveRect = localTimelineRef.current?.getBoundingClientRect(); + if (!moveRect) return; + + const moveX = moveEvent.clientX - moveRect.left - sidebarWidth; + const moveRelativeMs = pixelsToValue(moveX); + const moveAbsoluteMs = Math.max(0, Math.min(range.start + moveRelativeMs, videoDurationMs)); + + const newStart = Math.min(selectionAnchorRef.current, moveAbsoluteMs); + const newEnd = Math.max(selectionAnchorRef.current, moveAbsoluteMs); + + onTimeSelectionChange({ startMs: newStart, endMs: newEnd }); + }; + + const handleMouseUp = (e: MouseEvent) => { + if (isDraggingSelectionRef.current && selectionAnchorRef.current !== null) { + // Snap playhead to selection start when done dragging + const rect = localTimelineRef.current?.getBoundingClientRect(); + if (rect) { + const moveX = e.clientX - rect.left - sidebarWidth; + const moveRelativeMs = pixelsToValue(moveX); + const moveAbsoluteMs = Math.max(0, Math.min(range.start + moveRelativeMs, videoDurationMs)); + const start = Math.min(selectionAnchorRef.current, moveAbsoluteMs); + const end = Math.max(selectionAnchorRef.current, moveAbsoluteMs); + onSeek?.(start / 1000); + // Save drag endpoint as the new anchor for subsequent shift-clicks + selectionAnchorRef.current = end; + } + } + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + }; + + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + }, + [ + timelineMode, + sidebarWidth, + pixelsToValue, + range.start, + videoDurationMs, + onTimeSelectionChange, + timeSelection, + onSelectZoom, onSelectTrim, onSelectAnnotation, onSelectSpeed, onSelectAudio, onClearBlockSelection, + ], + ); + const handleTimelineClick = useCallback( (e: React.MouseEvent) => { + if (isDraggingSelectionRef.current) { + isDraggingSelectionRef.current = false; + return; + } + if (!onSeek || videoDurationMs <= 0) return; - // Only clear selection if clicking on empty space (not on items) - // This is handled by event propagation - items stop propagation - onSelectZoom?.(null); - onSelectTrim?.(null); - onSelectAnnotation?.(null); - onSelectSpeed?.(null); - onSelectAudio?.(null); - onClearBlockSelection?.(); + if (timelineMode === 'select' && e.shiftKey) return; + + // Only clear block selections when clicking empty space in move mode. + // In select mode, block selections are managed via mutual exclusivity in handleSelectXxx. + if (timelineMode !== 'select') { + onSelectZoom?.(null); + onSelectTrim?.(null); + onSelectAnnotation?.(null); + onSelectSpeed?.(null); + onSelectAudio?.(null); + onClearBlockSelection?.(); + } const rect = e.currentTarget.getBoundingClientRect(); const clickX = e.clientX - rect.left - sidebarWidth; @@ -510,8 +657,17 @@ function Timeline({ const absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs)); const timeInSeconds = absoluteMs / 1000; - onSeek(timeInSeconds); - }, [onSeek, onSelectZoom, onSelectTrim, onSelectAnnotation, onSelectSpeed, onSelectAudio, videoDurationMs, sidebarWidth, range.start, pixelsToValue]); + onTimeSelectionChange?.(null); + onSeek(timeInSeconds); + selectionAnchorRef.current = absoluteMs; + }, [onSeek, onSelectZoom, onSelectTrim, onSelectAnnotation, onSelectSpeed, onSelectAudio, videoDurationMs, sidebarWidth, range.start, pixelsToValue, onTimeSelectionChange, timelineMode, onClearBlockSelection]); + + useEffect(() => { + if (localTimelineRef.current) { + (localTimelineRef.current as any).__handleMouseDown = handleMouseDown; + (localTimelineRef.current as any).__handleTimelineClick = handleTimelineClick; + } + }, [handleMouseDown, handleTimelineClick]); const zoomItems = items.filter(item => item.rowId === ZOOM_ROW_ID); const trimItems = items.filter(item => item.rowId === TRIM_ROW_ID); @@ -523,9 +679,20 @@ function Timeline({
+ {timeSelection && ( +
range.end) ? 'none' : 'block' + }} + /> + )}
@@ -548,6 +716,7 @@ function Timeline({ onSelect={() => onSelectZoom?.(item.id)} zoomDepth={item.zoomDepth} variant="zoom" + timelineMode={timelineMode} > {item.label} @@ -564,6 +733,7 @@ function Timeline({ isSelected={selectAllBlocksActive || item.id === selectedTrimId} onSelect={() => onSelectTrim?.(item.id)} variant="trim" + timelineMode={timelineMode} > {item.label} @@ -584,6 +754,7 @@ function Timeline({ isSelected={selectAllBlocksActive || item.id === selectedAnnotationId} onSelect={() => onSelectAnnotation?.(item.id)} variant="annotation" + timelineMode={timelineMode} > {item.label} @@ -601,6 +772,7 @@ function Timeline({ onSelect={() => onSelectSpeed?.(item.id)} variant="speed" speedValue={item.speedValue} + timelineMode={timelineMode} > {item.label} @@ -617,6 +789,7 @@ function Timeline({ isSelected={selectAllBlocksActive || item.id === selectedAudioId} onSelect={() => onSelectAudio?.(item.id)} variant="audio" + timelineMode={timelineMode} > {item.label} @@ -669,6 +842,10 @@ export default function TimelineEditor({ onAspectRatioChange, onOpenCropEditor, isCropped = false, + timeSelection = null, + onTimeSelectionChange, + timelineMode = 'move', + onTimelineModeChange, }: TimelineEditorProps) { const t = useScopedT("settings"); const initialEditorPreferences = useMemo(() => loadEditorPreferences(), []); @@ -842,28 +1019,63 @@ export default function TimelineEditor({ const handleSelectZoom = useCallback((id: string | null) => { setSelectAllBlocksActive(false); - onSelectZoom(id); - }, [onSelectZoom]); + onSelectZoom?.(id); + if (id) { + onSelectTrim?.(null); + onSelectAnnotation?.(null); + onSelectSpeed?.(null); + onSelectAudio?.(null); + onTimeSelectionChange?.(null); + } + }, [onSelectZoom, onSelectTrim, onSelectAnnotation, onSelectSpeed, onSelectAudio, onTimeSelectionChange]); const handleSelectTrim = useCallback((id: string | null) => { setSelectAllBlocksActive(false); onSelectTrim?.(id); - }, [onSelectTrim]); + if (id) { + onSelectZoom?.(null); + onSelectAnnotation?.(null); + onSelectSpeed?.(null); + onSelectAudio?.(null); + onTimeSelectionChange?.(null); + } + }, [onSelectZoom, onSelectTrim, onSelectAnnotation, onSelectSpeed, onSelectAudio, onTimeSelectionChange]); const handleSelectAnnotation = useCallback((id: string | null) => { setSelectAllBlocksActive(false); onSelectAnnotation?.(id); - }, [onSelectAnnotation]); + if (id) { + onSelectZoom?.(null); + onSelectTrim?.(null); + onSelectSpeed?.(null); + onSelectAudio?.(null); + onTimeSelectionChange?.(null); + } + }, [onSelectZoom, onSelectTrim, onSelectAnnotation, onSelectSpeed, onSelectAudio, onTimeSelectionChange]); const handleSelectSpeed = useCallback((id: string | null) => { setSelectAllBlocksActive(false); onSelectSpeed?.(id); - }, [onSelectSpeed]); + if (id) { + onSelectZoom?.(null); + onSelectTrim?.(null); + onSelectAnnotation?.(null); + onSelectAudio?.(null); + onTimeSelectionChange?.(null); + } + }, [onSelectZoom, onSelectTrim, onSelectAnnotation, onSelectSpeed, onSelectAudio, onTimeSelectionChange]); const handleSelectAudio = useCallback((id: string | null) => { setSelectAllBlocksActive(false); onSelectAudio?.(id); - }, [onSelectAudio]); + if (id) { + onSelectZoom?.(null); + onSelectTrim?.(null); + onSelectAnnotation?.(null); + onSelectSpeed?.(null); + onTimeSelectionChange?.(null); + } + }, [onSelectZoom, onSelectTrim, onSelectAnnotation, onSelectSpeed, onSelectAudio, onTimeSelectionChange]); useEffect(() => { setRange(createInitialRange(totalMs)); @@ -993,25 +1205,27 @@ export default function TimelineEditor({ return; } - // Always place zoom at playhead - const startPos = Math.max(0, Math.min(currentTimeMs, totalMs)); - // Find the next zoom region after the playhead + // Use time selection if available, otherwise place at playhead + const startPos = timeSelection ? timeSelection.startMs : Math.max(0, Math.min(currentTimeMs, totalMs)); + const duration = timeSelection ? (timeSelection.endMs - timeSelection.startMs) : defaultRegionDurationMs; + + // Find the next zoom region after the start position const sorted = [...zoomRegions].sort((a, b) => a.startMs - b.startMs); const nextRegion = sorted.find(region => region.startMs > startPos); const gapToNext = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos; - // Check if playhead is inside any zoom region + // Check if start position is inside any zoom region const isOverlapping = sorted.some(region => startPos >= region.startMs && startPos < region.endMs); - if (isOverlapping || gapToNext <= 0) { + if (isOverlapping || gapToNext <= 0 || (timeSelection && duration > gapToNext)) { toast.error("Cannot place zoom here", { description: "Zoom already exists at this location or not enough space available.", }); return; } - const actualDuration = Math.min(defaultRegionDurationMs, gapToNext); + const actualDuration = Math.min(duration, gapToNext); onZoomAdded({ start: startPos, end: startPos + actualDuration }); - }, [videoDuration, totalMs, currentTimeMs, zoomRegions, onZoomAdded, defaultRegionDurationMs]); + }, [videoDuration, totalMs, currentTimeMs, zoomRegions, onZoomAdded, defaultRegionDurationMs, timeSelection]); const handleSuggestZooms = useCallback(() => { if (!videoDuration || videoDuration === 0 || totalMs === 0) { @@ -1129,25 +1343,27 @@ export default function TimelineEditor({ return; } - // Always place trim at playhead - const startPos = Math.max(0, Math.min(currentTimeMs, totalMs)); - // Find the next trim region after the playhead + // Use time selection if available, otherwise place at playhead + const startPos = timeSelection ? timeSelection.startMs : Math.max(0, Math.min(currentTimeMs, totalMs)); + const duration = timeSelection ? (timeSelection.endMs - timeSelection.startMs) : defaultRegionDurationMs; + + // Find the next trim region after the start position const sorted = [...trimRegions].sort((a, b) => a.startMs - b.startMs); const nextRegion = sorted.find(region => region.startMs > startPos); const gapToNext = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos; - // Check if playhead is inside any trim region + // Check if start position is inside any trim region const isOverlapping = sorted.some(region => startPos >= region.startMs && startPos < region.endMs); - if (isOverlapping || gapToNext <= 0) { + if (isOverlapping || gapToNext <= 0 || (timeSelection && duration > gapToNext)) { toast.error("Cannot place trim here", { description: "Trim already exists at this location or not enough space available.", }); return; } - const actualDuration = Math.min(defaultRegionDurationMs, gapToNext); + const actualDuration = Math.min(duration, gapToNext); onTrimAdded({ start: startPos, end: startPos + actualDuration }); - }, [videoDuration, totalMs, currentTimeMs, trimRegions, onTrimAdded, defaultRegionDurationMs]); + }, [videoDuration, totalMs, currentTimeMs, trimRegions, onTrimAdded, defaultRegionDurationMs, timeSelection]); const handleAddSpeed = useCallback(() => { if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onSpeedAdded) { @@ -1159,25 +1375,27 @@ export default function TimelineEditor({ return; } - // Always place speed region at playhead - const startPos = Math.max(0, Math.min(currentTimeMs, totalMs)); - // Find the next speed region after the playhead + // Use time selection if available, otherwise place at playhead + const startPos = timeSelection ? timeSelection.startMs : Math.max(0, Math.min(currentTimeMs, totalMs)); + const duration = timeSelection ? (timeSelection.endMs - timeSelection.startMs) : defaultRegionDurationMs; + + // Find the next speed region after the start position const sorted = [...speedRegions].sort((a, b) => a.startMs - b.startMs); const nextRegion = sorted.find(region => region.startMs > startPos); const gapToNext = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos; - // Check if playhead is inside any speed region + // Check if start position is inside any speed region const isOverlapping = sorted.some(region => startPos >= region.startMs && startPos < region.endMs); - if (isOverlapping || gapToNext <= 0) { + if (isOverlapping || gapToNext <= 0 || (timeSelection && duration > gapToNext)) { toast.error("Cannot place speed here", { description: "Speed region already exists at this location or not enough space available.", }); return; } - const actualDuration = Math.min(defaultRegionDurationMs, gapToNext); + const actualDuration = Math.min(duration, gapToNext); onSpeedAdded({ start: startPos, end: startPos + actualDuration }); - }, [videoDuration, totalMs, currentTimeMs, speedRegions, onSpeedAdded, defaultRegionDurationMs]); + }, [videoDuration, totalMs, currentTimeMs, speedRegions, onSpeedAdded, defaultRegionDurationMs, timeSelection]); const handleAddAudio = useCallback(async () => { if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onAudioAdded) { @@ -1237,12 +1455,13 @@ export default function TimelineEditor({ return; } - // Multiple annotations can exist at the same timestamp - const startPos = Math.max(0, Math.min(currentTimeMs, totalMs)); - const endPos = Math.min(startPos + defaultDuration, totalMs); + // Use time selection if available, multiple annotations can exist at the same timestamp + const startPos = timeSelection ? timeSelection.startMs : Math.max(0, Math.min(currentTimeMs, totalMs)); + const duration = timeSelection ? (timeSelection.endMs - timeSelection.startMs) : defaultDuration; + const endPos = Math.min(startPos + duration, totalMs); onAnnotationAdded({ start: startPos, end: endPos }); - }, [videoDuration, totalMs, currentTimeMs, onAnnotationAdded, defaultRegionDurationMs]); + }, [videoDuration, totalMs, currentTimeMs, onAnnotationAdded, defaultRegionDurationMs, timeSelection]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -1484,7 +1703,39 @@ export default function TimelineEditor({ return (
-
+
+
+ + +
+ +
diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index 5406210c..b6960c2f 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -336,3 +336,8 @@ function clamp(value: number, min: number, max: number) { if (Number.isNaN(value)) return (min + max) / 2; return Math.min(max, Math.max(min, value)); } + +export interface TimeSelection { + startMs: number; + endMs: number; +} diff --git a/src/components/video-editor/videoPlayback/videoEventHandlers.ts b/src/components/video-editor/videoPlayback/videoEventHandlers.ts index 9ee7ae54..c595128d 100644 --- a/src/components/video-editor/videoPlayback/videoEventHandlers.ts +++ b/src/components/video-editor/videoPlayback/videoEventHandlers.ts @@ -1,5 +1,5 @@ import type React from 'react'; -import type { TrimRegion, SpeedRegion } from '../types'; +import type { TrimRegion, SpeedRegion, TimeSelection } from '../types'; interface VideoEventHandlersParams { video: HTMLVideoElement; @@ -12,6 +12,7 @@ interface VideoEventHandlersParams { onTimeUpdate: (time: number) => void; trimRegionsRef: React.MutableRefObject; speedRegionsRef: React.MutableRefObject; + timeSelectionRef: React.MutableRefObject; } export function createVideoEventHandlers(params: VideoEventHandlersParams) { @@ -26,6 +27,7 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) { onTimeUpdate, trimRegionsRef, speedRegionsRef, + timeSelectionRef, } = params; const emitTime = (timeValue: number) => { @@ -69,6 +71,35 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) { // Apply playback speed from active speed region const activeSpeedRegion = findActiveSpeedRegion(currentTimeMs); video.playbackRate = activeSpeedRegion ? activeSpeedRegion.speed : 1; + + const timeSelection = timeSelectionRef.current; + if ( + timeSelection && + (currentTimeMs >= timeSelection.endMs || currentTimeMs < timeSelection.startMs) && + !isSeekingRef.current && + !video.paused && + !video.ended + ) { + const loopDuration = timeSelection.endMs - timeSelection.startMs; + if (loopDuration > 100) { + // Use a local variable to avoid reading back from currentTime before seek settles + const loopStartSec = timeSelection.startMs / 1000; + isSeekingRef.current = true; + video.currentTime = loopStartSec; + emitTime(loopStartSec); + + // Continue the update loop + timeUpdateAnimationRef.current = requestAnimationFrame(updateTime); + return; + } else { + // Selection too short to loop — stop playback at the selection boundary + video.pause(); + const clampedSec = timeSelection.startMs / 1000; + video.currentTime = clampedSec; + emitTime(clampedSec); + return; + } + } emitTime(video.currentTime); } diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts index 0a82fe2f..ef931768 100644 --- a/src/lib/shortcuts.ts +++ b/src/lib/shortcuts.ts @@ -6,6 +6,8 @@ export const SHORTCUT_ACTIONS = [ 'addKeyframe', 'deleteSelected', 'playPause', + 'moveMode', + 'selectMode', ] as const; export type ShortcutAction = (typeof SHORTCUT_ACTIONS)[number]; @@ -73,6 +75,8 @@ export const DEFAULT_SHORTCUTS: ShortcutsConfig = { addKeyframe: { key: 'f' }, deleteSelected: { key: 'd', ctrl: true }, playPause: { key: ' ' }, + moveMode: { key: 'v' }, + selectMode: { key: 'e' }, }; export const SHORTCUT_LABELS: Record = { @@ -83,6 +87,8 @@ export const SHORTCUT_LABELS: Record = { addKeyframe: 'Add Keyframe', deleteSelected: 'Delete Selected', playPause: 'Play / Pause', + moveMode: 'Move Mode', + selectMode: 'Select Mode', }; export function matchesShortcut(