diff --git a/README.md b/README.md index 222bb83..f99134f 100644 --- a/README.md +++ b/README.md @@ -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 `/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: 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 diff --git a/package.json b/package.json index 923c6ed..ec246d8 100644 --- a/package.json +++ b/package.json @@ -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": [], diff --git a/public/index.html b/public/index.html index 0ba1e17..d07ccb1 100644 --- a/public/index.html +++ b/public/index.html @@ -79,6 +79,7 @@ + diff --git a/public/player.js b/public/player.js index b82ec48..0ab19b2 100644 --- a/public/player.js +++ b/public/player.js @@ -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() {
0:000:00
-
`; + `; 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() {
0:000:00
-
${castOk(pl.a) ? '' : ''}
`); +
${castOk(pl.a) ? '' : ''}
`); 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; diff --git a/public/radio-view.js b/public/radio-view.js index 07af18f..f722f9e 100644 --- a/public/radio-view.js +++ b/public/radio-view.js @@ -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 ? `Now playing${esc(n.title)}${n.artist ? ' · ' + esc(n.artist) + '' : ''}` : 'Live'; } @@ -50,7 +51,7 @@ async function viewRadio() { v.innerHTML = `

Radio

Featured

${esc(j.name)}

${esc(rdoMeta(j))}

Live
-
${icon('ext')}Website
+
${icon('ext')}Website

Find stations

${rdoChips.map(([l, t]) => `${l}`).join('')}
`; 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, '

Loading…

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

Nothing to show right now.

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

${esc(e.message)}

`; } }; $('#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); }; diff --git a/public/style.css b/public/style.css index 1010eea..0b3cd4b 100644 --- a/public/style.css +++ b/public/style.css @@ -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; } diff --git a/public/sw.js b/public/sw.js index d5c1ccc..61b0c9d 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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']; diff --git a/public/visualizer.js b/public/visualizer.js new file mode 100644 index 0000000..d53997d --- /dev/null +++ b/public/visualizer.js @@ -0,0 +1,175 @@ +'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 = [['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(); +} +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); vizBackdrop(g, W, H); + ({ 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' : 'rings'; viz.parts = []; 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 = `
+
`; + 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(); }); diff --git a/tools/visualizer-test.js b/tools/visualizer-test.js new file mode 100644 index 0000000..f502367 --- /dev/null +++ b/tools/visualizer-test.js @@ -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 === 5, 'five looks'); +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');