mymusics

retro MySpace-style music player
Log | Files | Refs | README

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 }