diff --git a/bun.lock b/bun.lock index bf14cbe4..e38aca70 100644 --- a/bun.lock +++ b/bun.lock @@ -15,7 +15,12 @@ "name": "omnivoice-studio", "version": "0.0.0", "dependencies": { + "@fontsource-variable/inter": "^5.2.8", + "@fontsource-variable/source-serif-4": "^5.2.9", + "@fontsource/ibm-plex-mono": "^5.2.7", "@tauri-apps/plugin-dialog": "^2.7.0", + "@tauri-apps/plugin-process": "^2.3.0", + "@tauri-apps/plugin-updater": "^2.9.0", "@tauri-apps/plugin-window-state": "^2.4.1", "lucide-react": "^1.8.0", "react": "^19.2.5", @@ -96,6 +101,12 @@ "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.1", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="], + "@fontsource-variable/inter": ["@fontsource-variable/inter@5.2.8", "", {}, "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ=="], + + "@fontsource-variable/source-serif-4": ["@fontsource-variable/source-serif-4@5.2.9", "", {}, "sha512-PPcxjLFk/fS0WHg79pDM2YNvz61kC+oYZ5cWZZyCS0DHpJncmuYOuiZAsvj4tDxlWPBEvxxcRLQQNmSaRbPkqw=="], + + "@fontsource/ibm-plex-mono": ["@fontsource/ibm-plex-mono@5.2.7", "", {}, "sha512-MKAb8qV+CaiMQn2B0dIi1OV3565NYzp3WN5b4oT6LTkk+F0jR6j0ZN+5BKJiIhffDC3rtBULsYZE65+0018z9w=="], + "@hapi/address": ["@hapi/address@5.1.1", "", { "dependencies": { "@hapi/hoek": "^11.0.2" } }, "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA=="], "@hapi/formula": ["@hapi/formula@3.0.2", "", {}, "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw=="], @@ -192,6 +203,10 @@ "@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.7.0", "", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-4nS/hfGMGCXiAS3LtVjH9AgsSAPJeG/7R+q8agTFqytjnMa4Zq95Bq8WzVDkckpanX+yyRHXnRtrKXkANKDHvw=="], + "@tauri-apps/plugin-process": ["@tauri-apps/plugin-process@2.3.1", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA=="], + + "@tauri-apps/plugin-updater": ["@tauri-apps/plugin-updater@2.10.1", "", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA=="], + "@tauri-apps/plugin-window-state": ["@tauri-apps/plugin-window-state@2.4.1", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-OuvdrzyY8Q5Dbzpj+GcrnV1iCeoZbcFdzMjanZMMcAEUNy/6PH5pxZPXpaZLOR7whlzXiuzx0L9EKZbH7zpdRw=="], "@turbo/darwin-64": ["@turbo/darwin-64@2.9.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-X/56SnVXIQZBLKwniGTwEQTGmtE5brSACnKMBWpY3YafuxVYefrC2acamfjgxP7BG5w3I+6jf0UrLoSzgPcSJg=="], diff --git a/frontend/package.json b/frontend/package.json index df53b745..59645f0d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "omnivoice-studio", "private": true, - "version": "0.0.0", + "version": "0.2.0", "type": "module", "scripts": { "dev": "vite", @@ -12,7 +12,12 @@ "preview": "vite preview" }, "dependencies": { + "@fontsource-variable/inter": "^5.2.8", + "@fontsource-variable/source-serif-4": "^5.2.9", + "@fontsource/ibm-plex-mono": "^5.2.7", "@tauri-apps/plugin-dialog": "^2.7.0", + "@tauri-apps/plugin-process": "^2.3.0", + "@tauri-apps/plugin-updater": "^2.9.0", "@tauri-apps/plugin-window-state": "^2.4.1", "lucide-react": "^1.8.0", "react": "^19.2.5", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 8b4e1d6b..9c7c4551 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -15,7 +15,10 @@ const Settings = lazy(() => import('./pages/Settings')); const VoiceProfile = lazy(() => import('./pages/VoiceProfile')); const BatchQueue = lazy(() => import('./pages/BatchQueue')); const ToolsPage = lazy(() => import('./pages/ToolsPage')); +const SetupWizard = lazy(() => import('./pages/SetupWizard')); const KeyboardCheatsheet = lazy(() => import('./components/KeyboardCheatsheet')); +const LogsFooter = lazy(() => import('./components/LogsFooter')); +const ProjectsPage = lazy(() => import('./pages/Projects')); import Header from './components/Header'; import NavRail from './components/NavRail'; import ErrorBoundary from './components/ErrorBoundary'; @@ -188,7 +191,7 @@ function App() { const openVoiceProfile = useAppStore(s => s.openVoiceProfile); const closeVoiceProfile = useAppStore(s => s.closeVoiceProfile); const hideSidebar = mode === 'launchpad' || mode === 'settings' || mode === 'voice' - || mode === 'queue' || mode === 'tools'; + || mode === 'queue' || mode === 'tools' || mode === 'projects'; const availableSidebarTabs = mode === 'dub' ? ['projects', 'history', 'downloads'] : (mode === 'clone' || mode === 'design') @@ -332,6 +335,7 @@ function App() { const glossaryTerms = useAppStore(s => s.glossaryTerms); const setGlossaryTerms = useAppStore(s => s.setGlossaryTerms); const dualSubs = useAppStore(s => s.dualSubs); + const burnSubs = useAppStore(s => s.burnSubs); const setDualSubs = useAppStore(s => s.setDualSubs); const [translateProvider, setTranslateProvider] = useState('argos'); @@ -538,10 +542,78 @@ function App() { // ── LOAD DATA FROM SERVER ── const [sysStats, setSysStats] = useState(null); + // First-run gate — `/setup/status` reports whether required HF models are + // on disk. If not, we render in place of the main studio so + // the user actually SEES the download instead of a silent 5 GB hang. + // + // Packaged .app note: the frozen backend sidecar takes several seconds to + // import torch/torchaudio/whisper/etc. before it can serve /setup/status. + // A single fetch on mount lands during that window, fails, and the wizard + // would never render. So we retry with backoff until we get a response or + // the user gives up. `setupChecked` gates main-UI render so we don't flash + // the studio in front of a user who actually needs the wizard. + const [setupNeeded, setSetupNeeded] = useState(false); + const [setupChecked, setSetupChecked] = useState(false); + useEffect(() => { + let cancelled = false; + (async () => { + const { setupStatus } = await import('./api/setup'); + // ~30 attempts × ~1s ≈ 30s ceiling; enough for a cold sidecar on slow disks. + for (let attempt = 0; attempt < 30 && !cancelled; attempt++) { + try { + const s = await setupStatus(); + if (cancelled) return; + setSetupNeeded(!s.models_ready); + setSetupChecked(true); + return; + } catch { + await new Promise(r => setTimeout(r, 1000)); + } + } + if (!cancelled) setSetupChecked(true); + })(); + return () => { cancelled = true; }; + }, []); + + // ── Tauri auto-updater ── + // On boot, ask GitHub Releases if a newer build is available. If yes, + // prompt the user, download the signed bundle, restart into the new + // version. Only runs in packaged .app (not `tauri dev`) — the updater + // endpoint 404s until the first signed release is published, and we + // don't want that noise in the dev console. + useEffect(() => { + if (typeof window === 'undefined') return; + if (!('__TAURI_INTERNALS__' in window)) return; + if (import.meta.env.DEV) return; + let cancelled = false; + (async () => { + try { + const [{ check }, { relaunch }, { ask }] = await Promise.all([ + import('@tauri-apps/plugin-updater'), + import('@tauri-apps/plugin-process'), + import('@tauri-apps/plugin-dialog'), + ]); + const update = await check(); + if (cancelled || !update) return; + const proceed = await ask( + `A new version (${update.version}) of OmniVoice Studio is available.\n\nWhat's new:\n${update.body || '— see release notes'}\n\nDownload and install now?`, + { title: 'Update available', kind: 'info' }, + ); + if (!proceed) return; + await update.downloadAndInstall(); + await relaunch(); + } catch (e) { + // eslint-disable-next-line no-console + console.debug('Updater check failed (non-fatal):', e); + } + })(); + return () => { cancelled = true; }; + }, []); + // ── DESKTOP NATIVE INTEGRATION ── useEffect(() => { if (typeof window === 'undefined') return; - + // 1. Prevent default right-click to hide web nature const handleContextMenu = (e) => { // allow on inputs/textareas for copy/paste @@ -1265,7 +1337,7 @@ function App() { } }; - const handleDubIngestUrl = async (url) => { + const handleDubIngestUrl = async (url, opts = {}) => { const clean = (url || '').trim(); if (!clean) return; setDubStep('uploading'); setDubError(''); setDubTracks([]); setDubPrepStage('download'); @@ -1275,7 +1347,11 @@ function App() { dubClientJobIdRef.current = clientJobId; setDubJobId(clientJobId); try { - const data = await dubIngestUrl(clean, clientJobId, { signal: ctrl.signal }); + const data = await dubIngestUrl(clean, clientJobId, { + signal: ctrl.signal, + fetchSubs: !!opts.fetchSubs, + subLangs: opts.subLangs, + }); setDubJobId(data.job_id); setDubTaskId(data.task_id); await _waitForPrep(data.task_id, ctrl); @@ -1313,6 +1389,36 @@ function App() { } }; + // Retry transcribe on an existing job — the video + demucs + scene-cut + // preprocessing is already on disk, so we skip straight back to the ASR + // stream. Used by the "Retry" button on the transcribe-failed banner. + const handleDubRetryTranscribe = async () => { + if (!dubJobId) return; + const ctrl = new AbortController(); + dubAbortCtrlRef.current = ctrl; + setDubError(''); + setDubSegments([]); + setDubStep('transcribing'); + setTranscribeStart(Date.now()); + try { + await _waitForTranscribe(dubJobId, ctrl); + setTranscribeStart(null); + setDubStep('editing'); + } catch (err) { + setTranscribeStart(null); + if (err.name === 'AbortError') { + toast('Retry cancelled'); + setDubStep('idle'); + } else { + setDubError(err.message); + setDubStep('idle'); + toast.error('Transcription failed: ' + err.message); + } + } finally { + dubAbortCtrlRef.current = null; + } + }; + // Transcription elapsed timer useEffect(() => { if (!transcribeStart) { setTranscribeElapsed(0); return; } @@ -1533,12 +1639,11 @@ function App() { if (!destPath) return; // User cancelled await exportAction({ source_filename: sourceIdentifier, destination_path: destPath, mode }); - const data = await res.json(); toast.success(`Exported: ${fallbackName}`); loadExportHistory(); } catch (err) { console.error(err); - toast.error('Failed to bridge save dialog to rust/python backend.'); + toast.error(`Export failed: ${err?.message || err}`); } }; const revealInFolder = async (filePath) => { @@ -1633,7 +1738,8 @@ function App() { if (exportTracks['original'] !== false) selected.push('original'); dubTracks.forEach(t => { if (exportTracks[t] !== false) selected.push(t); }); const tracksParam = selected.join(','); - triggerDownload(`${API}/dub/download/${dubJobId}/dubbed_video.mp4?preserve_bg=${preserveBg}&default_track=${defaultTrack}&include_tracks=${encodeURIComponent(tracksParam)}`, 'dubbed_video.mp4'); + const burnParam = burnSubs ? `&burn_subs=1&dual=${dualSubs ? 1 : 0}` : ''; + triggerDownload(`${API}/dub/download/${dubJobId}/dubbed_video.mp4?preserve_bg=${preserveBg}&default_track=${defaultTrack}&include_tracks=${encodeURIComponent(tracksParam)}${burnParam}`, 'dubbed_video.mp4'); }; const handleDubAudioDownload = async () => { await finalizeTtsBeforeExport(); @@ -1786,6 +1892,66 @@ function App() { }; + // First-run gate: if /setup/status says models aren't on disk yet, render + // the wizard instead of the main studio. Dismisses itself once the user + // completes the download (or clicks "Skip" if they want to limp along). + // Also blocks render until we've heard back from the backend at least once + // — the frozen sidecar's cold-start import is ~5-10 s and without this we + // flash the empty studio before the wizard has a chance to mount. + if (!setupChecked) { + return ( +
+
OmniVoice Studio
+
Starting backend…
+
+ ); + } + if (setupNeeded) { + // Render outside the `app-container` grid so the wizard spans the full + // viewport instead of getting squeezed into whatever grid cell the + // studio layout reserves for the main content column. + return ( +
+ {/* Invisible drag strip across the top 28 px of the wizard — + matches the macOS traffic-light zone so the window can be + dragged / double-click-zoomed from anywhere along the top. */} +
{ + if ('__TAURI_INTERNALS__' in window) { + import('@tauri-apps/api/window').then(m => + m.getCurrentWindow().toggleMaximize() + ).catch(() => {}); + } + }} + style={{ + position: 'fixed', top: 0, left: 0, right: 0, + height: 28, zIndex: 10, + }} + /> + }> + setSetupNeeded(false)} /> + + + + +
+ ); + } + return (
setMode('launchpad')} /> + ) : mode === 'projects' ? ( + + }> + { loadProject(id); setMode('dub'); }} + onOpenProfile={(id) => { openVoiceProfile(id); }} + onRevealExport={(path) => { exportReveal({ path }).catch(() => {}); }} + /> + + ) : mode === 'launchpad' ? ( }> @@ -1896,6 +2075,7 @@ function App() { setDubLocalBlobUrl={setDubLocalBlobUrl} // Handlers — close over App.jsx scope so stay prop-threaded. handleDubAbort={handleDubAbort} handleDubUpload={handleDubUpload} handleDubIngestUrl={handleDubIngestUrl} + handleDubRetryTranscribe={handleDubRetryTranscribe} handleDubStop={handleDubStop} handleDubGenerate={handleDubGenerate} handleDubDownload={handleDubDownload} handleDubAudioDownload={handleDubAudioDownload} handleAudioExport={handleAudioExport} @@ -2033,6 +2213,11 @@ function App() { )} + {/* ═══ BOTTOM LOGS PANEL (VSCode-style) ═══ */} + + + +
); } diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 39897c06..428b19fd 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,4 +1,8 @@ -export const API = (import.meta as any).env?.DEV ? 'http://localhost:8000' : ''; +// Backend always listens on localhost:8000 — both in dev (Vite @ 5173 talking +// to a separate uvicorn) and in the built .app (Tauri webview @ tauri://localhost +// talking to the bundled frozen backend sidecar). Relative fetches against +// tauri://localhost don't reach the sidecar, so we hardcode the absolute host. +export const API = 'http://localhost:8000'; export class ApiError extends Error { status?: number; diff --git a/frontend/src/api/dub.ts b/frontend/src/api/dub.ts index 5151663e..04b1f1d6 100644 --- a/frontend/src/api/dub.ts +++ b/frontend/src/api/dub.ts @@ -12,12 +12,30 @@ export async function dubUpload( return apiPost('/dub/upload', fd, { signal }); } +export interface IngestUrlOptions { + signal?: AbortSignal; + /** Ask yt-dlp to also pull caption tracks (incl. YouTube auto-translations). */ + fetchSubs?: boolean; + /** Limit caption fetch to specific lang codes; defaults to all available. */ + subLangs?: string[]; +} + export async function dubIngestUrl( url: string, jobId: string, - { signal }: { signal?: AbortSignal } = {}, + opts: IngestUrlOptions = {}, ): Promise { - return apiPost('/dub/ingest-url', { url, job_id: jobId }, { signal }); + const { signal, fetchSubs, subLangs } = opts; + return apiPost( + '/dub/ingest-url', + { + url, + job_id: jobId, + fetch_subs: fetchSubs || undefined, + sub_langs: subLangs && subLangs.length ? subLangs : undefined, + }, + { signal }, + ); } export function transcribeStreamUrl(jobId: string): string { diff --git a/frontend/src/api/engines.ts b/frontend/src/api/engines.ts index 5bc38543..aaaca3a9 100644 --- a/frontend/src/api/engines.ts +++ b/frontend/src/api/engines.ts @@ -1,4 +1,4 @@ -import { apiJson, apiPost } from './client'; +import { apiJson, apiPost, apiDelete } from './client'; import type { AllEnginesResponse, EngineFamily, @@ -6,6 +6,30 @@ import type { SelectEngineResponse, } from './types'; +export interface TranslationEngine { + id: string; + display_name: string; + pip_package: string | null; + probe_module: string | null; + category: 'offline' | 'online' | 'llm'; + needs_key: boolean; + builtin?: boolean; + notes?: string; + installed: boolean; + availability_reason: string; +} +export interface TranslationEnginesResponse { + engines: TranslationEngine[]; + sandboxed: boolean; +} +export interface InstallEngineResponse { + status: 'installed' | 'already_installed' | 'installed_but_probe_failed' | 'uninstalled' | 'no_op'; + engine: string; + package?: string; + log_tail?: string; + restart_required?: boolean; +} + export async function listEngines(): Promise { return apiJson('/engines'); } @@ -24,6 +48,19 @@ export async function selectEngine(family: EngineFamily, backendId: string): Pro return apiPost('/engines/select', { family, backend_id: backendId }); } +export async function listTranslationEngines(): Promise { + return apiJson('/engines/translation'); +} + +export async function installTranslationEngine(id: string): Promise { + return apiPost(`/engines/translation/${id}/install`, {}); +} + +export async function uninstallTranslationEngine(id: string): Promise { + const res = await apiDelete(`/engines/translation/${id}`); + return (await res.json()) as InstallEngineResponse; +} + export interface JobsQuery { status?: string; projectId?: string; diff --git a/frontend/src/api/setup.ts b/frontend/src/api/setup.ts new file mode 100644 index 00000000..0b87796c --- /dev/null +++ b/frontend/src/api/setup.ts @@ -0,0 +1,103 @@ +import { API, apiJson, apiPost, apiFetch } from './client'; + +export interface MissingModel { + repo_id: string; + label: string; +} + +export interface SetupStatus { + models_ready: boolean; + missing: MissingModel[]; + hf_cache_dir: string; + disk_free_gb: number; + min_free_gb: number; + enough_disk: boolean; +} + +export interface SetupProgressEvent { + filename: string; + downloaded: number; + total: number; + pct: number; + phase: 'start' | 'progress' | 'done'; +} + +export async function setupStatus(): Promise { + return apiJson('/setup/status'); +} + +export async function setupWarmup(): Promise<{ status: string }> { + return apiPost<{ status: string }>('/setup/warmup'); +} + +export function setupDownloadStreamUrl(): string { + return `${API}/setup/download-stream`; +} + +// ── Model store ─────────────────────────────────────────────────────────── + +export interface KnownModel { + repo_id: string; + label: string; + role: 'TTS' | 'ASR' | 'Diarisation' | string; + size_gb: number; + required: boolean; + note?: string; + installed: boolean; + size_on_disk_bytes: number; + nb_files: number; +} + +export interface ModelList { + models: KnownModel[]; + total_installed_bytes: number; + hf_cache_dir: string; +} + +export async function listModels(): Promise { + return apiJson('/models'); +} + +export async function installModel(repo_id: string): Promise<{ status: string; repo_id: string }> { + return apiPost('/models/install', { repo_id }); +} + +// ── Device-aware model recommendation ───────────────────────────────────── + +export interface RecommendedModel { + repo_id: string; + label: string; + role: string; + size_gb: number; + required: boolean; + note: string | null; + installed: boolean; +} + +export interface Recommendations { + device: { + os: string; + arch: string; + is_mac_arm: boolean; + is_mac_intel: boolean; + is_linux: boolean; + is_windows: boolean; + has_cuda: boolean; + label: string; + }; + rationale: string; + models: RecommendedModel[]; + download_gb_remaining: number; + total_gb: number; + all_installed: boolean; +} + +export async function getRecommendations(): Promise { + return apiJson('/setup/recommendations'); +} + +export async function deleteModel(repo_id: string): Promise<{ deleted: boolean; repo_id: string; freed_bytes: number }> { + const r = await apiFetch(`/models/${encodeURIComponent(repo_id)}`, { method: 'DELETE' }); + return r.json(); +} + diff --git a/frontend/src/components/CheckpointBanner.jsx b/frontend/src/components/CheckpointBanner.jsx index 5ecc1952..1892b322 100644 --- a/frontend/src/components/CheckpointBanner.jsx +++ b/frontend/src/components/CheckpointBanner.jsx @@ -50,31 +50,48 @@ export default function CheckpointBanner({ stage, count, onContinue, onDismiss, return (
- +
- + {cfg.title} {typeof count === 'number' && ( - + {count} segment{count === 1 ? '' : 's'} )}
- + {cfg.hint}
diff --git a/frontend/src/components/CompareModal.css b/frontend/src/components/CompareModal.css index ffc2ed95..f49bc663 100644 --- a/frontend/src/components/CompareModal.css +++ b/frontend/src/components/CompareModal.css @@ -1,5 +1,101 @@ -/* CompareModal — only layout + content-specific styles remain here. - Base controls (Dialog, Panel, Button, Select, Textarea) come from ui/. */ +/* CompareModal — non-modal bottom drawer. Leaves the app interactive; + no backdrop wash. ESC or click-outside dismisses. Mirrors + ExportModal's drawer chrome so the two read as siblings. */ + +.compare-drawer { + position: fixed; + left: 0; + right: 0; + bottom: var(--logs-footer-height, 28px); + z-index: 90; + display: flex; + justify-content: center; + pointer-events: none; +} +.compare-drawer__sheet { + pointer-events: auto; + width: min(880px, calc(100vw - 24px)); + max-height: min(80vh, 640px); + display: flex; + flex-direction: column; + background: var(--chrome-bg); + border: 1px solid var(--chrome-border-strong); + border-bottom: none; + border-radius: 8px 8px 0 0; + box-shadow: + 0 -8px 24px rgba(0, 0, 0, 0.45), + 0 -1px 0 var(--chrome-border) inset; + animation: compare-drawer-slide-up var(--dur-base, 180ms) var(--ease-out, cubic-bezier(.2,.8,.2,1)); + overflow: hidden; +} +@keyframes compare-drawer-slide-up { + from { transform: translateY(100%); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +.compare-drawer__head { + display: flex; + align-items: center; + gap: var(--space-3); + padding: 10px var(--space-4); + border-bottom: 1px solid var(--chrome-border); + background: linear-gradient(180deg, rgba(255,255,255,0.02), transparent); + position: relative; +} +.compare-drawer__handle { + position: absolute; + top: 4px; + left: 50%; + transform: translateX(-50%); + width: 36px; + height: 3px; + border-radius: 2px; + background: var(--chrome-border-strong); +} +.compare-drawer__title { + display: inline-flex; + align-items: center; + gap: 6px; + font-family: var(--font-display, inherit); + font-size: var(--text-md); + font-weight: 600; + color: var(--chrome-fg); +} +.compare-drawer__close { + margin-left: auto; + width: var(--chrome-icon-btn, 22px); + height: var(--chrome-icon-btn, 22px); + display: inline-flex; + align-items: center; + justify-content: center; + background: transparent; + border: 1px solid transparent; + border-radius: var(--chrome-radius-pill); + color: var(--chrome-fg-muted); + cursor: pointer; + transition: background var(--dur-fast), color var(--dur-fast), border-color var(--dur-fast); +} +.compare-drawer__close:hover { + background: var(--chrome-hover-bg); + color: var(--chrome-fg); + border-color: var(--chrome-border-strong); +} + +.compare-drawer__body { + padding: var(--space-4); + overflow-y: auto; +} +.compare-drawer__foot { + display: flex; + justify-content: flex-end; + gap: var(--space-2); + padding: var(--space-3) var(--space-4); + border-top: 1px solid var(--chrome-border); + background: var(--chrome-bg); +} + +/* CompareModal — content-specific styles. Base controls (Panel, Button, + Select, Textarea) come from ui/. */ .ui-compare__desc { margin: 0 0 var(--space-5); diff --git a/frontend/src/components/CompareModal.jsx b/frontend/src/components/CompareModal.jsx index 887766d3..6a1861f0 100644 --- a/frontend/src/components/CompareModal.jsx +++ b/frontend/src/components/CompareModal.jsx @@ -1,9 +1,9 @@ -import React from 'react'; -import { Scale, Fingerprint, Play } from 'lucide-react'; +import React, { useEffect, useRef } from 'react'; +import { Scale, Fingerprint, Play, X } from 'lucide-react'; import { toast } from 'react-hot-toast'; import { PRESETS } from '../utils/constants'; import { generateSpeech } from '../api/generate'; -import { Dialog, Button, Panel, Field, Textarea, Select } from '../ui'; +import { Button, Panel, Field, Textarea, Select } from '../ui'; import './CompareModal.css'; export default function CompareModal({ @@ -19,6 +19,24 @@ export default function CompareModal({ steps, cfg, speed, denoise, postprocess, fileToMediaUrl, loadHistory, }) { + const drawerRef = useRef(null); + + useEffect(() => { + if (!open) return; + const onKey = (e) => { if (e.key === 'Escape') onClose?.(); }; + const onPointer = (e) => { + if (drawerRef.current && !drawerRef.current.contains(e.target)) onClose?.(); + }; + window.addEventListener('keydown', onKey); + // Defer click-outside so the opening click doesn't immediately close. + const t = setTimeout(() => window.addEventListener('mousedown', onPointer), 0); + return () => { + window.removeEventListener('keydown', onKey); + window.removeEventListener('mousedown', onPointer); + clearTimeout(t); + }; + }, [open, onClose]); + const runCompare = async () => { setIsComparing(true); setCompareResultA(null); @@ -73,14 +91,61 @@ export default function CompareModal({ const canCompare = !isComparing && compareVoiceA && compareVoiceB && compareText.trim(); + if (!open) return null; + return ( - A/B Voice Comparison} - footer={ - <> +
+
+
+
+ +
+

+ Compare two voices side by side to make casting decisions. App stays interactive behind. +

+ + +