diff --git a/src/App.svelte b/src/App.svelte index 74f0a39..b09a95a 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -8,15 +8,101 @@ import SetupWizard from '$lib/components/SetupWizard.svelte'; import PreferencesPanel from '$lib/components/PreferencesPanel.svelte'; import { loadPreferences } from '$lib/stores/preferences.svelte'; + import { getSelectedClipId, removeClip } from '$lib/stores/clips.svelte'; + import { adjustShuttle } from '$lib/transport/playback'; + import type { TransportKeyAction } from '$lib/transport/playback'; let showSetupWizard = $state(true); let showPreferences = $state(false); + let showExportDialog = $state(false); + let shuttleRate = $state(1); + + let transportControls = $state(null); $effect(() => { loadPreferences(); }); + + function dispatchTransport(action: TransportKeyAction) { + transportControls?.handleKeyAction(action); + } + + function handleGlobalKeydown(e: KeyboardEvent) { + const target = e.target as HTMLElement; + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return; + + switch (e.key) { + case ' ': + e.preventDefault(); + dispatchTransport('play-pause'); + break; + case ',': + e.preventDefault(); + dispatchTransport(e.shiftKey ? 'keyframe-back' : 'frame-back'); + break; + case '.': + e.preventDefault(); + dispatchTransport(e.shiftKey ? 'keyframe-forward' : 'frame-forward'); + break; + case 'ArrowLeft': + e.preventDefault(); + dispatchTransport(e.shiftKey ? 'seek-back-1' : 'seek-back-5'); + break; + case 'ArrowRight': + e.preventDefault(); + dispatchTransport(e.shiftKey ? 'seek-forward-1' : 'seek-forward-5'); + break; + case 'i': + case 'I': + e.preventDefault(); + dispatchTransport('mark-in'); + break; + case 'o': + case 'O': + e.preventDefault(); + dispatchTransport('mark-out'); + break; + case 'j': + case 'J': + e.preventDefault(); + shuttleRate = adjustShuttle(-1, shuttleRate); + break; + case 'k': + case 'K': + e.preventDefault(); + dispatchTransport('play-pause'); + break; + case 'l': + case 'L': + e.preventDefault(); + shuttleRate = adjustShuttle(1, shuttleRate); + break; + case 'Delete': + case 'Backspace': { + e.preventDefault(); + const selectedId = getSelectedClipId(); + if (selectedId) removeClip(selectedId); + break; + } + case 'a': + if (e.metaKey) { + e.preventDefault(); + } + break; + case 'e': + if (e.metaKey) { + e.preventDefault(); + showExportDialog = true; + } + break; + default: + break; + } + } + + {#if showSetupWizard} (showSetupWizard = false)} /> {/if} @@ -33,7 +119,7 @@
- +
diff --git a/src/lib/components/TransportControls.svelte b/src/lib/components/TransportControls.svelte index 8f84834..9811c8b 100644 --- a/src/lib/components/TransportControls.svelte +++ b/src/lib/components/TransportControls.svelte @@ -1,5 +1,76 @@ + +
- Transport controls +
+ + + + + + + + + +
+ +
+ {formatTime(session.currentTime)} + / + {formatTime(session.duration)} +
+ +
+ + +
diff --git a/src/lib/transport/playback.ts b/src/lib/transport/playback.ts new file mode 100644 index 0000000..e65e8d1 --- /dev/null +++ b/src/lib/transport/playback.ts @@ -0,0 +1,116 @@ +import { session } from '$lib/stores/videoSession.svelte'; + +export function seekTo(time: number) { + const clamped = Math.max(0, Math.min(time, session.duration)); + session.currentTime = clamped; + const videoEl = document.querySelector('video'); + if (videoEl) { + videoEl.currentTime = clamped; + } +} + +export function seekBy(seconds: number) { + seekTo(session.currentTime + seconds); +} + +export function togglePlayPause() { + const videoEl = document.querySelector('video'); + if (!videoEl) return; + if (videoEl.paused) { + void videoEl.play(); + } else { + videoEl.pause(); + } +} + +export function stepFrame(direction: 1 | -1) { + const videoEl = document.querySelector('video'); + if (videoEl && !videoEl.paused) { + videoEl.pause(); + } + seekTo(session.currentTime + direction * (1 / session.fps)); +} + +export function jumpKeyframe(direction: 1 | -1) { + if (session.keyframePositions.length === 0) return; + const positions = session.keyframePositions; + + if (direction > 0) { + const next = positions.find((p) => p > session.currentTime + 0.01); + if (next !== undefined) seekTo(next); + } else { + const prev = [...positions].reverse().find((p) => p < session.currentTime - 0.01); + if (prev !== undefined) seekTo(prev); + } +} + +export function adjustShuttle(dir: 1 | -1, shuttleRate: number): number { + const videoEl = document.querySelector('video'); + if (!videoEl) return shuttleRate; + + const nextRate = Math.max(0.25, Math.min(4, shuttleRate + dir * 0.5)); + videoEl.playbackRate = nextRate; + if (videoEl.paused) { + void videoEl.play(); + } + return nextRate; +} + +export type TransportKeyAction = + | 'play-pause' + | 'frame-back' + | 'frame-forward' + | 'keyframe-back' + | 'keyframe-forward' + | 'seek-back-5' + | 'seek-forward-5' + | 'seek-back-1' + | 'seek-forward-1' + | 'mark-in' + | 'mark-out'; + +export function runTransportAction( + action: TransportKeyAction, + markIn: (time: number) => void, + markOut: (time: number) => void +) { + switch (action) { + case 'play-pause': + togglePlayPause(); + break; + case 'frame-back': + stepFrame(-1); + break; + case 'frame-forward': + stepFrame(1); + break; + case 'keyframe-back': + jumpKeyframe(-1); + break; + case 'keyframe-forward': + jumpKeyframe(1); + break; + case 'seek-back-5': + seekBy(-5); + break; + case 'seek-forward-5': + seekBy(5); + break; + case 'seek-back-1': + seekBy(-1); + break; + case 'seek-forward-1': + seekBy(1); + break; + case 'mark-in': + markIn(session.currentTime); + break; + case 'mark-out': + markOut(session.currentTime); + break; + default: { + const _exhaustive: never = action; + void _exhaustive; + } + } +}