useMyMusicsPlayback.ts (10597B)
1 import { useCallback, useEffect, useRef, useState } from "react"; 2 3 import { loadStoredVolume, saveVolume } from "../lib/playerStorage"; 4 import { reportEvent } from "../lib/reportEvent"; 5 import type { EmbedPlaybackState } from "./useEmbedMessaging"; 6 7 export type TrackInfo = { 8 id: string; 9 title: string; 10 artist: string; 11 }; 12 13 export type QueuedTrack = TrackInfo & { streamUrl: string }; 14 15 export type PlaybackPhase = 16 | "idle" 17 | "loading" 18 | "buffering" 19 | "playing" 20 | "paused" 21 | "error"; 22 23 type RandomResponse = { 24 track: TrackInfo; 25 streamUrl: string; 26 }; 27 28 type ErrBody = { error?: string }; 29 30 type HealthBody = { 31 tracksReady?: boolean; 32 hint?: string; 33 metadataTsv?: string; 34 metadataExists?: boolean; 35 trackCount?: number; 36 }; 37 38 const MAX_ARCHIVE_STREAM_ERRORS = 3; 39 40 export type PlaybackOptions = { 41 /** Initial track id from URL ?track= or embed ?start= */ 42 startTrackId?: string | null; 43 /** Mount with random/up-next (default true) */ 44 autoplayOnMount?: boolean; 45 /** Auto-advance when track ends */ 46 autoAdvance?: boolean; 47 /** Start muted (embed) */ 48 startMuted?: boolean; 49 /** Callback when track/stream changes (embed messaging) */ 50 onTrackChange?: (track: TrackInfo | null, streamUrl: string | null) => void; 51 }; 52 53 export function useMyMusicsPlayback(options: PlaybackOptions = {}) { 54 const { 55 startTrackId = null, 56 autoplayOnMount = true, 57 autoAdvance: autoAdvanceInitial = true, 58 startMuted = false, 59 onTrackChange, 60 } = options; 61 62 const audioRef = useRef<HTMLAudioElement>(null); 63 const preloadAudioRef = useRef<HTMLAudioElement>(null); 64 const archiveStreamErrorsRef = useRef(0); 65 const upNextRef = useRef<QueuedTrack | null>(null); 66 const advanceStartedAtRef = useRef<number | null>(null); 67 const reportedPlayRef = useRef(false); 68 69 const [track, setTrack] = useState<TrackInfo | null>(null); 70 const [streamUrl, setStreamUrl] = useState<string | null>(null); 71 const [upNext, setUpNext] = useState<QueuedTrack | null>(null); 72 const [status, setStatus] = useState<string>(""); 73 const [playbackPhase, setPlaybackPhase] = useState<PlaybackPhase>("idle"); 74 const [history, setHistory] = useState<TrackInfo[]>([]); 75 const [autoPlay, setAutoPlay] = useState(autoAdvanceInitial); 76 const [healthWarn, setHealthWarn] = useState<string | null>(null); 77 const [poolTrackCount, setPoolTrackCount] = useState<number | null>(null); 78 const [queueBusy, setQueueBusy] = useState(false); 79 80 useEffect(() => { 81 upNextRef.current = upNext; 82 }, [upNext]); 83 84 useEffect(() => { 85 onTrackChange?.(track, streamUrl); 86 }, [track, streamUrl, onTrackChange]); 87 88 const applyTrack = useCallback( 89 (info: TrackInfo, url: string, addHistory = true) => { 90 setTrack(info); 91 setStreamUrl(url); 92 if (addHistory) setHistory((h) => [info, ...h].slice(0, 15)); 93 }, 94 [], 95 ); 96 97 const playUrl = useCallback((url: string) => { 98 const a = audioRef.current; 99 if (!a) return; 100 setPlaybackPhase("buffering"); 101 a.src = url; 102 a.load(); 103 void a.play().catch(() => { 104 setPlaybackPhase("error"); 105 }); 106 }, []); 107 108 const preloadUrl = useCallback((url: string | null) => { 109 const pre = preloadAudioRef.current; 110 if (!pre || !url) return; 111 if (pre.src === url) return; 112 pre.src = url; 113 pre.load(); 114 }, []); 115 116 const refillUpNext = useCallback(async (excludeId: string) => { 117 setQueueBusy(true); 118 try { 119 const res = await fetch( 120 `/api/track/up-next?exclude=${encodeURIComponent(excludeId)}`, 121 ); 122 const body = (await res.json()) as RandomResponse | ErrBody; 123 if (!res.ok) { 124 setUpNext(null); 125 return; 126 } 127 const data = body as RandomResponse; 128 const queued: QueuedTrack = { 129 id: data.track.id, 130 title: data.track.title, 131 artist: data.track.artist, 132 streamUrl: data.streamUrl, 133 }; 134 setUpNext(queued); 135 preloadUrl(data.streamUrl); 136 } catch { 137 setUpNext(null); 138 } finally { 139 setQueueBusy(false); 140 } 141 }, [preloadUrl]); 142 143 const fetchTrackById = useCallback(async (id: string): Promise<RandomResponse | null> => { 144 const res = await fetch(`/api/track/${encodeURIComponent(id)}`); 145 const body = (await res.json()) as RandomResponse | ErrBody; 146 if (!res.ok) return null; 147 return body as RandomResponse; 148 }, []); 149 150 const advance = useCallback(async () => { 151 setStatus(""); 152 setPlaybackPhase("loading"); 153 advanceStartedAtRef.current = Date.now(); 154 reportedPlayRef.current = false; 155 156 const queued = upNextRef.current; 157 try { 158 if (queued) { 159 const info: TrackInfo = { 160 id: queued.id, 161 title: queued.title, 162 artist: queued.artist, 163 }; 164 applyTrack(info, queued.streamUrl); 165 playUrl(queued.streamUrl); 166 setUpNext(null); 167 await refillUpNext(queued.id); 168 return; 169 } 170 171 const res = await fetch("/api/track/random"); 172 const body = (await res.json()) as RandomResponse | ErrBody; 173 if (!res.ok) { 174 setTrack(null); 175 setStreamUrl(null); 176 setUpNext(null); 177 setPlaybackPhase("error"); 178 setStatus( 179 "error" in body && body.error 180 ? body.error 181 : "Service unavailable. Try again later.", 182 ); 183 return; 184 } 185 const data = body as RandomResponse; 186 const info: TrackInfo = { 187 id: data.track.id, 188 title: data.track.title, 189 artist: data.track.artist, 190 }; 191 applyTrack(info, data.streamUrl); 192 playUrl(data.streamUrl); 193 await refillUpNext(info.id); 194 } catch { 195 setPlaybackPhase("error"); 196 setStatus("Network error while requesting a track."); 197 setUpNext(null); 198 } 199 }, [applyTrack, playUrl, refillUpNext]); 200 201 const loadTrackById = useCallback( 202 async (id: string) => { 203 setPlaybackPhase("loading"); 204 advanceStartedAtRef.current = Date.now(); 205 reportedPlayRef.current = false; 206 setStatus(""); 207 try { 208 const data = await fetchTrackById(id); 209 if (!data) { 210 setPlaybackPhase("error"); 211 setStatus("Track not found."); 212 return; 213 } 214 const info: TrackInfo = { 215 id: data.track.id, 216 title: data.track.title, 217 artist: data.track.artist, 218 }; 219 applyTrack(info, data.streamUrl); 220 playUrl(data.streamUrl); 221 await refillUpNext(info.id); 222 } catch { 223 setPlaybackPhase("error"); 224 setStatus("Network error while loading track."); 225 } 226 }, 227 [applyTrack, fetchTrackById, playUrl, refillUpNext], 228 ); 229 230 const handleAudioPlaying = useCallback(() => { 231 archiveStreamErrorsRef.current = 0; 232 setStatus(""); 233 setPlaybackPhase("playing"); 234 const started = advanceStartedAtRef.current; 235 if (started !== null && !reportedPlayRef.current && track) { 236 reportedPlayRef.current = true; 237 reportEvent({ 238 type: "time_to_play", 239 trackId: track.id, 240 ms: Date.now() - started, 241 }); 242 } 243 }, [track]); 244 245 const handleAudioError = useCallback(() => { 246 archiveStreamErrorsRef.current += 1; 247 setPlaybackPhase("error"); 248 if (track) { 249 reportEvent({ type: "stream_error", trackId: track.id, detail: "audio_element_error" }); 250 } 251 const n = archiveStreamErrorsRef.current; 252 if (n >= MAX_ARCHIVE_STREAM_ERRORS) { 253 setStatus( 254 "Internet Archive could not stream several tracks in a row (e.g. 503). Try Next or wait.", 255 ); 256 return; 257 } 258 setStatus("This track is not available from the Archive right now; trying another…"); 259 if (autoPlay) void advance(); 260 }, [autoPlay, advance, track]); 261 262 const requestNextTrack = useCallback(() => { 263 archiveStreamErrorsRef.current = 0; 264 void advance(); 265 }, [advance]); 266 267 const handleAudioPause = useCallback(() => { 268 if (audioRef.current?.paused) setPlaybackPhase("paused"); 269 }, []); 270 271 const handlePause = useCallback(() => { 272 audioRef.current?.pause(); 273 setPlaybackPhase("paused"); 274 }, []); 275 276 const handlePlay = useCallback(() => { 277 const a = audioRef.current; 278 if (!a) return; 279 void a.play().catch(() => {}); 280 }, []); 281 282 useEffect(() => { 283 void (async () => { 284 try { 285 const res = await fetch("/api/health"); 286 const h = (await res.json()) as HealthBody; 287 if (typeof h.trackCount === "number") setPoolTrackCount(h.trackCount); 288 if (!h.tracksReady) { 289 const parts = [ 290 h.hint, 291 h.metadataTsv && `Path: ${h.metadataTsv}`, 292 h.metadataExists === false && "File not found at configured path.", 293 typeof h.trackCount === "number" && `Tracks loaded: ${h.trackCount}.`, 294 ].filter(Boolean); 295 setHealthWarn( 296 parts.length > 0 297 ? parts.join(" ") 298 : "No tracks loaded. Check server metadata and /api/health.", 299 ); 300 } else { 301 setHealthWarn(null); 302 } 303 } catch { 304 setHealthWarn(null); 305 } 306 })(); 307 }, []); 308 309 useEffect(() => { 310 const a = audioRef.current; 311 if (!a) return; 312 const vol = loadStoredVolume(); 313 if (vol !== null) a.volume = vol; 314 if (startMuted) a.muted = true; 315 const onVol = () => saveVolume(a.volume); 316 a.addEventListener("volumechange", onVol); 317 return () => a.removeEventListener("volumechange", onVol); 318 }, [startMuted]); 319 320 useEffect(() => { 321 if (!autoplayOnMount) return; 322 if (startTrackId) { 323 void loadTrackById(startTrackId); 324 return; 325 } 326 void advance(); 327 // eslint-disable-next-line react-hooks/exhaustive-deps -- mount bootstrap 328 }, []); 329 330 const onEnded = useCallback(() => { 331 if (autoPlay) void advance(); 332 }, [autoPlay, advance]); 333 334 const showUpNextHint = 335 poolTrackCount === 1 && track && upNext && upNext.id === track.id; 336 337 const embedPlaybackState: EmbedPlaybackState = 338 playbackPhase === "playing" 339 ? "playing" 340 : playbackPhase === "paused" 341 ? "paused" 342 : playbackPhase === "buffering" || playbackPhase === "loading" 343 ? "buffering" 344 : playbackPhase === "error" 345 ? "error" 346 : "paused"; 347 348 return { 349 audioRef, 350 preloadAudioRef, 351 track, 352 streamUrl, 353 upNext, 354 status, 355 playbackPhase, 356 embedPlaybackState, 357 history, 358 autoPlay, 359 setAutoPlay, 360 healthWarn, 361 poolTrackCount, 362 queueBusy, 363 requestNextTrack, 364 loadTrackById, 365 handleAudioPlaying, 366 handleAudioError, 367 handlePlay, 368 handlePause, 369 handleAudioPause, 370 onEnded, 371 showUpNextHint, 372 }; 373 }