Files
TalkEdit/frontend/src/App.tsx

852 lines
30 KiB
TypeScript
Raw Normal View History

2026-04-15 17:40:27 -06:00
import { useEffect, useState, useMemo } from 'react';
import { useEditorStore } from './store/editorStore';
import VideoPlayer from './components/VideoPlayer';
import TranscriptEditor from './components/TranscriptEditor';
import WaveformTimeline from './components/WaveformTimeline';
import AIPanel from './components/AIPanel';
import ExportDialog from './components/ExportDialog';
import SettingsPanel from './components/SettingsPanel';
2026-03-30 18:36:41 -06:00
import DevPanel from './components/DevPanel';
2026-04-03 12:05:44 -06:00
import SilenceTrimmerPanel from './components/SilenceTrimmerPanel';
2026-04-15 18:00:34 -06:00
import ZoneEditor from './components/ZoneEditor';
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
import {
Film,
FolderOpen,
Settings,
Sparkles,
Download,
FileInput,
2026-03-30 18:36:41 -06:00
Save,
2026-04-03 11:14:31 -06:00
Scissors,
VolumeX,
2026-04-15 16:36:21 -06:00
SlidersHorizontal,
Gauge,
FilePlus2,
RefreshCw,
2026-04-15 18:00:34 -06:00
Grid3x3,
} from 'lucide-react';
2026-04-09 01:36:28 -06:00
const LAST_MEDIA_PATH_KEY = 'talkedit:lastMediaPath';
2026-04-15 18:00:34 -06:00
type Panel = 'ai' | 'settings' | 'export' | 'silence' | 'zones' | null;
export default function App() {
const {
2026-04-15 20:51:24 -06:00
projectFilePath,
videoPath,
2026-04-15 16:10:35 -06:00
exportedAudioPath,
words,
2026-04-15 16:10:35 -06:00
segments,
cutRanges,
muteRanges,
gainRanges,
speedRanges,
2026-04-15 16:10:35 -06:00
globalGainDb,
silenceTrimGroups,
transcriptionModel,
2026-04-15 16:10:35 -06:00
language,
isTranscribing,
transcriptionStatus,
2026-04-15 21:25:47 -06:00
markInTime,
markOutTime,
loadVideo,
2026-04-15 20:51:24 -06:00
setProjectFilePath,
setBackendUrl,
2026-04-15 21:25:47 -06:00
clearMarkRange,
setTranscription,
setTranscriptionModel,
setTranscribing,
2026-04-03 11:14:31 -06:00
selectedWordIndices,
addCutRange,
addMuteRange,
2026-04-15 16:36:21 -06:00
addGainRange,
addSpeedRange,
} = useEditorStore();
const [activePanel, setActivePanel] = useState<Panel>(null);
2026-04-15 20:17:05 -06:00
const [projectName, setProjectName] = useState<string | null>(null);
const [whisperModel, setWhisperModel] = useState('base');
useEffect(() => { if (transcriptionModel) setWhisperModel(transcriptionModel); }, [transcriptionModel]);
2026-04-03 11:14:31 -06:00
const [cutMode, setCutMode] = useState(false);
const [muteMode, setMuteMode] = useState(false);
2026-04-15 16:36:21 -06:00
const [gainMode, setGainMode] = useState(false);
const [gainModeDb, setGainModeDb] = useState(3);
const [speedMode, setSpeedMode] = useState(false);
const [speedModeValue, setSpeedModeValue] = useState(1.25);
const [showReprocessConfirm, setShowReprocessConfirm] = useState(false);
2026-04-15 16:10:35 -06:00
const [showUnsavedPrompt, setShowUnsavedPrompt] = useState(false);
const [pendingProceedAction, setPendingProceedAction] = useState<(() => Promise<void>) | null>(null);
const [lastSavedSignature, setLastSavedSignature] = useState<string | null>(null);
2026-04-15 16:10:35 -06:00
const projectSignature = useMemo(() => {
if (!videoPath) return null;
return JSON.stringify({
videoPath,
exportedAudioPath,
words,
segments,
cutRanges,
muteRanges,
gainRanges,
speedRanges,
2026-04-15 16:10:35 -06:00
globalGainDb,
silenceTrimGroups,
transcriptionModel,
language,
});
}, [
videoPath,
exportedAudioPath,
words,
segments,
cutRanges,
muteRanges,
gainRanges,
speedRanges,
2026-04-15 16:10:35 -06:00
globalGainDb,
silenceTrimGroups,
transcriptionModel,
language,
]);
const hasUnsavedChanges = Boolean(projectSignature) && projectSignature !== lastSavedSignature;
const loadProjectFromData = (data: any) => {
useEditorStore.getState().loadProject(data);
const loadedSignature = JSON.stringify({
videoPath: data.videoPath,
exportedAudioPath: data.exportedAudioPath ?? null,
words: data.words || [],
segments: data.segments || [],
2026-04-15 20:17:05 -06:00
cutRanges: [...(data.cutRanges || []), ...(data.deletedRanges || []).map((r: any) => ({ id: r.id, start: r.start, end: r.end }))],
2026-04-15 16:10:35 -06:00
muteRanges: data.muteRanges || [],
gainRanges: data.gainRanges || [],
speedRanges: data.speedRanges || [],
2026-04-15 16:10:35 -06:00
globalGainDb: typeof data.globalGainDb === 'number' ? data.globalGainDb : 0,
silenceTrimGroups: data.silenceTrimGroups || [],
transcriptionModel: data.transcriptionModel ?? null,
language: data.language || '',
});
setLastSavedSignature(loadedSignature);
};
const runGuarded = async (action: () => Promise<void>) => {
2026-04-15 17:40:27 -06:00
if (!hasUnsavedChanges) {
2026-04-15 16:10:35 -06:00
await action();
return;
}
setPendingProceedAction(() => action);
setShowUnsavedPrompt(true);
};
useKeyboardShortcuts();
2026-04-15 16:36:21 -06:00
// Handle Escape key to exit timeline zone modes
2026-04-03 11:14:31 -06:00
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setCutMode(false);
setMuteMode(false);
2026-04-15 16:36:21 -06:00
setGainMode(false);
setSpeedMode(false);
2026-04-03 11:14:31 -06:00
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
useEffect(() => {
2026-04-15 17:40:27 -06:00
window.electronAPI!.getBackendUrl().then(setBackendUrl);
}, [setBackendUrl]);
2026-04-09 01:36:28 -06:00
useEffect(() => {
2026-04-15 17:40:27 -06:00
if (videoPath) return;
2026-04-09 01:36:28 -06:00
const savedPath = sessionStorage.getItem(LAST_MEDIA_PATH_KEY);
if (savedPath) {
loadVideo(savedPath);
}
}, [videoPath, loadVideo]);
useEffect(() => {
if (videoPath) {
sessionStorage.setItem(LAST_MEDIA_PATH_KEY, videoPath);
return;
}
sessionStorage.removeItem(LAST_MEDIA_PATH_KEY);
}, [videoPath]);
const handleLoadProject = async () => {
2026-04-15 16:10:35 -06:00
await runGuarded(async () => {
try {
const projectPath = await window.electronAPI!.openProject();
if (!projectPath) return;
const content = await window.electronAPI!.readFile(projectPath);
const data = JSON.parse(content);
2026-04-15 20:51:24 -06:00
setProjectFilePath(projectPath);
2026-04-15 16:10:35 -06:00
loadProjectFromData(data);
2026-04-15 20:17:05 -06:00
setProjectName(projectPath.split(/[/\\]/).pop()?.replace(/\.aive$/i, '') ?? null);
2026-04-15 16:10:35 -06:00
} catch (err) {
console.error('Failed to load project:', err);
alert(`Failed to load project: ${err}`);
}
});
};
2026-04-15 20:51:24 -06:00
const writeProjectToPath = async (path: string): Promise<boolean> => {
2026-03-30 18:36:41 -06:00
try {
const data = useEditorStore.getState().saveProject();
2026-04-15 20:51:24 -06:00
const resolvedPath = path.endsWith('.aive') ? path : `${path}.aive`;
await window.electronAPI!.writeFile(resolvedPath, JSON.stringify(data, null, 2));
setProjectFilePath(resolvedPath);
setProjectName(resolvedPath.split(/[/\\]/).pop()?.replace(/\.aive$/i, '') ?? null);
2026-04-15 16:10:35 -06:00
if (projectSignature) {
setLastSavedSignature(projectSignature);
}
return true;
2026-03-30 18:36:41 -06:00
} catch (err) {
console.error('Failed to save project:', err);
alert(`Failed to save project: ${err}`);
2026-04-15 16:10:35 -06:00
return false;
2026-03-30 18:36:41 -06:00
}
};
2026-04-15 20:51:24 -06:00
const handleSaveProjectAs = async (): Promise<boolean> => {
const savePath = await window.electronAPI!.saveProject();
if (!savePath) return false;
return writeProjectToPath(savePath);
};
const handleSaveProject = async (): Promise<boolean> => {
if (!projectFilePath) {
return handleSaveProjectAs();
}
return writeProjectToPath(projectFilePath);
};
const handleOpenFile = async () => {
2026-04-15 16:10:35 -06:00
await runGuarded(async () => {
2026-04-15 17:40:27 -06:00
const path = await window.electronAPI!.openFile();
if (path) {
setLastSavedSignature(null);
2026-04-15 20:51:24 -06:00
setProjectFilePath(null);
setProjectName(null);
2026-04-15 17:40:27 -06:00
loadVideo(path);
await transcribeVideo(path);
}
2026-04-15 16:10:35 -06:00
});
};
const handleNewProject = () => {
2026-04-15 16:10:35 -06:00
runGuarded(async () => {
useEditorStore.getState().reset();
setLastSavedSignature(null);
setActivePanel(null);
2026-04-15 20:51:24 -06:00
setProjectFilePath(null);
setProjectName(null);
2026-04-15 16:10:35 -06:00
setCutMode(false);
setMuteMode(false);
2026-04-15 16:36:21 -06:00
setGainMode(false);
setSpeedMode(false);
2026-04-15 16:10:35 -06:00
});
};
const transcribeVideo = async (path: string) => {
setTranscribing(true, 0, 'Checking model...');
try {
// Step 1: ensure model is downloaded (may take a while on first run)
2026-04-03 10:25:48 -06:00
const MODEL_SIZES: Record<string, string> = {
'tiny': '~75 MB',
'tiny.en': '~75 MB',
'base': '~140 MB',
'base.en': '~140 MB',
'small': '~460 MB',
'small.en': '~460 MB',
'medium': '~1.5 GB',
'medium.en': '~1.5 GB',
'large': '~2.9 GB',
'large-v2': '~2.9 GB',
'large-v3': '~2.9 GB',
'large-v3-turbo': '~1.6 GB',
'distil-large-v3': '~1.5 GB',
'distil-medium.en': '~750 MB',
'distil-small.en': '~190 MB',
};
const modelLabel = MODEL_SIZES[whisperModel] ?? 'unknown size';
setTranscribing(true, 5, `Downloading ${whisperModel} model (${modelLabel})...`);
await window.electronAPI.ensureModel(whisperModel);
// Step 2: run transcription
setTranscribing(true, 20, 'Transcribing audio...');
const data = await window.electronAPI.transcribe(path, whisperModel);
setTranscription(data);
setTranscriptionModel(whisperModel);
} catch (err) {
console.error('Transcription error:', err);
alert(`Transcription failed. Check the console for details.\n\n${err}`);
} finally {
setTranscribing(false);
}
};
const handleReprocessProject = async () => {
if (!videoPath) return;
2026-04-15 16:10:35 -06:00
await runGuarded(async () => {
setShowReprocessConfirm(true);
});
};
const confirmReprocessProject = async () => {
if (!videoPath) return;
setShowReprocessConfirm(false);
await transcribeVideo(videoPath);
};
2026-04-15 16:10:35 -06:00
const handleUnsavedSaveAndContinue = async () => {
const action = pendingProceedAction;
if (!action) {
setShowUnsavedPrompt(false);
return;
}
const didSave = await handleSaveProject();
if (!didSave) return;
setShowUnsavedPrompt(false);
setPendingProceedAction(null);
await action();
};
const handleUnsavedDiscardAndContinue = async () => {
const action = pendingProceedAction;
setShowUnsavedPrompt(false);
setPendingProceedAction(null);
if (action) {
await action();
}
};
const handleUnsavedCancel = () => {
setShowUnsavedPrompt(false);
setPendingProceedAction(null);
};
2026-04-03 12:05:44 -06:00
const togglePanel = (panel: Panel) => {
setActivePanel((prev) => (prev === panel ? null : panel));
};
2026-04-03 11:14:31 -06:00
const handleCut = () => {
2026-04-15 21:25:47 -06:00
if (markInTime !== null && markOutTime !== null) {
const startTime = Math.min(markInTime, markOutTime);
const endTime = Math.max(markInTime, markOutTime);
if (endTime - startTime >= 0.01) {
addCutRange(startTime, endTime);
setActivePanel('zones');
}
clearMarkRange();
return;
}
2026-04-03 11:14:31 -06:00
if (selectedWordIndices.length > 0) {
// If words are selected, apply cut immediately
const sorted = [...selectedWordIndices].sort((a, b) => a - b);
const startTime = words[sorted[0]].start;
const endTime = words[sorted[sorted.length - 1]].end;
addCutRange(startTime, endTime);
} else {
// Toggle cut mode
setCutMode(!cutMode);
setMuteMode(false); // Exit mute mode
2026-04-15 16:36:21 -06:00
setGainMode(false); // Exit gain mode
setSpeedMode(false); // Exit speed mode
2026-04-03 11:14:31 -06:00
}
};
const handleMute = () => {
2026-04-15 21:25:47 -06:00
if (markInTime !== null && markOutTime !== null) {
const startTime = Math.min(markInTime, markOutTime);
const endTime = Math.max(markInTime, markOutTime);
if (endTime - startTime >= 0.01) {
addMuteRange(startTime, endTime);
setActivePanel('zones');
}
clearMarkRange();
return;
}
2026-04-03 11:14:31 -06:00
if (selectedWordIndices.length > 0) {
// If words are selected, apply mute immediately
const sorted = [...selectedWordIndices].sort((a, b) => a - b);
const startTime = words[sorted[0]].start;
const endTime = words[sorted[sorted.length - 1]].end;
addMuteRange(startTime, endTime);
} else {
// Toggle mute mode
setMuteMode(!muteMode);
setCutMode(false); // Exit cut mode
2026-04-15 16:36:21 -06:00
setGainMode(false); // Exit gain mode
setSpeedMode(false); // Exit speed mode
2026-04-15 16:36:21 -06:00
}
};
const handleGain = () => {
2026-04-15 21:25:47 -06:00
if (markInTime !== null && markOutTime !== null) {
const startTime = Math.min(markInTime, markOutTime);
const endTime = Math.max(markInTime, markOutTime);
if (endTime - startTime >= 0.01) {
addGainRange(startTime, endTime, gainModeDb);
setActivePanel('zones');
}
clearMarkRange();
return;
}
2026-04-15 16:36:21 -06:00
if (selectedWordIndices.length > 0) {
const sorted = [...selectedWordIndices].sort((a, b) => a - b);
const startTime = words[sorted[0]].start;
const endTime = words[sorted[sorted.length - 1]].end;
addGainRange(startTime, endTime, gainModeDb);
} else {
setGainMode(!gainMode);
setCutMode(false);
setMuteMode(false);
setSpeedMode(false);
}
};
const handleSpeed = () => {
2026-04-15 21:25:47 -06:00
if (markInTime !== null && markOutTime !== null) {
const startTime = Math.min(markInTime, markOutTime);
const endTime = Math.max(markInTime, markOutTime);
if (endTime - startTime >= 0.01) {
addSpeedRange(startTime, endTime, speedModeValue);
setActivePanel('zones');
}
clearMarkRange();
return;
}
if (selectedWordIndices.length > 0) {
const sorted = [...selectedWordIndices].sort((a, b) => a - b);
const startTime = words[sorted[0]].start;
const endTime = words[sorted[sorted.length - 1]].end;
addSpeedRange(startTime, endTime, speedModeValue);
} else {
setSpeedMode(!speedMode);
setCutMode(false);
setMuteMode(false);
setGainMode(false);
2026-04-03 11:14:31 -06:00
}
};
if (!videoPath) {
return (
<div className="h-screen flex flex-col items-center justify-center gap-8 bg-editor-bg px-6">
<div className="flex flex-col items-center gap-3">
<Film className="w-14 h-14 text-editor-accent opacity-80" />
2026-03-25 01:41:40 -06:00
<h1 className="text-3xl font-semibold tracking-tight">TalkEdit</h1>
<p className="text-editor-text-muted text-sm max-w-sm text-center">
2026-03-25 01:41:40 -06:00
Offline AI-powered video editor.
</p>
</div>
{/* Whisper model selector */}
<div className="flex items-center gap-3">
2026-04-03 10:25:48 -06:00
<label className="text-xs text-editor-text-muted whitespace-nowrap">Model:</label>
<select
value={whisperModel}
onChange={(e) => setWhisperModel(e.target.value)}
2026-04-03 10:34:04 -06:00
className="px-3 py-1.5 bg-editor-surface border border-editor-border rounded-lg text-xs text-black focus:outline-none focus:border-editor-accent"
>
2026-04-03 10:25:48 -06:00
<optgroup label="Multilingual (any language)">
<option value="tiny">tiny ~75 MB · fastest, low accuracy</option>
<option value="base">base ~140 MB · fast, decent accuracy</option>
<option value="small">small ~460 MB · good balance</option>
<option value="medium">medium ~1.5 GB · better accuracy</option>
<option value="large-v2">large-v2 ~2.9 GB · high accuracy</option>
<option value="large-v3">large-v3 ~2.9 GB · best overall </option>
<option value="large-v3-turbo">large-v3-turbo ~1.6 GB · fast + accurate </option>
<option value="distil-large-v3">distil-large-v3 ~1.5 GB · fast, near large-v3 quality</option>
</optgroup>
<optgroup label="English-only (faster &amp; more accurate for English)">
<option value="tiny.en">tiny.en ~75 MB · fastest English</option>
<option value="base.en">base.en ~140 MB · fast English</option>
<option value="small.en">small.en ~460 MB · good English</option>
<option value="medium.en">medium.en ~1.5 GB · great English</option>
<option value="distil-small.en">distil-small.en ~190 MB · fast English </option>
<option value="distil-medium.en">distil-medium.en ~750 MB · best fast English </option>
</optgroup>
</select>
</div>
2026-04-03 10:25:48 -06:00
<p className="text-[11px] text-editor-text-muted text-center max-w-sm">
For noisy/YouTube videos use <span className="text-white">large-v3</span> or <span className="text-white">large-v3-turbo</span>.
English-only models are ~10% faster and more accurate for English content.
</p>
2026-04-15 17:40:27 -06:00
<div className="flex flex-col items-center gap-3">
<button
onClick={handleOpenFile}
className="flex items-center gap-2 px-6 py-3 bg-editor-accent hover:bg-editor-accent-hover rounded-lg text-white font-medium transition-colors"
>
<FolderOpen className="w-5 h-5" />
Open Video File
</button>
<button
onClick={handleLoadProject}
className="flex items-center gap-2 px-4 py-2 text-sm text-editor-text-muted hover:text-editor-text hover:bg-editor-surface rounded-lg transition-colors"
>
<FileInput className="w-4 h-4" />
Load Project (.aive)
</button>
</div>
</div>
);
}
return (
<div className="h-screen flex flex-col bg-editor-bg overflow-hidden">
{/* Top bar */}
<header className="h-12 flex items-center px-4 border-b border-editor-border shrink-0">
<div className="flex items-center gap-0.5">
<ToolbarButton
icon={<FilePlus2 className="w-4 h-4" />}
label="New"
onClick={handleNewProject}
/>
<ToolbarButton
icon={<FolderOpen className="w-4 h-4" />}
label="Open"
2026-04-15 17:40:27 -06:00
onClick={handleOpenFile}
/>
<ToolbarButton
icon={<Save className="w-4 h-4" />}
label="Save"
onClick={handleSaveProject}
disabled={words.length === 0}
/>
2026-04-15 20:51:24 -06:00
<ToolbarButton
icon={<Save className="w-4 h-4" />}
label="Save As"
onClick={handleSaveProjectAs}
disabled={words.length === 0}
/>
2026-04-15 17:40:27 -06:00
<ToolbarButton
icon={<FileInput className="w-4 h-4" />}
label="Load"
onClick={handleLoadProject}
/>
2026-04-03 11:14:31 -06:00
<ToolbarButton
icon={<Scissors className="w-4 h-4" />}
label="Cut"
onClick={handleCut}
active={cutMode}
/>
<ToolbarButton
icon={<VolumeX className="w-4 h-4" />}
label="Mute"
onClick={handleMute}
active={muteMode}
/>
2026-04-15 18:00:34 -06:00
<div className="flex items-center gap-1">
<ToolbarButton
icon={<SlidersHorizontal className="w-4 h-4" />}
label="Gain Zone"
onClick={handleGain}
active={gainMode}
/>
<input
type="number"
min={-24}
max={24}
step={0.5}
value={gainModeDb}
onChange={(e) => setGainModeDb(Math.max(-24, Math.min(24, Number(e.target.value) || 0)))}
className="w-16 px-1.5 py-1 text-xs bg-editor-surface border border-editor-border rounded text-editor-text focus:outline-none focus:border-editor-accent"
title="Gain dB for new gain zones"
/>
</div>
<div className="flex items-center gap-1">
<ToolbarButton
icon={<Gauge className="w-4 h-4" />}
label="Speed Zone"
onClick={handleSpeed}
active={speedMode}
/>
<input
type="number"
min={0.25}
max={4}
step={0.05}
value={speedModeValue}
onChange={(e) => setSpeedModeValue(Math.max(0.25, Math.min(4, Number(e.target.value) || 1)))}
className="w-16 px-1.5 py-1 text-xs bg-editor-surface border border-editor-border rounded text-editor-text focus:outline-none focus:border-editor-accent"
title="Playback rate for new speed zones"
/>
</div>
2026-04-15 16:36:21 -06:00
<ToolbarButton
2026-04-15 18:00:34 -06:00
icon={<Grid3x3 className="w-4 h-4" />}
label="Zones"
active={activePanel === 'zones'}
onClick={() => togglePanel('zones')}
2026-04-15 16:10:35 -06:00
disabled={!videoPath}
/>
2026-04-03 12:05:44 -06:00
<ToolbarButton
icon={<span className="text-[10px] font-semibold">PA</span>}
label="Pause Trim"
active={activePanel === 'silence'}
onClick={() => togglePanel('silence')}
disabled={!videoPath}
/>
<div className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-editor-surface border border-editor-border">
<select
value={whisperModel}
onChange={(e) => setWhisperModel(e.target.value)}
className="bg-editor-surface text-xs text-editor-text focus:outline-none [color-scheme:dark]"
title="Transcription model"
>
<optgroup label="Multilingual">
<option value="tiny">tiny</option>
<option value="base">base</option>
<option value="small">small</option>
<option value="medium">medium</option>
<option value="large-v2">large-v2</option>
<option value="large-v3">large-v3</option>
<option value="large-v3-turbo">large-v3-turbo</option>
<option value="distil-large-v3">distil-large-v3</option>
</optgroup>
<optgroup label="English">
<option value="tiny.en">tiny.en</option>
<option value="base.en">base.en</option>
<option value="small.en">small.en</option>
<option value="medium.en">medium.en</option>
<option value="distil-small.en">distil-small.en</option>
<option value="distil-medium.en">distil-medium.en</option>
</optgroup>
</select>
<button
onClick={handleReprocessProject}
disabled={isTranscribing || !videoPath}
title="Reprocess transcript with selected model"
className="flex items-center gap-1 px-2 py-1 rounded text-xs text-editor-text hover:bg-editor-bg disabled:opacity-40 disabled:cursor-not-allowed"
>
<RefreshCw className={`w-3 h-3 ${isTranscribing ? 'animate-spin' : ''}`} />
Reprocess
</button>
</div>
<ToolbarButton
icon={<Sparkles className="w-4 h-4" />}
label="AI"
active={activePanel === 'ai'}
onClick={() => togglePanel('ai')}
disabled={words.length === 0}
/>
<ToolbarButton
icon={<Download className="w-4 h-4" />}
label="Export"
active={activePanel === 'export'}
onClick={() => togglePanel('export')}
disabled={words.length === 0}
/>
<ToolbarButton
icon={<Settings className="w-4 h-4" />}
label="Settings"
active={activePanel === 'settings'}
onClick={() => togglePanel('settings')}
/>
</div>
</header>
{/* Main content */}
<div className="flex-1 flex overflow-hidden">
{/* Left: video + transcript */}
<div className="flex-1 flex flex-col min-w-0">
<div className="flex-1 flex min-h-0">
{/* Video player */}
<div className="w-1/2 p-3 flex items-center justify-center bg-black/20">
<VideoPlayer />
</div>
{/* Transcript */}
<div className="w-1/2 border-l border-editor-border flex flex-col min-h-0">
{videoPath && (
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-editor-border shrink-0 bg-editor-surface/50">
2026-04-15 20:17:05 -06:00
{projectName && (
<span className="text-xs font-semibold text-editor-accent shrink-0">{projectName}</span>
)}
<span className="text-xs font-medium truncate text-editor-text">{videoPath.split(/[/\\]/).pop()}</span>
<span className="text-xs text-editor-text-muted ml-auto shrink-0">
{words.length} words &middot; {cutRanges.length} cuts &middot; {muteRanges.length} mutes &middot; {gainRanges.length} gains &middot; {speedRanges.length} speeds
</span>
{transcriptionModel && (
<span className="px-1.5 py-0.5 rounded border border-editor-border bg-editor-surface text-[10px] uppercase tracking-wide text-editor-text-muted shrink-0">
{transcriptionModel}
</span>
)}
</div>
)}
{isTranscribing ? (
<div className="flex-1 flex flex-col items-center justify-center gap-5">
{/* Animated waveform */}
<div className="flex items-end gap-[3px] h-10">
{[35, 60, 45, 80, 55, 70, 40, 65, 50, 75, 40, 58].map((h, i) => (
<div
key={i}
className="w-[5px] rounded-full bg-editor-accent wave-bar"
style={{
height: `${h}%`,
animationDelay: `${i * 75}ms`,
}}
/>
))}
</div>
<div className="text-center space-y-1">
<p className="text-sm font-medium text-editor-text">Processing audio</p>
<p className="text-xs text-editor-text-muted">{transcriptionStatus || 'Please wait...'}</p>
</div>
</div>
) : words.length > 0 ? (
2026-04-15 18:00:34 -06:00
<TranscriptEditor
cutMode={cutMode}
muteMode={muteMode}
gainMode={gainMode}
gainModeDb={gainModeDb}
speedMode={speedMode}
speedModeValue={speedModeValue}
2026-04-15 18:00:34 -06:00
/>
) : (
<div className="flex-1 flex items-center justify-center text-editor-text-muted text-sm">
No transcript yet
</div>
)}
</div>
</div>
{/* Waveform timeline */}
<div className="h-32 border-t border-editor-border shrink-0">
<WaveformTimeline
cutMode={cutMode}
muteMode={muteMode}
gainMode={gainMode}
gainModeDb={gainModeDb}
speedMode={speedMode}
speedModeValue={speedModeValue}
/>
</div>
</div>
{/* Right panel (AI / Export / Settings) */}
{activePanel && (
<div className="w-80 border-l border-editor-border overflow-y-auto shrink-0">
2026-04-15 18:00:34 -06:00
{activePanel === 'zones' && (
<ZoneEditor />
2026-04-15 16:36:21 -06:00
)}
2026-04-03 12:05:44 -06:00
{activePanel === 'silence' && <SilenceTrimmerPanel />}
{activePanel === 'ai' && <AIPanel />}
{activePanel === 'export' && <ExportDialog />}
{activePanel === 'settings' && <SettingsPanel />}
</div>
)}
</div>
2026-03-30 18:36:41 -06:00
{import.meta.env.DEV && <DevPanel />}
{showReprocessConfirm && (
<div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 px-4"
onClick={() => setShowReprocessConfirm(false)}
>
<div
className="w-full max-w-md rounded-xl border border-editor-border bg-editor-bg p-4 space-y-3"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-sm font-semibold">Reprocess transcript?</h3>
<p className="text-xs text-editor-text-muted leading-relaxed">
This will reprocess the current file with <span className="text-editor-text font-medium">{whisperModel}</span> and replace the current transcript words and timings.
</p>
<div className="flex items-center justify-end gap-2 pt-1">
<button
onClick={() => setShowReprocessConfirm(false)}
className="px-3 py-1.5 rounded-md text-xs text-editor-text-muted hover:text-editor-text hover:bg-editor-surface"
>
Cancel
</button>
<button
onClick={confirmReprocessProject}
className="px-3 py-1.5 rounded-md text-xs bg-editor-accent hover:bg-editor-accent-hover text-white"
>
Reprocess Now
</button>
</div>
</div>
</div>
)}
2026-04-15 16:10:35 -06:00
{showUnsavedPrompt && (
<div
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 px-4"
onClick={handleUnsavedCancel}
>
<div
className="w-full max-w-md rounded-xl border border-editor-border bg-editor-bg p-4 space-y-3"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-sm font-semibold">Save changes first?</h3>
<p className="text-xs text-editor-text-muted leading-relaxed">
There are unsaved changes in this project. Save before continuing?
</p>
<div className="flex items-center justify-end gap-2 pt-1">
<button
onClick={handleUnsavedCancel}
className="px-3 py-1.5 rounded-md text-xs text-editor-text-muted hover:text-editor-text hover:bg-editor-surface"
>
Cancel
</button>
<button
onClick={handleUnsavedDiscardAndContinue}
className="px-3 py-1.5 rounded-md text-xs text-editor-text hover:bg-editor-surface"
>
Don't Save
</button>
<button
onClick={handleUnsavedSaveAndContinue}
className="px-3 py-1.5 rounded-md text-xs bg-editor-accent hover:bg-editor-accent-hover text-white"
>
Save
</button>
</div>
</div>
</div>
)}
</div>
);
}
function ToolbarButton({
icon,
label,
active,
onClick,
disabled,
}: {
icon: React.ReactNode;
label: string;
active?: boolean;
onClick: () => void;
disabled?: boolean;
}) {
return (
<button
onClick={onClick}
disabled={disabled}
title={label}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
active
? 'bg-editor-accent text-white'
: 'text-editor-text-muted hover:text-editor-text hover:bg-editor-surface'
} ${disabled ? 'opacity-40 cursor-not-allowed' : ''}`}
>
{icon}
{label}
</button>
);
}