adding the ui
This commit is contained in:
parent
14445e7242
commit
eaeac63ef3
12 changed files with 2638 additions and 0 deletions
611
clio-ui/components/RecordingDetail.jsx
Normal file
611
clio-ui/components/RecordingDetail.jsx
Normal file
|
|
@ -0,0 +1,611 @@
|
|||
// RecordingDetail: audio player, transcript, highlights
|
||||
const { useState: useStateRD, useEffect: useEffectRD, useRef: useRefRD, useCallback: useCallbackRD } = React;
|
||||
|
||||
const API_BASE = '';
|
||||
|
||||
function RecordingDetail({ recording: r, data, onBack, allHighlights, setAllHighlights, initialSeekTime, onSeekHandled }) {
|
||||
const [currentTime, setCurrentTime] = useStateRD(0); // seconds into recording
|
||||
const [playing, setPlaying] = useStateRD(false);
|
||||
const [speed, setSpeed] = useStateRD(1.0);
|
||||
const [activeSegId, setActiveSegId] = useStateRD(null);
|
||||
const [showAddHighlight, setShowAddHighlight] = useStateRD(false);
|
||||
const [newHighlightTag, setNewHighlightTag] = useStateRD('important');
|
||||
const [newHighlightNote, setNewHighlightNote] = useStateRD('');
|
||||
const audioRef = useRefRD(null);
|
||||
const transcriptRef = useRefRD(null);
|
||||
|
||||
const totalSeconds = r.durationMinutes * 60;
|
||||
const recHighlights = allHighlights.filter(h => h.recordingId === r.id);
|
||||
|
||||
// Audio URL from API
|
||||
const audioUrl = `${API_BASE}/api/v1/recordings/${r.id}/audio`;
|
||||
|
||||
// Track recording ID to detect when recording changes
|
||||
const lastRecordingId = useRefRD(null);
|
||||
const hasAutoPlayed = useRefRD(false);
|
||||
|
||||
// Reset auto-play flag when recording changes
|
||||
useEffectRD(() => {
|
||||
if (lastRecordingId.current !== r.id) {
|
||||
hasAutoPlayed.current = false;
|
||||
lastRecordingId.current = r.id;
|
||||
}
|
||||
}, [r.id]);
|
||||
|
||||
// Auto-play when recording opens
|
||||
useEffectRD(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio || hasAutoPlayed.current) return;
|
||||
|
||||
const doStartPlayback = () => {
|
||||
audio.play().catch(() => {}); // Ignore errors (e.g., browser autoplay policy)
|
||||
hasAutoPlayed.current = true;
|
||||
};
|
||||
|
||||
// If audio is ready, start immediately; otherwise wait for loadedmetadata
|
||||
if (audio.readyState >= 1) {
|
||||
doStartPlayback();
|
||||
} else {
|
||||
audio.addEventListener('loadedmetadata', doStartPlayback, { once: true });
|
||||
return () => audio.removeEventListener('loadedmetadata', doStartPlayback);
|
||||
}
|
||||
}, [r.id]);
|
||||
|
||||
// Handle seeking to initial time (from search results, highlights, etc.)
|
||||
useEffectRD(() => {
|
||||
if (initialSeekTime == null) return;
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
|
||||
const doSeek = () => {
|
||||
audio.currentTime = initialSeekTime;
|
||||
setCurrentTime(initialSeekTime);
|
||||
if (onSeekHandled) onSeekHandled();
|
||||
};
|
||||
|
||||
if (audio.readyState >= 1) {
|
||||
doSeek();
|
||||
} else {
|
||||
audio.addEventListener('loadedmetadata', doSeek, { once: true });
|
||||
return () => audio.removeEventListener('loadedmetadata', doSeek);
|
||||
}
|
||||
}, [initialSeekTime, onSeekHandled]);
|
||||
|
||||
// Sync playback state with audio element
|
||||
useEffectRD(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
|
||||
const handleTimeUpdate = () => setCurrentTime(audio.currentTime);
|
||||
const handleEnded = () => setPlaying(false);
|
||||
const handlePlay = () => setPlaying(true);
|
||||
const handlePause = () => setPlaying(false);
|
||||
|
||||
audio.addEventListener('timeupdate', handleTimeUpdate);
|
||||
audio.addEventListener('ended', handleEnded);
|
||||
audio.addEventListener('play', handlePlay);
|
||||
audio.addEventListener('pause', handlePause);
|
||||
|
||||
return () => {
|
||||
audio.removeEventListener('timeupdate', handleTimeUpdate);
|
||||
audio.removeEventListener('ended', handleEnded);
|
||||
audio.removeEventListener('play', handlePlay);
|
||||
audio.removeEventListener('pause', handlePause);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Sync playback rate with speed state
|
||||
useEffectRD(() => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.playbackRate = speed;
|
||||
}
|
||||
}, [speed]);
|
||||
|
||||
// Track active transcript segment
|
||||
useEffectRD(() => {
|
||||
if (!r.transcript) return;
|
||||
const active = r.transcript.find(s => currentTime >= s.startSeconds && currentTime <= s.endSeconds);
|
||||
setActiveSegId(active ? active.id : null);
|
||||
}, [currentTime, r.transcript]);
|
||||
|
||||
function formatTime(secs) {
|
||||
const h = Math.floor(secs / 3600);
|
||||
const m = Math.floor((secs % 3600) / 60);
|
||||
const s = Math.floor(secs % 60);
|
||||
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function seek(e) {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const pct = (e.clientX - rect.left) / rect.width;
|
||||
const newTime = pct * totalSeconds;
|
||||
if (audioRef.current) {
|
||||
audioRef.current.currentTime = newTime;
|
||||
}
|
||||
setCurrentTime(newTime);
|
||||
}
|
||||
|
||||
function togglePlay() {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
if (playing) {
|
||||
audio.pause();
|
||||
} else {
|
||||
audio.play();
|
||||
}
|
||||
}
|
||||
|
||||
function skipTime(delta) {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
const newTime = Math.max(0, Math.min(totalSeconds, audio.currentTime + delta));
|
||||
audio.currentTime = newTime;
|
||||
setCurrentTime(newTime);
|
||||
}
|
||||
|
||||
function seekToTime(secs) {
|
||||
const audio = audioRef.current;
|
||||
if (audio) {
|
||||
audio.currentTime = secs;
|
||||
}
|
||||
setCurrentTime(secs);
|
||||
}
|
||||
|
||||
const [highlightStartTime, setHighlightStartTime] = useStateRD(null);
|
||||
const [highlightEndTime, setHighlightEndTime] = useStateRD(null);
|
||||
|
||||
// When showing add highlight, set start time to current position
|
||||
useEffectRD(() => {
|
||||
if (showAddHighlight && highlightStartTime === null) {
|
||||
setHighlightStartTime(Math.round(currentTime));
|
||||
}
|
||||
}, [showAddHighlight, currentTime, highlightStartTime]);
|
||||
|
||||
async function addHighlight() {
|
||||
const startSecs = highlightStartTime ?? Math.round(currentTime);
|
||||
const endSecs = highlightEndTime; // null for point highlight
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/highlights`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
recording_id: r.id,
|
||||
start_seconds: startSecs,
|
||||
end_seconds: endSecs,
|
||||
tag: newHighlightTag,
|
||||
note: newHighlightNote || null,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error('Failed to create highlight');
|
||||
|
||||
const data = await res.json();
|
||||
const newH = {
|
||||
id: `h-${data.id}`,
|
||||
recordingId: r.id,
|
||||
startSeconds: startSecs,
|
||||
endSeconds: endSecs,
|
||||
tag: newHighlightTag,
|
||||
note: newHighlightNote,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
setAllHighlights(prev => [...prev, newH]);
|
||||
} catch (err) {
|
||||
console.error('Failed to create highlight:', err);
|
||||
}
|
||||
|
||||
setShowAddHighlight(false);
|
||||
setNewHighlightNote('');
|
||||
setHighlightStartTime(null);
|
||||
setHighlightEndTime(null);
|
||||
}
|
||||
|
||||
async function removeHighlight(hid) {
|
||||
// Extract numeric ID from "h-123" format
|
||||
const numericId = hid.startsWith('h-') ? hid.slice(2) : hid;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/highlights/${numericId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok && res.status !== 404) {
|
||||
console.error('Failed to delete highlight');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete highlight:', err);
|
||||
}
|
||||
|
||||
setAllHighlights(prev => prev.filter(h => h.id !== hid));
|
||||
}
|
||||
|
||||
const TAGS = ['important', 'todo', 'idea', 'follow-up', 'meeting', 'personal', 'work'];
|
||||
const TAG_COLORS = {
|
||||
important: 'var(--red)', todo: 'var(--amber)', idea: 'var(--teal)',
|
||||
'follow-up': 'var(--amber-dim)', meeting: 'var(--green)', personal: 'var(--text2)', work: 'var(--teal)',
|
||||
};
|
||||
|
||||
const progressPct = (currentTime / totalSeconds) * 100;
|
||||
|
||||
// Waveform: fake bars
|
||||
const waveformBars = 120;
|
||||
const waveHeights = useRefRD(null);
|
||||
if (!waveHeights.current) {
|
||||
const rand = (seed) => { let s = seed; return () => { s = (s * 16807) % 2147483647; return (s - 1) / 2147483646; }; };
|
||||
const rng = rand(r.id * 42);
|
||||
waveHeights.current = Array.from({ length: waveformBars }, () => 0.1 + rng() * 0.9);
|
||||
}
|
||||
|
||||
const qualityDetails = [
|
||||
{ label: 'Quality', value: r.quality, cls: `q-${r.quality}` },
|
||||
{ label: 'Sample Rate', value: `${r.sampleRate / 1000} kHz`, cls: '' },
|
||||
{ label: 'Bit Depth', value: `${r.bitDepth}-bit`, cls: '' },
|
||||
{ label: 'Peak', value: `${r.peakDB.toFixed(1)} dB`, cls: r.peakDB > -30 ? 'q-good' : r.peakDB > -45 ? 'q-fair' : 'q-poor' },
|
||||
{ label: 'Size', value: `${r.fileSizeMB} MB`, cls: '' },
|
||||
{ label: 'Source', value: r.source, cls: `source-${r.source}` },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
{/* Header */}
|
||||
<div style={{ borderBottom: '1px solid var(--border)', padding: '12px 20px', display: 'flex', alignItems: 'center', gap: 12, background: 'var(--bg2)', flexShrink: 0 }}>
|
||||
<button onClick={onBack} style={{ color: 'var(--text2)', fontSize: 18, padding: '2px 6px', borderRadius: 3, lineHeight: 1 }}>‹</button>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text)' }}>
|
||||
{r.startTime.toLocaleString([], { weekday: 'short', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
|
||||
<span style={{ color: 'var(--text3)', marginLeft: 10 }}>#{r.id}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 10, marginTop: 3, alignItems: 'center' }}>
|
||||
<span className={`source-pill source-${r.source}`}>{r.source}</span>
|
||||
{qualityDetails.map(q => (
|
||||
<span key={q.label} style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)' }}>
|
||||
<span style={{ color: 'var(--text3)' }}>{q.label}: </span>
|
||||
<span className={q.cls} style={{ color: q.cls ? undefined : 'var(--text2)' }}>{q.value}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAddHighlight(s => !s)}
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono)', fontSize: 11,
|
||||
padding: '5px 12px', borderRadius: 4,
|
||||
background: showAddHighlight ? 'var(--amber)' : 'var(--bg4)',
|
||||
color: showAddHighlight ? 'var(--bg)' : 'var(--amber)',
|
||||
border: '1px solid var(--amber)',
|
||||
transition: 'all 0.12s',
|
||||
}}
|
||||
>◈ Highlight</button>
|
||||
</div>
|
||||
|
||||
{/* Add highlight bar */}
|
||||
{showAddHighlight && (
|
||||
<div style={{ background: 'var(--bg3)', borderBottom: '1px solid var(--border)', padding: '10px 20px', display: 'flex', flexDirection: 'column', gap: 8, flexShrink: 0 }}>
|
||||
{/* Time range row */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)' }}>Start:</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--amber)', minWidth: 50 }}>
|
||||
{formatTime(highlightStartTime ?? currentTime)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setHighlightStartTime(Math.round(currentTime))}
|
||||
style={{ fontFamily: 'var(--font-mono)', fontSize: 9, padding: '2px 6px', background: 'var(--bg4)', border: '1px solid var(--border)', borderRadius: 3, color: 'var(--text2)' }}
|
||||
>Set Start</button>
|
||||
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)', marginLeft: 8 }}>End:</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: highlightEndTime ? 'var(--amber)' : 'var(--text3)', minWidth: 50 }}>
|
||||
{highlightEndTime ? formatTime(highlightEndTime) : '—'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setHighlightEndTime(Math.round(currentTime))}
|
||||
style={{ fontFamily: 'var(--font-mono)', fontSize: 9, padding: '2px 6px', background: 'var(--bg4)', border: '1px solid var(--border)', borderRadius: 3, color: 'var(--text2)' }}
|
||||
>Set End</button>
|
||||
{highlightEndTime && (
|
||||
<button
|
||||
onClick={() => setHighlightEndTime(null)}
|
||||
style={{ fontFamily: 'var(--font-mono)', fontSize: 9, padding: '2px 6px', background: 'var(--bg4)', border: '1px solid var(--border)', borderRadius: 3, color: 'var(--text3)' }}
|
||||
>Clear</button>
|
||||
)}
|
||||
|
||||
{highlightEndTime && (
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--teal)', marginLeft: 8 }}>
|
||||
Duration: {formatTime(highlightEndTime - (highlightStartTime ?? currentTime))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tag and note row */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
{TAGS.map(t => (
|
||||
<button key={t} onClick={() => setNewHighlightTag(t)} style={{
|
||||
fontFamily: 'var(--font-mono)', fontSize: 10, padding: '3px 8px', borderRadius: 3,
|
||||
background: newHighlightTag === t ? TAG_COLORS[t] : 'var(--bg4)',
|
||||
color: newHighlightTag === t ? 'var(--bg)' : 'var(--text2)',
|
||||
border: `1px solid ${TAG_COLORS[t] || 'var(--border)'}`,
|
||||
}}>{t}</button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
value={newHighlightNote}
|
||||
onChange={e => setNewHighlightNote(e.target.value)}
|
||||
placeholder="Note (optional)…"
|
||||
onKeyDown={e => e.key === 'Enter' && addHighlight()}
|
||||
style={{
|
||||
flex: 1, background: 'var(--bg4)', border: '1px solid var(--border)',
|
||||
borderRadius: 4, padding: '4px 10px', color: 'var(--text)',
|
||||
fontFamily: 'var(--font-mono)', fontSize: 11,
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
<button onClick={addHighlight} style={{
|
||||
fontFamily: 'var(--font-mono)', fontSize: 11, padding: '4px 12px',
|
||||
background: 'var(--amber)', color: 'var(--bg)', borderRadius: 4, fontWeight: 600,
|
||||
}}>Save</button>
|
||||
<button onClick={() => { setShowAddHighlight(false); setHighlightStartTime(null); setHighlightEndTime(null); }} style={{ color: 'var(--text3)', fontSize: 16 }}>×</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main: player + transcript + highlights */}
|
||||
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
|
||||
|
||||
{/* Left: Player + Transcript */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
|
||||
{/* Player */}
|
||||
<div style={{ padding: '16px 20px', borderBottom: '1px solid var(--border)', background: 'var(--bg2)', flexShrink: 0 }}>
|
||||
{/* Waveform */}
|
||||
<div
|
||||
onClick={seek}
|
||||
style={{ height: 56, display: 'flex', alignItems: 'center', gap: 1, cursor: 'pointer', marginBottom: 8, position: 'relative' }}
|
||||
>
|
||||
{waveHeights.current.map((h, i) => {
|
||||
const barPct = i / waveformBars;
|
||||
const barTime = barPct * totalSeconds;
|
||||
const isPast = barPct <= progressPct / 100;
|
||||
const isActive = Math.abs(barPct - progressPct / 100) < 0.015;
|
||||
|
||||
// Check if bar is within any highlight range (or near a point highlight)
|
||||
const inHighlight = recHighlights.some(hl => {
|
||||
if (hl.endSeconds != null) {
|
||||
// Range highlight - check if bar is within range
|
||||
return barTime >= hl.startSeconds && barTime <= hl.endSeconds;
|
||||
} else {
|
||||
// Point highlight - check if near
|
||||
return Math.abs(hl.startSeconds - barTime) < (totalSeconds / waveformBars);
|
||||
}
|
||||
});
|
||||
|
||||
// Check if bar is within the pending highlight being created
|
||||
const inPendingHighlight = showAddHighlight && highlightStartTime != null && (
|
||||
highlightEndTime != null
|
||||
? barTime >= highlightStartTime && barTime <= highlightEndTime
|
||||
: Math.abs(highlightStartTime - barTime) < (totalSeconds / waveformBars)
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={i} style={{
|
||||
flex: 1,
|
||||
height: `${Math.round(h * 100)}%`,
|
||||
background: inPendingHighlight ? 'var(--red)' : inHighlight ? 'var(--amber)' : isPast ? 'var(--teal)' : 'var(--bg4)',
|
||||
borderRadius: 1,
|
||||
minHeight: 2,
|
||||
transition: 'background 0.05s',
|
||||
opacity: isActive ? 1 : 0.85,
|
||||
}} />
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Highlight range markers when creating */}
|
||||
{showAddHighlight && highlightStartTime != null && (
|
||||
<div style={{
|
||||
position: 'absolute', left: `${(highlightStartTime / totalSeconds) * 100}%`, top: 0, bottom: 0,
|
||||
width: 2, background: 'var(--red)', pointerEvents: 'none',
|
||||
}} />
|
||||
)}
|
||||
{showAddHighlight && highlightEndTime != null && (
|
||||
<div style={{
|
||||
position: 'absolute', left: `${(highlightEndTime / totalSeconds) * 100}%`, top: 0, bottom: 0,
|
||||
width: 2, background: 'var(--red)', pointerEvents: 'none',
|
||||
}} />
|
||||
)}
|
||||
|
||||
{/* Playhead */}
|
||||
<div style={{
|
||||
position: 'absolute', left: `${progressPct}%`, top: 0, bottom: 0,
|
||||
width: 2, background: 'var(--amber)', pointerEvents: 'none',
|
||||
boxShadow: '0 0 6px var(--amber)',
|
||||
}} />
|
||||
</div>
|
||||
|
||||
{/* Hidden audio element */}
|
||||
<audio ref={audioRef} src={audioUrl} preload="metadata" />
|
||||
|
||||
{/* Controls */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<button onClick={() => skipTime(-30)} style={{ color: 'var(--text2)', fontFamily: 'var(--font-mono)', fontSize: 11 }}>-30s</button>
|
||||
<button onClick={() => skipTime(-5)} style={{ color: 'var(--text2)', fontFamily: 'var(--font-mono)', fontSize: 11 }}>-5s</button>
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
style={{
|
||||
width: 36, height: 36, borderRadius: '50%',
|
||||
background: 'var(--amber)', color: 'var(--bg)',
|
||||
fontSize: 14, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>{playing ? '⏸' : '▶'}</button>
|
||||
<button onClick={() => skipTime(5)} style={{ color: 'var(--text2)', fontFamily: 'var(--font-mono)', fontSize: 11 }}>+5s</button>
|
||||
<button onClick={() => skipTime(30)} style={{ color: 'var(--text2)', fontFamily: 'var(--font-mono)', fontSize: 11 }}>+30s</button>
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
{/* Speed */}
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
{[0.5, 1.0, 1.5, 2.0, 3.0].map(s => (
|
||||
<button key={s} onClick={() => setSpeed(s)} style={{
|
||||
fontFamily: 'var(--font-mono)', fontSize: 10, padding: '2px 7px',
|
||||
borderRadius: 3,
|
||||
background: speed === s ? 'var(--amber)' : 'var(--bg4)',
|
||||
color: speed === s ? 'var(--bg)' : 'var(--text3)',
|
||||
border: '1px solid var(--border)',
|
||||
}}>{s}×</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text2)', minWidth: 100, textAlign: 'right' }}>
|
||||
{formatTime(currentTime)} / {formatTime(totalSeconds)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transcript */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
{/* Transcript header with scroll-to-current button */}
|
||||
<div style={{ padding: '8px 20px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0 }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
|
||||
Transcript · {r.transcript?.length || 0} segments
|
||||
</div>
|
||||
<div style={{ flex: 1 }} />
|
||||
{r.hasTranscript && r.transcript.length > 0 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
// Find the segment closest to current time
|
||||
const el = transcriptRef.current?.querySelector(`[data-seg-id="${activeSegId}"]`);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
} else {
|
||||
// Find closest segment
|
||||
const closest = r.transcript.reduce((prev, curr) =>
|
||||
Math.abs(curr.startSeconds - currentTime) < Math.abs(prev.startSeconds - currentTime) ? curr : prev
|
||||
);
|
||||
const closestEl = transcriptRef.current?.querySelector(`[data-seg-id="${closest.id}"]`);
|
||||
if (closestEl) closestEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono)', fontSize: 10, padding: '3px 10px',
|
||||
background: 'var(--bg4)', border: '1px solid var(--border)', borderRadius: 4,
|
||||
color: 'var(--amber)', cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
↓ Go to {formatTime(currentTime)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div ref={transcriptRef} style={{ flex: 1, overflow: 'auto', padding: '16px 20px' }}>
|
||||
{!r.hasTranscript || r.transcript.length === 0 ? (
|
||||
<div style={{ color: 'var(--text3)', fontFamily: 'var(--font-mono)', fontSize: 12, padding: '20px 0' }}>
|
||||
No transcript available. Run speech-to-text batch job to generate.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{r.transcript.map(seg => {
|
||||
const isActive = seg.id === activeSegId;
|
||||
const hasHighlight = recHighlights.some(h => h.segmentId === seg.id);
|
||||
return (
|
||||
<div
|
||||
key={seg.id}
|
||||
data-seg-id={seg.id}
|
||||
onClick={() => seekToTime(seg.startSeconds)}
|
||||
style={{
|
||||
display: 'flex', gap: 12, padding: '10px 12px', borderRadius: 5,
|
||||
background: isActive ? 'var(--amber-glow)' : 'transparent',
|
||||
border: `1px solid ${isActive ? 'var(--amber-dim)' : hasHighlight ? 'var(--amber-dim)' : 'transparent'}`,
|
||||
cursor: 'pointer', transition: 'all 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isActive) e.currentTarget.style.background = 'var(--bg3)'; }}
|
||||
onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
<div style={{ flexShrink: 0, paddingTop: 2 }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: isActive ? 'var(--amber)' : 'var(--text3)' }}>
|
||||
{formatTime(seg.startSeconds)}
|
||||
</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 9, color: 'var(--text3)', marginTop: 2 }}>
|
||||
{Math.round(seg.confidence * 100)}%
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, fontFamily: 'var(--font-sans)', fontSize: 14, lineHeight: 1.6, color: isActive ? 'var(--text)' : 'var(--text2)', textWrap: 'pretty' }}>
|
||||
{seg.text}
|
||||
</div>
|
||||
{hasHighlight && (
|
||||
<div style={{ flexShrink: 0, color: 'var(--amber)', fontSize: 12, paddingTop: 2 }}>◈</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Highlights panel */}
|
||||
<div style={{ width: 260, flexShrink: 0, borderLeft: '1px solid var(--border)', background: 'var(--bg2)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<div style={{ padding: '14px 16px', borderBottom: '1px solid var(--border)', flexShrink: 0 }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
|
||||
Highlights · {recHighlights.length}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '12px 12px' }}>
|
||||
{recHighlights.length === 0 ? (
|
||||
<div style={{ color: 'var(--text3)', fontFamily: 'var(--font-mono)', fontSize: 11, padding: '8px 4px' }}>
|
||||
No highlights yet.<br />Press ◈ Highlight to mark a moment.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{recHighlights
|
||||
.sort((a, b) => a.startSeconds - b.startSeconds)
|
||||
.map(h => (
|
||||
<HighlightCard key={h.id} highlight={h} tagColors={TAG_COLORS} onSeek={seekToTime} onRemove={removeHighlight} formatTime={formatTime} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TAG_COLORS = {
|
||||
important: 'var(--red)', todo: 'var(--amber)', idea: 'var(--teal)',
|
||||
'follow-up': 'var(--amber-dim)', meeting: 'var(--green)', personal: 'var(--text2)', work: 'var(--teal)',
|
||||
};
|
||||
|
||||
function HighlightCard({ highlight: h, tagColors, onSeek, onRemove, formatTime }) {
|
||||
const color = tagColors[h.tag] || 'var(--text3)';
|
||||
const hasRange = h.endSeconds != null;
|
||||
return (
|
||||
<div style={{
|
||||
background: 'var(--bg3)', border: `1px solid ${color}44`,
|
||||
borderLeft: `3px solid ${color}`, borderRadius: '0 4px 4px 0',
|
||||
padding: '8px 10px',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||
<button
|
||||
onClick={() => onSeek(h.startSeconds)}
|
||||
style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--amber)' }}
|
||||
>
|
||||
{formatTime(h.startSeconds)}
|
||||
{hasRange && ` → ${formatTime(h.endSeconds)}`}
|
||||
</button>
|
||||
{hasRange && (
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 9, color: 'var(--teal)' }}>
|
||||
({formatTime(h.endSeconds - h.startSeconds)})
|
||||
</span>
|
||||
)}
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 9, padding: '1px 5px', borderRadius: 3, background: `${color}22`, color }}>{h.tag}</span>
|
||||
</div>
|
||||
<button onClick={() => onRemove(h.id)} style={{ color: 'var(--text3)', fontSize: 13, lineHeight: 1 }}>×</button>
|
||||
</div>
|
||||
{h.note && (
|
||||
<div style={{ fontFamily: 'var(--font-sans)', fontSize: 12, color: 'var(--text2)', lineHeight: 1.5 }}>{h.note}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { RecordingDetail, HighlightCard, TAG_COLORS });
|
||||
Loading…
Add table
Add a link
Reference in a new issue