mymusics

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

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 }