6 Commits
Author SHA1 Message Date
Claude 95c9127e8e Merge visualizer
build-container / build (push) Successful in 4m58s
2026-10-25 19:54:11 +00:00
Claude 36cbdcf200 Visualizer: add the classic iTunes-style swirl and make it the music default
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
2026-10-25 19:44:41 +00:00
Claude 67e8f7ed5d Add music visualizer for music, radio, podcasts and audiobooks
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
2026-10-25 19:43:19 +00:00
Claude 661a15162c Merge radio-art: cover art, LIVE display and a simpler player for radio
build-container / build (push) Successful in 3m25s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
2026-10-25 16:52:39 +00:00
Claude 2d975baea2 Radio: cover art for the song that is on (iTunes), else the station logo or a tile; LIVE instead of a length; no seek bar or queue for a live station
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
2026-10-25 16:51:49 +00:00
Claude 424d797ecf Merge radio-lyrics: artist and lyrics for radio, LRCLIB lyrics
build-container / build (push) Successful in 3m33s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
2026-10-25 16:36:51 +00:00
11 changed files with 285 additions and 18 deletions

No files matched your search

+7 -1
View File
@@ -62,7 +62,7 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke
- **Book store** (Books -> Book store): search free, DRM-free books from **Project Gutenberg** (its own catalogue feed), **Standard Ebooks** (its public website: its OPDS feed is for paying members only, so the hub reads the public search pages and downloads the free EPUB), the **Internet Archive** (public-domain EPUBs only; borrow-only items are refused) and any **OPDS catalogue** an admin adds under *Catalogues* (Calibre-Web, Kavita, Komga and so on; an optional username and password are kept encrypted on the server and never sent to the browser). One search covers every source, results show their cover and source, and *Add to library* downloads the book on the server straight onto your shelf. The server only ever contacts a source's own addresses (redirects to other sites are refused), covers come through the hub, and a catalogue book must live on that catalogue's own address. Settings: `GUTENBERG_URL`, `STANDARD_EBOOKS_URL` and `ARCHIVE_URL` can point at mirrors. `node tools/store-test.js` tests it against fake catalogues; it was also tried against the live Project Gutenberg, Standard Ebooks and Internet Archive (searches, and real EPUBs downloaded); if one of them changes its pages or feed, the line under the search box names the source that failed.
- The reader scripts are in `public/vendor/reader/` (epub.js, JSZip, pdf.js, mammoth, the MOBI parser) and load only when a book is opened or added. `node tools/books-test.js` tests the server; `python3 tools/mkbooks.py <dir>` makes a small EPUB, PDF and MOBI to try. **DRM-protected books are refused** (a Kindle file with copy protection is detected on upload and the reason is shown); only DRM-free books work. The MOBI reader was tried on a generated MOBI 6 file, not on real Kindle files, so tell me about any book that opens oddly. Not included: text-to-speech, offline reading.
**Free audiobooks** (Audiobooks -> Free audiobooks; works without any audiobook sign-in): search **LibriVox**, thousands of public-domain books read aloud by volunteers, by title or author. A book shows its length, language, description and chapters; *Play* (or *Resume*) queues every chapter in the same player as Music so the next one follows by itself, and starting a book puts it on your shelf (*My free audiobooks*, with a progress bar; *Remove from shelf* in the book's sheet). Where you got to is kept on the server per person, so it follows you between devices. The hub searches LibriVox and hands the browser each chapter's address, but only addresses on LibriVox or the Internet Archive, which hosts the recordings; covers come through the hub. `LIBRIVOX_URL` points at a mirror. `node tools/librivox-test.js` tests it against a fake LibriVox; it has not been run against the live site from the build environment.
- **Radio**: internet radio, with **The Journey FM** (Liberty University, Lynchburg, Virginia; MP3 192 kbps, `http://ic.liberty.edu:8000/WRVL`) always on top, showing what is playing now and the last songs played from the station's own feed. Below it, search thousands of stations from the community [Radio Browser](https://www.radio-browser.info/) directory (free, no key) by name or with quick topic chips (Christian, Worship, Gospel, Talk, News, Classical, Jazz...). The heart saves a station to *My stations* (your own list). Stations play in the same player as Music, so they keep playing as you move between tabs. The hub fetches each stream itself and passes it on, because many stations only offer plain http, which a browser refuses to play inside an https page; it also reads the song title carried in the stream (the player shows it) and fetches station logos. The hub never connects to private, local or link-local addresses for a station. Give it to people in Settings -> People (Radio). `JOURNEYFM_STREAM_URL`, `JOURNEYFM_NOW_URL` and `RADIO_BROWSER_URL` can override the addresses. Lyrics come from the Lyrica service if you run one (`LYRICS_API_URL`), and otherwise (or when Lyrica finds nothing) from [LRCLIB](https://lrclib.net), a free lyrics database that needs no key: the artist and song title are sent to it, and set `LYRICS_LRCLIB=0` to never ask it. `node tools/radio-test.js` and `node tools/lrclib-test.js` test it against fakes; the real Journey stream could not be reached from the build environment, so if it will not play, open the station's own *Website* button and tell me what changed. **Song and artist**: the player shows the song as the title with the artist under it (and the station as the album), also on the mini player, the Now Playing sheet, the Radio page and the phone's lock screen; it follows the station every 10 seconds. The Journey FM's own feed gives the artist and when each song began; other stations send `Artist - Title` inside the stream, which the hub splits. **Lyrics** (the Lyrics button on the player or the Radio page) are looked up for the song that is on now and change when the song does. A station that says when a song began (The Journey FM) gets synced lyrics that follow along; because what you hear is a few seconds behind the station's clock, it starts 5 seconds early and the - and + buttons move it a second at a time (remembered for that station). Other stations show the words without following along. Not included: HLS-only stations, recording, alarms.
- **Radio**: internet radio, with **The Journey FM** (Liberty University, Lynchburg, Virginia; MP3 192 kbps, `http://ic.liberty.edu:8000/WRVL`) always on top, showing what is playing now and the last songs played from the station's own feed. Below it, search thousands of stations from the community [Radio Browser](https://www.radio-browser.info/) directory (free, no key) by name or with quick topic chips (Christian, Worship, Gospel, Talk, News, Classical, Jazz...). The heart saves a station to *My stations* (your own list). Stations play in the same player as Music, so they keep playing as you move between tabs. The hub fetches each stream itself and passes it on, because many stations only offer plain http, which a browser refuses to play inside an https page; it also reads the song title carried in the stream (the player shows it) and fetches station logos. The hub never connects to private, local or link-local addresses for a station. Give it to people in Settings -> People (Radio). `JOURNEYFM_STREAM_URL`, `JOURNEYFM_NOW_URL` and `RADIO_BROWSER_URL` can override the addresses. Lyrics come from the Lyrica service if you run one (`LYRICS_API_URL`), and otherwise (or when Lyrica finds nothing) from [LRCLIB](https://lrclib.net), a free lyrics database that needs no key: the artist and song title are sent to it, and set `LYRICS_LRCLIB=0` to never ask it. `node tools/radio-test.js` and `node tools/lrclib-test.js` test it against fakes; the real Journey stream could not be reached from the build environment, so if it will not play, open the station's own *Website* button and tell me what changed. **Song and artist**: the player shows the song as the title with the artist under it (and the station as the album), also on the mini player, the Now Playing sheet, the Radio page and the phone's lock screen; it follows the station every 10 seconds. The Journey FM's own feed gives the artist and when each song began; other stations send `Artist - Title` inside the stream, which the hub splits. **Cover art**: the cover of the song that is on (from Apple's free iTunes search, no key; the song's artist and title are sent to it), else the station's logo, else a coloured tile with the station's initials, so there is never an empty picture. A live station shows *LIVE* in place of a length, and has no seek bar, queue or skip buttons. **Lyrics** (the Lyrics button on the player or the Radio page) are looked up for the song that is on now and change when the song does. A station that says when a song began (The Journey FM) gets synced lyrics that follow along; because what you hear is a few seconds behind the station's clock, it starts 5 seconds early and the - and + buttons move it a second at a time (remembered for that station). Other stations show the words without following along. Not included: HLS-only stations, recording, alarms.
- **Verse By Verse** (Podcast -> *Verse By Verse*; needs only the Podcast tab, not Podgrab): free verse-by-verse Bible teaching from [Verse By Verse Ministry International](https://versebyverseministry.org). Browse the ministry's studies (about 80: Old and New Testament books, topical series, single teachings, women's and youth), search them, and open a study to see its lessons with dates and summaries. *Start* / *Continue* plays the study lesson after lesson in the same player as Music, a lesson resumes where you left off (kept on the server per person, so it follows you between devices), and each lesson's handout (PDF) opens from *Notes*. The hub reads the study list from the ministry's category pages and the lessons from each study's own podcast feed (`/bible-studies/<study>/podcast.xml`), keeps what it read for 30 to 60 minutes so the ministry's site is not asked on every visit, and sends the browser straight to the ministry's media server for the audio. Only the ministry's own addresses are ever used. `VBV_URL` can point at a mirror. `node tools/vbv-test.js` tests it against a fake copy of the site; it was also tried against the live site (80 studies, a study's lessons, audio and handout links). If the ministry redesigns its pages, the study list is the first thing that would break, and the screen would say so.
- **Save to Files**: copy things into your own Seafile from where you find them. **Books** (long-press or right-click a book -> *Save to Files*), **free audiobooks** (a book's sheet -> *Save to Files*, one numbered MP3 per chapter in a folder) and **Verse By Verse** (a study's *Save to Files* button saves every lesson and handout, or just the audio or just the handouts; the download button on a lesson saves that one). You pick the library and folder (it remembers the last one); the hub makes a tidy subfolder (`Books`, `Audiobooks/<title>`, `Verse By Verse/<study>`), names files safely and in order, **skips anything already there** (so running it again fills in what is missing) and counts lessons that have no handout instead of failing. The hub does the downloading and uploading itself with the Seafile sign-in already saved for you, so a whole study (several GB) does not pass through your phone and keeps going if you tap *Hide*; a sheet shows progress, *Stop* cancels, and *Open folder* jumps to the result. It needs the Files tab and the tab the content comes from (Books, Audiobooks or Podcast); each person can have two saves running at once; files up to 500 MB. `node tools/savefiles-test.js` tests it against a fake Seafile.
- **Bible**: the **Berean Standard Bible** (BSB, public domain: https://berean.bible/), all 66 books, with its section headings, poetry layout and footnotes. The whole text is one file (`public/vendor/bible/bsb.json`, about 1.5 MB compressed, made by `python3 tools/make-bsb.py` from the free Hello AO Bible API) that the browser loads once and keeps, so reading is instant and works offline. **Choose** a book and chapter, **search** for words (every word must be in the verse) or type a place (`John 3:16`, `1 cor 13:4-7`, `ps 23`), swipe or use the arrow keys to turn chapters, and tap verses to **highlight** (four colours), add a **note**, or **copy** them with their reference. Tap a small letter for a footnote. *My highlights and notes* lists everything. Your place, highlights and notes are kept on the server per person, so they follow you between devices; text size and font are remembered per device. Give it to people in Settings -> People (Bible). `node tools/bible-test.js` tests the saving and checks all 31,086 verses are in the file.
@@ -100,6 +100,12 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke
- Message text is shown in a sandboxed frame with no scripts, and pictures from the internet stay hidden until you press Show pictures. Not included: a combined inbox across accounts (switch account at the top), message previews in the list for Gmail/iCloud, drafts autosave, contacts, Microsoft 365 work accounts, other IMAP providers (easy to add the same way).
**One-time Outlook.com setup** (step by step, with troubleshooting, in [docs/mail-setup.md](docs/mail-setup.md); needs a free Microsoft account and takes five minutes): go to entra.microsoft.com -> App registrations -> New registration. Name it NE Hub; supported account types **Personal Microsoft accounts only**; Redirect URI: platform **Web**, value `<your hub address>/api/mail/callback` (for example `https://hub.necloud.us/api/mail/callback`; it must match `PUBLIC_URL` exactly). Then API permissions -> Add -> Microsoft Graph -> Delegated: `Mail.ReadWrite`, `Mail.Send`, `User.Read`, `offline_access`. Copy the **Application (client) ID** and paste it, with the secret, into **Settings -> Microsoft app (admin)** (or set `MS_CLIENT_ID` / `MS_CLIENT_SECRET` in the environment, which win). If you registered it as a Web app, also make a client secret (Certificates & secrets) and put its value in `MS_CLIENT_SECRET`; if you used platform "Mobile and desktop applications" instead, no secret is needed. Leave `MS_CLIENT_ID` empty to switch Mail off. (The old bundled Cypht webmail and its `nehub-cypht` volume are gone; you can delete that volume.) Connecting from the installed iPhone/iPad app may open Microsoft's page in Safari; if the app does not notice, reopen it.
## Visualizer
Tap **Visuals** on the Now Playing sheet, the Music card or the Journey FM card for a full-screen picture that moves with the sound: Classic (the swirling, colour-cycling look iTunes had, and the default for music), Rings (cover art in the middle), Bars, Waves, Aurora and Sparks. It works for music, radio, podcasts and audiobooks, picks a calm look for speech, and takes its colours from the cover. Tap the picture to hide the buttons; Esc closes it. The screen stays awake while it is open.
It listens to the sound the browser is playing. Where it cannot (iPhone Safari, or audio that comes from another site such as Verse By Verse and LibriVox), it falls back to an "ambient" animation that still keeps time with play and pause, and the top of the screen says which mode is on.
## Install on iPhone / iPad (PWA)
Open the site in **Safari** (it must be the `https://` address), tap **Share -> Add to Home Screen**. The home-screen app has proper
+1 -1
View File
@@ -5,7 +5,7 @@
"main": "server.js",
"scripts": {
"start": "node server.js",
"test": "for t in smoke tasks push users cal plex player lyrics signal mail mail-imap files podcast abapi abdirect books store librivox radio vbv savefiles bible lrclib; do node tools/$t-test.js || exit 1; done",
"test": "for t in smoke tasks push users cal plex player lyrics signal mail mail-imap files podcast abapi abdirect books store librivox radio vbv savefiles bible lrclib visualizer; do node tools/$t-test.js || exit 1; done",
"audit": "npm audit --omit=dev --audit-level=high"
},
"keywords": [],
+1
View File
@@ -79,6 +79,7 @@
<script src="/save-to-files.js"></script>
<script src="/vbv-view.js"></script>
<script src="/lyrics-view.js"></script>
<script src="/visualizer.js"></script>
<script src="/settings.js"></script>
<script src="/files.js"></script>
<script src="/files-extra.js"></script>
+12 -9
View File
@@ -62,6 +62,7 @@ function loadCur(delay = 220) {
if (delay) pl.a.pause();
setMeta(t); renderMini(); updateNow(); renderNpCard(); syncUI(); markPlaying(); saveState();
if (lyr.open) { lyrShell(); lyrReload(); }
if (typeof vizTrackChanged === 'function') vizTrackChanged();
const go = () => {
if (tok !== loadTok) return;
const hit = tc.m.get(t.src);
@@ -214,20 +215,22 @@ function modeUI() {
document.querySelectorAll('#mr, #npr, #cnr').forEach(b => { b.classList.toggle('on', pl.repeat !== 'off'); b.dataset.r = pl.repeat === 'one' ? '1' : ''; b.setAttribute('aria-label', 'Repeat: ' + pl.repeat); });
}
function syncUI() {
const a = pl.a, d = a.duration || (cur()?.dur || 0) / 1000, pct = d ? (a.currentTime / d) * 100 : 0;
const a = pl.a, liveT = /^radio:/.test(String(cur() && cur().key)), d = liveT ? 0 : a.duration || (cur()?.dur || 0) / 1000, pct = d ? (a.currentTime / d) * 100 : 0;
const state = pl.buf && !a.paused ? 'buf' : String(a.paused), ic = state === 'buf' ? icon('spin') : icon(a.paused ? 'play' : 'pause');
const mt = $('#mt'); if (mt && mt.dataset.s !== state) { mt.innerHTML = ic; mt.dataset.s = state; }
const bf = $('#mbuf'); if (bf) { let e = 0; try { for (let i = 0; i < a.buffered.length; i++) if (a.buffered.start(i) <= a.currentTime + 1) e = Math.max(e, a.buffered.end(i)); } catch {} bf.style.width = d ? Math.min(100, (e / d) * 100) + '%' : '0'; }
const tm = $('#mtm'); if (tm) { const v = fmtDur(a.currentTime * 1000) + ' / ' + fmtDur(d * 1000); if (tm.textContent !== v) tm.textContent = v; }
const tm = $('#mtm'); if (tm) { const v = liveT ? 'LIVE' : fmtDur(a.currentTime * 1000) + ' / ' + fmtDur(d * 1000); if (tm.textContent !== v) tm.textContent = v; }
const mp = $('#mp'); if (mp) mp.style.width = pct + '%';
const t = cur(), sub = t ? (pl.buf ? (navigator.onLine ? 'Buffering…' : 'Waiting for connection…') : t.artist) : '';
const ms = $('#mo small'); if (ms && ms.textContent !== sub) ms.textContent = sub;
const ns = $('#npsub'); if (ns && t) { const v = pl.buf ? sub : t.artist + (t.album ? ' · ' + t.album : ''); if (ns.textContent !== v) ns.textContent = v; }
const nt = $('#npt'); if (nt && nt.dataset.s !== state) { nt.innerHTML = ic; nt.dataset.s = state; }
const sk = $('#npk'); if (sk && !sk.dataset.drag) { sk.max = d || 0; sk.value = a.currentTime; }
const c = $('#npc'); if (c) c.textContent = fmtDur(a.currentTime * 1000);
const dd = $('#npd'); if (dd) dd.textContent = fmtDur(d * 1000);
const sk = $('#npk'); if (sk && !sk.dataset.drag) { sk.max = d || 0; sk.value = liveT ? 0 : a.currentTime; } if (sk) sk.hidden = liveT;
const c = $('#npc'); if (c) c.textContent = liveT ? '\u25CF LIVE' : fmtDur(a.currentTime * 1000);
const dd = $('#npd'); if (dd) dd.textContent = liveT ? '' : fmtDur(d * 1000);
for (const id of ['nps', 'npr', 'npq', 'npp', 'npn', 'mpv', 'mn', 'mseek']) { const e = $('#' + id); if (e) e.hidden = liveT; } // (a live station has no queue, no skipping and no seeking)
const lt = $('#lyrt'); if (lt && lt.dataset.s !== state) { lt.innerHTML = ic; lt.dataset.s = state; }
const vt = $('#vizt'); if (vt && vt.dataset.s !== state) { vt.innerHTML = ic; vt.dataset.s = state; }
// the Now Playing card on the Music tab
const ct = $('#cnt'); if (ct && ct.dataset.s !== state) { ct.innerHTML = ic; ct.dataset.s = state; }
const ck = $('#cnk'); if (ck && !ck.dataset.drag) { ck.max = d || 0; ck.value = a.currentTime; }
@@ -247,11 +250,11 @@ function renderNpCard() {
<div class="npc-seek"><span id="cnc">0:00</span><input type="range" id="cnk" min="0" step="1" value="0" aria-label="Seek"><span id="cnd">0:00</span></div>
<div class="npc-ctl"><button class="ibtn" id="cns" aria-label="Shuffle">${icon('shuffle')}</button><button class="ibtn" id="cnp" aria-label="Previous">${icon('prev')}</button>
<button class="ibtn big" id="cnt" aria-label="Play or pause"></button><button class="ibtn" id="cnn" aria-label="Next">${icon('next')}</button><button class="ibtn" id="cnr" aria-label="Repeat">${icon('repeat')}</button>
<button class="btn ghost npc-l" id="cnl">${lyrLabel()}</button><button class="btn ghost npc-q" id="cnq">Queue · ${Math.max(0, pl.q.length - pl.i - 1)} up next</button><button class="btn ghost npc-q" id="cnz">${esc(sleepLabel())}</button></div>`;
<button class="btn ghost npc-l" id="cnl">${lyrLabel()}</button><button class="btn ghost npc-l" id="cnv">Visuals</button><button class="btn ghost npc-q" id="cnq">Queue · ${Math.max(0, pl.q.length - pl.i - 1)} up next</button><button class="btn ghost npc-q" id="cnz">${esc(sleepLabel())}</button></div>`;
const sk = el.querySelector('#cnk');
sk.oninput = () => { sk.dataset.drag = 1; el.querySelector('#cnc').textContent = fmtDur(sk.value * 1000); };
sk.onchange = () => { pl.a.currentTime = +sk.value; delete sk.dataset.drag; };
el.querySelector('#cnart').onclick = openNow; el.querySelector('#cnq').onclick = openQueue; el.querySelector('#cnz').onclick = sleepSheet; el.querySelector('#cnl').onclick = lyrOpen;
el.querySelector('#cnart').onclick = openNow; el.querySelector('#cnq').onclick = openQueue; el.querySelector('#cnz').onclick = sleepSheet; el.querySelector('#cnl').onclick = lyrOpen; el.querySelector('#cnv').onclick = vizOpen;
el.querySelector('#cnp').onclick = prev; el.querySelector('#cnn').onclick = next; el.querySelector('#cnt').onclick = toggle;
el.querySelector('#cns').onclick = () => setShuffle(!pl.shuffle); el.querySelector('#cnr').onclick = () => setRepeat(pl.repeat === 'off' ? 'all' : pl.repeat === 'all' ? 'one' : 'off');
modeUI(); syncUI();
@@ -271,11 +274,11 @@ function openNow() {
<input type="range" id="npk" min="0" step="1" value="0" aria-label="Seek"><div class="times"><span id="npc">0:00</span><span id="npd">0:00</span></div>
<div class="ctl"><button class="ibtn" id="npp" aria-label="Previous">${icon('prev')}</button><button class="ibtn big" id="npt" aria-label="Play or pause"></button>
<button class="ibtn" id="npn" aria-label="Next">${icon('next')}</button></div>
<div class="bar" style="justify-content:center"><button class="ibtn" id="nps" aria-label="Shuffle">${icon('shuffle')}</button><button class="btn ghost" id="npl">${lyrLabel()}</button>${castOk(pl.a) ? '<button class="btn ghost" id="npcast">Cast</button>' : ''}<button class="btn ghost" id="npq">Queue · ${pl.q.length - pl.i - 1} up next</button><button class="ibtn" id="npr" aria-label="Repeat">${icon('repeat')}</button></div></div>`);
<div class="bar" style="justify-content:center"><button class="ibtn" id="nps" aria-label="Shuffle">${icon('shuffle')}</button><button class="btn ghost" id="npl">${lyrLabel()}</button><button class="btn ghost" id="npv">Visuals</button>${castOk(pl.a) ? '<button class="btn ghost" id="npcast">Cast</button>' : ''}<button class="btn ghost" id="npq">Queue · ${pl.q.length - pl.i - 1} up next</button><button class="ibtn" id="npr" aria-label="Repeat">${icon('repeat')}</button></div></div>`);
const sk = el.querySelector('#npk');
sk.oninput = () => { sk.dataset.drag = 1; el.querySelector('#npc').textContent = fmtDur(sk.value * 1000); };
sk.onchange = () => { pl.a.currentTime = +sk.value; delete sk.dataset.drag; };
el.querySelector('#npq').onclick = openQueue; el.querySelector('#npl').onclick = () => { closeSheet(); lyrOpen(); }; if (el.querySelector('#npcast')) el.querySelector('#npcast').onclick = () => castTo(pl.a);
el.querySelector('#npq').onclick = openQueue; el.querySelector('#npl').onclick = () => { closeSheet(); lyrOpen(); }; el.querySelector('#npv').onclick = () => { closeSheet(); vizOpen(); }; if (el.querySelector('#npcast')) el.querySelector('#npcast').onclick = () => castTo(pl.a);
el.querySelector('#nps').onclick = () => setShuffle(!pl.shuffle);
el.querySelector('#npr').onclick = () => setRepeat(pl.repeat === 'off' ? 'all' : pl.repeat === 'all' ? 'one' : 'off');
el.querySelector('#npp').onclick = prev; el.querySelector('#npn').onclick = next; el.querySelector('#npt').onclick = toggle;
+6 -2
View File
@@ -3,8 +3,9 @@
// 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: rdoIcon(s), src: '/api/radio/play/' + encodeURIComponent(s.id), nocache: true, dur: 0, station: s.name });
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']];
@@ -26,11 +27,13 @@ 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 ? `<small>Now playing</small><b>${esc(n.title)}</b>${n.artist ? '<span> · ' + esc(n.artist) + '</span>' : ''}` : '<small>Live</small>'; }
@@ -48,7 +51,7 @@ async function viewRadio() {
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 class="bar"><button class="btn" id="rdojp">${icon('play')}Listen live</button><button class="btn ghost" id="rdojl">${icon('lyrics')}Lyrics</button><button class="btn ghost" id="rdojv">Visuals</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); };
@@ -66,6 +69,7 @@ async function viewRadio() {
};
$('#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, '<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); };
+11
View File
@@ -939,3 +939,14 @@ body.mselecting .mrow { user-select: none; -webkit-user-select: none; -webkit-to
.bbooks button { min-height: 44px; border-radius: 10px; border: 1px solid var(--line); background: var(--panel2); color: var(--text); padding: 0 10px; text-align: left; } .bbooks button.on { background: var(--accent); color: var(--accent-t); }
.bgrid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; max-height: 58dvh; overflow: auto; }
.bgrid a { display: grid; place-items: center; height: 46px; border-radius: 10px; background: var(--panel2); color: inherit; font-weight: 600; } .bgrid a.on { background: var(--accent); color: var(--accent-t); }
/* Music visualizer: a full-screen picture that moves with the sound */
#viz { position: fixed; inset: 0; z-index: 40; background: #000; color: #fff; display: flex; flex-direction: column; justify-content: space-between; touch-action: manipulation; user-select: none; }
body.vizopen { overflow: hidden; }
#viz canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
#viz > .viztop, #viz > .vizfoot { position: relative; z-index: 1; display: flex; align-items: center; gap: 10px; padding: calc(var(--sat, 0px) + 12px) 16px 12px; transition: opacity .4s; background: linear-gradient(rgba(0,0,0,.55), transparent); }
#viz > .vizfoot { justify-content: center; gap: 22px; padding: 12px 16px calc(var(--sab, 0px) + 20px); background: linear-gradient(transparent, rgba(0,0,0,.55)); }
#viz .viztop .grow { min-width: 0; } #viz .viztop b, #viz .viztop small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-shadow: 0 1px 4px rgba(0,0,0,.6); }
#viz .viztop small { opacity: .75; font-size: 12px; }
#viz .btn, #viz .ibtn { color: #fff; border-color: rgba(255,255,255,.35); background: rgba(0,0,0,.35); }
#viz.quiet > .viztop, #viz.quiet > .vizfoot { opacity: 0; pointer-events: none; }
+1 -1
View File
@@ -1,5 +1,5 @@
// App-shell cache only; Seafile/API traffic always goes to the network.
const CACHE = 'hub-v46';
const CACHE = 'hub-v48';
const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder
const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
+192
View File
@@ -0,0 +1,192 @@
'use strict';
// ---- Visualizer: full-screen moving pictures for whatever is playing (music, radio, podcasts, audiobooks), coloured from the cover art. It listens to the real sound
// where the browser lets it do so without touching playback (HTMLMediaElement.captureStream: Chrome, Edge, Firefox, Android). Elsewhere (Safari on an iPhone or iPad can
// only tap audio in a way that can silence it) and for audio that comes from another website, it draws an "ambient" version that moves like the music or the voice
// but is not driven by the sound. The screen says which one you are looking at.
const VIZ_MODES = [['classic', 'Classic'], ['rings', 'Rings'], ['bars', 'Bars'], ['wave', 'Waves'], ['aurora', 'Aurora'], ['sparks', 'Sparks']];
const VIZ_N = 48;
const viz = { open: false, el: null, cv: null, g: null, raf: 0, last: 0, t: 0, mode: 'rings', bands: new Float32Array(VIZ_N), tap: null, tapFailed: false, live: false, silent: 0, hue: 220, pal: ['#6a8cff', '#a06bff', '#4fd1c5'], art: null, soft: null,
bass: 0, mid: 0, high: 0, avg: 0, beat: 0, rings: [], parts: [], chromeT: 0, wake: null, level: 0.25, ph: Math.random() * 100 };
const vizKind = t => /^(pod|ab|lv|vbv):/.test(String(t && t.key)) ? 'speech' : 'music';
const vizPref = k => { try { return localStorage.getItem('viz_mode:' + k); } catch { return null; } };
const vizSetPref = (k, m) => { try { localStorage.setItem('viz_mode:' + k, m); } catch {} };
const vizClamp = (v, a = 0, b = 1) => Math.max(a, Math.min(b, v));
// ---- colours from the cover
function vizColors(t) {
const hashHue = () => { let h = 0; for (const c of String(t.title || t.key)) h = (h * 31 + c.charCodeAt(0)) % 360; return h; };
const set = h => { viz.hue = h; viz.pal = [`hsl(${h} 78% 62%)`, `hsl(${(h + 55) % 360} 78% 64%)`, `hsl(${(h + 305) % 360} 72% 58%)`]; };
set(hashHue()); viz.art = null; viz.soft = null;
if (!t.thumb) return;
const img = new Image(); img.onload = () => {
if (!viz.open) return;
try {
const c = document.createElement('canvas'); c.width = c.height = 24; const x = c.getContext('2d'); x.drawImage(img, 0, 0, 24, 24); const d = x.getImageData(0, 0, 24, 24).data;
let r = 0, g = 0, b = 0, w = 0; for (let i = 0; i < d.length; i += 4) { const mx = Math.max(d[i], d[i + 1], d[i + 2]), mn = Math.min(d[i], d[i + 1], d[i + 2]), sat = (mx - mn) / (mx || 1), k = sat * mx; if (mx > 50 && sat > 0.22) { r += d[i] * k; g += d[i + 1] * k; b += d[i + 2] * k; w += k; } }
if (w > 0) { r /= w; g /= w; b /= w; const mx = Math.max(r, g, b), mn = Math.min(r, g, b), df = mx - mn; let h = df === 0 ? 0 : mx === r ? ((g - b) / df) % 6 : mx === g ? (b - r) / df + 2 : (r - g) / df + 4; set(Math.round(((h * 60) + 360) % 360)); }
viz.art = img; const s = document.createElement('canvas'); s.width = s.height = 32; s.getContext('2d').drawImage(img, 0, 0, 32, 32); viz.soft = s; // (a tiny copy, stretched later: a cheap blur)
} catch { viz.art = img; }
};
img.src = px.art(t.thumb, 300);
}
// ---- listening to the real sound (without touching playback)
function vizTapStart() {
if (viz.tap || viz.tapFailed) return;
const a = pl.a, cs = a.captureStream || a.mozCaptureStream, AC = window.AudioContext || window.webkitAudioContext;
if (!cs || !AC) { viz.tapFailed = true; return; }
try {
const stream = cs.call(a), ctx = new AC(), an = ctx.createAnalyser(); an.fftSize = 1024; an.smoothingTimeConstant = 0.7;
viz.tap = { stream, ctx, an, data: new Uint8Array(an.frequencyBinCount), src: null, track: null }; ctx.resume().catch(() => {});
} catch { viz.tapFailed = true; viz.tap = null; }
}
function vizTapAttach() { // joins whichever audio track the stream has right now (a new song or episode may bring a new one)
const k = viz.tap; if (!k) return; const tr = k.stream.getAudioTracks().find(x => x.readyState === 'live'); if (!tr || k.track === tr) return;
try { if (k.src) k.src.disconnect(); k.src = k.ctx.createMediaStreamSource(new MediaStream([tr])); k.src.connect(k.an); k.track = tr; } catch {}
}
function vizTapStop() { const k = viz.tap; viz.tap = null; if (!k) return; try { if (k.src) k.src.disconnect(); k.stream.getTracks().forEach(t => t.stop()); k.ctx.close(); } catch {} }
// ---- the 48 bands: from the sound, or made up to move like it
function vizBands(dt, playing, kind) {
const B = viz.bands; let reacted = false;
if (viz.tap) {
vizTapAttach(); const k = viz.tap; if (k.ctx.state === 'suspended') k.ctx.resume().catch(() => {});
k.an.getByteFrequencyData(k.data); let sum = 0; for (let i = 0; i < k.data.length; i++) sum += k.data[i];
if (sum > 0) { viz.silent = 0; reacted = true; } else if (playing) viz.silent += dt;
if (reacted) {
let peak = 0; const out = new Float32Array(VIZ_N);
for (let i = 0; i < VIZ_N; i++) { const a = Math.floor(Math.pow(300, i / VIZ_N)) + 1, b = Math.max(a + 1, Math.floor(Math.pow(300, (i + 1) / VIZ_N)) + 1); let m = 0; for (let j = a; j < b && j < k.data.length; j++) m = Math.max(m, k.data[j]); out[i] = (m / 255) * (0.75 + 0.9 * i / VIZ_N); peak = Math.max(peak, out[i]); }
viz.level = Math.max(peak, viz.level * (1 - dt * 0.25)); const gain = 0.9 / Math.max(0.35, viz.level); // (quiet recordings still fill the picture)
for (let i = 0; i < VIZ_N; i++) { const v = vizClamp(out[i] * gain), s = v > B[i] ? 0.65 : 0.14; B[i] += (v - B[i]) * s; }
}
}
viz.live = reacted || (viz.live && viz.silent < 2.5 && !!viz.tap);
if (!viz.live) { // ambient
const t = viz.t + viz.ph; let beat, energy;
if (kind === 'music') { const p = (t % 0.5) / 0.5, bar = Math.floor(t / 0.5) % 4; beat = Math.exp(-p * 5.5) * (bar === 0 ? 1 : 0.72); energy = 0.55 + 0.25 * Math.sin(t * 0.21) + 0.12 * Math.sin(t * 0.9); }
else { beat = vizClamp(0.5 + 0.28 * Math.sin(t * 7.1) + 0.24 * Math.sin(t * 11.7 + 1) + 0.2 * Math.sin(t * 2.9 + 2), 0, 1) * vizClamp(0.7 + 0.5 * Math.sin(t * 0.45), 0.15, 1); energy = 0.34; } // (a voice: soft, irregular, phrase by phrase)
for (let i = 0; i < VIZ_N; i++) {
const f = i / VIZ_N, base = (kind === 'music' ? 0.18 + 0.5 * (1 - f) : 0.12 + 0.55 * Math.exp(-Math.pow((f - 0.22) / 0.2, 2))) * energy * 1.6;
const wob = 0.62 + 0.38 * Math.sin(t * (0.8 + f * 3.1) + i * 1.7) * Math.sin(t * 0.37 + i), pulse = kind === 'music' ? (0.5 + 0.5 * beat * (1 - f * 0.55)) : (0.35 + 0.65 * beat);
const v = playing ? vizClamp(base * wob * pulse * 1.5 + 0.03 * Math.random()) : 0, s = v > B[i] ? 0.5 : 0.1; B[i] += (v - B[i]) * s;
}
}
let b = 0, m = 0, h = 0, a = 0; for (let i = 0; i < VIZ_N; i++) { a += B[i]; if (i < 6) b += B[i]; else if (i < 24) m += B[i]; else h += B[i]; }
viz.bass = b / 6; viz.mid = m / 18; viz.high = h / 24; const prev = viz.avg; viz.avg = a / VIZ_N;
viz.beat = Math.max(0, viz.beat - dt * 3.2); if (viz.bass > 0.35 && viz.bass - (viz._pb || 0) > 0.05 || (!viz.live && kind === 'music' && viz.t - (viz._lb || 0) > 0.48 && playing)) { viz.beat = 1; viz._lb = viz.t; if (viz.rings.length < 5) viz.rings.push({ r: 0, a: 0.55 }); } viz._pb = viz.bass;
}
// ---- drawing
const vizRound = (g, x, y, w, h, r) => { r = Math.min(r, w / 2, h / 2); g.beginPath(); g.moveTo(x + r, y); g.arcTo(x + w, y, x + w, y + h, r); g.arcTo(x + w, y + h, x, y + h, r); g.arcTo(x, y + h, x, y, r); g.arcTo(x, y, x + w, y, r); g.closePath(); };
function vizBackdrop(g, W, H) {
g.globalCompositeOperation = 'source-over'; g.fillStyle = '#05060b'; g.fillRect(0, 0, W, H);
if (viz.soft) { g.globalAlpha = 0.22 + 0.1 * viz.bass; g.drawImage(viz.soft, -W * 0.1, -H * 0.1, W * 1.2, H * 1.2); g.globalAlpha = 1; }
const grd = g.createRadialGradient(W / 2, H * 0.45, 0, W / 2, H * 0.45, Math.max(W, H) * 0.75); grd.addColorStop(0, `hsla(${viz.hue},70%,45%,${0.16 + viz.bass * 0.25})`); grd.addColorStop(1, 'rgba(5,6,11,.82)'); g.fillStyle = grd; g.fillRect(0, 0, W, H);
}
function vizBars(g, W, H) {
const B = viz.bands, gap = W / VIZ_N, bw = Math.max(3, gap * 0.6), base = H * 0.7, maxh = H * 0.46;
for (let i = 0; i < VIZ_N; i++) {
const h = Math.max(5, B[i] * maxh), x = i * gap + (gap - bw) / 2, grad = g.createLinearGradient(0, base - h, 0, base); grad.addColorStop(0, viz.pal[1]); grad.addColorStop(1, viz.pal[0]);
g.fillStyle = grad; vizRound(g, x, base - h, bw, h, bw / 2); g.fill();
g.globalAlpha = 0.16; g.fillStyle = viz.pal[0]; vizRound(g, x, base + 8, bw, h * 0.42, bw / 2); g.fill(); g.globalAlpha = 1;
}
}
function vizWave(g, W, H) {
g.globalCompositeOperation = 'lighter'; g.lineCap = 'round'; const t = viz.t, B = viz.bands;
for (let L = 0; L < 4; L++) {
const amp = H * (0.05 + 0.1 * (L === 0 ? viz.bass : L === 1 ? viz.mid : L === 2 ? viz.high * 1.4 : viz.avg)), f = 1.2 + L * 0.7, sp = 0.6 + L * 0.35;
g.beginPath(); for (let x = 0; x <= W; x += 6) { const u = x / W, band = B[Math.floor(u * (VIZ_N - 1))]; const y = H * 0.48 + Math.sin(u * Math.PI * 2 * f + t * sp + L) * amp * (0.5 + band) + Math.sin(u * 17 + t * 1.7 + L * 2) * amp * 0.18 * band; x === 0 ? g.moveTo(x, y) : g.lineTo(x, y); }
g.strokeStyle = viz.pal[L % 3]; g.lineWidth = 3 + L; g.globalAlpha = 0.75 - L * 0.1; g.shadowColor = viz.pal[L % 3]; g.shadowBlur = 18; g.stroke();
}
g.shadowBlur = 0; g.globalAlpha = 1; g.globalCompositeOperation = 'source-over';
}
function vizRingsDraw(g, W, H, dt) {
const cx = W / 2, cy = H * 0.46, R = Math.min(W, H) * 0.18 * (1 + viz.bass * 0.14), B = viz.bands, N = 96;
g.globalCompositeOperation = 'lighter';
for (const r of viz.rings) { r.r += dt * R * 1.1; r.a -= dt * 0.35; g.beginPath(); g.arc(cx, cy, R * 1.15 + r.r, 0, Math.PI * 2); g.strokeStyle = viz.pal[1]; g.globalAlpha = Math.max(0, r.a); g.lineWidth = 2; g.stroke(); }
viz.rings = viz.rings.filter(r => r.a > 0); g.globalAlpha = 1;
g.lineCap = 'round'; g.lineWidth = Math.max(2, W * 0.007);
for (let i = 0; i < N; i++) {
const v = B[i < N / 2 ? Math.floor(i / (N / 2) * (VIZ_N - 1)) : Math.floor((N - 1 - i) / (N / 2) * (VIZ_N - 1))], a = (i / N) * Math.PI * 2 - Math.PI / 2, r0 = R * 1.1, r1 = r0 + 4 + v * R * 0.95;
g.strokeStyle = viz.pal[i % 3 === 0 ? 1 : 0]; g.globalAlpha = 0.45 + 0.55 * v; g.beginPath(); g.moveTo(cx + Math.cos(a) * r0, cy + Math.sin(a) * r0); g.lineTo(cx + Math.cos(a) * r1, cy + Math.sin(a) * r1); g.stroke();
}
g.globalAlpha = 1; g.globalCompositeOperation = 'source-over';
g.save(); g.beginPath(); g.arc(cx, cy, R, 0, Math.PI * 2); g.closePath(); g.clip();
if (viz.art && viz.art.naturalWidth) g.drawImage(viz.art, cx - R, cy - R, R * 2, R * 2); else { const gr = g.createLinearGradient(cx - R, cy - R, cx + R, cy + R); gr.addColorStop(0, viz.pal[0]); gr.addColorStop(1, viz.pal[2]); g.fillStyle = gr; g.fillRect(cx - R, cy - R, R * 2, R * 2); }
g.restore(); g.beginPath(); g.arc(cx, cy, R, 0, Math.PI * 2); g.strokeStyle = 'rgba(255,255,255,.35)'; g.lineWidth = 2; g.stroke();
}
function vizAurora(g, W, H) {
g.globalCompositeOperation = 'lighter'; const t = viz.t * 0.35 + viz.ph;
for (let i = 0; i < 6; i++) {
const e = viz.bands[Math.floor((i / 6) * VIZ_N * 0.7)], x = W * (0.5 + 0.38 * Math.sin(t * (0.7 + i * 0.23) + i * 2.1)), y = H * (0.45 + 0.3 * Math.cos(t * (0.55 + i * 0.19) + i)), r = Math.max(W, H) * (0.18 + 0.08 * (i % 3) + e * 0.18);
const gr = g.createRadialGradient(x, y, 0, x, y, r), col = viz.pal[i % 3]; gr.addColorStop(0, col.replace('hsl(', 'hsla(').replace(')', ` / ${0.14 + e * 0.22})`)); gr.addColorStop(1, col.replace('hsl(', 'hsla(').replace(')', ' / 0)')); g.fillStyle = gr; g.fillRect(0, 0, W, H);
}
g.globalCompositeOperation = 'source-over';
}
function vizSparks(g, W, H, dt) {
g.globalCompositeOperation = 'lighter';
const n = Math.round(1 + viz.high * 9 + viz.beat * 14); for (let i = 0; i < n && viz.parts.length < 260; i++) { const a = Math.random() * Math.PI * 2, s = (0.04 + Math.random() * 0.14 + viz.bass * 0.25) * Math.min(W, H); viz.parts.push({ x: W / 2 + Math.cos(a) * W * 0.05, y: H * 0.5 + Math.sin(a) * W * 0.05, vx: Math.cos(a) * s, vy: Math.sin(a) * s - H * 0.02, life: 1, r: 1.5 + Math.random() * 3.5, c: Math.floor(Math.random() * 3) }); }
for (const p of viz.parts) { p.x += p.vx * dt; p.y += p.vy * dt; p.vy += H * 0.01 * dt; p.vx *= 1 - dt * 0.4; p.life -= dt * 0.55; if (p.life <= 0) continue; g.globalAlpha = p.life * 0.9; g.fillStyle = viz.pal[p.c]; g.beginPath(); g.arc(p.x, p.y, p.r * (0.5 + p.life), 0, Math.PI * 2); g.fill(); }
viz.parts = viz.parts.filter(p => p.life > 0); g.globalAlpha = 1; g.globalCompositeOperation = 'source-over';
const R = Math.min(W, H) * 0.09 * (1 + viz.bass * 0.2); g.save(); g.beginPath(); g.arc(W / 2, H * 0.5, R, 0, Math.PI * 2); g.clip(); if (viz.art && viz.art.naturalWidth) g.drawImage(viz.art, W / 2 - R, H * 0.5 - R, R * 2, R * 2); else { g.fillStyle = viz.pal[0]; g.fillRect(W / 2 - R, H * 0.5 - R, R * 2, R * 2); } g.restore();
}
/** The old iTunes look: a picture that keeps zooming and turning into itself while the sound draws mirrored, colour-cycling curves on top. */
function vizClassic(g, W, H, dt) {
const cx = W / 2, cy = H * 0.46, B = viz.bands, R = Math.min(W, H);
if (viz.fresh !== 'classic') { viz.fresh = 'classic'; g.globalCompositeOperation = 'source-over'; g.globalAlpha = 1; g.fillStyle = '#000'; g.fillRect(0, 0, W, H); }
g.globalCompositeOperation = 'source-over'; g.save(); g.translate(cx, cy); g.rotate((0.004 + viz.mid * 0.012) * (Math.sin(viz.t * 0.3) > 0 ? 1 : -1)); const z = 1.012 + viz.bass * 0.03; g.scale(z, z); g.translate(-cx, -cy);
g.globalAlpha = 0.9; g.drawImage(viz.cv, 0, 0, W, H); g.restore(); g.globalAlpha = 0.07; g.fillStyle = '#000'; g.fillRect(0, 0, W, H); g.globalAlpha = 1;
g.globalCompositeOperation = 'lighter'; g.lineCap = 'round'; g.lineJoin = 'round'; const hue = (viz.hue + viz.t * 38) % 360, N = VIZ_N, T = viz.t;
g.translate(cx, cy);
for (let k = 0; k < 6; k++) for (let m = 0; m < 2; m++) {
g.save(); g.rotate(k * Math.PI / 3 + T * 0.05); if (m) g.scale(1, -1);
g.beginPath(); for (let i = 0; i <= N; i++) { const u = i / N, e = B[Math.min(N - 1, i)], a = u * Math.PI / 3 * 1.1, r = R * (0.06 + 0.5 * u * (0.35 + e) + 0.035 * Math.sin(u * 14 + T * 2)); const x = Math.cos(a) * r, y = Math.sin(a) * r; i ? g.lineTo(x, y) : g.moveTo(x, y); }
g.strokeStyle = `hsl(${(hue + k * 24 + m * 60) % 360} 95% ${52 + viz.avg * 20}%)`; g.lineWidth = 1.4 + viz.bass * 3.5; g.globalAlpha = 0.55 + viz.avg * 0.4; g.stroke(); g.restore();
}
if (viz.beat > 0.2) { const gr = g.createRadialGradient(0, 0, 0, 0, 0, R * 0.35); gr.addColorStop(0, `hsla(${hue},100%,70%,${viz.beat * 0.6})`); gr.addColorStop(1, 'hsla(0,0%,0%,0)'); g.globalAlpha = 1; g.fillStyle = gr; g.fillRect(-cx, -cy, W, H); }
g.setTransform(1, 0, 0, 1, 0, 0); const dpr = viz.cv.width / W; g.setTransform(dpr, 0, 0, dpr, 0, 0); g.globalAlpha = 1; g.globalCompositeOperation = 'source-over';
}
function vizFrame(now) {
viz.raf = requestAnimationFrame(vizFrame); if (!viz.g) return;
const dt = Math.min(0.05, (now - (viz.last || now)) / 1000); viz.last = now; viz.t += dt;
const t = cur(), playing = !pl.a.paused && !pl.buf, kind = vizKind(t), g = viz.g, W = viz.W, H = viz.H;
vizBands(dt, playing, kind);
if (viz.mode !== 'classic') { viz.fresh = ''; vizBackdrop(g, W, H); }
({ classic: vizClassic, bars: vizBars, wave: vizWave, rings: vizRingsDraw, aurora: vizAurora, sparks: vizSparks }[viz.mode] || vizRingsDraw)(g, W, H, dt);
const st = viz.el.querySelector('#vizst'), msg = viz.live ? 'Reacting to the sound' : viz.tapFailed ? 'Ambient (this browser cannot listen to the audio)' : viz.tap ? 'Ambient (this audio cannot be analysed here)' : 'Ambient';
if (st.textContent !== msg) st.textContent = msg;
if (viz.chromeT && now - viz.chromeT > 3500) { viz.el.classList.add('quiet'); viz.chromeT = 0; }
}
function vizSize() {
if (!viz.cv) return; const dpr = Math.min(2, window.devicePixelRatio || 1), w = viz.cv.clientWidth, h = viz.cv.clientHeight;
viz.cv.width = Math.round(w * dpr); viz.cv.height = Math.round(h * dpr); viz.g = viz.cv.getContext('2d'); viz.g.setTransform(dpr, 0, 0, dpr, 0, 0); viz.W = w; viz.H = h;
}
// ---- the screen
function vizOpen() {
const t = cur(); if (!t || viz.open) return; viz.open = true; document.body.classList.add('vizopen');
const kind = vizKind(t), saved = vizPref(kind); viz.mode = VIZ_MODES.some(m => m[0] === saved) ? saved : kind === 'speech' ? 'aurora' : 'classic'; viz.parts = []; viz.fresh = ''; viz.rings = []; viz.silent = 0; viz.live = false; viz.bands.fill(0);
const el = viz.el = document.createElement('div'); el.id = 'viz'; el.setAttribute('role', 'dialog'); el.setAttribute('aria-label', 'Visualizer');
el.innerHTML = `<canvas id="vizc"></canvas><div class="viztop"><div class="grow"><b id="vizti"></b><small id="vizar"></small><small id="vizst"></small></div><button class="btn ghost" id="vizm"></button><button class="ibtn" id="vizx" aria-label="Close" title="Close (Esc)">${icon('x')}</button></div>
<div class="vizfoot"><button class="ibtn" id="vizpv" aria-label="Previous">${icon('prev')}</button><button class="ibtn big" id="vizt" aria-label="Play or pause"></button><button class="ibtn" id="viznx" aria-label="Next">${icon('next')}</button></div>`;
document.body.appendChild(el); viz.cv = el.querySelector('#vizc'); vizSize(); vizText(); vizColors(t);
const m = el.querySelector('#vizm'); m.textContent = VIZ_MODES.find(x => x[0] === viz.mode)[1];
m.onclick = e => { e.stopPropagation(); const i = VIZ_MODES.findIndex(x => x[0] === viz.mode); viz.mode = VIZ_MODES[(i + 1) % VIZ_MODES.length][0]; vizSetPref(vizKind(cur()), viz.mode); m.textContent = VIZ_MODES.find(x => x[0] === viz.mode)[1]; viz.parts = []; viz.rings = []; };
el.querySelector('#vizx').onclick = e => { e.stopPropagation(); vizClose(); }; el.querySelector('#vizt').onclick = e => { e.stopPropagation(); toggle(); }; el.querySelector('#vizpv').onclick = e => { e.stopPropagation(); prev(); }; el.querySelector('#viznx').onclick = e => { e.stopPropagation(); next(); };
el.addEventListener('pointerdown', e => { if (e.target.closest('button')) return; el.classList.toggle('quiet'); viz.chromeT = el.classList.contains('quiet') ? 0 : performance.now(); }); // (tap the picture to hide or show the buttons)
viz.chromeT = performance.now(); addEventListener('keydown', vizKey); addEventListener('resize', vizSize); addEventListener('hashchange', vizClose);
vizTapStart(); viz.last = 0; viz.raf = requestAnimationFrame(vizFrame); vizWake(); syncUI();
}
function vizText() {
const t = cur(); if (!t || !viz.el) return; viz.el.querySelector('#vizti').textContent = t.title; viz.el.querySelector('#vizar').textContent = t.artist + (t.album && vizKind(t) === 'music' ? ' · ' + t.album : '');
const live = /^radio:/.test(String(t.key)); for (const id of ['vizpv', 'viznx']) viz.el.querySelector('#' + id).hidden = live;
}
/** A new song or episode (or a radio station's next song): the words, the colours and the cover change with it. */
function vizTrackChanged() { if (!viz.open) return; const t = cur(); if (!t) return; vizText(); vizColors(t); const k = vizKind(t), s = vizPref(k); if (s && VIZ_MODES.some(m => m[0] === s)) viz.mode = s; else viz.mode = k === 'speech' ? 'aurora' : viz.mode; viz.el.querySelector('#vizm').textContent = VIZ_MODES.find(x => x[0] === viz.mode)[1]; }
function vizClose() {
if (!viz.open) return; viz.open = false; cancelAnimationFrame(viz.raf); removeEventListener('keydown', vizKey); removeEventListener('resize', vizSize); removeEventListener('hashchange', vizClose);
vizTapStop(); if (viz.wake) { try { viz.wake.release(); } catch {} viz.wake = null; } if (viz.el) viz.el.remove(); viz.el = null; viz.cv = null; viz.g = null; document.body.classList.remove('vizopen');
}
const vizKey = e => { if (e.key === 'Escape' && !document.getElementById('sheet')) vizClose(); else if (e.key === ' ' && !/^(INPUT|TEXTAREA|BUTTON|SELECT)$/.test(e.target.tagName || '')) { e.preventDefault(); toggle(); } };
async function vizWake() { try { if (navigator.wakeLock) { viz.wake = await navigator.wakeLock.request('screen'); } } catch {} } // (keeps the screen on while you watch)
document.addEventListener('visibilitychange', () => { if (viz.open && document.visibilityState === 'visible' && !viz.wake) vizWake(); });
+30 -1
View File
@@ -5,7 +5,7 @@ const fs = require('fs'), path = require('path'), http = require('http'), https
const users = require('./users');
const FEATURED = [{
id: 'journeyfm', name: 'The Journey FM', url: process.env.JOURNEYFM_STREAM_URL || 'http://ic.liberty.edu:8000/WRVL', homepage: 'https://www.myjourneyfm.com/', favicon: '', tags: 'christian,contemporary christian,worship',
id: 'journeyfm', name: 'The Journey FM', url: process.env.JOURNEYFM_STREAM_URL || 'http://ic.liberty.edu:8000/WRVL', homepage: 'https://www.myjourneyfm.com/', favicon: 'https://www.myjourneyfm.com/wp-content/uploads/sites/2/2025/12/cropped-Version-4-192x192.png', tags: 'christian,contemporary christian,worship',
country: 'United States', state: 'Virginia', codec: 'MP3', bitrate: 192, featured: true,
}];
const JOURNEY_NOW = () => process.env.JOURNEYFM_NOW_URL || 'https://www.myjourneyfm.com/wp-json/custom/v2/recentlyPlayed';
@@ -91,6 +91,27 @@ function icyFilter(metaint, onTitle) {
};
}
// ---- cover art for the song that is on: iTunes' free search (no key), else the station's logo, else a plain coloured tile with the station's initials
const ITUNES = () => (process.env.ITUNES_URL || 'https://itunes.apple.com').replace(/\/+$/, '');
const artCache = new Map(); // "artist|title" -> { t, url }
const nrm = s => String(s || '').toLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g, '').replace(/\(.*?\)|\[.*?\]/g, ' ').replace(/[^a-z0-9]+/g, ' ').trim();
async function songArtUrl(artist, title) {
const key = nrm(artist) + '|' + nrm(title), c = artCache.get(key); if (c && Date.now() - c.t < 6 * 3600e3) return c.url;
let url = '';
try {
const d = JSON.parse(await readText(ITUNES() + '/search?' + new URLSearchParams({ term: artist + ' ' + title, entity: 'song', media: 'music', limit: '8' }), 1e6, { timeout: 8000 })), na = nrm(artist).split(' ')[0], nt = nrm(title);
const hit = (d.results || []).find(x => { const t = nrm(x.trackName), a = nrm(x.artistName); return (t === nt || t.includes(nt) || nt.includes(t)) && (a.includes(na) || nrm(artist).includes(a.split(' ')[0])); });
if (hit && hit.artworkUrl100) url = hit.artworkUrl100.replace(/\/\d+x\d+bb\./, '/600x600bb.');
} catch {}
if (artCache.size > 500) artCache.clear(); artCache.set(key, { t: Date.now(), url }); return url;
}
const initials = n => String(n || 'Radio').split(/\s+/).filter(Boolean).slice(0, 2).map(w => w[0]).join('').toUpperCase().replace(/[<>&"']/g, '');
const tile = n => { const hue = [...String(n)].reduce((h, c) => (h * 31 + c.charCodeAt(0)) % 360, 0); return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="hsl(${hue} 60% 45%)"/><stop offset="1" stop-color="hsl(${(hue + 40) % 360} 60% 28%)"/></linearGradient></defs><rect width="400" height="400" fill="url(#g)"/><text x="200" y="240" font-family="Georgia,serif" font-size="150" text-anchor="middle" fill="#fff" fill-opacity=".92">${initials(n)}</text></svg>`; };
async function imageFrom(url, max = 1.5e6) { // a picture, or null
try { const r = await open(url, { timeout: 8000 }), type = String(r.headers['content-type'] || '').split(';')[0].toLowerCase(); if (r.statusCode !== 200 || !/^image\/(jpeg|png|gif|webp)$/.test(type)) { r.resume(); return null; }
const c = []; let n = 0; for await (const d of r) { n += d.length; if (n > max) { r.destroy(); return null; } c.push(d); } return { type, data: Buffer.concat(c) }; } catch { return null; }
}
async function handle(req, res) {
const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/radio'.length) || '/', me = users.current();
try {
@@ -114,6 +135,14 @@ async function handle(req, res) {
const n = nowMap.get(me.id + ':' + m[1]); const fresh = n && Date.now() - n.t < 600e3, sp = fresh ? /^(.+?)\s+-\s+(.+)$/.exec(n.title) : null; // many stations send "Artist - Title"
return json(res, 200, { title: fresh ? (sp ? sp[2] : n.title) : '', artist: sp ? sp[1] : '', startedAt: 0, recent: [] });
}
if (p === '/art' && req.method === 'GET') { // never a broken picture: the song's cover, else the station's logo, else a tile
const artist = clean(u.searchParams.get('artist')), title = clean(u.searchParams.get('title')); let img = null, name = 'Radio';
const sid = u.searchParams.get('s') || ''; let st = null; try { st = sid ? await station(sid) : null; } catch {} if (st) name = st.name;
if (artist && title) { const url = await songArtUrl(artist, title); if (url && (/(^|\.)mzstatic\.com$/.test(new URL(url).hostname) || process.env.ITUNES_URL)) img = await imageFrom(url); }
if (!img && st && st.favicon) img = await imageFrom(st.favicon);
if (img) { res.writeHead(200, { 'content-type': img.type, 'cache-control': 'private, max-age=86400', 'x-content-type-options': 'nosniff' }); return res.end(img.data); }
res.writeHead(200, { 'content-type': 'image/svg+xml', 'cache-control': 'private, max-age=3600', 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'", 'x-content-type-options': 'nosniff' }); return res.end(tile(name));
}
if (p === '/icon' && req.method === 'GET') { // station logos come through the hub too (they are often plain http, and fetching them directly would tell their servers who is listening)
const res2 = await open(u.searchParams.get('u') || '', { timeout: 8000 }), type = String(res2.headers['content-type'] || '').split(';')[0].toLowerCase();
if (res2.statusCode !== 200 || !/^image\/(png|jpeg|gif|webp|x-icon|vnd\.microsoft\.icon|svg\+xml)$/.test(type)) { res2.resume(); throw fail(404, 'No icon'); }
+9 -3
View File
@@ -2,9 +2,9 @@
const http = require('http'), assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path');
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-radio-'));
process.env.RADIO_ALLOW_PRIVATE = '1'; // the fake servers below are on this machine
const AUDIO = Buffer.alloc(4096, 0x55), META = Buffer.from("StreamTitle='Some Band - Great Song';"); let port;
const seen = []; const AUDIO = Buffer.alloc(4096, 0x55), META = Buffer.from("StreamTitle='Some Band - Great Song';"); let port;
const fake = http.createServer((req, res) => {
const u = new URL(req.url, 'http://x'), p = u.pathname, J = o => { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); };
const u = new URL(req.url, 'http://x'), p = u.pathname; seen.push(p); const J = o => { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); };
if (p === '/json/stations/search') return J([
{ stationuuid: 'aaaaaaaa-1111', name: 'Good FM', url_resolved: `http://127.0.0.1:${port}/stream`, codec: 'MP3', bitrate: 128, tags: 'christian,worship,pop,extra,more', country: 'US', state: 'Ohio', hls: 0, homepage: 'https://good.fm', favicon: `http://127.0.0.1:${port}/logo.png` },
{ stationuuid: 'bbbbbbbb-2222', name: 'HLS only', url_resolved: `http://127.0.0.1:${port}/x.m3u8`, codec: 'AAC', hls: 1 }, { stationuuid: 'cccccccc-3333', name: 'Odd codec', url_resolved: 'http://a/b', codec: 'FLAC', hls: 0 }, { stationuuid: 'dddddddd-4444', name: ' ', url_resolved: 'http://a/b', codec: 'MP3', hls: 0 }]);
@@ -15,11 +15,13 @@ const fake = http.createServer((req, res) => {
return res.end(Buffer.concat([AUDIO.subarray(0, 1024), Buffer.from([blk.length / 16]), blk, AUDIO.subarray(1024, 2048), Buffer.from([0]), AUDIO.subarray(2048, 3072)]));
}
if (p === '/pls') { res.writeHead(200, { 'content-type': 'audio/x-scpls' }); return res.end('[playlist]'); }
if (p === '/search') { const term = u.searchParams.get('term') || ''; return J({ results: /Great Song/.test(term) ? [{ trackName: 'Other Thing', artistName: 'Else', artworkUrl100: `http://127.0.0.1:${port}/art/wrong-100x100bb.gif` }, { trackName: 'Great Song (Radio Edit)', artistName: 'Some Band', artworkUrl100: `http://127.0.0.1:${port}/art/100x100bb.gif` }] : [] }); }
if (p === '/art/600x600bb.gif') { res.writeHead(200, { 'content-type': 'image/gif' }); return res.end(Buffer.from('GIF89a-cover')); }
if (p === '/logo.png') { res.writeHead(200, { 'content-type': 'image/png' }); return res.end(Buffer.from('89504e470d0a1a0a', 'hex')); }
if (p === '/journey/getRecentlyPlayed' || p === '/journey/getNowPlaying') return J([{ TITLE: 'THE JOURNEY', ARTIST: null, ACTIVITY_DATE: '10/24/2026 22:01:21' }, { TITLE: "A LIL' CHURCH (NOBODY'S TOO LOST)", ARTIST: 'TOBYMAC', ACTIVITY_DATE: '10/24/2026 22:01:37' }, { TITLE: 'RESCUE', ARTIST: 'JORDAN ST. CYR', ACTIVITY_DATE: '10/24/2026 21:57:43' }]);
res.writeHead(404); res.end();
}).listen(0, () => {
port = fake.address().port; Object.assign(process.env, { RADIO_BROWSER_URL: `http://127.0.0.1:${port}`, JOURNEYFM_NOW_URL: `http://127.0.0.1:${port}/journey`, JOURNEYFM_STREAM_URL: `http://127.0.0.1:${port}/stream` });
port = fake.address().port; Object.assign(process.env, { RADIO_BROWSER_URL: `http://127.0.0.1:${port}`, JOURNEYFM_NOW_URL: `http://127.0.0.1:${port}/journey`, JOURNEYFM_STREAM_URL: `http://127.0.0.1:${port}/stream`, ITUNES_URL: `http://127.0.0.1:${port}` });
const radio = require('../radio'), users = require('../users');
const people = { a: users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), b: users.create({ name: 'Bob', role: 'user', tabs: ['radio'], handle: 'Yg' }) };
const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] === 'b' ? people.b : people.a, () => radio.handle(req, res))).listen(0, async () => {
@@ -38,6 +40,10 @@ const fake = http.createServer((req, res) => {
// The Journey's own feed
r = await j(await call('/now/journeyfm')); assert.equal(r.title, "A Lil' Church (Nobody's Too Lost)"); assert.equal(r.artist, 'Tobymac'); assert.equal(new Date(r.startedAt).toISOString(), '2026-10-25T02:01:37.000Z', "the station's clock (US Eastern) becomes a real moment in time"); assert.equal(r.recent.length, 2, "the station's own sound bites (no artist) are not listed songs");
const E = radio.easternEpoch; assert.equal(new Date(E('01/15/2026 08:00:00')).toISOString(), '2026-01-15T13:00:00.000Z', 'winter: Eastern time is 5 hours behind'); assert.equal(new Date(E('07/04/2026 12:00:00')).toISOString(), '2026-07-04T16:00:00.000Z', 'summer: 4 hours behind'); assert.equal(new Date(E('03/08/2026 03:30:00')).toISOString(), '2026-03-08T07:30:00.000Z', 'just after the clocks go forward'); assert.equal(E('nonsense'), 0);
r = await call('/art?s=aaaaaaaa-1111&artist=Some%20Band&title=Great%20Song'); assert.equal(r.headers.get('content-type'), 'image/gif'); assert.equal((await r.text()), 'GIF89a-cover', "the cover of the song that is on (the right match, at the larger size)"); assert.ok(seen.includes('/art/600x600bb.gif'));
r = await call('/art?s=aaaaaaaa-1111&artist=Nobody&title=Nothing'); assert.equal(r.headers.get('content-type'), 'image/png', 'no cover found: the station logo');
r = await call('/art?s=eeeeeeee-5555'); assert.equal(r.headers.get('content-type'), 'image/svg+xml', 'no logo either: a coloured tile'); assert.match(await r.text(), />PF</, 'with the station initials');
r = await call('/art'); assert.equal(r.status, 200); assert.equal(r.headers.get('content-type'), 'image/svg+xml', 'never a broken picture');
// saved stations
assert.equal((await call('/stations', { method: 'POST', body: JSON.stringify({ id: 'aaaaaaaa-1111' }) })).status, 200); r = await j(await call('/stations')); assert.deepEqual(r.mine.map(s => s.name), ['Good FM']);
await call('/stations', { method: 'POST', body: JSON.stringify({ id: 'aaaaaaaa-1111' }) }); assert.equal((await j(await call('/stations'))).mine.length, 1, 'saving twice keeps one');
+15
View File
@@ -0,0 +1,15 @@
// Checks the visualizer's pure logic and that it is wired into the pages (the pictures themselves are checked in a browser).
const fs = require('fs'), vm = require('vm'), assert = require('assert');
let n = 0; const ok = (c, m) => { assert(c, m); n++; };
const src = fs.readFileSync(__dirname + '/../public/visualizer.js', 'utf8');
new vm.Script(src); ok(true, 'parses');
const ctx = { window: {}, document: { createElement: () => ({ getContext: () => null }), addEventListener() {}, body: {} }, localStorage: { getItem: () => null, setItem() {} }, performance: { now: () => 0 }, requestAnimationFrame() {}, addEventListener() {}, navigator: {}, console };
vm.createContext(ctx); vm.runInContext(src + '\nthis.vizKind = vizKind; this.VIZ_MODES = VIZ_MODES;', ctx);
for (const k of ['pod:1', 'ab:2', 'lv:3', 'vbv:4']) ok(ctx.vizKind({ key: k }) === 'speech', k + ' is speech');
for (const k of ['12345', 'radio:journeyfm']) ok(ctx.vizKind({ key: k }) === 'music', k + ' is music');
ok(ctx.VIZ_MODES.length === 6 && ctx.VIZ_MODES[0][0] === 'classic', 'six looks, Classic first');
const root = __dirname + '/../public/', rd = f => fs.readFileSync(root + f, 'utf8');
ok(rd('index.html').includes('/visualizer.js'), 'script is loaded');
ok(rd('player.js').includes("id=\"npv\"") && rd('player.js').includes("id=\"cnv\""), 'buttons in the player and the Music card');
ok(rd('player.js').includes('vizTrackChanged') && rd('radio-view.js').includes('vizTrackChanged'), 'follows track changes');
console.log('visualizer: ' + n + ' checks passed');