// 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 audioRef = useRefRD(null); const transcriptRef = useRefRD(null); // Use actual audio duration if available, fallback to metadata const totalSeconds = audioDuration || 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 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 } }, [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); } }; audio.addEventListener('timeupdate', handleTimeUpdate); audio.addEventListener('ended', handleEnded); audio.addEventListener('play', handlePlay); audio.addEventListener('pause', handlePause); audio.addEventListener('loadedmetadata', handleDurationChange); 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', handleDurationChange); audio.removeEventListener('durationchange', handleDurationChange); }; }, []); // 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 (