improved chapters/markers
This commit is contained in:
@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState, useMemo } from 'react';
|
import { useEffect, useState, useMemo, useCallback, useRef } from 'react';
|
||||||
import { useEditorStore } from './store/editorStore';
|
import { useEditorStore } from './store/editorStore';
|
||||||
import VideoPlayer from './components/VideoPlayer';
|
import VideoPlayer from './components/VideoPlayer';
|
||||||
import TranscriptEditor from './components/TranscriptEditor';
|
import TranscriptEditor from './components/TranscriptEditor';
|
||||||
@ -68,6 +68,54 @@ export default function App() {
|
|||||||
|
|
||||||
const [activePanel, setActivePanel] = useState<Panel>(null);
|
const [activePanel, setActivePanel] = useState<Panel>(null);
|
||||||
const [projectName, setProjectName] = useState<string | null>(null);
|
const [projectName, setProjectName] = useState<string | null>(null);
|
||||||
|
const [splitRatio, setSplitRatio] = useState(() => {
|
||||||
|
try { return Number(localStorage.getItem('talkedit:splitRatio')) || 0.5; } catch { return 0.5; }
|
||||||
|
});
|
||||||
|
const splitRef = useRef<HTMLDivElement>(null);
|
||||||
|
const isDraggingSplit = useRef(false);
|
||||||
|
|
||||||
|
const startSplitDrag = useCallback((e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
isDraggingSplit.current = true;
|
||||||
|
const container = splitRef.current?.parentElement;
|
||||||
|
if (!container) return;
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
const onMove = (me: MouseEvent) => {
|
||||||
|
if (!isDraggingSplit.current) return;
|
||||||
|
const pct = (me.clientX - rect.left) / rect.width;
|
||||||
|
const clamped = Math.max(0.15, Math.min(0.85, pct));
|
||||||
|
setSplitRatio(clamped);
|
||||||
|
localStorage.setItem('talkedit:splitRatio', String(clamped));
|
||||||
|
};
|
||||||
|
const onUp = () => { isDraggingSplit.current = false; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); };
|
||||||
|
window.addEventListener('mousemove', onMove);
|
||||||
|
window.addEventListener('mouseup', onUp);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Draggable right sidebar
|
||||||
|
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
||||||
|
try { return Number(localStorage.getItem('talkedit:sidebarWidth')) || 320; } catch { return 320; }
|
||||||
|
});
|
||||||
|
const isDraggingSidebar = useRef(false);
|
||||||
|
|
||||||
|
const startSidebarDrag = useCallback((e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
isDraggingSidebar.current = true;
|
||||||
|
const container = document.querySelector('.main-content') as HTMLElement;
|
||||||
|
if (!container) return;
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
const onMove = (me: MouseEvent) => {
|
||||||
|
if (!isDraggingSidebar.current) return;
|
||||||
|
const w = rect.right - me.clientX;
|
||||||
|
const clamped = Math.max(180, Math.min(600, w));
|
||||||
|
setSidebarWidth(clamped);
|
||||||
|
localStorage.setItem('talkedit:sidebarWidth', String(clamped));
|
||||||
|
};
|
||||||
|
const onUp = () => { isDraggingSidebar.current = false; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); };
|
||||||
|
window.addEventListener('mousemove', onMove);
|
||||||
|
window.addEventListener('mouseup', onUp);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [whisperModel, setWhisperModel] = useState('base');
|
const [whisperModel, setWhisperModel] = useState('base');
|
||||||
useEffect(() => { if (transcriptionModel) setWhisperModel(transcriptionModel); }, [transcriptionModel]);
|
useEffect(() => { if (transcriptionModel) setWhisperModel(transcriptionModel); }, [transcriptionModel]);
|
||||||
const [cutMode, setCutMode] = useState(false);
|
const [cutMode, setCutMode] = useState(false);
|
||||||
@ -666,17 +714,24 @@ export default function App() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<div className="flex-1 flex overflow-hidden">
|
<div className="main-content flex-1 flex overflow-hidden">
|
||||||
{/* Left: video + transcript */}
|
{/* Left: video + transcript */}
|
||||||
<div className="flex-1 flex flex-col min-w-0">
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
<div className="flex-1 flex min-h-0">
|
<div ref={splitRef} className="flex-1 flex min-h-0" style={{ position: 'relative' }}>
|
||||||
{/* Video player */}
|
{/* Video player */}
|
||||||
<div className="w-1/2 p-3 flex items-center justify-center bg-black/20">
|
<div className="p-3 flex items-center justify-center bg-black/20 overflow-hidden" style={{ width: `${splitRatio * 100}%`, minWidth: 0 }}>
|
||||||
<VideoPlayer />
|
<VideoPlayer />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Draggable divider */}
|
||||||
|
<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"
|
||||||
|
style={{ cursor: isDraggingSplit.current ? 'col-resize' : 'col-resize' }}
|
||||||
|
onMouseDown={startSplitDrag}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Transcript */}
|
{/* Transcript */}
|
||||||
<div className="w-1/2 border-l border-editor-border flex flex-col min-h-0">
|
<div className="border-l border-editor-border flex flex-col min-h-0" style={{ width: `${(1 - splitRatio) * 100}%`, minWidth: 0 }}>
|
||||||
{videoPath && (
|
{videoPath && (
|
||||||
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-editor-border shrink-0 bg-editor-surface/50">
|
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-editor-border shrink-0 bg-editor-surface/50">
|
||||||
{projectName && (
|
{projectName && (
|
||||||
@ -745,7 +800,13 @@ export default function App() {
|
|||||||
|
|
||||||
{/* Right panel (AI / Export / Settings) */}
|
{/* Right panel (AI / Export / Settings) */}
|
||||||
{activePanel && (
|
{activePanel && (
|
||||||
<div className="w-80 border-l border-editor-border overflow-y-auto shrink-0">
|
<div className="flex shrink-0">
|
||||||
|
{/* Draggable sidebar divider */}
|
||||||
|
<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}
|
||||||
|
/>
|
||||||
|
<div className="overflow-y-auto" style={{ width: sidebarWidth }}>
|
||||||
{activePanel === 'zones' && (
|
{activePanel === 'zones' && (
|
||||||
<ZoneEditor />
|
<ZoneEditor />
|
||||||
)}
|
)}
|
||||||
@ -755,6 +816,7 @@ export default function App() {
|
|||||||
{activePanel === 'export' && <ExportDialog />}
|
{activePanel === 'export' && <ExportDialog />}
|
||||||
{activePanel === 'settings' && <SettingsPanel />}
|
{activePanel === 'settings' && <SettingsPanel />}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{import.meta.env.DEV && <DevPanel />}
|
{import.meta.env.DEV && <DevPanel />}
|
||||||
|
|||||||
151
frontend/src/components/MarkersPanel.tsx
Normal file
151
frontend/src/components/MarkersPanel.tsx
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useEditorStore } from '../store/editorStore';
|
||||||
|
import { MapPin, Trash2, PencilLine, Check, X, Copy } from 'lucide-react';
|
||||||
|
|
||||||
|
const COLORS = ['#6366f1', '#ef4444', '#22c55e', '#f59e0b', '#3b82f6', '#ec4899', '#8b5cf6', '#14b8a6'];
|
||||||
|
|
||||||
|
export default function MarkersPanel() {
|
||||||
|
const { timelineMarkers, addTimelineMarker, updateTimelineMarker, removeTimelineMarker, getChapters } =
|
||||||
|
useEditorStore();
|
||||||
|
const currentTime = useEditorStore((s) => s.currentTime);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [editLabel, setEditLabel] = useState('');
|
||||||
|
const [newLabel, setNewLabel] = useState('');
|
||||||
|
const [newColor, setNewColor] = useState(COLORS[0]);
|
||||||
|
const [showChapters, setShowChapters] = useState(false);
|
||||||
|
|
||||||
|
const chapters = getChapters();
|
||||||
|
|
||||||
|
const addAtCurrentTime = () => {
|
||||||
|
addTimelineMarker(currentTime, newLabel || undefined, newColor);
|
||||||
|
setNewLabel('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const startEdit = (id: string, label: string) => {
|
||||||
|
setEditingId(id);
|
||||||
|
setEditLabel(label);
|
||||||
|
};
|
||||||
|
|
||||||
|
const commitEdit = (id: string) => {
|
||||||
|
if (editLabel.trim()) {
|
||||||
|
updateTimelineMarker(id, { label: editLabel.trim() });
|
||||||
|
}
|
||||||
|
setEditingId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportChapters = () => {
|
||||||
|
const lines = chapters.map((ch) => {
|
||||||
|
const h = Math.floor(ch.startTime / 3600);
|
||||||
|
const m = Math.floor((ch.startTime % 3600) / 60);
|
||||||
|
const s = Math.floor(ch.startTime % 60);
|
||||||
|
const timeStr = `${h > 0 ? `${h}:` : ''}${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||||
|
return `${timeStr} ${ch.label}`;
|
||||||
|
});
|
||||||
|
const text = lines.join('\n');
|
||||||
|
navigator.clipboard.writeText(text).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="text-sm font-semibold flex items-center gap-1.5">
|
||||||
|
<MapPin className="w-4 h-4" />
|
||||||
|
Timeline Markers
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-editor-text-muted">
|
||||||
|
Drop markers at key points. Markers become YouTube-compatible chapters.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add marker at current time */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
value={newLabel}
|
||||||
|
onChange={(e) => setNewLabel(e.target.value)}
|
||||||
|
placeholder={`${currentTime.toFixed(2)}s`}
|
||||||
|
className="flex-1 px-2 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:outline-none focus:border-editor-accent"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-0.5">
|
||||||
|
{COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
onClick={() => setNewColor(c)}
|
||||||
|
className={`w-4 h-4 rounded-full border ${newColor === c ? 'border-white ring-1 ring-white' : 'border-transparent'}`}
|
||||||
|
style={{ backgroundColor: c }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={addAtCurrentTime}
|
||||||
|
className="w-full flex items-center justify-center gap-1 px-2 py-1.5 text-xs bg-editor-accent/20 text-editor-accent hover:bg-editor-accent/30 rounded"
|
||||||
|
>
|
||||||
|
<MapPin className="w-3 h-3" />
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Marker list */}
|
||||||
|
{timelineMarkers.length > 0 && (
|
||||||
|
<div className="space-y-1 max-h-60 overflow-y-auto">
|
||||||
|
{timelineMarkers.map((m) => (
|
||||||
|
<div
|
||||||
|
key={m.id}
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 rounded bg-editor-surface border border-editor-border text-xs"
|
||||||
|
>
|
||||||
|
<div className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: m.color }} />
|
||||||
|
<span className="text-[10px] text-editor-text-muted w-14 shrink-0">{m.time.toFixed(2)}s</span>
|
||||||
|
{editingId === m.id ? (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
value={editLabel}
|
||||||
|
onChange={(e) => setEditLabel(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
className="flex-1 px-1.5 py-0.5 text-xs bg-editor-bg border border-editor-border rounded focus:outline-none focus:border-editor-accent"
|
||||||
|
/>
|
||||||
|
<button onClick={() => commitEdit(m.id)} className="p-0.5 text-editor-success"><Check className="w-3 h-3" /></button>
|
||||||
|
<button onClick={() => setEditingId(null)} className="p-0.5 text-editor-text-muted"><X className="w-3 h-3" /></button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="flex-1 truncate">{m.label}</span>
|
||||||
|
<button onClick={() => startEdit(m.id, m.label)} className="p-0.5 hover:text-editor-accent"><PencilLine className="w-3 h-3" /></button>
|
||||||
|
<button onClick={() => removeTimelineMarker(m.id)} className="p-0.5 hover:text-editor-danger"><Trash2 className="w-3 h-3" /></button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Chapters */}
|
||||||
|
{chapters.length > 0 && (
|
||||||
|
<div className="space-y-2 pt-1 border-t border-editor-border">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowChapters(!showChapters)}
|
||||||
|
className="flex items-center gap-1 text-xs font-medium text-editor-text-muted hover:text-editor-text"
|
||||||
|
>
|
||||||
|
{showChapters ? '▼' : '▶'} Chapters ({chapters.length})
|
||||||
|
</button>
|
||||||
|
{showChapters && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{chapters.map((ch) => (
|
||||||
|
<div key={ch.markerId} className="flex items-center gap-2 text-[10px] text-editor-text-muted">
|
||||||
|
<span className="font-mono">{ch.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={exportChapters}
|
||||||
|
className="flex items-center gap-1 text-[10px] text-editor-accent hover:underline"
|
||||||
|
>
|
||||||
|
<Copy className="w-2.5 h-2.5" />
|
||||||
|
Copy as YouTube timestamps
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -612,26 +612,39 @@ export default function WaveformTimeline({
|
|||||||
if (markInTime !== null) drawMarkLine(markInTime, 'I');
|
if (markInTime !== null) drawMarkLine(markInTime, 'I');
|
||||||
if (markOutTime !== null) drawMarkLine(markOutTime, 'O');
|
if (markOutTime !== null) drawMarkLine(markOutTime, 'O');
|
||||||
|
|
||||||
// Draw timeline markers (colored pins)
|
// Draw timeline markers (numbered circles)
|
||||||
for (const marker of timelineMarkers) {
|
const sortedMarkers = [...timelineMarkers].sort((a, b) => a.time - b.time);
|
||||||
|
for (let mi = 0; mi < sortedMarkers.length; mi++) {
|
||||||
|
const marker = sortedMarkers[mi];
|
||||||
|
const number = mi + 1;
|
||||||
const x = (sourceToDisplayTime(marker.time, timelineSegments, dur) - scroll) * pxPerSec;
|
const x = (sourceToDisplayTime(marker.time, timelineSegments, dur) - scroll) * pxPerSec;
|
||||||
if (x < -4 || x > width + 4) continue;
|
if (x < -8 || x > width + 8) continue;
|
||||||
// Draw a pin triangle
|
const radius = 7;
|
||||||
|
const cy = waveTop - radius - 2;
|
||||||
|
// Draw filled circle
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(x, waveTop - 8);
|
ctx.arc(x, cy, radius, 0, Math.PI * 2);
|
||||||
ctx.lineTo(x - 4, waveTop - 2);
|
|
||||||
ctx.lineTo(x + 4, waveTop - 2);
|
|
||||||
ctx.closePath();
|
|
||||||
ctx.fillStyle = marker.color;
|
ctx.fillStyle = marker.color;
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = '#0f1117';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.stroke();
|
||||||
|
// Draw number
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.font = 'bold 9px sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(String(number), x, cy);
|
||||||
|
ctx.textAlign = 'start';
|
||||||
|
ctx.textBaseline = 'alphabetic';
|
||||||
|
// Draw a thin vertical line below the circle
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, cy + radius);
|
||||||
|
ctx.lineTo(x, waveTop + waveH);
|
||||||
ctx.strokeStyle = marker.color;
|
ctx.strokeStyle = marker.color;
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
// Draw a thin vertical line
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(x, waveTop);
|
|
||||||
ctx.lineTo(x, waveTop + waveH);
|
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
// Label
|
// Label to the right
|
||||||
ctx.fillStyle = marker.color;
|
ctx.fillStyle = marker.color;
|
||||||
ctx.font = '9px sans-serif';
|
ctx.font = '9px sans-serif';
|
||||||
ctx.textBaseline = 'bottom';
|
ctx.textBaseline = 'bottom';
|
||||||
|
|||||||
83
frontend/src/lib/keybindings.ts
Normal file
83
frontend/src/lib/keybindings.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* Configurable keyboard shortcuts system.
|
||||||
|
* Stores bindings in localStorage under 'talkedit:keybindings'.
|
||||||
|
* Provides default presets and conflict detection.
|
||||||
|
*/
|
||||||
|
import type { KeyBinding, HotkeyPreset } from '../types/project';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'talkedit:keybindings';
|
||||||
|
|
||||||
|
export const DEFAULT_PRESETS: Record<HotkeyPreset, KeyBinding[]> = {
|
||||||
|
'left-hand': [
|
||||||
|
{ id: 'play-pause', label: 'Play / Pause', keys: 'Space', category: 'transport' },
|
||||||
|
{ id: 'rewind', label: 'Rewind 5s', keys: 'Q', category: 'transport' },
|
||||||
|
{ id: 'forward', label: 'Forward 5s', keys: 'E', category: 'transport' },
|
||||||
|
{ id: 'speed-up', label: 'Speed Up', keys: 'W', category: 'transport' },
|
||||||
|
{ id: 'slow-down', label: 'Slow Down', keys: 'S', category: 'transport' },
|
||||||
|
{ id: 'pause', label: 'Pause', keys: 'D', category: 'transport' },
|
||||||
|
{ id: 'mark-in', label: 'Mark In Point', keys: 'A', category: 'edit' },
|
||||||
|
{ id: 'mark-out', label: 'Mark Out Point', keys: 'F', category: 'edit' },
|
||||||
|
{ id: 'cut', label: 'Cut Selection', keys: 'X', category: 'edit' },
|
||||||
|
{ id: 'undo', label: 'Undo', keys: 'Ctrl+Z', category: 'edit' },
|
||||||
|
{ id: 'redo', label: 'Redo', keys: 'Ctrl+Shift+Z', category: 'edit' },
|
||||||
|
{ id: 'save', label: 'Save', keys: 'Ctrl+S', category: 'file' },
|
||||||
|
{ id: 'export', label: 'Export', keys: 'Ctrl+E', category: 'file' },
|
||||||
|
{ id: 'search', label: 'Find in Transcript', keys: 'Ctrl+F', category: 'edit' },
|
||||||
|
{ id: 'help', label: 'Shortcut Help', keys: '?', category: 'view' },
|
||||||
|
],
|
||||||
|
'standard': [
|
||||||
|
{ id: 'play-pause', label: 'Play / Pause', keys: 'Space', category: 'transport' },
|
||||||
|
{ id: 'rewind', label: 'Rewind 5s', keys: 'ArrowLeft', category: 'transport' },
|
||||||
|
{ id: 'forward', label: 'Forward 5s', keys: 'ArrowRight', category: 'transport' },
|
||||||
|
{ id: 'speed-up', label: 'Speed Up', keys: 'L', category: 'transport' },
|
||||||
|
{ id: 'slow-down', label: 'Slow Down', keys: 'J', category: 'transport' },
|
||||||
|
{ id: 'pause', label: 'Pause', keys: 'K', category: 'transport' },
|
||||||
|
{ id: 'mark-in', label: 'Mark In Point', keys: 'I', category: 'edit' },
|
||||||
|
{ id: 'mark-out', label: 'Mark Out Point', keys: 'O', category: 'edit' },
|
||||||
|
{ id: 'cut', label: 'Cut Selection', keys: 'Delete', category: 'edit' },
|
||||||
|
{ id: 'undo', label: 'Undo', keys: 'Ctrl+Z', category: 'edit' },
|
||||||
|
{ id: 'redo', label: 'Redo', keys: 'Ctrl+Shift+Z', category: 'edit' },
|
||||||
|
{ id: 'save', label: 'Save', keys: 'Ctrl+S', category: 'file' },
|
||||||
|
{ id: 'export', label: 'Export', keys: 'Ctrl+E', category: 'file' },
|
||||||
|
{ id: 'search', label: 'Find in Transcript', keys: 'Ctrl+F', category: 'edit' },
|
||||||
|
{ id: 'help', label: 'Shortcut Help', keys: '?', category: 'view' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function loadBindings(): KeyBinding[] {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (stored) return JSON.parse(stored);
|
||||||
|
} catch { /* use defaults */ }
|
||||||
|
return DEFAULT_PRESETS['standard'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveBindings(bindings: KeyBinding[]) {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(bindings));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyPreset(preset: HotkeyPreset): KeyBinding[] {
|
||||||
|
const bindings = DEFAULT_PRESETS[preset];
|
||||||
|
saveBindings(bindings);
|
||||||
|
return bindings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectConflicts(bindings: KeyBinding[]): string[] {
|
||||||
|
const conflicts: string[] = [];
|
||||||
|
const seen = new Map<string, string>();
|
||||||
|
for (const b of bindings) {
|
||||||
|
if (seen.has(b.keys)) {
|
||||||
|
conflicts.push(`"${b.keys}" is used by both "${seen.get(b.keys)}" and "${b.label}"`);
|
||||||
|
}
|
||||||
|
seen.set(b.keys, b.label);
|
||||||
|
}
|
||||||
|
return conflicts;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findBinding(bindings: KeyBinding[], id: string): KeyBinding | undefined {
|
||||||
|
return bindings.find((b) => b.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBoundKey(bindings: KeyBinding[], id: string): string {
|
||||||
|
return findBinding(bindings, id)?.keys || '';
|
||||||
|
}
|
||||||
81
frontend/src/lib/thumbnails.ts
Normal file
81
frontend/src/lib/thumbnails.ts
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* Frontend-side video thumbnail extraction.
|
||||||
|
* Captures frames from the <video> element using canvas.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const THUMBNAIL_CACHE = new Map<string, string>();
|
||||||
|
|
||||||
|
export function extractThumbnail(video: HTMLVideoElement, time: number, width = 160, height = 90): string | null {
|
||||||
|
const cacheKey = `${video.src}_${time.toFixed(3)}_${width}x${height}`;
|
||||||
|
const cached = THUMBNAIL_CACHE.get(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
// Seek to the time, wait for seeked, then capture
|
||||||
|
// Since this is synchronous, we use the current ready frame
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return null;
|
||||||
|
|
||||||
|
// Try to draw the current frame at the requested time
|
||||||
|
const originalTime = video.currentTime;
|
||||||
|
video.currentTime = time;
|
||||||
|
|
||||||
|
// We can't synchronously wait for seek, so catch the 'seeked' event externally
|
||||||
|
// For now, draw whatever video frame is available
|
||||||
|
ctx.drawImage(video, 0, 0, width, height);
|
||||||
|
|
||||||
|
// Return to original time (best-effort)
|
||||||
|
video.currentTime = originalTime;
|
||||||
|
|
||||||
|
const dataUrl = canvas.toDataURL('image/jpeg', 0.6);
|
||||||
|
THUMBNAIL_CACHE.set(cacheKey, dataUrl);
|
||||||
|
return dataUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function extractThumbnails(
|
||||||
|
video: HTMLVideoElement,
|
||||||
|
times: number[],
|
||||||
|
width = 160,
|
||||||
|
height = 90,
|
||||||
|
): Promise<Map<number, string>> {
|
||||||
|
const results = new Map<number, string>();
|
||||||
|
const originalTime = video.currentTime;
|
||||||
|
|
||||||
|
for (const time of times) {
|
||||||
|
const cacheKey = `${video.src}_${time.toFixed(3)}_${width}x${height}`;
|
||||||
|
const cached = THUMBNAIL_CACHE.get(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
results.set(time, cached);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seek and wait for the frame to be available
|
||||||
|
video.currentTime = time;
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const handler = () => {
|
||||||
|
video.removeEventListener('seeked', handler);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
video.addEventListener('seeked', handler);
|
||||||
|
// Fallback: resolve after a short timeout
|
||||||
|
setTimeout(resolve, 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (ctx) {
|
||||||
|
ctx.drawImage(video, 0, 0, width, height);
|
||||||
|
const dataUrl = canvas.toDataURL('image/jpeg', 0.5);
|
||||||
|
THUMBNAIL_CACHE.set(cacheKey, dataUrl);
|
||||||
|
results.set(time, dataUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore original position
|
||||||
|
video.currentTime = originalTime;
|
||||||
|
return results;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user