2026-05-19 17:11:45 -04:00
|
|
|
|
// 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);
|
2026-05-19 19:35:41 -04:00
|
|
|
|
const [audioDuration, setAudioDuration] = useStateRD(null); // actual audio duration
|
2026-05-19 17:11:45 -04:00
|
|
|
|
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);
|
|
|
|
|
|
|
2026-05-19 19:35:41 -04:00
|
|
|
|
// Use actual audio duration if available, fallback to metadata
|
|
|
|
|
|
const totalSeconds = audioDuration || r.durationMinutes * 60;
|
2026-05-19 17:11:45 -04:00
|
|
|
|
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);
|
|
|
|
|
|
|
2026-05-19 19:35:41 -04:00
|
|
|
|
// Reset state when recording changes
|
2026-05-19 17:11:45 -04:00
|
|
|
|
useEffectRD(() => {
|
|
|
|
|
|
if (lastRecordingId.current !== r.id) {
|
|
|
|
|
|
hasAutoPlayed.current = false;
|
|
|
|
|
|
lastRecordingId.current = r.id;
|
2026-05-19 19:35:41 -04:00
|
|
|
|
setAudioDuration(null); // Reset to use metadata until audio loads
|
2026-05-19 17:11:45 -04:00
|
|
|
|
}
|
|
|
|
|
|
}, [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);
|
2026-05-19 19:35:41 -04:00
|
|
|
|
const handleDurationChange = () => {
|
|
|
|
|
|
if (audio.duration && isFinite(audio.duration)) {
|
|
|
|
|
|
setAudioDuration(audio.duration);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
2026-05-19 17:11:45 -04:00
|
|
|
|
|
|
|
|
|
|
audio.addEventListener('timeupdate', handleTimeUpdate);
|
|
|
|
|
|
audio.addEventListener('ended', handleEnded);
|
|
|
|
|
|
audio.addEventListener('play', handlePlay);
|
|
|
|
|
|
audio.addEventListener('pause', handlePause);
|
2026-05-19 19:35:41 -04:00
|
|
|
|
audio.addEventListener('loadedmetadata', handleDurationChange);
|
|
|
|
|
|
audio.addEventListener('durationchange', handleDurationChange);
|
|
|
|
|
|
|
|
|
|
|
|
// Check if duration is already available
|
|
|
|
|
|
if (audio.duration && isFinite(audio.duration)) {
|
|
|
|
|
|
setAudioDuration(audio.duration);
|
|
|
|
|
|
}
|
2026-05-19 17:11:45 -04:00
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
audio.removeEventListener('timeupdate', handleTimeUpdate);
|
|
|
|
|
|
audio.removeEventListener('ended', handleEnded);
|
|
|
|
|
|
audio.removeEventListener('play', handlePlay);
|
|
|
|
|
|
audio.removeEventListener('pause', handlePause);
|
2026-05-19 19:35:41 -04:00
|
|
|
|
audio.removeEventListener('loadedmetadata', handleDurationChange);
|
|
|
|
|
|
audio.removeEventListener('durationchange', handleDurationChange);
|
2026-05-19 17:11:45 -04:00
|
|
|
|
};
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
// 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 });
|