more polish

This commit is contained in:
2026-05-06 12:15:46 -06:00
parent a96e42c9f9
commit acf7f2e64c
10 changed files with 160 additions and 223 deletions

View File

@ -646,7 +646,7 @@ export default function App() {
<div className="flex items-center gap-1">
<ToolbarButton
icon={<SlidersHorizontal className="w-4 h-4" />}
label="Gain Zone"
label="Sound Gain"
onClick={handleGain}
active={gainMode}
disabled={!canEdit}
@ -667,7 +667,7 @@ export default function App() {
<div className="flex items-center gap-1">
<ToolbarButton
icon={<Gauge className="w-4 h-4" />}
label="Speed Zone"
label="Speed Adjust"
onClick={handleSpeed}
active={speedMode}
disabled={!canEdit}
@ -685,9 +685,10 @@ export default function App() {
disabled={!canEdit}
/>
</div>
<div className="w-px h-5 bg-editor-border mx-1" />
<ToolbarButton
icon={<Grid3x3 className="w-4 h-4" />}
label="Zones"
label="Edit Zones"
active={activePanel === 'zones'}
onClick={() => togglePanel('zones')}
disabled={!videoPath || !canEdit}
@ -695,7 +696,7 @@ export default function App() {
/>
<ToolbarButton
icon={<span className="text-[10px] font-semibold">PA</span>}
label="Pause Trim"
label="Trim Silence"
active={activePanel === 'silence'}
onClick={() => togglePanel('silence')}
disabled={!videoPath || !canEdit}
@ -703,7 +704,7 @@ export default function App() {
/>
<ToolbarButton
icon={<MapPin className="w-4 h-4" />}
label="Markers"
label="Chapter Marks"
active={activePanel === 'markers'}
onClick={() => togglePanel('markers')}
disabled={!videoPath || !canEdit}
@ -711,7 +712,7 @@ export default function App() {
/>
<ToolbarButton
icon={<Music className="w-4 h-4" />}
label="Music"
label="Bkg. Music"
active={activePanel === 'music'}
onClick={() => togglePanel('music')}
disabled={!videoPath || !canEdit}
@ -719,12 +720,13 @@ export default function App() {
/>
<ToolbarButton
icon={<ListVideo className="w-4 h-4" />}
label="Append"
label="Add Clips"
active={activePanel === 'append'}
onClick={() => togglePanel('append')}
disabled={!videoPath || !canEdit}
title="Append additional video clips — concatenate multiple files during export"
/>
<div className="w-px h-5 bg-editor-border mx-1" />
<div className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-editor-surface border border-editor-border">
<select
value={whisperModel}
@ -751,19 +753,11 @@ export default function App() {
<option value="distil-medium.en">distil-medium.en</option>
</optgroup>
</select>
<button
onClick={handleReprocessProject}
disabled={isTranscribing || !videoPath || !canEdit}
title="Re-run transcription with the selected Whisper model — replaces current transcript"
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>
<div className="w-px h-5 bg-editor-border mx-1" />
<ToolbarButton
icon={<Sparkles className="w-4 h-4" />}
label="AI"
label="AI Tools"
active={activePanel === 'ai'}
onClick={() => togglePanel('ai')}
disabled={words.length === 0 || !canEdit}
@ -774,7 +768,7 @@ export default function App() {
label="Export"
active={activePanel === 'export'}
onClick={() => togglePanel('export')}
disabled={words.length === 0}
disabled={!videoPath}
/>
<ToolbarButton
icon={<Settings className="w-4 h-4" />}
@ -800,6 +794,7 @@ export default function App() {
className="w-1 shrink-0 bg-editor-border cursor-col-resize hover:bg-editor-accent/50 active:bg-editor-accent transition-colors relative z-10"
style={{ cursor: isDraggingSplit.current ? 'col-resize' : 'col-resize' }}
onMouseDown={startSplitDrag}
title="Drag to resize"
/>
{/* Transcript */}
@ -877,6 +872,7 @@ export default function App() {
<div
className="w-1 shrink-0 bg-editor-border cursor-col-resize hover:bg-editor-accent/50 active:bg-editor-accent transition-colors relative z-10"
onMouseDown={startSidebarDrag}
title="Drag to resize"
/>
<div className="overflow-y-auto" style={{ width: sidebarWidth }}>
{activePanel === 'zones' && (
@ -886,7 +882,7 @@ export default function App() {
{activePanel === 'markers' && <MarkersPanel />}
{activePanel === 'music' && <BackgroundMusicPanel />}
{activePanel === 'append' && <AppendClipPanel />}
{activePanel === 'ai' && <AIPanel />}
{activePanel === 'ai' && <AIPanel onReprocess={handleReprocessProject} whisperModel={whisperModel} setWhisperModel={setWhisperModel} />}
{activePanel === 'export' && <ExportDialog />}
{activePanel === 'settings' && <SettingsPanel />}
</div>

View File

@ -1,10 +1,16 @@
import { useCallback, useState } from 'react';
import { useEditorStore } from '../store/editorStore';
import { useAIStore } from '../store/aiStore';
import { Sparkles, Scissors, Film, Loader2, Check, X, Play, Download } from 'lucide-react';
import { Sparkles, Scissors, Film, Loader2, Check, X, Play, Download, RotateCcw, RefreshCw } from 'lucide-react';
import type { ClipSuggestion } from '../types/project';
export default function AIPanel() {
interface AIPanelProps {
onReprocess: () => void;
whisperModel: string;
setWhisperModel: (model: string) => void;
}
export default function AIPanel({ onReprocess, whisperModel, setWhisperModel }: AIPanelProps) {
const { words, videoPath, backendUrl, deleteWordRange, setCurrentTime } = useEditorStore();
const {
defaultProvider,
@ -20,10 +26,12 @@ export default function AIPanel() {
setProcessing,
} = useAIStore();
const [activeTab, setActiveTab] = useState<'filler' | 'clips'>('filler');
const [activeTab, setActiveTab] = useState<'filler' | 'clips' | 'reprocess'>('filler');
const [error, setError] = useState<string | null>(null);
const detectFillers = useCallback(async () => {
if (words.length === 0) return;
setError(null);
setProcessing(true, 'Detecting filler words...');
try {
const config = providers[defaultProvider];
@ -41,11 +49,15 @@ export default function AIPanel() {
custom_filler_words: customFillerWords || undefined,
}),
});
if (!res.ok) throw new Error('Filler detection failed');
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.error || `Filler detection failed (${res.status})`);
}
const data = await res.json();
setFillerResult(data);
} catch (err) {
console.error(err);
setError(err instanceof Error ? err.message : 'Filler detection failed');
} finally {
setProcessing(false);
}
@ -53,6 +65,7 @@ export default function AIPanel() {
const createClips = useCallback(async () => {
if (words.length === 0) return;
setError(null);
setProcessing(true, 'Finding best clip segments...');
try {
const config = providers[defaultProvider];
@ -75,11 +88,15 @@ export default function AIPanel() {
target_duration: 60,
}),
});
if (!res.ok) throw new Error('Clip creation failed');
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.error || `Clip creation failed (${res.status})`);
}
const data = await res.json();
setClipSuggestions(data.clips || []);
} catch (err) {
console.error(err);
setError(err instanceof Error ? err.message : 'Clip creation failed');
} finally {
setProcessing(false);
}
@ -159,6 +176,13 @@ export default function AIPanel() {
label="Create Clips"
title="Find the best segments for social media clips"
/>
<TabButton
active={activeTab === 'reprocess'}
onClick={() => setActiveTab('reprocess')}
icon={<RefreshCw className="w-3.5 h-3.5" />}
label="Reprocess"
title="Re-run transcription with a different Whisper model"
/>
</div>
<div className="flex-1 overflow-y-auto p-4">
@ -184,7 +208,7 @@ export default function AIPanel() {
onClick={detectFillers}
disabled={isProcessing || words.length === 0}
title="Scan the entire transcript for filler words (um, uh, like, you know) and mark for removal"
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-50 rounded-lg text-sm font-medium transition-colors"
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-40 rounded-lg text-sm font-medium transition-colors"
>
{isProcessing ? (
<>
@ -199,6 +223,17 @@ export default function AIPanel() {
)}
</button>
{error && (
<div className="bg-red-500/10 border border-red-500/40 rounded text-xs text-red-300 p-2 flex items-center justify-between">
<span>{error}</span>
<button
onClick={detectFillers}
className="flex items-center gap-1 px-2 py-1 text-xs bg-red-500/20 hover:bg-red-500/30 rounded transition-colors shrink-0 ml-2"
>
<RotateCcw className="w-3 h-3" /> Retry
</button>
</div>
)}
{fillerResult && fillerResult.fillerWords.length > 0 && (
<div className="space-y-3">
<div className="flex items-center justify-between">
@ -214,7 +249,7 @@ export default function AIPanel() {
<Check className="w-3 h-3" /> Apply All
</button>
<button
onClick={() => setFillerResult(null)}
onClick={() => { setFillerResult(null); setError(null); }}
title="Clear detected filler word results without applying"
className="flex items-center gap-1 px-2 py-1 text-xs bg-editor-border text-editor-text-muted rounded hover:bg-editor-surface"
>
@ -254,7 +289,7 @@ export default function AIPanel() {
onClick={createClips}
disabled={isProcessing || words.length === 0}
title="Analyze transcript to find the most engaging 20-60 second segments for social media"
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-50 rounded-lg text-sm font-medium transition-colors"
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-40 rounded-lg text-sm font-medium transition-colors"
>
{isProcessing ? (
<>
@ -269,6 +304,17 @@ export default function AIPanel() {
)}
</button>
{error && (
<div className="bg-red-500/10 border border-red-500/40 rounded text-xs text-red-300 p-2 flex items-center justify-between">
<span>{error}</span>
<button
onClick={createClips}
className="flex items-center gap-1 px-2 py-1 text-xs bg-red-500/20 hover:bg-red-500/30 rounded transition-colors shrink-0 ml-2"
>
<RotateCcw className="w-3 h-3" /> Retry
</button>
</div>
)}
{clipSuggestions.length > 0 && (
<div className="space-y-3">
{clipSuggestions.map((clip, i) => (
@ -292,7 +338,7 @@ export default function AIPanel() {
onClick={() => handleExportClip(clip, i)}
disabled={exportingClipIndex === i}
title="Export just this segment as a standalone video file"
className="flex-1 flex items-center justify-center gap-1 px-2 py-1.5 text-xs bg-editor-success/20 text-editor-success rounded hover:bg-editor-success/30 disabled:opacity-50 transition-colors"
className="flex-1 flex items-center justify-center gap-1 px-2 py-1.5 text-xs bg-editor-success/20 text-editor-success rounded hover:bg-editor-success/30 disabled:opacity-40 transition-colors"
>
{exportingClipIndex === i ? (
<Loader2 className="w-3 h-3 animate-spin" />
@ -308,6 +354,52 @@ export default function AIPanel() {
)}
</div>
)}
{activeTab === 'reprocess' && (
<div className="space-y-4">
<p className="text-xs text-editor-text-muted">
Re-run transcription with a different model replaces the current transcript entirely.
</p>
<div className="space-y-1.5">
<label className="text-[11px] text-editor-text-muted font-medium">
Whisper Model
</label>
<select
value={whisperModel}
onChange={(e) => setWhisperModel(e.target.value)}
className="w-full px-2.5 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
>
<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>
<button
onClick={onReprocess}
disabled={isProcessing || words.length === 0}
title="Re-run transcription with the selected model — this will replace all current words"
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-40 rounded-lg text-sm font-medium transition-colors"
>
<RefreshCw className="w-4 h-4" />
Reprocess Transcript
</button>
</div>
)}
</div>
</div>
);

View File

@ -507,7 +507,7 @@ export default function ExportDialog() {
<button
onClick={handleExport}
disabled={isExporting || !videoPath}
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-50 rounded-lg text-sm font-semibold transition-colors"
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-40 rounded-lg text-sm font-semibold transition-colors"
title="Start export with current settings"
>
{isExporting ? (

View File

@ -189,7 +189,7 @@ function LicenseActivateDialog({
<button
onClick={onActivate}
disabled={activating || !keyValue.trim()}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-50 rounded-lg text-sm font-medium transition-colors"
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-40 rounded-lg text-sm font-medium transition-colors"
>
{activating ? (
<Loader2 className="w-4 h-4 animate-spin" />

View File

@ -100,7 +100,7 @@ export default function MarkersPanel() {
</div>
{/* Marker list */}
{timelineMarkers.length > 0 && (
{timelineMarkers.length > 0 ? (
<div className="space-y-1 max-h-60 overflow-y-auto">
{timelineMarkers.map((m) => (
<div
@ -130,6 +130,12 @@ export default function MarkersPanel() {
</div>
))}
</div>
) : (
<div className="p-4 rounded border border-dashed border-editor-border text-center">
<p className="text-xs text-editor-text-muted">
No markers yet. Press I and O on the timeline to set mark in/out points, then add a marker here.
</p>
</div>
)}
{/* Chapters */}

View File

@ -190,7 +190,7 @@ export default function SilenceTrimmerPanel() {
<button
onClick={detectSilence}
disabled={isDetecting || !videoPath}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-50 rounded-lg text-sm font-medium transition-colors"
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-40 rounded-lg text-sm font-medium transition-colors"
title="Scan the entire audio track for silent pauses"
>
{isDetecting ? (

View File

@ -1,174 +0,0 @@
import { useMemo, useState } from 'react';
import { useEditorStore } from '../store/editorStore';
import { Trash2, Volume2 } from 'lucide-react';
interface VolumePanelProps {
gainMode: boolean;
onToggleGainMode: () => void;
timelineGainDb: number;
onTimelineGainDbChange: (gainDb: number) => void;
}
export default function VolumePanel({
gainMode,
onToggleGainMode,
timelineGainDb,
onTimelineGainDbChange,
}: VolumePanelProps) {
const {
words,
selectedWordIndices,
globalGainDb,
gainRanges,
setGlobalGainDb,
addGainRange,
updateGainRange,
removeGainRange,
} = useEditorStore();
const [selectionGainDb, setSelectionGainDb] = useState(3);
const canApplySelection = selectedWordIndices.length > 0;
const selectedRange = useMemo(() => {
if (!canApplySelection) return null;
const sorted = [...selectedWordIndices].sort((a, b) => a - b);
const startWord = words[sorted[0]];
const endWord = words[sorted[sorted.length - 1]];
if (!startWord || !endWord) return null;
return {
start: startWord.start,
end: endWord.end,
};
}, [canApplySelection, selectedWordIndices, words]);
const applySelectionGain = () => {
if (!selectedRange) return;
addGainRange(selectedRange.start, selectedRange.end, selectionGainDb);
};
return (
<div className="p-4 space-y-4">
<div className="space-y-1">
<h3 className="text-sm font-semibold flex items-center gap-2">
<Volume2 className="w-4 h-4" />
Volume / Gain
</h3>
<p className="text-xs text-editor-text-muted">
Apply global gain or per-selection gain ranges.
</p>
</div>
<div className="space-y-2">
<label className="text-xs text-editor-text-muted font-medium">Global Gain (dB)</label>
<input
type="range"
min={-24}
max={24}
step={0.5}
value={globalGainDb}
onChange={(e) => setGlobalGainDb(Number(e.target.value))}
className="w-full"
/>
<div className="flex items-center justify-between text-xs">
<span className="text-editor-text-muted">-24 dB</span>
<span className="font-medium text-editor-text">{globalGainDb.toFixed(1)} dB</span>
<span className="text-editor-text-muted">+24 dB</span>
</div>
</div>
<div className="space-y-2 pt-1 border-t border-editor-border">
<label className="text-xs text-editor-text-muted font-medium">Timeline Gain Zone (dB)</label>
<div className="flex items-center gap-2">
<input
type="number"
min={-24}
max={24}
step={0.5}
value={timelineGainDb}
onChange={(e) => onTimelineGainDbChange(Math.max(-24, Math.min(24, Number(e.target.value) || 0)))}
className="w-24 px-2 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
/>
<button
onClick={onToggleGainMode}
className={`px-3 py-1.5 text-xs rounded transition-colors ${
gainMode
? 'bg-editor-accent text-white hover:bg-editor-accent-hover'
: 'bg-editor-accent/20 text-editor-accent hover:bg-editor-accent/30'
}`}
>
{gainMode ? 'Exit Zone Mode' : 'Add Gain Zones'}
</button>
</div>
<p className="text-[11px] text-editor-text-muted">
In gain zone mode, drag on the timeline to create a zone with this dB value.
</p>
</div>
<div className="space-y-2 pt-1 border-t border-editor-border">
<label className="text-xs text-editor-text-muted font-medium">Selection Gain (dB)</label>
<div className="flex items-center gap-2">
<input
type="number"
min={-24}
max={24}
step={0.5}
value={selectionGainDb}
onChange={(e) => setSelectionGainDb(Number(e.target.value) || 0)}
className="w-24 px-2 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
/>
<button
onClick={applySelectionGain}
disabled={!canApplySelection || !selectedRange}
className="px-3 py-1.5 text-xs rounded bg-editor-accent/20 text-editor-accent hover:bg-editor-accent/30 disabled:opacity-40"
>
Apply To Selection
</button>
</div>
<p className="text-[11px] text-editor-text-muted">
{canApplySelection
? `${selectedWordIndices.length} selected words${selectedRange ? ` (${selectedRange.start.toFixed(2)}s - ${selectedRange.end.toFixed(2)}s)` : ''}`
: 'Select transcript words to apply a gain range.'}
</p>
</div>
{gainRanges.length > 0 && (
<div className="space-y-2 pt-1 border-t border-editor-border">
<div className="text-xs font-medium">Gain Ranges</div>
<div className="max-h-56 overflow-y-auto space-y-1 pr-1">
{gainRanges.map((range) => (
<div
key={range.id}
className="px-2 py-1.5 rounded bg-editor-surface border border-editor-border text-xs flex items-center gap-2"
>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">
{range.start.toFixed(2)}s - {range.end.toFixed(2)}s
</div>
<div className="text-editor-text-muted">{range.id}</div>
</div>
<input
type="number"
min={-24}
max={24}
step={0.5}
value={range.gainDb}
onChange={(e) => updateGainRange(range.id, Number(e.target.value) || 0)}
className="w-20 px-2 py-1 text-xs bg-editor-bg border border-editor-border rounded"
title="Gain dB"
/>
<button
onClick={() => removeGainRange(range.id)}
className="p-1 rounded hover:bg-editor-danger/20 text-editor-danger"
title="Delete gain range"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@ -1314,6 +1314,13 @@ export default function WaveformTimeline({
{audioError}
</pre>
</div>
) : !waveformDataRef.current ? (
<div className="flex-1 flex items-center justify-center">
<div className="flex flex-col items-center gap-3">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-editor-border border-t-indigo-400" />
<span className="text-xs text-editor-text-muted">Loading waveform...</span>
</div>
</div>
) : (
<div className="flex-1 relative flex flex-col">
{showThumbnails && thumbnailFrames.size > 0 && (

View File

@ -94,7 +94,7 @@ export default function ZoneEditor() {
case 'cut':
return 'border-red-500/40 bg-red-500/5';
case 'mute':
return 'border-orange-500/40 bg-orange-500/5';
return 'border-blue-500/40 bg-blue-500/20';
case 'gain':
return 'border-amber-500/40 bg-amber-500/5';
case 'speed':
@ -212,7 +212,7 @@ export default function ZoneEditor() {
onClick={() => setViewMode('mute')}
className={`px-2 py-1 text-xs rounded transition-colors ${
viewMode === 'mute'
? 'bg-orange-500/30 text-orange-500'
? 'bg-blue-500/20 text-blue-400'
: 'text-editor-text-muted hover:text-editor-text'
}`}
title="Show only Mute zones"
@ -245,7 +245,7 @@ export default function ZoneEditor() {
</div>
{totalZones === 0 ? (
<div className="p-4 rounded border border-dashed border-editor-border text-center">
<div className="p-4 rounded-lg border border-dashed border-editor-border text-center">
<p className="text-xs text-editor-text-muted">
No zones yet. Create zones from the toolbar or by highlighting words.
</p>
@ -264,13 +264,12 @@ export default function ZoneEditor() {
<div
key={range.id}
onClick={() => setFocusedZone({ type: 'cut', id: range.id })}
className={`px-2 py-1.5 rounded border text-xs flex items-center gap-2 group cursor-pointer transition-colors ${getZoneTypeColor('cut')} ${isZoneFocused('cut', range.id) ? 'ring-1 ring-red-400 border-red-400/80 bg-red-500/12' : ''}`}
className={`px-2 py-1.5 rounded-lg border text-xs flex items-center gap-2 group cursor-pointer transition-colors ${getZoneTypeColor('cut')} ${isZoneFocused('cut', range.id) ? 'ring-1 ring-red-400 border-red-400/80 bg-red-500/12' : ''}`}
>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">
{formatTimelineLikeTime(range.start)} - {formatTimelineLikeTime(range.end)}
</div>
<div className="text-editor-text-muted text-[10px]">{range.id}</div>
</div>
{renderPreviewButton(range.start, range.end, 'hover:bg-red-500/20 text-red-500/70 hover:text-red-500')}
<button
@ -292,7 +291,7 @@ export default function ZoneEditor() {
{/* Mute Zones */}
{(viewMode === 'all' || viewMode === 'mute') && muteRanges.length > 0 && (
<div className="space-y-2">
<div className="text-xs font-semibold text-orange-500/80 flex items-center gap-2">
<div className="text-xs font-semibold text-blue-400 flex items-center gap-2">
<Volume2 className="w-3.5 h-3.5" />
Mute Zones ({muteRanges.length})
</div>
@ -301,21 +300,20 @@ export default function ZoneEditor() {
<div
key={range.id}
onClick={() => setFocusedZone({ type: 'mute', id: range.id })}
className={`px-2 py-1.5 rounded border text-xs flex items-center gap-2 group cursor-pointer transition-colors ${getZoneTypeColor('mute')} ${isZoneFocused('mute', range.id) ? 'ring-1 ring-orange-400 border-orange-400/80 bg-orange-500/12' : ''}`}
className={`px-2 py-1.5 rounded-lg border text-xs flex items-center gap-2 group cursor-pointer transition-colors ${getZoneTypeColor('mute')} ${isZoneFocused('mute', range.id) ? 'ring-1 ring-blue-400 border-blue-400/80 bg-blue-500/20' : ''}`}
>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">
{formatTimelineLikeTime(range.start)} - {formatTimelineLikeTime(range.end)}
</div>
<div className="text-editor-text-muted text-[10px]">{range.id}</div>
</div>
{renderPreviewButton(range.start, range.end, 'hover:bg-orange-500/20 text-orange-500/70 hover:text-orange-500')}
{renderPreviewButton(range.start, range.end, 'hover:bg-blue-500/20 text-blue-400 hover:text-blue-400')}
<button
onClick={(e) => {
e.stopPropagation();
removeZone('mute', range.id);
}}
className="p-1 rounded hover:bg-orange-500/20 text-orange-500/70 hover:text-orange-500 opacity-0 group-hover:opacity-100 transition-opacity"
className="p-1 rounded hover:bg-blue-500/20 text-blue-400 hover:text-blue-400 opacity-0 group-hover:opacity-100 transition-opacity"
title="Delete mute zone"
>
<Trash2 className="w-3.5 h-3.5" />
@ -326,12 +324,12 @@ export default function ZoneEditor() {
</div>
)}
{/* Gain Zones */}
{/* Sound Gain */}
{(viewMode === 'all' || viewMode === 'gain') && gainRanges.length > 0 && (
<div className="space-y-2">
<div className="text-xs font-semibold text-amber-500/80 flex items-center gap-2">
<SlidersHorizontal className="w-3.5 h-3.5" />
Gain Zones ({gainRanges.length})
Sound Gain ({gainRanges.length})
</div>
{/* Global Gain Slider */}
@ -366,7 +364,7 @@ export default function ZoneEditor() {
<div
key={range.id}
onClick={() => setFocusedZone({ type: 'gain', id: range.id })}
className={`px-2 py-1.5 rounded border text-xs flex items-center gap-2 group cursor-pointer transition-colors ${getZoneTypeColor('gain')} ${isZoneFocused('gain', range.id) ? 'ring-1 ring-amber-400 border-amber-400/80 bg-amber-500/12' : ''}`}
className={`px-2 py-1.5 rounded-lg border text-xs flex items-center gap-2 group cursor-pointer transition-colors ${getZoneTypeColor('gain')} ${isZoneFocused('gain', range.id) ? 'ring-1 ring-amber-400 border-amber-400/80 bg-amber-500/12' : ''}`}
>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">
@ -404,19 +402,19 @@ export default function ZoneEditor() {
</div>
)}
{/* Speed Zones */}
{/* Speed Adjust */}
{(viewMode === 'all' || viewMode === 'speed') && speedRanges.length > 0 && (
<div className="space-y-2">
<div className="text-xs font-semibold text-emerald-500/80 flex items-center gap-2">
<Gauge className="w-3.5 h-3.5" />
Speed Zones ({speedRanges.length})
Speed Adjust ({speedRanges.length})
</div>
<div className="space-y-1">
{speedRanges.map((range) => (
<div
key={range.id}
onClick={() => setFocusedZone({ type: 'speed', id: range.id })}
className={`px-2 py-1.5 rounded border text-xs flex items-center gap-2 group cursor-pointer transition-colors ${getZoneTypeColor('speed')} ${isZoneFocused('speed', range.id) ? 'ring-1 ring-emerald-400 border-emerald-400/80 bg-emerald-500/12' : ''}`}
className={`px-2 py-1.5 rounded-lg border text-xs flex items-center gap-2 group cursor-pointer transition-colors ${getZoneTypeColor('speed')} ${isZoneFocused('speed', range.id) ? 'ring-1 ring-emerald-400 border-emerald-400/80 bg-emerald-500/12' : ''}`}
>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">

View File

@ -187,11 +187,23 @@ function toggleCheatsheet(bindings: KeyBinding[]) {
)
.join('');
overlay.innerHTML = `<div style="background:#1a1d27;border:1px solid #2a2d3a;border-radius:12px;padding:24px 32px;max-width:450px;" onclick="event.stopPropagation()">
overlay.innerHTML = `<div style="background:#1a1d27;border:1px solid #2a2d3a;border-radius:12px;padding:24px 32px;max-width:450px;position:relative;" onclick="event.stopPropagation()">
<h3 style="margin:0 0 16px;font-size:14px;font-weight:600;color:#e2e8f0">Keyboard Shortcuts</h3>
<table style="font-size:13px">${rows}</table>
<p style="margin:16px 0 0;font-size:11px;color:#94a3b8;text-align:center">Customize in Settings &bull; Press ? to close</p>
<button id="cheatsheet-close" style="position:absolute;top:12px;right:16px;background:none;border:none;color:#94a3b8;font-size:18px;cursor:pointer;line-height:1;padding:4px;">&times;</button>
</div>`;
document.body.appendChild(overlay);
const closeBtn = overlay.querySelector('#cheatsheet-close') as HTMLButtonElement;
if (closeBtn) closeBtn.onclick = () => overlay.remove();
const escHandler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
overlay.remove();
document.removeEventListener('keydown', escHandler);
}
};
document.addEventListener('keydown', escHandler);
}