// Shared Azuracast audio + now-playing engine for all variations. // Exposes: window.useEphemeralRadio() — React hook returning {state, track, listeners, toggle, audioRef, analyserData} // Use flac as the primary stream — leaning into the lossless ethos. // mp3 is a fallback for browsers that can't decode flac (Safari). const STREAM_URL_FLAC = "https://listen.ephemeral.club/listen/ephemeral/radio.flac"; const STREAM_URL_MP3 = "https://listen.ephemeral.club/listen/ephemeral/radio.mp3"; function pickStream() { try { const a = document.createElement('audio'); const ok = a.canPlayType('audio/flac') || a.canPlayType('audio/x-flac'); return ok ? STREAM_URL_FLAC : STREAM_URL_MP3; } catch { return STREAM_URL_MP3; } } const STREAM_URL = pickStream(); const STREAM_FORMAT = STREAM_URL === STREAM_URL_FLAC ? "FLAC · 24-bit · 44.1 kHz" : "MP3 · 320 kbps"; window.EPHEMERAL_STREAM_FORMAT = STREAM_FORMAT; const NOWPLAYING_URL = "https://listen.ephemeral.club/api/nowplaying/1"; // Local placeholder art for tracks that don't have artwork or whose art doesn't fit the aesthetic const PLACEHOLDER_ART = "placeholder-art.png"; function isGenericArt(url) { if (!url) return true; // Azuracast default art is usually served from /static/ and contains "default" return /default|generic|missing|no[-_]?art/i.test(url); } // --------------------------------------------------------------------------- // Purchase link — operator-set, per track. // AzuraCast lets you attach Custom Fields to media; when one holds a store URL // it flows through the now-playing API on song.custom_fields. We surface it as // a "buy" button ONLY when present, so the button appears exactly when a track // is actually for sale — there is no reliable client-side way to guess this. // custom_fields arrives in one of two shapes depending on AzuraCast version: // array : [{ name, value }, ...] // object: { name: value, ... } // We accept either and match a few likely field names. To light up the button, // add a Custom Field in AzuraCast (Administration → Custom Fields) named one of // the below and set it to the Bandcamp (or any store) URL on purchasable media. // --------------------------------------------------------------------------- const BUY_FIELD_NAMES = ["buy_url","buy","bandcamp","bandcamp_url","purchase","purchase_url","buy_link","store","store_url"]; // Opt-in preview switch: append ?pvbuy=1 (or ?pvbuy=) to the page URL to // force the buy button on for design/QA without configuring AzuraCast. Never // active for normal visitors — the param must be explicitly present. const _previewBuy = (() => { try { const v = new URLSearchParams(location.search).get("pvbuy"); if (!v) return null; return /^https?:\/\//i.test(v) ? v : "https://bandcamp.com"; } catch { return null; } })(); function extractBuyUrl(song) { if (!song) return null; const cf = song.custom_fields; let val = null; if (Array.isArray(cf)) { const hit = cf.find(f => f && BUY_FIELD_NAMES.includes(String(f.name || "").toLowerCase()) && f.value); val = hit ? hit.value : null; } else if (cf && typeof cf === "object") { for (const k of Object.keys(cf)) { if (BUY_FIELD_NAMES.includes(k.toLowerCase()) && cf[k]) { val = cf[k]; break; } } } if (!val || typeof val !== "string") return _previewBuy; val = val.trim(); return /^https?:\/\//i.test(val) ? val : _previewBuy; } // Multi-artist tags often come as "Artist A; Artist B; Artist C". // For external lookups, take only the primary (first) artist. function primaryArtist(artist) { if (!artist) return ''; return artist.split(';')[0].trim(); } // --------------------------------------------------------------------------- // iTunes Search API fallback for missing/generic embedded art. // No API key required. Search by album when available (best art quality), // otherwise fall back to song search. Cache hits AND misses to avoid // hammering the API on every poll cycle. // --------------------------------------------------------------------------- const _itunesCache = new Map(); // `${primaryArtist}|${album||title}` -> URL or null async function fetchITunesArt(artist, album, title) { const primary = primaryArtist(artist); if (!primary) return null; const queryAlbum = !!album; const term = queryAlbum ? `${primary} ${album}` : `${primary} ${title || ''}`; if (!term.trim()) return null; const key = `${primary}|${album || title || ''}`; if (_itunesCache.has(key)) return _itunesCache.get(key); const entity = queryAlbum ? 'album' : 'song'; const url = `https://itunes.apple.com/search?term=${encodeURIComponent(term)}&media=music&entity=${entity}&limit=1`; try { const r = await fetch(url); if (!r.ok) throw new Error('itunes fetch'); const j = await r.json(); const small = j.results?.[0]?.artworkUrl100; // Upscale 100x100 → 600x600 (iTunes URLs include the size in the path) const big = small ? small.replace(/100x100/g, '600x600') : null; _itunesCache.set(key, big); // cache misses (null) too — avoids retrying failures return big; } catch { _itunesCache.set(key, null); return null; } } // Singleton audio + analyser shared across all artboards on the canvas let _sharedAudio = null; let _sharedCtx = null; let _sharedAnalyser = null; let _sharedSource = null; function getSharedAudio() { if (_sharedAudio) return _sharedAudio; const a = new Audio(); a.crossOrigin = "anonymous"; a.preload = "none"; a.src = STREAM_URL; _sharedAudio = a; return a; } function ensureAnalyser() { if (_sharedAnalyser) return _sharedAnalyser; try { const Ctx = window.AudioContext || window.webkitAudioContext; _sharedCtx = new Ctx(); const audio = getSharedAudio(); _sharedSource = _sharedCtx.createMediaElementSource(audio); _sharedAnalyser = _sharedCtx.createAnalyser(); _sharedAnalyser.fftSize = 128; _sharedAnalyser.smoothingTimeConstant = 0.82; _sharedSource.connect(_sharedAnalyser); _sharedAnalyser.connect(_sharedCtx.destination); } catch (e) { console.warn("Analyser unavailable — CORS or autoplay restriction", e); } return _sharedAnalyser; } function useEphemeralRadio() { const [state, setState] = React.useState("idle"); // idle | loading | playing | error const [track, setTrack] = React.useState(null); const [listeners, setListeners] = React.useState(null); const [history, setHistory] = React.useState([]); const [live, setLive] = React.useState({ isLive: false, streamer: null }); const [levels, setLevels] = React.useState(() => new Uint8Array(64)); // Volume: persisted to localStorage (1.0 default), only applied on desktop UI const [volume, _setVolume] = React.useState(() => { try { const v = parseFloat(localStorage.getItem('eph.volume')); return isFinite(v) ? Math.min(1, Math.max(0, v)) : 1; } catch { return 1; } }); const audioRef = React.useRef(null); // Mount: get singleton audio React.useEffect(() => { audioRef.current = getSharedAudio(); const a = audioRef.current; const onPlay = () => setState("playing"); const onPlaying = () => setState("playing"); const onCanPlay = () => { if (!a.paused) setState("playing"); }; const onPause = () => setState("idle"); const onWaiting = () => { if (!a.paused) setState("loading"); }; const onError = () => setState("error"); // timeupdate is the ground-truth heartbeat: if currentTime is advancing, // we're actually playing regardless of what waiting/playing/canplay said. // This recovers from buffer hiccups where `playing` fails to re-fire after `waiting`. const onTimeUpdate = () => { if (!a.paused) setState(s => s === "playing" ? s : "playing"); }; a.addEventListener("play", onPlay); a.addEventListener("playing", onPlaying); a.addEventListener("canplay", onCanPlay); a.addEventListener("pause", onPause); a.addEventListener("waiting", onWaiting); a.addEventListener("error", onError); a.addEventListener("timeupdate", onTimeUpdate); // sync state on mount if (!a.paused) setState("playing"); return () => { a.removeEventListener("play", onPlay); a.removeEventListener("playing", onPlaying); a.removeEventListener("canplay", onCanPlay); a.removeEventListener("pause", onPause); a.removeEventListener("waiting", onWaiting); a.removeEventListener("error", onError); a.removeEventListener("timeupdate", onTimeUpdate); }; }, []); // Poll now-playing every 12s React.useEffect(() => { let cancelled = false; async function fetchNP() { try { const r = await fetch(NOWPLAYING_URL, { cache: "no-store" }); if (!r.ok) throw new Error("np fetch"); const j = await r.json(); const data = Array.isArray(j) ? j[0] : j; if (cancelled || !data) return; const np = data.now_playing || {}; const song = np.song || {}; // ART RESOLUTION ORDER: // 1. Embedded art (Azuracast) — if present and not generic // 2. iTunes Search API — async, updates track once resolved // 3. Local placeholder — used until/unless iTunes resolves const embedded = isGenericArt(song.art) ? null : song.art; setTrack({ title: song.title || "untitled", artist: song.artist || "unknown", album: song.album || "", art: embedded || PLACEHOLDER_ART, buyUrl: extractBuyUrl(song), startedAt: np.played_at ? np.played_at * 1000 : Date.now(), duration: np.duration || 0, elapsed: np.elapsed || 0, }); setListeners(data.listeners?.current ?? null); // Azuracast live streamer info — set when a DJ takes over from AutoDJ const liveInfo = data.live || {}; setLive({ isLive: !!liveInfo.is_live, streamer: liveInfo.streamer_name || null, }); // History items — same three-tier resolution const historyItems = (data.song_history || []).slice(0, 6).map(h => { const hsong = h.song || {}; const hembedded = isGenericArt(hsong.art) ? null : hsong.art; return { title: hsong.title || "", artist: hsong.artist || "", album: hsong.album || "", art: hembedded || PLACEHOLDER_ART, }; }); setHistory(historyItems); // Async-resolve iTunes art for the current track if embedded was missing. // Guard against the track having changed before the lookup completes. if (!embedded && song.artist && (song.album || song.title)) { fetchITunesArt(song.artist, song.album, song.title).then(it => { if (cancelled || !it) return; setTrack(t => { if (!t || t.title !== song.title || t.artist !== song.artist) return t; return { ...t, art: it }; }); }); } // Async-resolve iTunes art for any history items missing embedded art. // Cache means same-album repeats are free after the first hit. historyItems.forEach((h, idx) => { if (h.art !== PLACEHOLDER_ART) return; if (!h.artist || (!h.album && !h.title)) return; fetchITunesArt(h.artist, h.album, h.title).then(it => { if (cancelled || !it) return; setHistory(prev => { const slot = prev[idx]; if (!slot || slot.title !== h.title || slot.artist !== h.artist) return prev; const next = [...prev]; next[idx] = { ...slot, art: it }; return next; }); }); }); } catch (e) { if (!cancelled) { // If the API is unreachable, leave previous track in place (or empty on first load) setTrack(t => t || { title: "—", artist: "—", album: "", art: PLACEHOLDER_ART, buyUrl: _previewBuy, startedAt: Date.now(), duration: 0, elapsed: 0, }); } } } fetchNP(); const id = setInterval(fetchNP, 12000); return () => { cancelled = true; clearInterval(id); }; }, []); // Analyser RAF — only run while playing React.useEffect(() => { if (state !== "playing") return; let raf; const an = ensureAnalyser(); if (!an) return; const buf = new Uint8Array(an.frequencyBinCount); const tick = () => { an.getByteFrequencyData(buf); setLevels(new Uint8Array(buf)); raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, [state]); const toggle = React.useCallback(async () => { const a = audioRef.current; if (!a) return; if (a.paused) { // Activate AudioContext on user gesture so analyser works try { ensureAnalyser(); if (_sharedCtx?.state === "suspended") await _sharedCtx.resume(); } catch {} setState("loading"); // Force fresh fetch of live stream try { a.src = STREAM_URL + "?t=" + Date.now(); a.load(); } catch {} try { await a.play(); } catch (e) { setState("error"); console.warn(e); } } else { a.pause(); } }, []); // Volume: apply to audio element + persist const setVolume = React.useCallback((v) => { const clamped = Math.min(1, Math.max(0, v)); _setVolume(clamped); const a = audioRef.current; if (a) a.volume = clamped; try { localStorage.setItem('eph.volume', String(clamped)); } catch {} }, []); // Apply current volume whenever audio element is ready React.useEffect(() => { const a = audioRef.current; if (a) a.volume = volume; }, [volume]); return { state, track, listeners, history, toggle, audioRef, levels, live, volume, setVolume }; } window.useEphemeralRadio = useEphemeralRadio;