volume panel; copilot instructions
This commit is contained in:
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user