diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 5809cfd..20555c8 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -73,6 +73,10 @@ Use project virtualenvs where available (`.venv312`, `.venv`, or `venv`) for bac - **color-scheme:dark**: All ` )} {activePanel === 'silence' && } + {activePanel === 'markers' && } {activePanel === 'ai' && } {activePanel === 'export' && } {activePanel === 'settings' && } diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index 38e58cb..0d856e7 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -1,8 +1,9 @@ import { useAIStore } from '../store/aiStore'; import { useState, useEffect, useCallback } from 'react'; -import type { AIProvider } from '../types/project'; +import type { AIProvider, KeyBinding, HotkeyPreset } from '../types/project'; import { useEditorStore } from '../store/editorStore'; -import { Bot, Cloud, Brain, RefreshCw } from 'lucide-react'; +import { Bot, Cloud, Brain, RefreshCw, Keyboard } from 'lucide-react'; +import { loadBindings, saveBindings, applyPreset as applyKeyPreset, DEFAULT_PRESETS, detectConflicts as detectKeyConflicts } from '../lib/keybindings'; export default function SettingsPanel() { const { providers, defaultProvider, setProviderConfig, setDefaultProvider } = useAIStore(); @@ -19,6 +20,51 @@ export default function SettingsPanel() { window.localStorage.setItem(CONFIDENCE_THRESHOLD_KEY, String(clamped)); } }; + // Keyboard shortcuts state + const [bindings, setBindings] = useState(() => { + try { return loadBindings(); } catch { return DEFAULT_PRESETS['standard']; } + }); + const [editingKey, setEditingKey] = useState(null); + const [editKeyValue, setEditKeyValue] = useState(''); + const conflicts = detectKeyConflicts(bindings); + + const persistBindings = (newB: KeyBinding[]) => { + saveBindings(newB); + setBindings(newB); + }; + + const applyPresetAction = (preset: HotkeyPreset) => { + persistBindings(applyKeyPreset(preset)); + }; + + const startKeyEdit = (idx: number) => { + setEditingKey(bindings[idx].id); + setEditKeyValue(bindings[idx].keys); + }; + + const handleKeyCapture = (e: React.KeyboardEvent, idx: number) => { + e.preventDefault(); + const parts: string[] = []; + if (e.ctrlKey || e.metaKey) parts.push('Ctrl'); + if (e.shiftKey) parts.push('Shift'); + if (e.altKey) parts.push('Alt'); + const key = e.key === ' ' ? 'Space' : e.key.length === 1 ? e.key.toUpperCase() : e.key; + if (!['Control', 'Shift', 'Alt', 'Meta'].includes(key)) parts.push(key); + if (parts.length === 0) return; + const combo = parts.join('+'); + const newBindings = bindings.map((b, i) => (i === idx ? { ...b, keys: combo } : b)); + setEditKeyValue(combo); + setEditingKey(null); + persistBindings(newBindings); + }; + + const handleReset = (idx: number) => { + const standard = DEFAULT_PRESETS['standard']; + const existing = standard.find((b: KeyBinding) => b.id === bindings[idx].id); + if (!existing) return; + persistBindings(bindings.map((b, i) => (i === idx ? { ...existing } : b))); + }; + const [ollamaModels, setOllamaModels] = useState([]); const [loadingModels, setLoadingModels] = useState(false); @@ -112,6 +158,60 @@ export default function SettingsPanel() { + {/* Keyboard shortcuts */} +
+

+ + Keyboard Shortcuts +

+
+ + +
+ {conflicts.length > 0 && ( +
+ ⚠️ {conflicts.join('; ')} +
+ )} +
+ {bindings.map((b, i) => ( +
+ {b.label} + startKeyEdit(i)} + onChange={(e) => { + setEditingKey(b.id); + setEditKeyValue(e.target.value); + }} + onKeyDown={(e) => handleKeyCapture(e, i)} + className="w-28 px-2 py-1 text-[10px] font-mono bg-editor-bg border border-editor-border rounded text-center focus:outline-none focus:border-editor-accent" + placeholder="Type shortcut" + /> + +
+ ))} +
+

+ Press ? anytime to view shortcuts. Changes apply immediately. +

+
+ {/* Default provider selector */}
diff --git a/frontend/src/components/WaveformTimeline.tsx b/frontend/src/components/WaveformTimeline.tsx index 8a6ce1b..bb75e55 100644 --- a/frontend/src/components/WaveformTimeline.tsx +++ b/frontend/src/components/WaveformTimeline.tsx @@ -1,6 +1,7 @@ import { useRef, useEffect, useCallback, useState, useMemo } from 'react'; import { useEditorStore } from '../store/editorStore'; import { AlertTriangle } from 'lucide-react'; +import { extractThumbnails } from '../lib/thumbnails'; const RULER_H = 20; // px reserved at top of canvas for the time ruler const COLLAPSED_CUT_DISPLAY_SECONDS = 0.08; @@ -239,13 +240,18 @@ export default function WaveformTimeline({ const videoPath = useEditorStore((s) => s.videoPath); const backendUrl = useEditorStore((s) => s.backendUrl); const duration = useEditorStore((s) => s.duration); + const setCurrentTime = useEditorStore((s) => s.setCurrentTime); const cutRanges = useEditorStore((s) => s.cutRanges); const muteRanges = useEditorStore((s) => s.muteRanges); const gainRanges = useEditorStore((s) => s.gainRanges); const speedRanges = useEditorStore((s) => s.speedRanges); + const timelineMarkers = useEditorStore((s) => s.timelineMarkers); const markInTime = useEditorStore((s) => s.markInTime); const markOutTime = useEditorStore((s) => s.markOutTime); - const setCurrentTime = useEditorStore((s) => s.setCurrentTime); + const [showThumbnails, setShowThumbnails] = useState(() => typeof window !== 'undefined' && localStorage.getItem('talkedit:showThumbnails') === 'true'); + const [thumbnailFrames, setThumbnailFrames] = useState>(new Map()); + void setShowThumbnails; + const thumbnailContainerRef = useRef(null); const addCutRange = useEditorStore((s) => s.addCutRange); const addMuteRange = useEditorStore((s) => s.addMuteRange); const addGainRange = useEditorStore((s) => s.addGainRange); @@ -606,6 +612,33 @@ export default function WaveformTimeline({ if (markInTime !== null) drawMarkLine(markInTime, 'I'); if (markOutTime !== null) drawMarkLine(markOutTime, 'O'); + // Draw timeline markers (colored pins) + for (const marker of timelineMarkers) { + const x = (sourceToDisplayTime(marker.time, timelineSegments, dur) - scroll) * pxPerSec; + if (x < -4 || x > width + 4) continue; + // Draw a pin triangle + ctx.beginPath(); + ctx.moveTo(x, waveTop - 8); + ctx.lineTo(x - 4, waveTop - 2); + ctx.lineTo(x + 4, waveTop - 2); + ctx.closePath(); + ctx.fillStyle = marker.color; + ctx.fill(); + ctx.strokeStyle = marker.color; + ctx.lineWidth = 1; + // Draw a thin vertical line + ctx.beginPath(); + ctx.moveTo(x, waveTop); + ctx.lineTo(x, waveTop + waveH); + ctx.stroke(); + // Label + ctx.fillStyle = marker.color; + ctx.font = '9px sans-serif'; + ctx.textBaseline = 'bottom'; + ctx.fillText(marker.label, Math.min(width - 50, Math.max(2, x + 5)), waveTop - 2); + ctx.textBaseline = 'alphabetic'; + } + const mid = waveTop + waveH / 2; ctx.beginPath(); ctx.strokeStyle = '#4a4d5e'; @@ -1169,6 +1202,26 @@ export default function WaveformTimeline({ if (selectedZone.type === 'speed' && !showSpeedZones) setSelectedZone(null); }, [selectedZone, showCutZones, showMuteZones, showGainZones, showSpeedZones]); + // Capture thumbnail frames when enabled + useEffect(() => { + if (!showThumbnails) { setThumbnailFrames(new Map()); return; } + const dur = displayDuration || waveformDataRef.current?.duration || 0; + if (dur <= 0) return; + const video = document.querySelector('video') as HTMLVideoElement | null; + if (!video) return; + + const interval = 10; + const times: number[] = []; + for (let t = 0; t < dur; t += interval) times.push(t); + + let cancelled = false; + extractThumbnails(video, times).then((frames) => { + if (!cancelled) setThumbnailFrames(frames); + }); + + return () => { cancelled = true; }; + }, [showThumbnails, videoUrl, displayDuration]); + if (!videoUrl) { return (
@@ -1248,16 +1301,46 @@ export default function WaveformTimeline({
) : ( -
- - +
+ {showThumbnails && thumbnailFrames.size > 0 && ( +
+
+ {Array.from(thumbnailFrames.entries()).map(([time, dataUrl]) => { + const dur = displayDuration || waveformDataRef.current?.duration || 1; + const pct = (time / dur) * 100; + return ( + {`Thumbnail { + const video = document.querySelector('video') as HTMLVideoElement | null; + if (video) { video.currentTime = time; setCurrentTime(time); } + }} + /> + ); + })} +
+
+ )} +
+ + +
)}
diff --git a/frontend/src/hooks/useKeyboardShortcuts.ts b/frontend/src/hooks/useKeyboardShortcuts.ts index 41aa49a..a8577d9 100644 --- a/frontend/src/hooks/useKeyboardShortcuts.ts +++ b/frontend/src/hooks/useKeyboardShortcuts.ts @@ -1,5 +1,7 @@ import { useEffect, useRef } from 'react'; import { useEditorStore } from '../store/editorStore'; +import { loadBindings } from '../lib/keybindings'; +import type { KeyBinding } from '../types/project'; export function useKeyboardShortcuts() { const addCutRange = useEditorStore((s) => s.addCutRange); @@ -10,9 +12,13 @@ export function useKeyboardShortcuts() { const clearMarkRange = useEditorStore((s) => s.clearMarkRange); const selectedWordIndices = useEditorStore((s) => s.selectedWordIndices); const words = useEditorStore((s) => s.words); - const playbackRateRef = useRef(1); + // Read bindings fresh from localStorage on every call to avoid stale closures + const getBindings = (): KeyBinding[] => { + try { return loadBindings(); } catch { return []; } + }; + useEffect(() => { const getVideo = (): HTMLVideoElement | null => document.querySelector('video'); @@ -22,81 +28,58 @@ export function useKeyboardShortcuts() { const video = getVideo(); - switch (true) { - // --- Undo / Redo --- - case e.key === 'z' && (e.ctrlKey || e.metaKey) && e.shiftKey: { - e.preventDefault(); - useEditorStore.temporal.getState().redo(); - return; - } - case e.key === 'z' && (e.ctrlKey || e.metaKey): { - e.preventDefault(); + // Build a key string from the event for matching + const parts: string[] = []; + if (e.ctrlKey || e.metaKey) parts.push('Ctrl'); + if (e.shiftKey && !['Shift'].includes(e.key)) parts.push('Shift'); + if (e.altKey) parts.push('Alt'); + const keyStr = e.key === ' ' ? 'Space' : e.key.length === 1 ? e.key.toUpperCase() : e.key; + parts.push(keyStr); + const combo = parts.join('+'); + + // Look up binding — fresh read every keystroke so Settings changes take effect immediately + const currentBindings = getBindings(); + const binding = currentBindings.find((b) => b.keys === combo); + if (!binding) return; // Unbound key — ignore + + e.preventDefault(); + + switch (binding.id) { + case 'undo': useEditorStore.temporal.getState().undo(); return; - } - - // --- Delete / Backspace: cut selected words --- - case e.key === 'Delete' || e.key === 'Backspace': { + case 'redo': + useEditorStore.temporal.getState().redo(); + return; + case 'cut': { if (selectedWordIndices.length > 0) { - e.preventDefault(); 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); + addCutRange(words[sorted[0]].start, words[sorted[sorted.length - 1]].end); return; } - if (markInTime !== null && markOutTime !== null) { - e.preventDefault(); const start = Math.min(markInTime, markOutTime); const end = Math.max(markInTime, markOutTime); - if (end - start >= 0.01) { - addCutRange(start, end); - } + if (end - start >= 0.01) addCutRange(start, end); clearMarkRange(); } return; } - - // --- Space: play / pause --- - case e.key === ' ' && !e.ctrlKey: { - e.preventDefault(); - if (video) { - if (video.paused) video.play(); - else video.pause(); - } + case 'play-pause': + if (video) { if (video.paused) video.play(); else video.pause(); } return; - } - - // --- J: reverse / slow down --- - case e.key === 'j' || e.key === 'J': { - e.preventDefault(); + case 'slow-down': { if (video) { playbackRateRef.current = Math.max(-2, playbackRateRef.current - 0.5); - if (playbackRateRef.current < 0) { - // HTML5 video doesn't support negative rates natively; step back - video.currentTime = Math.max(0, video.currentTime - 2); - } else { - video.playbackRate = playbackRateRef.current; - if (video.paused) video.play(); - } + if (playbackRateRef.current < 0) video.currentTime = Math.max(0, video.currentTime - 2); + else { video.playbackRate = playbackRateRef.current; if (video.paused) video.play(); } } return; } - - // --- K: pause --- - case e.key === 'k' || e.key === 'K': { - e.preventDefault(); - if (video) { - video.pause(); - playbackRateRef.current = 1; - } + case 'pause': + if (video) { video.pause(); playbackRateRef.current = 1; } return; - } - - // --- L: forward / speed up --- - case e.key === 'l' || e.key === 'L': { - e.preventDefault(); + case 'speed-up': { if (video) { playbackRateRef.current = Math.min(4, playbackRateRef.current + 0.5); video.playbackRate = Math.max(0.25, playbackRateRef.current); @@ -104,60 +87,37 @@ export function useKeyboardShortcuts() { } return; } - - // --- Arrow Left: seek back 5s --- - case e.key === 'ArrowLeft' && !e.ctrlKey: { - e.preventDefault(); + case 'rewind': if (video) video.currentTime = Math.max(0, video.currentTime - 5); return; - } - - // --- Arrow Right: seek forward 5s --- - case e.key === 'ArrowRight' && !e.ctrlKey: { - e.preventDefault(); + case 'forward': if (video) video.currentTime = Math.min(video.duration, video.currentTime + 5); return; - } - - // --- I: mark in-point --- - case e.key === 'i' || e.key === 'I': { - e.preventDefault(); + case 'mark-in': if (video) setMarkInTime(video.currentTime); return; - } - - // --- O: mark out-point --- - case e.key === 'o' || e.key === 'O': { - e.preventDefault(); + case 'mark-out': if (video) setMarkOutTime(video.currentTime); return; - } - - // --- Ctrl+S: save project --- - case e.key === 's' && (e.ctrlKey || e.metaKey): { - e.preventDefault(); + case 'save': { const saveBtn = document.querySelector('[title="Save"]') as HTMLButtonElement | null; if (saveBtn) saveBtn.click(); else saveProject(); return; } - - // --- Ctrl+E: export --- - case e.key === 'e' && (e.ctrlKey || e.metaKey): { - e.preventDefault(); - // Trigger export panel via DOM click + case 'export': { const exportBtn = document.querySelector('[title="Export"]') as HTMLButtonElement; if (exportBtn) exportBtn.click(); return; } - - // --- ?: show shortcut cheatsheet --- - case e.key === '?' || (e.key === '/' && e.shiftKey): { - e.preventDefault(); - toggleCheatsheet(); + case 'search': { + const findBtn = document.querySelector('[title="Find (Ctrl+F)"]') as HTMLButtonElement; + if (findBtn) findBtn.click(); return; } - + case 'help': + toggleCheatsheet(currentBindings); + return; default: break; } @@ -205,7 +165,7 @@ async function saveProject() { } } -function toggleCheatsheet() { +function toggleCheatsheet(bindings: KeyBinding[]) { const existing = document.getElementById('keyboard-cheatsheet'); if (existing) { existing.remove(); @@ -220,32 +180,17 @@ function toggleCheatsheet() { overlay.remove(); }; - const shortcuts = [ - ['Space', 'Play / Pause'], - ['J', 'Reverse / Slow down'], - ['K', 'Pause'], - ['L', 'Forward / Speed up'], - ['\u2190 / \u2192', 'Seek \u00b15 seconds'], - ['I / O', 'Mark in / out points'], - ['Delete', 'Cut selected words'], - ['Ctrl+Z', 'Undo'], - ['Ctrl+Shift+Z', 'Redo'], - ['Ctrl+S', 'Save project'], - ['Ctrl+E', 'Export'], - ['?', 'This cheatsheet'], - ]; - - const rows = shortcuts + const rows = bindings .map( - ([key, desc]) => - `${key}${desc}`, + (b) => + `${b.keys}${b.label}${b.category}`, ) .join(''); - overlay.innerHTML = `
+ overlay.innerHTML = `

Keyboard Shortcuts

${rows}
-

Press ? or click outside to close

+

Customize in Settings • Press ? to close

`; document.body.appendChild(overlay); diff --git a/frontend/src/store/editorStore.ts b/frontend/src/store/editorStore.ts index 955baf3..ae97c41 100644 --- a/frontend/src/store/editorStore.ts +++ b/frontend/src/store/editorStore.ts @@ -12,6 +12,8 @@ import type { SilenceDetectionRange, SilenceTrimSettings, SilenceTrimGroup, + TimelineMarker, + Chapter, } from '../types/project'; interface EditorState { @@ -27,6 +29,7 @@ interface EditorState { speedRanges: SpeedRange[]; globalGainDb: number; silenceTrimGroups: SilenceTrimGroup[]; + timelineMarkers: TimelineMarker[]; transcriptionModel: string | null; language: string; @@ -89,6 +92,10 @@ interface EditorActions { settings: SilenceTrimSettings; }) => { groupId: string; appliedCount: number }; removeSilenceTrimGroup: (groupId: string) => void; + addTimelineMarker: (time: number, label?: string, color?: string) => void; + updateTimelineMarker: (id: string, updates: Partial) => void; + removeTimelineMarker: (id: string) => void; + getChapters: () => Chapter[]; setTranscribing: (active: boolean, progress?: number, status?: string) => void; setExporting: (active: boolean, progress?: number) => void; setZonePreviewPaddingSeconds: (seconds: number) => void; @@ -122,6 +129,7 @@ const initialState: EditorState = { speedRanges: [], globalGainDb: 0, silenceTrimGroups: [], + timelineMarkers: [], transcriptionModel: null, language: '', currentTime: 0, @@ -182,7 +190,7 @@ export const useEditorStore = create()( setTranscriptionModel: (model) => set({ transcriptionModel: model }), saveProject: (): ProjectFile => { - const { videoPath, words, segments, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, silenceTrimGroups, transcriptionModel, language, exportedAudioPath } = get(); + const { videoPath, words, segments, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, silenceTrimGroups, timelineMarkers, transcriptionModel, language, exportedAudioPath } = get(); if (!videoPath) throw new Error('No video loaded'); const now = new Date().toISOString(); // Strip globalStartIndex (runtime-only field) before persisting. @@ -204,6 +212,7 @@ export const useEditorStore = create()( speedRanges, globalGainDb, silenceTrimGroups, + timelineMarkers, language, createdAt: now, // will be overwritten if we track original creation time later modifiedAt: now, @@ -453,6 +462,40 @@ export const useEditorStore = create()( }); }, + addTimelineMarker: (time, label, color) => { + const { timelineMarkers } = get(); + const newMarker: TimelineMarker = { + id: `marker_${nextRangeId++}`, + time, + label: label || `Marker ${timelineMarkers.length + 1}`, + color: color || '#6366f1', + }; + set({ timelineMarkers: [...timelineMarkers, newMarker].sort((a, b) => a.time - b.time) }); + }, + + updateTimelineMarker: (id, updates) => { + const { timelineMarkers } = get(); + set({ + timelineMarkers: timelineMarkers + .map((m) => (m.id === id ? { ...m, ...updates } : m)) + .sort((a, b) => a.time - b.time), + }); + }, + + removeTimelineMarker: (id) => { + const { timelineMarkers } = get(); + set({ timelineMarkers: timelineMarkers.filter((m) => m.id !== id) }); + }, + + getChapters: () => { + const { timelineMarkers } = get(); + return timelineMarkers.map((m) => ({ + markerId: m.id, + label: m.label, + startTime: m.time, + })); + }, + setTranscribing: (active, progress, status) => set({ isTranscribing: active, @@ -587,6 +630,7 @@ export const useEditorStore = create()( speedRanges: data.speedRanges || [], globalGainDb: typeof data.globalGainDb === 'number' ? data.globalGainDb : 0, silenceTrimGroups: data.silenceTrimGroups || [], + timelineMarkers: data.timelineMarkers || [], transcriptionModel: data.transcriptionModel ?? null, language: data.language || '', exportedAudioPath: data.exportedAudioPath ?? null, diff --git a/frontend/src/types/project.ts b/frontend/src/types/project.ts index 41feeea..037d934 100644 --- a/frontend/src/types/project.ts +++ b/frontend/src/types/project.ts @@ -72,6 +72,7 @@ export interface ProjectFile { speedRanges?: SpeedRange[]; globalGainDb?: number; silenceTrimGroups?: SilenceTrimGroup[]; + timelineMarkers?: TimelineMarker[]; language: string; createdAt: string; modifiedAt: string; @@ -93,6 +94,28 @@ export interface ExportOptions { captionStyle?: CaptionStyle; } +export interface TimelineMarker { + id: string; + time: number; + label: string; + color: string; +} + +export interface Chapter { + markerId: string; + label: string; + startTime: number; +} + +export interface KeyBinding { + id: string; + label: string; + keys: string; // e.g. "Ctrl+Z" + category: string; // "transport", "edit", "file", "view" +} + +export type HotkeyPreset = 'left-hand' | 'standard'; + export interface CaptionStyle { fontName: string; fontSize: number; diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo index 8290b9e..97817e0 100644 --- a/frontend/tsconfig.tsbuildinfo +++ b/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/AIPanel.tsx","./src/components/DevPanel.tsx","./src/components/ExportDialog.tsx","./src/components/SettingsPanel.tsx","./src/components/SilenceTrimmerPanel.tsx","./src/components/TranscriptEditor.tsx","./src/components/VideoPlayer.tsx","./src/components/VolumePanel.tsx","./src/components/WaveformTimeline.tsx","./src/components/ZoneEditor.tsx","./src/hooks/useKeyboardShortcuts.ts","./src/hooks/useVideoSync.ts","./src/lib/dev-logger.ts","./src/lib/tauri-bridge.ts","./src/store/aiStore.ts","./src/store/editorStore.test.ts","./src/store/editorStore.ts","./src/types/project.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/AIPanel.tsx","./src/components/DevPanel.tsx","./src/components/ExportDialog.tsx","./src/components/MarkersPanel.tsx","./src/components/SettingsPanel.tsx","./src/components/SilenceTrimmerPanel.tsx","./src/components/TranscriptEditor.tsx","./src/components/VideoPlayer.tsx","./src/components/VolumePanel.tsx","./src/components/WaveformTimeline.tsx","./src/components/ZoneEditor.tsx","./src/hooks/useKeyboardShortcuts.ts","./src/hooks/useVideoSync.ts","./src/lib/dev-logger.ts","./src/lib/keybindings.ts","./src/lib/tauri-bridge.ts","./src/lib/thumbnails.ts","./src/store/aiStore.ts","./src/store/editorStore.test.ts","./src/store/editorStore.ts","./src/types/project.ts"],"version":"5.9.3"} \ No newline at end of file