- Associate labels with controls in CaptionSettingsPanel (9 warnings) - Add tabindex and keyboard handlers to dialog overlays (8 warnings) - Add <track> to video element for caption accessibility - Add keyboard support to resize handle separator Co-authored-by: Cursor <cursoragent@cursor.com>
283 lines
7.4 KiB
Svelte
283 lines
7.4 KiB
Svelte
<script lang="ts">
|
||
import UrlInput from '$lib/components/UrlInput.svelte';
|
||
import VideoPlayer from '$lib/components/VideoPlayer.svelte';
|
||
import TransportControls from '$lib/components/TransportControls.svelte';
|
||
import Timeline from '$lib/components/Timeline.svelte';
|
||
import ClipList from '$lib/components/ClipList.svelte';
|
||
import StatusBar from '$lib/components/StatusBar.svelte';
|
||
import SetupWizard from '$lib/components/SetupWizard.svelte';
|
||
import PreferencesPanel from '$lib/components/PreferencesPanel.svelte';
|
||
import ExportDialog from '$lib/components/ExportDialog.svelte';
|
||
import AboutDialog from '$lib/components/AboutDialog.svelte';
|
||
import ProcessingModal from '$lib/components/ProcessingModal.svelte';
|
||
import { loadPreferences } from '$lib/stores/preferences.svelte';
|
||
import { getSelectedClipId, removeClip } from '$lib/stores/clips.svelte';
|
||
import { adjustShuttle, resetShuttleRate } from '$lib/transport/playback';
|
||
import { session } from '$lib/stores/videoSession.svelte';
|
||
import type { TransportKeyAction } from '$lib/transport/playback';
|
||
|
||
let showSetupWizard = $state(true);
|
||
let showPreferences = $state(false);
|
||
let showExportDialog = $state(false);
|
||
let showAboutDialog = $state(false);
|
||
let shuttleRate = $state(1);
|
||
|
||
let transportControls = $state<TransportControls | null>(null);
|
||
|
||
// Resizable split: timeline height in px (clip list gets the rest)
|
||
let timelineHeight = $state(180);
|
||
let isResizing = $state(false);
|
||
let resizeStartY = $state(0);
|
||
let resizeStartHeight = $state(0);
|
||
let splitAreaEl = $state<HTMLDivElement | null>(null);
|
||
|
||
$effect(() => {
|
||
loadPreferences();
|
||
});
|
||
|
||
$effect(() => {
|
||
if (session.status === 'resolving') {
|
||
shuttleRate = 1;
|
||
resetShuttleRate();
|
||
}
|
||
});
|
||
|
||
function dispatchTransport(action: TransportKeyAction) {
|
||
transportControls?.handleKeyAction(action);
|
||
}
|
||
|
||
function handleResizeStart(e: MouseEvent) {
|
||
e.preventDefault();
|
||
isResizing = true;
|
||
resizeStartY = e.clientY;
|
||
resizeStartHeight = timelineHeight;
|
||
}
|
||
|
||
function handleResizeMove(e: MouseEvent) {
|
||
if (!isResizing || !splitAreaEl) return;
|
||
const delta = e.clientY - resizeStartY;
|
||
const totalHeight = splitAreaEl.clientHeight;
|
||
const newHeight = Math.max(40, Math.min(totalHeight - 20, resizeStartHeight + delta));
|
||
timelineHeight = newHeight;
|
||
}
|
||
|
||
function handleResizeEnd() {
|
||
isResizing = false;
|
||
}
|
||
|
||
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;
|
||
case '/':
|
||
if (e.metaKey) {
|
||
e.preventDefault();
|
||
showAboutDialog = true;
|
||
}
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<svelte:window
|
||
onkeydown={handleGlobalKeydown}
|
||
onmousemove={handleResizeMove}
|
||
onmouseup={handleResizeEnd}
|
||
/>
|
||
|
||
{#if showSetupWizard}
|
||
<SetupWizard onComplete={() => (showSetupWizard = false)} />
|
||
{/if}
|
||
|
||
{#if showPreferences}
|
||
<PreferencesPanel onClose={() => (showPreferences = false)} />
|
||
{/if}
|
||
|
||
{#if showExportDialog}
|
||
<ExportDialog onClose={() => (showExportDialog = false)} />
|
||
{/if}
|
||
|
||
{#if showAboutDialog}
|
||
<AboutDialog onClose={() => (showAboutDialog = false)} />
|
||
{/if}
|
||
|
||
{#if session.processingStep !== 'idle' && session.processingStep !== 'done'}
|
||
<ProcessingModal />
|
||
{/if}
|
||
|
||
<div class="app-shell">
|
||
<header class="toolbar">
|
||
<UrlInput />
|
||
<button class="about-btn" onclick={() => (showAboutDialog = true)} title="About">ℹ</button>
|
||
<button class="prefs-btn" onclick={() => (showPreferences = true)} title="Preferences">⚙</button>
|
||
</header>
|
||
|
||
<main class="content">
|
||
<VideoPlayer />
|
||
<TransportControls bind:this={transportControls} />
|
||
<div class="timeline-clip-area" bind:this={splitAreaEl}>
|
||
<div class="timeline-pane" style="height: {timelineHeight}px">
|
||
<Timeline />
|
||
</div>
|
||
<div
|
||
class="resize-handle"
|
||
class:active={isResizing}
|
||
role="slider"
|
||
aria-orientation="horizontal"
|
||
aria-label="Resize timeline and clip list"
|
||
aria-valuenow={timelineHeight}
|
||
aria-valuemin={40}
|
||
aria-valuemax={500}
|
||
tabindex="0"
|
||
onmousedown={handleResizeStart}
|
||
onkeydown={(e) => {
|
||
if (e.key === 'ArrowDown') {
|
||
e.preventDefault();
|
||
timelineHeight = Math.min(500, timelineHeight + 10);
|
||
} else if (e.key === 'ArrowUp') {
|
||
e.preventDefault();
|
||
timelineHeight = Math.max(40, timelineHeight - 10);
|
||
}
|
||
}}
|
||
></div>
|
||
<div class="cliplist-pane">
|
||
<ClipList onExport={() => (showExportDialog = true)} />
|
||
</div>
|
||
</div>
|
||
</main>
|
||
|
||
<StatusBar onOpenAbout={() => (showAboutDialog = true)} />
|
||
</div>
|
||
|
||
<style>
|
||
.app-shell {
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: 100vh;
|
||
}
|
||
|
||
.toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 8px 12px;
|
||
background: var(--bg-secondary);
|
||
border-bottom: 1px solid var(--border);
|
||
gap: 8px;
|
||
}
|
||
|
||
.content {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.timeline-clip-area {
|
||
display: flex;
|
||
flex-direction: column;
|
||
flex-shrink: 0;
|
||
min-height: 80px;
|
||
}
|
||
|
||
.timeline-pane {
|
||
flex-shrink: 0;
|
||
min-height: 40px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.resize-handle {
|
||
height: 5px;
|
||
background: var(--border, #353550);
|
||
cursor: ns-resize;
|
||
flex-shrink: 0;
|
||
transition: background 0.1s;
|
||
}
|
||
|
||
.resize-handle:hover,
|
||
.resize-handle.active {
|
||
background: var(--accent, #89b4fa);
|
||
}
|
||
|
||
.cliplist-pane {
|
||
flex: 1;
|
||
min-height: 20px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.about-btn {
|
||
font-size: 16px;
|
||
padding: 4px 8px;
|
||
}
|
||
|
||
.prefs-btn {
|
||
font-size: 18px;
|
||
padding: 4px 8px;
|
||
}
|
||
</style>
|