// 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 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); 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 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]); // Track recording ID to detect when recording changes const lastRecordingId = useRefRD(null); const hasAutoPlayed = useRefRD(false); // Reset state when recording changes 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 } }, [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); const handleDurationChange = () => { if (audio.duration && isFinite(audio.duration)) { setAudioDuration(audio.duration); } }; const handleLoadedMetadata = () => { if (audio.duration && isFinite(audio.duration)) { setAudioDuration(audio.duration); } }; audio.addEventListener('timeupdate', handleTimeUpdate); audio.addEventListener('ended', handleEnded); audio.addEventListener('play', handlePlay); audio.addEventListener('pause', handlePause); audio.addEventListener('loadedmetadata', handleLoadedMetadata); audio.addEventListener('durationchange', handleDurationChange); // Check if duration is already available if (audio.duration && isFinite(audio.duration)) { setAudioDuration(audio.duration); } return () => { audio.removeEventListener('timeupdate', handleTimeUpdate); audio.removeEventListener('ended', handleEnded); audio.removeEventListener('play', handlePlay); audio.removeEventListener('pause', handlePause); audio.removeEventListener('loadedmetadata', handleLoadedMetadata); audio.removeEventListener('durationchange', handleDurationChange); }; }, [r.id]); // Re-attach listeners when recording changes // 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`, { 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}`, { 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 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 (
{/* Header */}
{r.startTime.toLocaleString([], { weekday: 'short', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })} #{r.id}
{r.source} {qualityDetails.map(q => ( {q.label}: {q.value} ))}
{/* Add highlight bar */} {showAddHighlight && (
{/* Time range row */}
Start: {formatTime(highlightStartTime ?? currentTime)} End: {highlightEndTime ? formatTime(highlightEndTime) : '—'} {highlightEndTime && ( )} {highlightEndTime && ( Duration: {formatTime(highlightEndTime - (highlightStartTime ?? currentTime))} )}
{/* Tag and note row */}
{TAGS.map(t => ( ))}
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', }} />
)} {/* Main: player + transcript + highlights */}
{/* Left: Player + Transcript */}
{/* Player - simplified with native controls */}
{audioLoading && (
Loading audio...
)} {/* Native audio element with controls */} {audioBlobUrl && (
); } 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 (
{hasRange && ( ({formatTime(h.endSeconds - h.startSeconds)}) )} {h.tag}
{h.note && (
{h.note}
)}
); } Object.assign(window, { RecordingDetail, HighlightCard, TAG_COLORS });