diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 2b8b940..99fcefd 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -27,6 +27,7 @@ let videoEl: HTMLVideoElement | undefined = $state(); let videoInput: HTMLInputElement | undefined = $state(); let projectInput: HTMLInputElement | undefined = $state(); + let timeline: Timeline | undefined = $state(); let controller: VideoController | null = null; let audioEngine = createAudioEngine(); let statusMessage = $state(''); @@ -43,6 +44,7 @@ controller?.destroy(); void audioEngine.init(videoEl).then(() => rescheduleAudio()); controller = attachVideoPlayer(videoEl); + queueMicrotask(() => timeline?.focus()); } $effect(() => { @@ -217,6 +219,9 @@ type="button" class="play-btn" disabled={!app.videoUrl} + onkeydown={(event) => { + if (event.code === 'Space' && !event.ctrlKey) event.preventDefault(); + }} onclick={() => { controller?.togglePlayPause(); rescheduleAudio(); @@ -243,6 +248,7 @@ { controller?.seek(time); rescheduleAudio(); diff --git a/frontend/src/lib/timeline/Timeline.svelte b/frontend/src/lib/timeline/Timeline.svelte index 781ac70..9e00176 100644 --- a/frontend/src/lib/timeline/Timeline.svelte +++ b/frontend/src/lib/timeline/Timeline.svelte @@ -18,6 +18,8 @@ timeToPx, } from './math'; + const DRAG_THRESHOLD_PX = 4; + interface Props { onSeek?: (time: number) => void; onReschedule?: () => void; @@ -26,12 +28,22 @@ let { onSeek, onReschedule }: Props = $props(); let viewportEl: HTMLDivElement | undefined = $state(); - let dragMode = $state<'none' | 'marker' | 'marquee' | 'move'>('none'); + let dragMode = $state<'none' | 'marker' | 'marquee' | 'move' | 'scrub'>('none'); + let pendingAction = $state<'none' | 'marker' | 'marquee' | 'scrub'>('none'); let dragMarkerId = $state(null); + let pointerDownX = 0; let dragStartX = 0; - let marqueeStart = 0; - let marqueeEnd = 0; + let marqueeStartX = $state(0); + let marqueeEndX = $state(0); let moveStartTimes = new Map(); + let didDrag = false; + let isScrubbing = false; + let marqueeAdditive = false; + let capturedPointerId: number | null = null; + + export function focus() { + viewportEl?.focus({ preventScroll: true }); + } function updateScrollCenter() { setScrollLeft( @@ -42,7 +54,7 @@ $effect(() => { void app.currentTime; void app.pixelsPerSecond; - if (app.duration > 0 && viewportEl) updateScrollCenter(); + if (app.duration > 0 && viewportEl && !isScrubbing) updateScrollCenter(); }); $effect(() => { @@ -73,39 +85,114 @@ return event.clientX - rect.left + app.scrollLeft; } + function timeAtX(x: number): number { + return snapToFrame( + Math.max(0, Math.min(app.duration || Infinity, pxToTime(x, app.pixelsPerSecond))), + ); + } + + function scrubToX(x: number) { + isScrubbing = true; + onSeek?.(timeAtX(x)); + } + + function activateDragMode() { + if (dragMode !== 'none' || pendingAction === 'none') return; + + if (pendingAction === 'marker' && dragMarkerId) { + dragMode = app.selectedIds.has(dragMarkerId) ? 'move' : 'marker'; + return; + } + + if (pendingAction === 'marquee') { + dragMode = 'marquee'; + return; + } + + if (pendingAction === 'scrub') { + dragMode = 'scrub'; + isScrubbing = true; + } + } + function handlePointerDown(event: PointerEvent) { if (!viewportEl) return; + const x = localX(event); - const time = pxToTime(x, app.pixelsPerSecond); const target = event.target as HTMLElement; const markerEl = target.closest('[data-marker-id]') as HTMLElement | null; + const inRuler = target.closest('.ruler'); + + pointerDownX = x; + dragStartX = x; + didDrag = false; + dragMode = 'none'; + + // Middle-click drag: scrub playhead anywhere on the timeline + if (event.button === 1) { + event.preventDefault(); + pendingAction = 'scrub'; + dragMode = 'scrub'; + scrubToX(x); + viewportEl.setPointerCapture(event.pointerId); + capturedPointerId = event.pointerId; + return; + } + + if (event.button !== 0) return; if (markerEl?.dataset.markerId) { + event.preventDefault(); const id = markerEl.dataset.markerId; - toggleSelection(id, event.shiftKey); - dragMode = app.selectedIds.has(id) ? 'move' : 'marker'; + const wasSelected = app.selectedIds.has(id); + if (event.shiftKey) { + toggleSelection(id, true); + } else if (!wasSelected) { + setSelectedIds(new Set([id])); + } + pendingAction = 'marker'; dragMarkerId = id; - dragStartX = x; moveStartTimes = new Map( app.markers.filter((m) => app.selectedIds.has(m.id)).map((m) => [m.id, m.time]), ); viewportEl.setPointerCapture(event.pointerId); + capturedPointerId = event.pointerId; return; } + if (inRuler) { + pendingAction = 'scrub'; + viewportEl.setPointerCapture(event.pointerId); + capturedPointerId = event.pointerId; + return; + } + + // Track empty area: box-select (shift adds to existing selection) + marqueeAdditive = event.shiftKey; if (!event.shiftKey) setSelectedIds(new Set()); - dragMode = 'marquee'; - marqueeStart = time; - marqueeEnd = time; + pendingAction = 'marquee'; + marqueeStartX = x; + marqueeEndX = x; viewportEl.setPointerCapture(event.pointerId); + capturedPointerId = event.pointerId; } function handlePointerMove(event: PointerEvent) { - if (dragMode === 'none' || !viewportEl) return; + if (pendingAction === 'none' || !viewportEl) return; const x = localX(event); + if (dragMode === 'scrub') { + scrubToX(x); + return; + } + + if (!didDrag && Math.abs(x - pointerDownX) >= DRAG_THRESHOLD_PX) { + didDrag = true; + activateDragMode(); + } + if (dragMode === 'marquee') { - marqueeEnd = pxToTime(x, app.pixelsPerSecond); + marqueeEndX = x; return; } @@ -121,38 +208,73 @@ } if (dragMode === 'marker' && dragMarkerId) { - const time = snapToFrame( - Math.max(0, Math.min(app.duration || Infinity, pxToTime(x, app.pixelsPerSecond))), - ); - setMarkerTimes(new Map([[dragMarkerId, time]])); + setMarkerTimes(new Map([[dragMarkerId, timeAtX(x)]])); } } - function handlePointerUp(event: PointerEvent) { + function finishPointerInteraction(event?: PointerEvent) { + if (pendingAction === 'none') return; + + const x = event ? localX(event) : pointerDownX; + const wasScrubbing = isScrubbing; + if (dragMode === 'marquee') { - const minT = Math.min(marqueeStart, marqueeEnd); - const maxT = Math.max(marqueeStart, marqueeEnd); + const minX = Math.min(marqueeStartX, marqueeEndX); + const maxX = Math.max(marqueeStartX, marqueeEndX); + const minT = pxToTime(minX, app.pixelsPerSecond); + const maxT = pxToTime(maxX, app.pixelsPerSecond); const hits = app.markers.filter((m) => m.time >= minT && m.time <= maxT).map((m) => m.id); - setSelectedIds(new Set(hits)); + if (marqueeAdditive) { + const next = new Set(app.selectedIds); + for (const id of hits) next.add(id); + setSelectedIds(next); + } else { + setSelectedIds(new Set(hits)); + } + } else if (dragMode === 'none' && pendingAction === 'scrub' && !didDrag) { + onSeek?.(timeAtX(x)); } if (dragMode === 'move' || dragMode === 'marker') { onReschedule?.(); } + if (dragMode === 'scrub' || wasScrubbing) { + onReschedule?.(); + } + dragMode = 'none'; + pendingAction = 'none'; dragMarkerId = null; moveStartTimes.clear(); - viewportEl?.releasePointerCapture(event.pointerId); + didDrag = false; + isScrubbing = false; + marqueeAdditive = false; + + if (viewportEl && capturedPointerId !== null && event) { + viewportEl.releasePointerCapture(capturedPointerId); + } + capturedPointerId = null; + + if (wasScrubbing && app.duration > 0) { + updateScrollCenter(); + } } - function handleRulerClick(event: MouseEvent) { - if (dragMode !== 'none') return; - const x = localX(event as unknown as PointerEvent); - const time = snapToFrame( - Math.max(0, Math.min(app.duration || Infinity, pxToTime(x, app.pixelsPerSecond))), - ); - onSeek?.(time); + function handlePointerUp(event: PointerEvent) { + finishPointerInteraction(event); + } + + function handlePointerCancel(event: PointerEvent) { + finishPointerInteraction(event); + } + + function handleLostPointerCapture() { + if (pendingAction !== 'none') finishPointerInteraction(); + } + + function handleContextMenu(event: MouseEvent) { + if (event.button === 1 || isScrubbing) event.preventDefault(); } function handleZoomInput(event: Event) { @@ -171,12 +293,8 @@ }); const playheadX = $derived(timeToPx(app.currentTime, app.pixelsPerSecond)); const marqueeVisible = $derived(dragMode === 'marquee'); - const marqueeLeft = $derived(timeToPx(Math.min(marqueeStart, marqueeEnd), app.pixelsPerSecond)); - const marqueeWidth = $derived( - Math.abs( - timeToPx(marqueeEnd, app.pixelsPerSecond) - timeToPx(marqueeStart, app.pixelsPerSecond), - ), - ); + const marqueeLeft = $derived(Math.min(marqueeStartX, marqueeEndX)); + const marqueeWidth = $derived(Math.abs(marqueeEndX - marqueeStartX));
@@ -193,20 +311,26 @@ Alt + scroll to zoom
-
+ + + +
- - -
+
{#each ticks as tick}
{formatTime(tick)} @@ -214,18 +338,13 @@ {/each}
- -
+
{#each app.markers as marker (marker.id)}