Files
gui-video-clipper/src/App.svelte
cottongin 9499d776fd fix: about dialog, cache paths, keyframes, reprocess always uses low-res
- Replace native macOS About menu with custom Svelte dialog via Tauri event
- Inline SVG icon in AboutDialog (fixes broken image in webview)
- Align cache directory with Tauri $APPDATA, add to asset protocol scope
- Fix ffprobe 8.x keyframe parsing (skip_frame nokey, flexible parser)
- Copy caption files into cache dir when outside cache path
- Add -y flag to ffmpeg thumbnail extraction (prevent hang on re-extract)
- Reprocess always uses preview (low-res) file, never HQ export
- Prefer cached preview file for analysis even when HQ is cached
- Guard upgrade toast when already on HQ

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 16:16:00 -04:00

292 lines
7.6 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 { 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);
$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 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>