import { useState, useRef, useCallback, useEffect } from 'react'; import { Play, Pause } from 'lucide-react'; import { useAuthedFileUrl } from '../../lib/authedFile'; interface Props { audioData: string; // base64 data URL (data:audio/webm;base64,...) OR an /api/files/* path } function formatDuration(s: number): string { if (!isFinite(s) || isNaN(s)) return '0:00'; const mins = Math.floor(s / 60); const secs = Math.floor(s % 60); return `${mins}:${secs.toString().padStart(2, '0')}`; } export default function AudioBubble({ audioData }: Props) { const [playing, setPlaying] = useState(false); const [progress, setProgress] = useState(0); const [duration, setDuration] = useState(0); const audioRef = useRef(null); const barRef = useRef(null); // Historical audio is stored as an /api/files/* path, which a native Audio element // can't fetch (the Bearer token can't ride on the request) — resolve it to a blob URL // fetched with auth. data: URLs (freshly-recorded clips) pass straight through. const { url: resolvedAudioUrl } = useAuthedFileUrl(audioData); // Create Audio element once the source URL is ready useEffect(() => { if (!resolvedAudioUrl) return; const audio = new Audio(resolvedAudioUrl); audio.addEventListener('durationchange', () => { // webm often reports Infinity initially — skip until real if (isFinite(audio.duration)) setDuration(audio.duration); }); audio.addEventListener('timeupdate', () => { if (isFinite(audio.duration) && audio.duration > 0) { setProgress(audio.currentTime / audio.duration); // Update duration if we didn't get it from durationchange if (duration === 0) setDuration(audio.duration); } }); audio.addEventListener('ended', () => { setPlaying(false); setProgress(0); }); // Workaround for webm duration=Infinity: seek to large value to force browser to calculate duration audio.addEventListener('loadedmetadata', () => { if (!isFinite(audio.duration)) { audio.currentTime = 1e10; audio.addEventListener('timeupdate', function seekBack() { if (isFinite(audio.duration)) { setDuration(audio.duration); audio.currentTime = 0; audio.removeEventListener('timeupdate', seekBack); } }); } }); audioRef.current = audio; return () => { audio.pause(); audio.src = ''; }; }, [resolvedAudioUrl]); const togglePlay = useCallback(() => { const audio = audioRef.current; if (!audio) return; if (playing) { audio.pause(); setPlaying(false); } else { audio.play(); setPlaying(true); } }, [playing]); const handleSeek = useCallback((e: React.MouseEvent) => { const audio = audioRef.current; const bar = barRef.current; if (!audio || !bar || !isFinite(audio.duration)) return; const rect = bar.getBoundingClientRect(); const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); audio.currentTime = ratio * audio.duration; setProgress(ratio); }, []); return (
{formatDuration(playing ? progress * duration : duration)}
); }