Files
gui-video-clipper/src/App.svelte

453 lines
12 KiB
Svelte
Raw Normal View History

<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, preferences, setClipListPosition, setClipListWidth } 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 { listen } from '@tauri-apps/api/event';
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);
let isSidebarResizing = $state(false);
let sidebarResizeStartX = $state(0);
let sidebarResizeStartWidth = $state(0);
let windowWidth = $state(window.innerWidth);
$effect(() => {
loadPreferences();
});
// Listen for native macOS menu "About" action
$effect(() => {
const unlisten = listen('open-about', () => {
showAboutDialog = true;
});
return () => { unlisten.then((fn) => fn()); };
});
$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 toggleClipListPosition() {
const next = preferences.clipListPosition === 'bottom' ? 'left' : 'bottom';
setClipListPosition(next);
}
function handleSidebarResizeStart(e: MouseEvent) {
e.preventDefault();
isSidebarResizing = true;
sidebarResizeStartX = e.clientX;
sidebarResizeStartWidth = preferences.clipListWidth;
}
function handleSidebarResizeMove(e: MouseEvent) {
if (!isSidebarResizing) return;
const delta = e.clientX - sidebarResizeStartX;
const maxWidth = Math.round(windowWidth * 0.4);
const newWidth = Math.max(150, Math.min(maxWidth, sidebarResizeStartWidth + delta));
preferences.clipListWidth = newWidth;
}
function handleSidebarResizeEnd() {
if (isSidebarResizing) {
isSidebarResizing = false;
setClipListWidth(preferences.clipListWidth);
}
}
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={(e) => {
handleResizeMove(e);
handleSidebarResizeMove(e);
}}
onmouseup={() => {
handleResizeEnd();
handleSidebarResizeEnd();
}}
onresize={() => {
windowWidth = window.innerWidth;
}}
/>
{#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="layout-btn"
onclick={toggleClipListPosition}
title={preferences.clipListPosition === 'bottom'
? 'Move clip list to sidebar'
: 'Move clip list to bottom'}
>
{preferences.clipListPosition === 'bottom' ? '⬒' : '⬓'}
</button>
<button class="about-btn" onclick={() => (showAboutDialog = true)} title="About">ℹ</button>
<button class="prefs-btn" onclick={() => (showPreferences = true)} title="Preferences">⚙</button>
</header>
{#if preferences.clipListPosition === 'bottom'}
<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>
{:else}
<main class="content content-left">
<div class="upper-section">
<div class="cliplist-sidebar" style="width: {preferences.clipListWidth}px">
<ClipList onExport={() => (showExportDialog = true)} position="left" />
</div>
<div
class="sidebar-resize-handle"
class:active={isSidebarResizing}
role="slider"
aria-orientation="vertical"
aria-label="Resize clip list sidebar"
aria-valuenow={preferences.clipListWidth}
aria-valuemin={150}
aria-valuemax={Math.round(windowWidth * 0.4)}
tabindex="0"
onmousedown={handleSidebarResizeStart}
onkeydown={(e) => {
if (e.key === 'ArrowRight') {
e.preventDefault();
setClipListWidth(
Math.min(Math.round(windowWidth * 0.4), preferences.clipListWidth + 10),
);
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
setClipListWidth(Math.max(150, preferences.clipListWidth - 10));
}
}}
></div>
<div class="right-column">
<VideoPlayer />
<TransportControls bind:this={transportControls} />
</div>
</div>
<div class="timeline-clip-area" bind:this={splitAreaEl}>
<div
class="resize-handle"
class:active={isResizing}
role="slider"
aria-orientation="horizontal"
aria-label="Resize upper section and timeline"
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="timeline-pane" style="height: {timelineHeight}px">
<Timeline />
</div>
</div>
</main>
{/if}
<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;
}
.content-left {
display: flex;
flex-direction: column;
overflow: hidden;
}
.upper-section {
flex: 1;
display: flex;
flex-direction: row;
min-height: 0;
overflow: hidden;
}
.cliplist-sidebar {
flex-shrink: 0;
overflow-y: auto;
overflow-x: hidden;
border-right: 1px solid var(--border);
background: var(--bg-secondary);
}
.sidebar-resize-handle {
width: 5px;
background: var(--border);
cursor: ew-resize;
flex-shrink: 0;
transition: background 0.1s;
}
.sidebar-resize-handle:hover,
.sidebar-resize-handle.active {
background: var(--accent);
}
.right-column {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
}
.layout-btn {
font-size: 16px;
padding: 4px 8px;
}
.about-btn {
font-size: 16px;
padding: 4px 8px;
}
.prefs-btn {
font-size: 18px;
padding: 4px 8px;
}
</style>