Files
gui-video-clipper/src/App.svelte
cottongin 9d1f1cfcf3 fix: resolve all Svelte a11y warnings
- 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>
2026-09-22 13:30:27 -04:00

283 lines
7.4 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>