volume panel; copilot instructions

This commit is contained in:
2026-04-15 16:10:35 -06:00
parent 0df967507f
commit 4f90750497
11 changed files with 688 additions and 106 deletions

View File

@ -1,4 +1,4 @@
import { useEffect, useState, useRef } from 'react';
import { useEffect, useState, useRef, useMemo } from 'react';
import { useEditorStore } from './store/editorStore';
import VideoPlayer from './components/VideoPlayer';
import TranscriptEditor from './components/TranscriptEditor';
@ -8,6 +8,7 @@ import ExportDialog from './components/ExportDialog';
import SettingsPanel from './components/SettingsPanel';
import DevPanel from './components/DevPanel';
import SilenceTrimmerPanel from './components/SilenceTrimmerPanel';
import VolumePanel from './components/VolumePanel';
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
import {
Film,
@ -20,6 +21,7 @@ import {
Save,
Scissors,
VolumeX,
Volume2,
FilePlus2,
RefreshCw,
} from 'lucide-react';
@ -27,13 +29,22 @@ import {
const IS_ELECTRON = !!window.electronAPI;
const LAST_MEDIA_PATH_KEY = 'talkedit:lastMediaPath';
type Panel = 'ai' | 'settings' | 'export' | 'silence' | null;
type Panel = 'ai' | 'settings' | 'export' | 'silence' | 'volume' | null;
export default function App() {
const {
videoPath,
exportedAudioPath,
words,
segments,
deletedRanges,
cutRanges,
muteRanges,
gainRanges,
globalGainDb,
silenceTrimGroups,
transcriptionModel,
language,
isTranscribing,
transcriptionProgress,
transcriptionStatus,
@ -54,8 +65,72 @@ export default function App() {
const [cutMode, setCutMode] = useState(false);
const [muteMode, setMuteMode] = useState(false);
const [showReprocessConfirm, setShowReprocessConfirm] = useState(false);
const [showUnsavedPrompt, setShowUnsavedPrompt] = useState(false);
const [pendingProceedAction, setPendingProceedAction] = useState<(() => Promise<void>) | null>(null);
const [lastSavedSignature, setLastSavedSignature] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const projectSignature = useMemo(() => {
if (!videoPath) return null;
return JSON.stringify({
videoPath,
exportedAudioPath,
words,
segments,
deletedRanges,
cutRanges,
muteRanges,
gainRanges,
globalGainDb,
silenceTrimGroups,
transcriptionModel,
language,
});
}, [
videoPath,
exportedAudioPath,
words,
segments,
deletedRanges,
cutRanges,
muteRanges,
gainRanges,
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 || [],
deletedRanges: data.deletedRanges || [],
cutRanges: data.cutRanges || [],
muteRanges: data.muteRanges || [],
gainRanges: data.gainRanges || [],
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>) => {
if (!IS_ELECTRON || !hasUnsavedChanges) {
await action();
return;
}
setPendingProceedAction(() => action);
setShowUnsavedPrompt(true);
};
useKeyboardShortcuts();
// Handle Escape key to exit cut/mute modes
@ -99,58 +174,69 @@ export default function App() {
const handleLoadProject = async () => {
if (!IS_ELECTRON) return;
try {
const projectPath = await window.electronAPI!.openProject();
if (!projectPath) return;
const content = await window.electronAPI!.readFile(projectPath);
const data = JSON.parse(content);
useEditorStore.getState().loadProject(data);
} catch (err) {
console.error('Failed to load project:', err);
alert(`Failed to load project: ${err}`);
}
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);
loadProjectFromData(data);
} catch (err) {
console.error('Failed to load project:', err);
alert(`Failed to load project: ${err}`);
}
});
};
const handleSaveProject = async () => {
if (!IS_ELECTRON) return;
const handleSaveProject = async (): Promise<boolean> => {
if (!IS_ELECTRON) return false;
try {
const savePath = await window.electronAPI!.saveProject();
if (!savePath) return;
if (!savePath) return false;
const data = useEditorStore.getState().saveProject();
const path = savePath.endsWith('.aive') ? savePath : `${savePath}.aive`;
await window.electronAPI!.writeFile(path, JSON.stringify(data, null, 2));
if (projectSignature) {
setLastSavedSignature(projectSignature);
}
return true;
} catch (err) {
console.error('Failed to save project:', err);
alert(`Failed to save project: ${err}`);
return false;
}
return false;
};
const handleOpenFile = async () => {
if (IS_ELECTRON) {
const path = await window.electronAPI!.openFile();
if (path) {
loadVideo(path);
await transcribeVideo(path);
await runGuarded(async () => {
if (IS_ELECTRON) {
const path = await window.electronAPI!.openFile();
if (path) {
setLastSavedSignature(null);
loadVideo(path);
await transcribeVideo(path);
}
} else {
// Browser: use the manual path input
const path = manualPath.trim();
if (path) {
loadVideo(path);
await transcribeVideo(path);
}
}
} else {
// Browser: use the manual path input
const path = manualPath.trim();
if (path) {
loadVideo(path);
await transcribeVideo(path);
}
}
});
};
const handleNewProject = () => {
const shouldReset = window.confirm('Start a new project? Unsaved changes in the current session will be lost.');
if (!shouldReset) return;
useEditorStore.getState().reset();
setActivePanel(null);
setManualPath('');
setCutMode(false);
setMuteMode(false);
runGuarded(async () => {
useEditorStore.getState().reset();
setLastSavedSignature(null);
setActivePanel(null);
setManualPath('');
setCutMode(false);
setMuteMode(false);
});
};
const handleManualSubmit = async (e: React.FormEvent) => {
@ -209,7 +295,9 @@ export default function App() {
return;
}
setShowReprocessConfirm(true);
await runGuarded(async () => {
setShowReprocessConfirm(true);
});
};
const confirmReprocessProject = async () => {
@ -218,6 +306,33 @@ export default function App() {
await transcribeVideo(videoPath);
};
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);
};
const togglePanel = (panel: Panel) => {
setActivePanel((prev) => (prev === panel ? null : panel));
};
@ -403,6 +518,13 @@ export default function App() {
onClick={handleMute}
active={muteMode}
/>
<ToolbarButton
icon={<Volume2 className="w-4 h-4" />}
label="Volume"
active={activePanel === 'volume'}
onClick={() => togglePanel('volume')}
disabled={!videoPath}
/>
<ToolbarButton
icon={<span className="text-[10px] font-semibold">PA</span>}
label="Pause Trim"
@ -520,6 +642,7 @@ export default function App() {
{/* Right panel (AI / Export / Settings) */}
{activePanel && (
<div className="w-80 border-l border-editor-border overflow-y-auto shrink-0">
{activePanel === 'volume' && <VolumePanel />}
{activePanel === 'silence' && <SilenceTrimmerPanel />}
{activePanel === 'ai' && <AIPanel />}
{activePanel === 'export' && <ExportDialog />}
@ -559,6 +682,43 @@ export default function App() {
</div>
</div>
)}
{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>
);
}