CozyAudioBar.tsx (8025B)
1 import { type FormEvent, type RefObject, useCallback, useEffect, useRef, useState } from "react"; 2 3 function formatTime(seconds: number): string { 4 if (!Number.isFinite(seconds) || seconds < 0) return "0:00"; 5 const m = Math.floor(seconds / 60); 6 const sec = Math.floor(seconds % 60); 7 return `${m}:${sec.toString().padStart(2, "0")}`; 8 } 9 10 function formatDurationLabel(seconds: number): string { 11 if (!Number.isFinite(seconds) || seconds <= 0) return "--:--"; 12 return formatTime(seconds); 13 } 14 15 /** Duration from element metadata or seekable ranges (streaming MP3). */ 16 function safeDuration(el: HTMLAudioElement): number { 17 const d = el.duration; 18 if (Number.isFinite(d) && d > 0 && d !== Number.POSITIVE_INFINITY) { 19 return d; 20 } 21 try { 22 if (el.seekable && el.seekable.length > 0) { 23 const end = el.seekable.end(el.seekable.length - 1); 24 if (Number.isFinite(end) && end > 0 && end !== Number.POSITIVE_INFINITY) { 25 return end; 26 } 27 } 28 } catch { 29 /* ignore */ 30 } 31 return 0; 32 } 33 34 type Props = { 35 audioRef: RefObject<HTMLAudioElement | null>; 36 /** No track / no usable stream */ 37 disabled?: boolean; 38 }; 39 40 export function CozyAudioBar({ audioRef, disabled }: Props) { 41 const [currentTime, setCurrentTime] = useState(0); 42 const [duration, setDuration] = useState(0); 43 const [playing, setPlaying] = useState(false); 44 const [muted, setMuted] = useState(false); 45 const scrubbingRef = useRef(false); 46 47 useEffect(() => { 48 const el = audioRef.current; 49 if (!el) return; 50 51 const syncDuration = () => { 52 setDuration(safeDuration(el)); 53 }; 54 55 const syncFromElement = () => { 56 if (!scrubbingRef.current) { 57 setCurrentTime(el.currentTime); 58 } 59 syncDuration(); 60 setPlaying(!el.paused); 61 setMuted(el.muted); 62 }; 63 64 const onTimeUpdate = () => { 65 if (!scrubbingRef.current) { 66 setCurrentTime(el.currentTime); 67 } 68 }; 69 70 const onLoadedMeta = () => syncFromElement(); 71 const onProgress = () => syncDuration(); 72 const onLoadedData = () => syncDuration(); 73 const onCanPlay = () => syncDuration(); 74 const onSeeked = () => { 75 scrubbingRef.current = false; 76 setCurrentTime(el.currentTime); 77 syncDuration(); 78 }; 79 const onSeeking = () => { 80 setCurrentTime(el.currentTime); 81 }; 82 const onPlay = () => setPlaying(true); 83 const onPause = () => setPlaying(false); 84 const onVol = () => setMuted(el.muted); 85 86 el.addEventListener("timeupdate", onTimeUpdate); 87 el.addEventListener("loadedmetadata", onLoadedMeta); 88 el.addEventListener("durationchange", syncDuration); 89 el.addEventListener("progress", onProgress); 90 el.addEventListener("loadeddata", onLoadedData); 91 el.addEventListener("canplay", onCanPlay); 92 el.addEventListener("seeked", onSeeked); 93 el.addEventListener("seeking", onSeeking); 94 el.addEventListener("play", onPlay); 95 el.addEventListener("pause", onPause); 96 el.addEventListener("volumechange", onVol); 97 98 syncFromElement(); 99 100 const endScrub = () => { 101 scrubbingRef.current = false; 102 setCurrentTime(el.currentTime); 103 }; 104 window.addEventListener("pointerup", endScrub); 105 window.addEventListener("pointercancel", endScrub); 106 107 return () => { 108 el.removeEventListener("timeupdate", onTimeUpdate); 109 el.removeEventListener("loadedmetadata", onLoadedMeta); 110 el.removeEventListener("durationchange", syncDuration); 111 el.removeEventListener("progress", onProgress); 112 el.removeEventListener("loadeddata", onLoadedData); 113 el.removeEventListener("canplay", onCanPlay); 114 el.removeEventListener("seeked", onSeeked); 115 el.removeEventListener("seeking", onSeeking); 116 el.removeEventListener("play", onPlay); 117 el.removeEventListener("pause", onPause); 118 el.removeEventListener("volumechange", onVol); 119 window.removeEventListener("pointerup", endScrub); 120 window.removeEventListener("pointercancel", endScrub); 121 }; 122 }, [audioRef]); 123 124 const dur = duration; 125 const pct = 126 dur > 0 && Number.isFinite(dur) ? Math.min(100, Math.max(0, (currentTime / dur) * 100)) : 0; 127 128 const togglePlay = useCallback(() => { 129 const el = audioRef.current; 130 if (!el || disabled) return; 131 if (el.paused) void el.play().catch(() => {}); 132 else el.pause(); 133 }, [audioRef, disabled]); 134 135 const applySeekPercent = useCallback( 136 (pctValue: number) => { 137 const el = audioRef.current; 138 if (!el || disabled || dur <= 0) return; 139 const clamped = Math.min(100, Math.max(0, pctValue)); 140 const next = (clamped / 100) * dur; 141 el.currentTime = next; 142 setCurrentTime(next); 143 }, 144 [audioRef, disabled, dur], 145 ); 146 147 const onSeek = useCallback( 148 (e: FormEvent<HTMLInputElement>) => { 149 applySeekPercent(parseFloat(e.currentTarget.value)); 150 }, 151 [applySeekPercent], 152 ); 153 154 const startScrub = useCallback(() => { 155 scrubbingRef.current = true; 156 }, []); 157 158 const toggleMute = useCallback(() => { 159 const el = audioRef.current; 160 if (!el || disabled) return; 161 el.muted = !el.muted; 162 setMuted(el.muted); 163 }, [audioRef, disabled]); 164 165 const durLabel = formatDurationLabel(dur); 166 const curLabel = formatTime(currentTime); 167 const seekDisabled = disabled || dur <= 0; 168 169 return ( 170 <div 171 className={`cozy-player${disabled ? " cozy-player--disabled" : ""}`} 172 role="group" 173 aria-label="Audio playback" 174 > 175 <button 176 type="button" 177 className="cozy-player__play" 178 onClick={() => void togglePlay()} 179 disabled={disabled} 180 aria-label={playing ? "Pause" : "Play"} 181 > 182 {playing ? ( 183 <svg className="cozy-player__icon" viewBox="0 0 24 24" aria-hidden> 184 <rect x="6" y="5" width="4" height="14" rx="1" fill="currentColor" /> 185 <rect x="14" y="5" width="4" height="14" rx="1" fill="currentColor" /> 186 </svg> 187 ) : ( 188 <svg className="cozy-player__icon" viewBox="0 0 24 24" aria-hidden> 189 <path d="M9 6.5v11l9-5.5z" fill="currentColor" /> 190 </svg> 191 )} 192 </button> 193 194 <div className="cozy-player__progress-wrap"> 195 <span className="cozy-player__time cozy-player__time--current">{curLabel}</span> 196 <input 197 type="range" 198 className="cozy-player__scrub" 199 min={0} 200 max={100} 201 step={0.05} 202 value={pct} 203 onPointerDown={startScrub} 204 onInput={onSeek} 205 onChange={onSeek} 206 disabled={seekDisabled} 207 aria-label="Seek" 208 aria-valuemin={0} 209 aria-valuemax={100} 210 aria-valuenow={Math.round(pct)} 211 aria-valuetext={`${curLabel} of ${durLabel}`} 212 /> 213 <span className="cozy-player__time cozy-player__time--duration">{durLabel}</span> 214 </div> 215 216 <button 217 type="button" 218 className="cozy-player__mute" 219 onClick={() => void toggleMute()} 220 disabled={disabled} 221 aria-label={muted ? "Unmute" : "Mute"} 222 aria-pressed={muted} 223 > 224 {muted ? ( 225 <svg className="cozy-player__icon" viewBox="0 0 24 24" aria-hidden> 226 <path 227 fill="currentColor" 228 d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z" 229 /> 230 </svg> 231 ) : ( 232 <svg className="cozy-player__icon" viewBox="0 0 24 24" aria-hidden> 233 <path 234 fill="currentColor" 235 d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z" 236 /> 237 </svg> 238 )} 239 </button> 240 </div> 241 ); 242 }