clio/clio-ui/components/RecordingDetail.jsx

587 lines
26 KiB
React
Raw Permalink Normal View History

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);
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 [audioBlobUrl, setAudioBlobUrl] = useStateRD(null);
const [audioLoading, setAudioLoading] = useStateRD(true);
2026-05-19 17:11:45 -04:00
const audioRef = useRefRD(null);
const transcriptRef = useRefRD(null);
// Use actual audio duration if available, fallback to metadata
const metadataDuration = r.durationMinutes * 60;
const totalSeconds = audioDuration || metadataDuration || 1; // avoid division by zero
2026-05-19 17:11:45 -04:00
const recHighlights = allHighlights.filter(h => h.recordingId === r.id);
// Fetch audio with auth and create blob URL
useEffectRD(() => {
let cancelled = false;
let blobUrl = null;
async function fetchAudio() {
setAudioLoading(true);
try {
const res = await window.authFetch(`${API_BASE}/api/v1/recordings/${r.id}/audio`);
if (cancelled) return;
if (!res.ok) throw new Error('Failed to fetch audio');
const blob = await res.blob();
if (cancelled) return;
blobUrl = URL.createObjectURL(blob);
setAudioBlobUrl(blobUrl);
} catch (err) {
console.error('Failed to load audio:', err);
} finally {
if (!cancelled) setAudioLoading(false);
}
}
fetchAudio();
return () => {
cancelled = true;
if (blobUrl) URL.revokeObjectURL(blobUrl);
};
}, [r.id]);
2026-05-19 17:11:45 -04:00
// Track recording ID to detect when recording changes
const lastRecordingId = useRefRD(null);
const hasAutoPlayed = useRefRD(false);
// 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;
setAudioDuration(null); // Reset to use metadata until audio loads
setAudioBlobUrl(null); // Clear old blob URL
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;
2026-05-19 20:54:48 -04:00
if (!audioBlobUrl) return; // Wait for audio to be loaded
2026-05-19 17:11:45 -04:00
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);
}
2026-05-19 20:54:48 -04:00
}, [initialSeekTime, onSeekHandled, audioBlobUrl]);
2026-05-19 17:11:45 -04:00
// Sync playback state with audio element
useEffectRD(() => {
const audio = audioRef.current;
if (!audio) return;
const handleTimeUpdate = () => setCurrentTime(audio.currentTime);
2026-05-19 17:11:45 -04:00
const handleEnded = () => setPlaying(false);
const handlePlay = () => setPlaying(true);
const handlePause = () => setPlaying(false);
const handleDurationChange = () => {
2026-05-19 19:38:01 -04:00
if (audio.duration && isFinite(audio.duration)) {
setAudioDuration(audio.duration);
}
};
const handleLoadedMetadata = () => {
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:38:01 -04:00
audio.addEventListener('loadedmetadata', handleLoadedMetadata);
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:38:01 -04:00
audio.removeEventListener('loadedmetadata', handleLoadedMetadata);
audio.removeEventListener('durationchange', handleDurationChange);
2026-05-19 17:11:45 -04:00
};
}, [r.id]); // Re-attach listeners when recording changes
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 window.authFetch(`${API_BASE}/api/v1/highlights`, {
2026-05-19 17:11:45 -04:00
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 window.authFetch(`${API_BASE}/api/v1/highlights/${numericId}`, {
2026-05-19 17:11:45 -04:00
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)',
};
2026-05-19 20:54:48 -04:00
const recordingDetails = [
{ label: 'Sample Rate', value: `${r.sampleRate / 1000} kHz` },
{ label: 'Bit Depth', value: `${r.bitDepth}-bit` },
{ label: 'Size', value: `${r.fileSizeMB} MB` },
2026-05-19 17:11:45 -04:00
];
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>
2026-05-19 20:54:48 -04:00
{recordingDetails.map(d => (
<span key={d.label} style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)' }}>
<span style={{ color: 'var(--text3)' }}>{d.label}: </span>
<span style={{ color: 'var(--text2)' }}>{d.value}</span>
2026-05-19 17:11:45 -04:00
</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' }}>
2026-05-19 20:45:15 -04:00
{/* Player - simplified with native controls */}
2026-05-19 17:11:45 -04:00
<div style={{ padding: '16px 20px', borderBottom: '1px solid var(--border)', background: 'var(--bg2)', flexShrink: 0 }}>
2026-05-19 20:45:15 -04:00
{audioLoading && (
<div style={{ color: 'var(--text3)', fontSize: 11, fontFamily: 'var(--font-mono)', marginBottom: 8 }}>
Loading audio...
</div>
)}
2026-05-19 17:11:45 -04:00
2026-05-19 20:45:15 -04:00
{/* Native audio element with controls */}
{audioBlobUrl && (
<audio
ref={audioRef}
src={audioBlobUrl}
controls
style={{ width: '100%', marginBottom: 12 }}
/>
)}
2026-05-19 17:11:45 -04:00
2026-05-19 20:45:15 -04:00
{/* Extra controls: skip buttons and speed */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button onClick={() => skipTime(-30)} style={{ color: 'var(--text2)', fontFamily: 'var(--font-mono)', fontSize: 11, padding: '4px 8px', background: 'var(--bg4)', borderRadius: 4 }}>-30s</button>
<button onClick={() => skipTime(-5)} style={{ color: 'var(--text2)', fontFamily: 'var(--font-mono)', fontSize: 11, padding: '4px 8px', background: 'var(--bg4)', borderRadius: 4 }}>-5s</button>
<button onClick={() => skipTime(5)} style={{ color: 'var(--text2)', fontFamily: 'var(--font-mono)', fontSize: 11, padding: '4px 8px', background: 'var(--bg4)', borderRadius: 4 }}>+5s</button>
<button onClick={() => skipTime(30)} style={{ color: 'var(--text2)', fontFamily: 'var(--font-mono)', fontSize: 11, padding: '4px 8px', background: 'var(--bg4)', borderRadius: 4 }}>+30s</button>
2026-05-19 17:11:45 -04:00
<div style={{ flex: 1 }} />
{/* Speed */}
2026-05-19 20:45:15 -04:00
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)' }}>Speed:</span>
{[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>
))}
2026-05-19 17:11:45 -04:00
</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 });