Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
193 lines
20 KiB
JavaScript
193 lines
20 KiB
JavaScript
'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(); });
|