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
This commit is contained in:
3 files changed
+23
-6
No files matched your search
@@ -102,7 +102,7 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke
|
||||
|
||||
## 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.
|
||||
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.
|
||||
|
||||
|
||||
+21
-4
@@ -3,7 +3,7 @@
|
||||
// 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_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 };
|
||||
@@ -130,12 +130,29 @@ function vizSparks(g, W, H, dt) {
|
||||
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); vizBackdrop(g, W, H);
|
||||
({ bars: vizBars, wave: vizWave, rings: vizRingsDraw, aurora: vizAurora, sparks: vizSparks }[viz.mode] || vizRingsDraw)(g, W, H, dt);
|
||||
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; }
|
||||
@@ -148,7 +165,7 @@ function vizSize() {
|
||||
// ---- 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 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>`;
|
||||
|
||||
@@ -7,7 +7,7 @@ const ctx = { window: {}, document: { createElement: () => ({ getContext: () =>
|
||||
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');
|
||||
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');
|
||||
|
||||
Reference in new issue
Block a user