Merge visualizer
build-container / build (push) Successful in 4m58s

This commit is contained in:
Claude committed 2026-10-25 19:54:11 +00:00
commit 95c9127e8e
9 files changed
+236 -7

No files matched your search

+6
View 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>
+6 -4
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);
@@ -229,6 +230,7 @@ function syncUI() {
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; }
@@ -248,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();
@@ -272,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;
+3 -1
View File
@@ -33,6 +33,7 @@ function rdoApplyNow(t, n) {
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>'; }
@@ -50,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); };
@@ -68,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-v47';
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(); });
+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');