Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
77 lines
9.5 KiB
JavaScript
77 lines
9.5 KiB
JavaScript
'use strict';
|
|
// ---- Radio: The Journey FM on top, your saved stations, and a search of thousands more (Radio Browser). The hub passes the stream on (and reads the song title
|
|
// out of it), so stations that only offer plain http still play, and everything plays in the same player as Music.
|
|
const rdoApi = (p, opts) => jfetch('/api/radio' + p, opts);
|
|
const rdoOpt = (m, o) => ({ method: m, headers: { 'content-type': 'application/json' }, body: o ? JSON.stringify(o) : undefined });
|
|
const rdoArt = (s, song) => '/api/radio/art?s=' + encodeURIComponent(s.id) + (song && song.title ? '&artist=' + encodeURIComponent(song.artist || '') + '&title=' + encodeURIComponent(song.title) : '');
|
|
const rdoIcon = s => s.favicon ? '/api/radio/icon?u=' + encodeURIComponent(s.favicon) : '';
|
|
const rdoTrack = s => ({ key: 'radio:' + s.id, title: s.name, artist: 'Live radio', album: [s.state, s.country].filter(Boolean).join(', '), album0: [s.state, s.country].filter(Boolean).join(', '), thumb: rdoArt(s), src: '/api/radio/play/' + encodeURIComponent(s.id), nocache: true, dur: 0, station: s.name });
|
|
const rdoPlaying = () => { const t = cur(); return t && String(t.key).startsWith('radio:') ? t : null; };
|
|
const rdoMeta = s => [s.tags && s.tags.split(',').slice(0, 2).join(', '), s.state || s.country, s.codec && (s.codec + (s.bitrate ? ' ' + s.bitrate + 'k' : ''))].filter(Boolean).join(' · ');
|
|
const rdoChips = [['Christian', 'christian'], ['Worship', 'worship'], ['Gospel', 'gospel'], ['Talk', 'talk'], ['News', 'news'], ['Classical', 'classical'], ['Jazz', 'jazz'], ['Country', 'country'], ['Oldies', 'oldies']];
|
|
let rdoSaved = [], rdoNow = {};
|
|
|
|
/** While a station plays, ask what song is on every 20 seconds and show it in the player. */
|
|
async function rdoPoll() {
|
|
const t = rdoPlaying(); if (!t) return;
|
|
const id = t.key.slice(6);
|
|
try {
|
|
const n = await rdoApi('/now/' + encodeURIComponent(id)); if (!rdoPlaying() || rdoPlaying().key !== t.key) return;
|
|
rdoNow[id] = n; rdoApplyNow(t, n);
|
|
const el = document.getElementById('rdonow'); if (el && el.dataset.id === id) rdoDrawNow(el, n);
|
|
const sm = document.querySelector(`.rdorow[data-id="${id}"] small`); if (sm && n.title) sm.textContent = 'Now: ' + n.title + (n.artist ? ' \u2014 ' + n.artist : '');
|
|
} catch {}
|
|
}
|
|
/** What is on now goes into the player (the song as the title, the artist under it, the station as the album), the lock screen, and the lyrics if they are open. */
|
|
function rdoApplyNow(t, n) {
|
|
const was = t.song ? t.song.title + '|' + t.song.artist : '';
|
|
t.song = n.title ? { title: n.title, artist: n.artist || '', startedAt: n.startedAt || 0 } : null;
|
|
t.title = n.title || t.station; t.artist = n.title ? (n.artist || t.station) : 'Live radio'; t.album = n.title ? t.station : t.album0;
|
|
t.thumb = rdoArt({ id: t.key.slice(6) }, t.song); // the cover of the song that is on, else the station's logo
|
|
const now = t.song ? t.song.title + '|' + t.song.artist : '';
|
|
if (was === now && t.shown) return;
|
|
t.shown = true;
|
|
for (const f of ['setMeta', 'renderMini', 'updateNow', 'renderNpCard', 'syncUI']) { try { if (typeof window[f] === 'function') (f === 'setMeta' ? window[f](t) : window[f]()); } catch {} }
|
|
if (lyr.open && was !== now) { lyrShell(); lyrReload(); }
|
|
}
|
|
setInterval(rdoPoll, 10000); pl.a.addEventListener('playing', () => { if (rdoPlaying()) setTimeout(rdoPoll, 1500); });
|
|
function rdoDrawNow(el, n) { el.innerHTML = n && n.title ? `<small>Now playing</small><b>${esc(n.title)}</b>${n.artist ? '<span> · ' + esc(n.artist) + '</span>' : ''}` : '<small>Live</small>'; }
|
|
|
|
async function viewRadio() {
|
|
const v = $('#view'); v.innerHTML = '<div class="wrap"><h1>Radio</h1><p class="empty">Loading…</p></div>';
|
|
let feat, mine;
|
|
try { ({ featured: feat, mine } = await rdoApi('/stations')); } catch (e) { v.innerHTML = `<div class="wrap"><h1>Radio</h1><p class="err">${esc(e.message)}</p></div>`; return; }
|
|
if (!location.hash.startsWith('#/radio')) return;
|
|
rdoSaved = mine; const S = { q: '', tag: 'christian', items: [], more: false, busy: false, seq: 0, off: 0 };
|
|
const saved = id => rdoSaved.some(x => x.id === id);
|
|
const row = (s, fav) => `<div class="row rdorow${rdoPlaying() && rdoPlaying().key === 'radio:' + s.id ? ' playing' : ''}" data-id="${esc(s.id)}"><button class="rdoplay" data-play aria-label="Play ${esc(s.name)}"><span class="art">${s.favicon ? `<img alt="" loading="lazy" src="${esc(rdoIcon(s))}" onerror="this.remove()">` : ''}</span>
|
|
<span class="grow"><b>${esc(s.name)}</b><small>${esc(rdoMeta(s))}</small></span>${icon('play')}</button>${s.featured ? '' : `<button class="ibtn" data-fav aria-label="${saved(s.id) ? 'Remove from my stations' : 'Save to my stations'}" title="${saved(s.id) ? 'Remove from my stations' : 'Save to my stations'}" style="${saved(s.id) ? 'color:var(--danger)' : ''}">${icon('heart')}</button>`}</div>`;
|
|
const j = feat[0];
|
|
v.innerHTML = `<div class="wrap"><h1>Radio</h1>
|
|
<div class="card rdofeat" style="max-width:none"><div class="sub" style="margin:0">Featured</div><h2 style="margin:2px 0 0;font-size:22px;text-transform:none;letter-spacing:0;color:var(--text)">${esc(j.name)}</h2>
|
|
<p class="sub" style="margin:2px 0 10px">${esc(rdoMeta(j))}</p><div id="rdonow" data-id="${esc(j.id)}" class="rdonow"><small>Live</small></div>
|
|
<div class="bar"><button class="btn" id="rdojp">${icon('play')}Listen live</button><button class="btn ghost" id="rdojl">${icon('lyrics')}Lyrics</button><a class="btn ghost" href="${esc(j.homepage)}" target="_blank" rel="noopener">${icon('ext')}Website</a><button class="btn ghost" id="rdojr">Recently played</button></div></div>
|
|
<div id="rdomine"></div><h2>Find stations</h2><input type="search" id="rdoq" placeholder="Search stations by name" autocomplete="off" autocapitalize="off" style="width:100%">
|
|
<div class="seg" id="rdochips">${rdoChips.map(([l, t]) => `<a href="#" data-t="${t}" class="${t === S.tag ? 'on' : ''}">${l}</a>`).join('')}</div><div id="rdores" class="list"></div><div id="rdomore" class="bar" style="justify-content:center"></div></div>`;
|
|
const playStation = s => { const t = rdoTrack(s); playQueue([t], 0); toast('Tuning in to ' + s.name); drawAll(); rdoApi('/now/' + encodeURIComponent(s.id)).then(n => { if (rdoPlaying() && rdoPlaying().key === t.key) { rdoNow[s.id] = n; rdoApplyNow(rdoPlaying(), n); const el = document.getElementById('rdonow'); if (el && el.dataset.id === s.id) rdoDrawNow(el, n); } }).catch(() => {}); setTimeout(rdoPoll, 2500); };
|
|
const bindRows = (root, byId) => { root.querySelectorAll('.rdorow').forEach(r => { const s = byId(r.dataset.id); r.querySelector('[data-play]').onclick = () => playStation(s);
|
|
const f = r.querySelector('[data-fav]'); if (f) f.onclick = async () => { try { if (saved(s.id)) { await rdoApi('/stations/' + encodeURIComponent(s.id), rdoOpt('DELETE')); rdoSaved = rdoSaved.filter(x => x.id !== s.id); } else { await rdoApi('/stations', rdoOpt('POST', { id: s.id })); rdoSaved.push(s); } drawAll(); } catch (e) { toast(e.message); } }; }); };
|
|
const drawMine = () => { $('#rdomine').innerHTML = rdoSaved.length ? `<h2>My stations</h2><div class="list">${rdoSaved.map(s => row(s)).join('')}</div>` : ''; bindRows($('#rdomine'), id => rdoSaved.find(x => x.id === id)); };
|
|
const drawRes = () => { $('#rdores').innerHTML = S.items.length ? S.items.map(s => row(s)).join('') : S.busy ? '<p class="empty">Searching…</p>' : '<p class="empty">No stations found.</p>'; bindRows($('#rdores'), id => S.items.find(x => x.id === id));
|
|
$('#rdomore').innerHTML = S.more && !S.busy ? '<button class="btn ghost" id="rdomb">Show more</button>' : ''; if ($('#rdomb')) $('#rdomb').onclick = () => run(false); };
|
|
const drawAll = () => { drawMine(); drawRes(); };
|
|
const run = async reset => {
|
|
const my = ++S.seq; if (reset) { S.off = 0; S.items = []; } S.busy = true; if (reset) drawRes();
|
|
try { const r = await rdoApi(`/search?q=${encodeURIComponent(S.q)}&tag=${encodeURIComponent(S.q ? '' : S.tag)}&offset=${S.off}`); if (my !== S.seq) return; S.items = S.items.concat(r.items); S.more = r.more; S.off += 30; }
|
|
catch (e) { if (my === S.seq) { $('#rdores').innerHTML = `<p class="err" style="padding:12px">${esc(e.message)}</p>`; S.busy = false; return; } }
|
|
if (my === S.seq) { S.busy = false; drawRes(); }
|
|
};
|
|
$('#rdojp').onclick = () => playStation(j);
|
|
$('#rdojl').onclick = () => { if (!rdoPlaying() || rdoPlaying().key !== 'radio:' + j.id) playStation(j); setTimeout(() => { if (typeof lyrOpen === 'function' && !lyr.open) lyrOpen(); }, 1200); };
|
|
$('#rdojr').onclick = async () => { const el = openSheet('Recently played', j.name, '<p class="empty">Loading…</p>'); try { const n = await rdoApi('/now/journeyfm'); el.querySelector('.empty').outerHTML = n.recent.length ? `<div class="list">${n.recent.map(x => `<div class="row"><span class="grow"><b>${esc(x.title)}</b><small>${esc(x.artist)}</small></span></div>`).join('')}</div>` : '<p class="empty">Nothing to show right now.</p>'; } catch (e) { el.querySelector('.empty').outerHTML = `<p class="err">${esc(e.message)}</p>`; } };
|
|
$('#rdochips').querySelectorAll('a').forEach(a => a.onclick = e => { e.preventDefault(); S.tag = a.dataset.t; S.q = ''; $('#rdoq').value = ''; $('#rdochips').querySelectorAll('a').forEach(x => x.classList.toggle('on', x === a)); run(true); });
|
|
let t; $('#rdoq').oninput = e => { clearTimeout(t); t = setTimeout(() => { S.q = e.target.value.trim(); $('#rdochips').querySelectorAll('a').forEach(x => x.classList.toggle('on', !S.q && x.dataset.t === S.tag)); run(true); }, 450); };
|
|
drawMine(); run(true);
|
|
rdoApi('/now/journeyfm').then(n => { const el = $('#rdonow'); if (el) { rdoNow.journeyfm = n; rdoDrawNow(el, n); } }).catch(() => {});
|
|
}
|