'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 && store.get('lookups', true) !== false ? '&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(); } if (typeof vizTrackChanged === 'function' && was !== now) vizTrackChanged(); } setInterval(rdoPoll, 10000); pl.a.addEventListener('playing', () => { if (rdoPlaying()) setTimeout(rdoPoll, 1500); }); function rdoDrawNow(el, n) { el.innerHTML = n && n.title ? `Now playing${esc(n.title)}${n.artist ? ' · ' + esc(n.artist) + '' : ''}` : 'Live'; } async function viewRadio(host) { // host: { el, hash } when shown inside the Store (the station finder); on the Radio tab itself only what was added from the Store is listed const v = host ? host.el : $('#view'), here = () => location.hash.startsWith(host ? host.hash : '#/radio'); v.innerHTML = '

Radio

Loading…

'; let feat, mine, added; try { ({ featured: feat, mine, added } = await rdoApi('/stations')); } catch (e) { v.innerHTML = `

Radio

${esc(e.message)}

`; return; } if (!here()) return; rdoSaved = mine; const inStore = !!host, finder = inStore || !allowed('store'), S = { q: '', tag: 'christian', items: [], more: false, busy: false, seq: 0, off: 0 }; // (someone without the Store tab can still find stations here) const isFeat = id => feat.some(f => f.id === id), isAdded = id => isFeat(id) ? added.includes(id) : rdoSaved.some(x => x.id === id); const j = feat[0], showJ = !inStore && added.includes(j.id); const row = s => { const on = isAdded(s.id), label = on ? 'Remove from my stations' : 'Add to Radio'; const act = inStore ? `` : isFeat(s.id) ? '' : ``; return `
${act}
`; }; const empty = !inStore && !showJ && !rdoSaved.length; const findHtml = finder ? `

Find stations

${rdoChips.map(([l, t]) => `${l}`).join('')}
` : empty ? '' : `
${icon('search')}Find more stations in the Store
`; v.innerHTML = `
${host ? '' : '

Radio

'} ${inStore ? `

Add a station and it shows up in Radio under Listen.

Featured

` : showJ ? `
Featured

${esc(j.name)}

${esc(rdoMeta(j))}

Live
${icon('ext')}Website
` : ''} ${inStore ? '' : '
'}${empty ? `

Nothing here yet. ${allowed('store') ? 'Add stations from the Store and they will show up here.' : 'Find a station below and tap the heart.'}

${allowed('store') ? `
${icon('search')}Open the Store
` : ''}` : ''} ${findHtml}
`; 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 toggle = async s => { if (isAdded(s.id)) { await rdoApi('/stations/' + encodeURIComponent(s.id), rdoOpt('DELETE')); if (isFeat(s.id)) added = added.filter(x => x !== s.id); else rdoSaved = rdoSaved.filter(x => x.id !== s.id); } else { await rdoApi('/stations', rdoOpt('POST', { id: s.id })); if (isFeat(s.id)) added.push(s.id); else rdoSaved.push(s); } }; const bindRows = (root, byId) => { if (!root) return; 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 { await toggle(s); drawAll(); } catch (e) { toast(e.message); } }; }); }; const drawFeat = () => { const el = $('#rdofeat'); if (!el) return; el.innerHTML = feat.map(row).join(''); bindRows(el, id => feat.find(x => x.id === id)); }; const drawMine = () => { const el = $('#rdomine'); if (!el) return; el.innerHTML = rdoSaved.length ? `

My stations

${rdoSaved.map(row).join('')}
` : ''; bindRows(el, id => rdoSaved.find(x => x.id === id)); }; const drawRes = () => { const res = $('#rdores'); if (!res) return; res.innerHTML = S.items.length ? S.items.map(row).join('') : S.busy ? '

Searching…

' : '

No stations found.

'; bindRows(res, id => S.items.find(x => x.id === id)); $('#rdomore').innerHTML = S.more && !S.busy ? '' : ''; if ($('#rdomb')) $('#rdomb').onclick = () => run(false); }; const drawAll = () => { drawFeat(); 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 = `

${esc(e.message)}

`; S.busy = false; return; } } if (my === S.seq) { S.busy = false; drawRes(); } }; if (showJ) { $('#rdojp').onclick = () => playStation(j); $('#rdojl').onclick = () => { if (!rdoPlaying() || rdoPlaying().key !== 'radio:' + j.id) playStation(j); setTimeout(() => { if (typeof lyrOpen === 'function' && !lyr.open) lyrOpen(); }, 1200); }; $('#rdojv').onclick = () => { if (!rdoPlaying() || rdoPlaying().key !== 'radio:' + j.id) playStation(j); setTimeout(() => { if (typeof vizOpen === 'function') vizOpen(); }, 400); }; $('#rdojr').onclick = async () => { const el = openSheet('Recently played', j.name, '

Loading…

'); try { const n = await rdoApi('/now/journeyfm'); el.querySelector('.empty').outerHTML = n.recent.length ? `
${n.recent.map(x => `
${esc(x.title)}${esc(x.artist)}
`).join('')}
` : '

Nothing to show right now.

'; } catch (e) { el.querySelector('.empty').outerHTML = `

${esc(e.message)}

`; } }; $('#rdojx').onclick = async () => { try { await rdoApi('/stations/' + encodeURIComponent(j.id), rdoOpt('DELETE')); toast('Removed from Radio'); viewRadio(host); } catch (e) { toast(e.message); } }; rdoApi('/now/journeyfm').then(n => { const el = $('#rdonow'); if (el) { rdoNow.journeyfm = n; rdoDrawNow(el, n); } }).catch(() => {}); } if (finder) { $('#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); }; run(true); } drawFeat(); drawMine(); }