Fix the flat drop on Linux and morph its play/pause in one shape

The kick wave in the vertex shader squared a negative number with pow(), which
GLSL leaves undefined. Mesa returns NaN there, so on Linux the drop's position
pinned to the clamp: it stayed a plain round sphere without wobble, and its
normals split it into a dark yin-yang. The squares are written out now, and the
normal comes from the slope of the displacement instead of two whole positions.

The play/pause icon is one shape whose corners move: each pause bar turns into
half of the play triangle, with a small swing in between, instead of two icons
faded over each other. It sits on a disc of frosted artwork with a lit rim, and a
ring round it shows the track's progress, or runs while the track loads, which
replaces the flat spinner. The surface calms down under the icon gradually
instead of jumping flat, a double-tap no longer flickers the icon, and a mouse
resting on the playing drop shows the pause bars faintly.

Also:
- Low-density desktop screens render the drop at twice the resolution.
- Quality steps follow the display's own frame rate and step back up again.
- A lost WebGL context brings the drop back once it is restored.
- A paused drop keeps breathing at a low frame rate on desktop.
- Without Web Audio (iOS) jumps in the waveform count as kicks, so the drop lands on drops there too.
- Artists without an image get their own colour from their name, so their drop no longer sinks into the page.
- z toggles zen mode: just the drop, bigger, and the music.
This commit is contained in:
nvrl committed 2026-11-01 19:04:18 +01:00
1 parent bcdca34244
commit 2dcca0bc94
9 files changed
+346 -110

No files matched your search

+1
View File
@@ -190,6 +190,7 @@
<dt>x</dt><dd>not for me</dd> <dt>x</dt><dd>not for me</dd>
<dt>s</dt><dd>share</dd> <dt>s</dt><dd>share</dd>
<dt>v</dt><dd>liquid / simple visuals</dd> <dt>v</dt><dd>liquid / simple visuals</dd>
<dt>z</dt><dd>zen: just the drop</dd>
<dt>m · + −</dt><dd>mute · volume</dd> <dt>m · + −</dt><dd>mute · volume</dd>
<dt>?</dt><dd>this help</dd> <dt>?</dt><dd>this help</dd>
</dl> </dl>
+162 -42
View File
@@ -30,30 +30,53 @@ const SNARE_RELEASE = 0.15; // s
// playing turns the triangle back into the bars as they fade. // playing turns the triangle back into the bars as they fade.
const ICON_MELT = 0.35; // s the bars show before turning into the triangle const ICON_MELT = 0.35; // s the bars show before turning into the triangle
const ICON_FADE = 0.3; // s the bars show after playing starts const ICON_FADE = 0.3; // s the bars show after playing starts
const ICON_MORPH = 0.22; // s from one shape to the other
const ICON_IN = 0.05; // s; fades in fast …
const ICON_OUT = 0.12; // … and out a little slower
const CALM = 0.25; // s for the surface to settle down under the icon, and back
const HOVER = 0.45; // how much the bars show while a mouse rests on the playing drop
const TEXTURE_SIZE = 512; const TEXTURE_SIZE = 512;
const SLOW_FRAME = 0.02; // s; frames slower than this for SLOW_FOR … // Quality follows how fast frames come against what this display normally does, so
const SLOW_FOR = 2; // … seconds step the quality down // a 30 Hz screen is not mistaken for a slow GPU.
const SLOW_RATIO = 1.4; // a frame this much slower than usual is slow …
const SLOW_FOR = 2; // … and this many seconds of them step the quality down
const FAST_FOR = 10; // s of frames on time before it tries one step up again
/// Quality steps: icosphere subdivisions and resolution scale. /// Quality steps: icosphere subdivisions and resolution scale.
const QUALITY: [number, number][] = [[5, 1], [4, 1], [4, 0.6], [3, 0.5]]; const QUALITY: [number, number][] = [[5, 1], [4, 1], [4, 0.6], [3, 0.5]];
const UNIFORMS = [ const UNIFORMS = [
"uProj", "uDist", "uTime", "uMorph", "uLevel", "uWobble", "uKick", "uKickAge", "uSnare", "uDrop", "uBass", "uGrowl", "uProj", "uDist", "uTime", "uMorph", "uLevel", "uWobble", "uKick", "uKickAge", "uSnare", "uDrop", "uBass", "uGrowl",
"uGain", "uSpike", "uStep", "uTwist", "uRipple", "uShock", "uScale", "uLift", "uJelly", "uPointer", "uRot", "uFront", "uIcon", "uIconA", "uGain", "uSpike", "uStep", "uTwist", "uRipple", "uShock", "uScale", "uLift", "uJelly", "uPointer", "uRot", "uFront",
"uIcon", "uIconA", "uCalm", "uBusy", "uProgress", "uClock",
"uArt", "uAccent", "uHue", "uIri", "uMetal", "uHeat", "uArt", "uAccent", "uHue", "uIri", "uMetal", "uHeat",
] as const; ] as const;
const ease = (dt: number, tau: number) => 1 - Math.exp(-dt / tau);
/// What the page knows about playback, besides the music itself.
export interface DropState {
playing: boolean;
/// 0..1 of the track played.
progress: number;
/// Waiting for audio.
buffering: boolean;
/// A mouse rests on the drop.
hover: boolean;
}
export class LiquidBlob { export class LiquidBlob {
readonly canvas = document.createElement("canvas"); readonly canvas = document.createElement("canvas");
/// Called once if the GPU drops the context; the page then falls back to plain art. /// Called if the GPU drops the context; the page shows plain art until `onRestored`.
onLost: (() => void) | null = null; onLost: (() => void) | null = null;
onRestored: (() => void) | null = null;
/// Turning it by hand. /// Turning it by hand.
readonly spin = new Spin(); readonly spin = new Spin();
private gl: WebGL2RenderingContext; private gl: WebGL2RenderingContext;
private u = {} as Record<(typeof UNIFORMS)[number], WebGLUniformLocation | null>; private u = {} as Record<(typeof UNIFORMS)[number], WebGLUniformLocation | null>;
private vao: WebGLVertexArrayObject; private vao!: WebGLVertexArrayObject;
private buffers: WebGLBuffer[] = []; private buffers: WebGLBuffer[] = [];
private count = 0; private count = 0;
private tex: WebGLTexture; private tex!: WebGLTexture;
private quality: number; private quality: number;
private look: Look | null = null; private look: Look | null = null;
private accent: [number, number, number] = [1, 0.3, 0.43]; private accent: [number, number, number] = [1, 0.3, 0.43];
@@ -64,15 +87,26 @@ export class LiquidBlob {
private pointer = [0, 0, 1, 0]; private pointer = [0, 0, 1, 0];
private pointerGoal = [0, 0, 1, 0]; private pointerGoal = [0, 0, 1, 0];
private slow = 0; private slow = 0;
private fast = 0;
/// Usual frame time of this display, s.
private usual = 1 / 60;
/// Seconds on time needed before stepping up; doubles each time a step up fails.
private fastFor = FAST_FOR;
private steppedUp = false;
/// Springs, position and speed: the jump (upwards) and the pump (size). /// Springs, position and speed: the jump (upwards) and the pump (size).
private jump = [0, 0]; private jump = [0, 0];
private pump = [0, 0]; private pump = [0, 0];
private snare = 0; private snare = 0;
/// The icon: its shape (0 pause, 1 play), how much it shows, and since when the /// The icon: its shape (0 pause, 1 play), how much it shows, and since when the
/// playing state is what it is. Null until the first frame of a track. /// playing state is what it is. `iconFor` is null until the first frame of a track.
private icon = [1, 1]; private iconShape = 1;
private iconAlpha = 0;
private iconFor: boolean | null = null; private iconFor: boolean | null = null;
private iconAge = 0; private iconAge = 0;
/// Take the next playing state as it is, without showing a change.
private settle = false;
private busy = 0;
private calm = 0;
/// Null where WebGL2 is not available. /// Null where WebGL2 is not available.
static create(touch: boolean): LiquidBlob | null { static create(touch: boolean): LiquidBlob | null {
@@ -89,10 +123,31 @@ export class LiquidBlob {
if (!gl) throw new Error("no WebGL2"); if (!gl) throw new Error("no WebGL2");
this.gl = gl; this.gl = gl;
this.canvas.className = "liquid-canvas"; this.canvas.className = "liquid-canvas";
this.canvas.addEventListener("webglcontextlost", () => { this.quality = touch ? 1 : 0;
this.canvas.addEventListener("webglcontextlost", (e) => {
e.preventDefault(); // or it never comes back
this.ready = false; this.ready = false;
this.onLost?.(); this.onLost?.();
}); });
this.canvas.addEventListener("webglcontextrestored", () => {
try {
this.init();
} catch (err) {
console.warn("liquid visuals not restored:", (err as Error).message);
return;
}
this.onRestored?.();
});
this.init();
}
get lost(): boolean {
return this.gl.isContextLost();
}
/// Everything on the GPU; again after a lost context comes back.
private init(): void {
const gl = this.gl;
const prog = gl.createProgram(); const prog = gl.createProgram();
gl.attachShader(prog, this.shader(gl.VERTEX_SHADER, VERTEX)); gl.attachShader(prog, this.shader(gl.VERTEX_SHADER, VERTEX));
gl.attachShader(prog, this.shader(gl.FRAGMENT_SHADER, FRAGMENT)); gl.attachShader(prog, this.shader(gl.FRAGMENT_SHADER, FRAGMENT));
@@ -102,7 +157,7 @@ export class LiquidBlob {
gl.useProgram(prog); gl.useProgram(prog);
for (const name of UNIFORMS) this.u[name] = gl.getUniformLocation(prog, name); for (const name of UNIFORMS) this.u[name] = gl.getUniformLocation(prog, name);
this.vao = gl.createVertexArray(); this.vao = gl.createVertexArray();
this.quality = touch ? 1 : 0; this.buffers = [];
this.buildMesh(); this.buildMesh();
this.tex = gl.createTexture(); this.tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, this.tex); gl.bindTexture(gl.TEXTURE_2D, this.tex);
@@ -147,16 +202,17 @@ export class LiquidBlob {
/// Show a new track in `slot`. Resolves once its artwork is on the GPU, which is /// Show a new track in `slot`. Resolves once its artwork is on the GPU, which is
/// when the slot's own image can be hidden. /// when the slot's own image can be hidden.
async mount(slot: HTMLElement, look: Look, art: string | null, initial: string): Promise<boolean> { /// `tint` colours the stand-in when there is no artwork.
async mount(slot: HTMLElement, look: Look, art: string | null, initial: string, tint: string): Promise<boolean> {
if (this.canvas.parentElement !== slot) slot.append(this.canvas); if (this.canvas.parentElement !== slot) slot.append(this.canvas);
const gen = ++this.gen; const gen = ++this.gen;
this.look = look; this.look = look;
this.ready = false; this.ready = false;
this.morph = 0; this.morph = 0;
this.slow = 0; this.slow = this.fast = 0;
this.spin.reset(); this.spin.reset();
this.iconFor = null; this.iconFor = null;
const source = await artwork(art, initial).catch(() => artwork(null, initial)); const source = await artwork(art, initial, tint).catch(() => artwork(null, initial, tint));
if (gen !== this.gen || this.gl.isContextLost()) return false; if (gen !== this.gen || this.gl.isContextLost()) return false;
const gl = this.gl; const gl = this.gl;
gl.bindTexture(gl.TEXTURE_2D, this.tex); gl.bindTexture(gl.TEXTURE_2D, this.tex);
@@ -179,14 +235,21 @@ export class LiquidBlob {
this.pointerGoal = [dx, dy, dz, on && r < 1.4 ? 1 : 0]; this.pointerGoal = [dx, dy, dz, on && r < 1.4 ? 1 : 0];
} }
/// The playing state just flipped and flipped back (a double-tap): let the icon
/// go without playing out the change.
settleIcon(): void {
this.settle = true;
}
/// Draw one frame. Returns true while it still moves without music (morphing in, /// Draw one frame. Returns true while it still moves without music (morphing in,
/// settling, spinning, following the pointer). /// settling, spinning, following the pointer, showing the icon).
frame(f: Features, dt: number, playing: boolean): boolean { frame(f: Features, dt: number, s: DropState): boolean {
const look = this.look; const look = this.look;
if (!look || !this.ready) return false; if (!look || !this.ready) return false;
const gl = this.gl; const gl = this.gl;
this.watch(dt); const { playing } = s;
const scale = Math.min(devicePixelRatio || 1, this.touch ? 1 : 1.5) * QUALITY[this.quality][1]; if (playing) this.watch(dt);
const scale = this.density() * QUALITY[this.quality][1];
const w = Math.round(this.canvas.clientWidth * scale), h = Math.round(this.canvas.clientHeight * scale); const w = Math.round(this.canvas.clientWidth * scale), h = Math.round(this.canvas.clientHeight * scale);
if (!w || !h) return false; if (!w || !h) return false;
if (this.canvas.width !== w || this.canvas.height !== h) { if (this.canvas.width !== w || this.canvas.height !== h) {
@@ -202,11 +265,12 @@ export class LiquidBlob {
const k = 1 - Math.exp(-dt / 0.15); const k = 1 - Math.exp(-dt / 0.15);
for (let i = 0; i < 4; i++) this.pointer[i] += (this.pointerGoal[i] - this.pointer[i]) * k; for (let i = 0; i < 4; i++) this.pointer[i] += (this.pointerGoal[i] - this.pointer[i]) * k;
const spinning = this.spin.step(dt); const spinning = this.spin.step(dt);
const showing = this.showIcon(playing, dt); const showing = this.showIcon(s, dt);
const moving = this.bounce(f, dt, look); const moving = this.bounce(f, dt, look);
const u = this.u; const u = this.u;
gl.uniform1f(u.uTime, this.time); gl.uniform1f(u.uTime, this.time);
gl.uniform1f(u.uClock, (performance.now() / 1000) % 1000);
gl.uniform1f(u.uMorph, morph); gl.uniform1f(u.uMorph, morph);
gl.uniform1f(u.uLevel, (f.sub + f.low) / 2); gl.uniform1f(u.uLevel, (f.sub + f.low) / 2);
gl.uniform1f(u.uWobble, f.wobble); gl.uniform1f(u.uWobble, f.wobble);
@@ -215,7 +279,7 @@ export class LiquidBlob {
gl.uniform1f(u.uDrop, f.drop); gl.uniform1f(u.uDrop, f.drop);
gl.uniform1f(u.uBass, f.bass * look.throb); gl.uniform1f(u.uBass, f.bass * look.throb);
gl.uniform1f(u.uGrowl, f.growl); gl.uniform1f(u.uGrowl, f.growl);
this.snare += (f.snare - this.snare) * (1 - Math.exp(-dt / (f.snare > this.snare ? SNARE_ATTACK : SNARE_RELEASE))); this.snare += (f.snare - this.snare) * ease(dt, f.snare > this.snare ? SNARE_ATTACK : SNARE_RELEASE);
gl.uniform1f(u.uSnare, this.snare); gl.uniform1f(u.uSnare, this.snare);
gl.uniform1f(u.uGain, look.gain); gl.uniform1f(u.uGain, look.gain);
gl.uniform1f(u.uSpike, look.spike); gl.uniform1f(u.uSpike, look.spike);
@@ -240,6 +304,15 @@ export class LiquidBlob {
return this.morph < 1 || moving || spinning || showing || this.pointer[3] > 0.01 || this.pointerGoal[3] > 0; return this.morph < 1 || moving || spinning || showing || this.pointer[3] > 0.01 || this.pointerGoal[3] > 0;
} }
/// Canvas pixels per CSS pixel at full quality. Phones stay at 1 for the battery.
/// Low-density desktop screens render at 2 and scale down, which smooths the
/// edges and the icon that would otherwise fall on too few pixels.
private density(): number {
const dpr = devicePixelRatio || 1;
if (this.touch) return Math.min(dpr, 1);
return dpr < 1.5 ? 2 : 1.5;
}
/// Step the springs and set the jump uniforms. True while they still move. /// Step the springs and set the jump uniforms. True while they still move.
private bounce(f: Features, dt: number, look: Look): boolean { private bounce(f: Features, dt: number, look: Look): boolean {
const [jump, pump] = [this.jump, this.pump]; const [jump, pump] = [this.jump, this.pump];
@@ -270,41 +343,84 @@ export class LiquidBlob {
return Math.abs(jump[0]) + Math.abs(jump[1]) + Math.abs(pump[1]) > 0.002; return Math.abs(jump[0]) + Math.abs(jump[1]) + Math.abs(pump[1]) > 0.002;
} }
/// Step the play/pause icon and set its uniforms. True while it still moves. /// Step the play/pause icon, the loading ring and the calm under them, and set
private showIcon(playing: boolean, dt: number): boolean { /// their uniforms. True while any of them still moves.
const icon = this.icon; private showIcon(s: DropState, dt: number): boolean {
const { playing } = s;
if (this.iconFor === null) { if (this.iconFor === null) {
// A new track: no pop, just what holds now. // A new track: no pop, just what holds now.
icon[0] = icon[1] = playing ? 0 : 1; this.iconShape = playing ? 0 : 1;
this.iconAlpha = this.calm = playing ? 0 : 1;
this.iconAge = 1; this.iconAge = 1;
} else if (this.iconFor !== playing) { } else if (this.iconFor !== playing) {
this.iconAge = 0; this.iconAge = 0;
if (!playing) icon[0] = 0; // Pausing starts from the bars; jump to them only while nothing shows.
icon[1] = 1; if (!playing && this.iconAlpha < 0.2) this.iconShape = 0;
}
if (this.settle) {
this.iconAge = ICON_MELT + ICON_FADE;
this.settle = false;
} }
this.iconFor = playing; this.iconFor = playing;
this.iconAge += dt; this.iconAge += dt;
const goal = playing ? [0, this.iconAge < ICON_FADE ? 1 : 0] : [this.iconAge < ICON_MELT ? 0 : 1, 1]; const [shape, alpha] = !playing
const k = 1 - Math.exp(-dt / 0.1); ? [this.iconAge < ICON_MELT ? 0 : 1, 1]
for (let i = 0; i < 2; i++) icon[i] += (goal[i] - icon[i]) * k; : [0, this.iconAge < ICON_FADE ? 1 : s.hover ? HOVER : 0];
this.gl.uniform1f(this.u.uIcon, icon[0]); // The shape moves at a steady pace; the shader eases it.
this.gl.uniform1f(this.u.uIconA, icon[1]); const step = dt / ICON_MORPH;
return Math.abs(goal[0] - icon[0]) + Math.abs(goal[1] - icon[1]) > 0.005 || this.iconAge < ICON_MELT; this.iconShape += Math.max(-step, Math.min(step, shape - this.iconShape));
this.iconAlpha += (alpha - this.iconAlpha) * ease(dt, alpha > this.iconAlpha ? ICON_IN : ICON_OUT);
const busy = s.buffering ? 1 : 0;
this.busy += (busy - this.busy) * ease(dt, 0.15);
const calm = Math.max(alpha, busy);
this.calm += (calm - this.calm) * ease(dt, CALM);
const u = this.u;
this.gl.uniform1f(u.uIcon, this.iconShape);
this.gl.uniform1f(u.uIconA, this.iconAlpha);
this.gl.uniform1f(u.uBusy, this.busy);
this.gl.uniform1f(u.uCalm, this.calm);
this.gl.uniform1f(u.uProgress, s.progress);
return (
s.buffering ||
this.iconAge < ICON_MELT ||
Math.abs(shape - this.iconShape) > 0.001 ||
Math.abs(alpha - this.iconAlpha) > 0.005 ||
Math.abs(calm - this.calm) > 0.005 ||
this.busy > 0.005
);
} }
/// Step the quality down when frames keep coming in slow. /// Step the quality down when frames keep coming in slow, and back up once they
/// have been on time for a while.
private watch(dt: number): void { private watch(dt: number): void {
this.slow = dt > SLOW_FRAME ? this.slow + dt : Math.max(0, this.slow - dt); // The usual frame time drops quickly to what the display does and creeps up only slowly.
if (this.slow < SLOW_FOR || this.quality >= QUALITY.length - 1) return; this.usual = Math.min(1 / 24, this.usual + (dt - this.usual) * (dt < this.usual ? 0.2 : 0.004));
this.slow = 0; const late = dt > this.usual * SLOW_RATIO + 0.002;
this.slow = late ? this.slow + dt : Math.max(0, this.slow - dt);
this.fast = late ? 0 : this.fast + dt;
const best = this.touch ? 1 : 0;
if (this.slow >= SLOW_FOR && this.quality < QUALITY.length - 1) {
// Stepping up did not hold: wait longer before the next try.
if (this.steppedUp) this.fastFor *= 2;
this.steppedUp = false;
this.setQuality(this.quality + 1);
} else if (this.fast >= this.fastFor && this.quality > best) {
this.steppedUp = true;
this.setQuality(this.quality - 1);
}
}
private setQuality(q: number): void {
this.slow = this.fast = 0;
const before = QUALITY[this.quality][0]; const before = QUALITY[this.quality][0];
this.quality++; this.quality = q;
if (QUALITY[this.quality][0] !== before) this.buildMesh(); if (QUALITY[q][0] !== before) this.buildMesh();
} }
} }
/// The artwork cropped square, or a gradient with the artist's initial. /// The artwork cropped square, or a gradient in `tint` with the artist's initial.
async function artwork(src: string | null, initial: string): Promise<HTMLCanvasElement> { async function artwork(src: string | null, initial: string, tint: string): Promise<HTMLCanvasElement> {
const c = document.createElement("canvas"); const c = document.createElement("canvas");
c.width = c.height = TEXTURE_SIZE; c.width = c.height = TEXTURE_SIZE;
const ctx = c.getContext("2d")!; const ctx = c.getContext("2d")!;
@@ -315,12 +431,16 @@ async function artwork(src: string | null, initial: string): Promise<HTMLCanvasE
const side = Math.min(img.naturalWidth, img.naturalHeight); const side = Math.min(img.naturalWidth, img.naturalHeight);
ctx.drawImage(img, (img.naturalWidth - side) / 2, (img.naturalHeight - side) / 2, side, side, 0, 0, TEXTURE_SIZE, TEXTURE_SIZE); ctx.drawImage(img, (img.naturalWidth - side) / 2, (img.naturalHeight - side) / 2, side, side, 0, 0, TEXTURE_SIZE, TEXTURE_SIZE);
} else { } else {
// Dark enough that the initial and the rim light read, light enough that the
// drop does not sink into the page.
// `tint` is `hsl(h s% l%)` from color.ts; only its lightness changes.
const shade = (l: number) => tint.replace(/[\d.]+%\s*\)$/, `${l}%)`);
const g = ctx.createLinearGradient(0, 0, TEXTURE_SIZE, TEXTURE_SIZE); const g = ctx.createLinearGradient(0, 0, TEXTURE_SIZE, TEXTURE_SIZE);
g.addColorStop(0, "#2a2a2e"); g.addColorStop(0, shade(42));
g.addColorStop(1, "#111"); g.addColorStop(1, shade(14));
ctx.fillStyle = g; ctx.fillStyle = g;
ctx.fillRect(0, 0, TEXTURE_SIZE, TEXTURE_SIZE); ctx.fillRect(0, 0, TEXTURE_SIZE, TEXTURE_SIZE);
ctx.fillStyle = "#ffffff66"; ctx.fillStyle = "#ffffffcc";
ctx.font = `700 ${TEXTURE_SIZE * 0.4}px system-ui, sans-serif`; ctx.font = `700 ${TEXTURE_SIZE * 0.4}px system-ui, sans-serif`;
ctx.textAlign = "center"; ctx.textAlign = "center";
ctx.textBaseline = "middle"; ctx.textBaseline = "middle";
+86 -47
View File
@@ -55,31 +55,46 @@ float snoise(vec3 v) {
`; `;
// The play/pause icon on the front of the drop, as distances in units of the radius // The play/pause icon on the front of the drop, as distances in units of the radius
// (negative inside), with rounded corners like the page's icons. // (negative inside). Each pause bar is a quad whose corners slide into one half of
// the play triangle, so every step between the two is one clean shape, not two
// faded over each other.
const ICON = /* glsl */ ` const ICON = /* glsl */ `
float sdBox(vec2 p, vec2 b) { // Inigo Quilez's polygon distance, MIT licence, for four corners.
vec2 d = abs(p) - b; float sdQuad(vec2 p, vec2 v0, vec2 v1, vec2 v2, vec2 v3) {
return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0); vec2 v[4] = vec2[4](v0, v1, v2, v3);
float d = dot(p - v0, p - v0);
float s = 1.0;
for (int i = 0, j = 3; i < 4; j = i, i++) {
vec2 e = v[j] - v[i];
vec2 w = p - v[i];
vec2 b = w - e * clamp(dot(w, e) / dot(e, e), 0.0, 1.0);
d = min(d, dot(b, b));
bvec3 c = bvec3(p.y >= v[i].y, p.y < v[j].y, e.x * w.y > e.y * w.x);
if (all(c) || all(not(c))) s = -s;
}
return s * sqrt(d);
} }
// Inigo Quilez's equilateral triangle, MIT licence; turned to point right and // 0 pause, 1 play. Corners go bottom left, bottom right, top right, top left.
// moved right a little, so it looks centred. float sdPlayPause(vec2 p, float t) {
float sdPlay(vec2 p, float r) { float k = smoothstep(0.0, 1.0, t);
p = vec2(-p.y, p.x - 0.035); // A small swing in the middle of the morph, level again at both ends, so the
const float k = sqrt(3.0); // change reads as a motion and not a dissolve.
p.x = abs(p.x) - r; float a = 0.35 * sin(3.14159 * k);
p.y = p.y + r / k; p = mat2(cos(a), sin(a), -sin(a), cos(a)) * p;
if (p.x + k * p.y > 0.0) p = vec2(p.x - k * p.y, -k * p.x - p.y) / 2.0; float left = sdQuad(p,
p.x -= clamp(p.x, -2.0 * r, 0.0); mix(vec2(-0.13, -0.16), vec2(-0.12, -0.17), k),
return -length(p) * sign(p.y); mix(vec2(-0.05, -0.16), vec2(0.04, -0.079), k),
} mix(vec2(-0.05, 0.16), vec2(0.04, 0.079), k),
mix(vec2(-0.13, 0.16), vec2(-0.12, 0.17), k));
float sdPause(vec2 p) { float right = sdQuad(p,
return sdBox(vec2(abs(p.x) - 0.1, p.y), vec2(0.035, 0.15)) - 0.025; mix(vec2(0.05, -0.16), vec2(0.02, -0.091), k),
} mix(vec2(0.13, -0.16), vec2(0.18, -0.001), k),
mix(vec2(0.13, 0.16), vec2(0.18, 0.001), k),
float sdPlayIcon(vec2 p) { mix(vec2(0.05, 0.16), vec2(0.02, 0.091), k));
return sdPlay(p, 0.15) - 0.03; // A smooth union, so the seam between the halves fills in as they meet.
float h = clamp(0.5 + 0.5 * (right - left) / 0.03, 0.0, 1.0);
return mix(right, left, h) - 0.03 * h * (1.0 - h) - 0.02;
} }
`; `;
@@ -103,7 +118,7 @@ uniform float uJelly; // extra wobble while a jump rings out, 0..1
uniform vec4 uPointer; // direction on the sphere, strength uniform vec4 uPointer; // direction on the sphere, strength
uniform mat3 uRot; // turned by hand, object to view space uniform mat3 uRot; // turned by hand, object to view space
uniform vec3 uFront; // towards the viewer, in object space uniform vec3 uFront; // towards the viewer, in object space
uniform float uIconA; // how much the play/pause icon shows, see ICON uniform float uCalm; // calms the surface while the icon or the loading ring shows
out vec3 vPos; // view space out vec3 vPos; // view space
out vec3 vNormal; // view space out vec3 vNormal; // view space
@@ -130,28 +145,33 @@ float disp(vec3 p) {
float n = snoise(q * 2.4 - vec3(0.0, t * 0.6, t * 0.9)); float n = snoise(q * 2.4 - vec3(0.0, t * 0.6, t * 0.9));
float spikes = pow(max(n, 0.0), 3.0) * (0.05 + 0.25 * uSnare + 0.15 * uGrowl * uBass + 0.1 * uDrop) * uSpike; float spikes = pow(max(n, 0.0), 3.0) * (0.05 + 0.25 * uSnare + 0.15 * uGrowl * uBass + 0.1 * uDrop) * uSpike;
// After each kick a wave runs from the front of the drop round to the back. // After each kick a wave runs from the front of the drop round to the back.
// Squares are written out: pow() of a negative base is undefined in GLSL, and
// Mesa really returns NaN for it, which flattened the whole drop on Linux.
float angle = acos(clamp(dot(p, uFront), -1.0, 1.0)); float angle = acos(clamp(dot(p, uFront), -1.0, 1.0));
float wave = exp(-pow((angle - uKickAge * 5.0) * 1.8, 2.0)) * exp(-uKickAge * 2.8) * 0.09 * uShock; float ahead = (angle - uKickAge * 5.0) * 1.8;
float dent = -0.18 * uPointer.w * exp(-pow(distance(p, uPointer.xyz) * 2.2, 2.0)); float wave = exp(-ahead * ahead) * exp(-uKickAge * 2.8) * 0.09 * uShock;
float near = distance(p, uPointer.xyz) * 2.2;
float dent = -0.18 * uPointer.w * exp(-near * near);
// Calmer while the icon shows, so it stays readable. // Calmer while the icon shows, so it stays readable.
return clamp((lobes + sway + ripples + spikes + wave) * uGain + dent, -0.3, 0.4) * (1.0 - 0.6 * uIconA); return clamp((lobes + sway + ripples + spikes + wave) * uGain + dent, -0.3, 0.4) * (1.0 - 0.6 * uCalm);
}
vec3 shape(vec3 p) {
vec3 s = p * (1.0 + disp(p) * uMorph);
s.z *= mix(0.04, 1.0, uMorph);
return s;
} }
void main() { void main() {
vec3 p = position; vec3 p = position;
// Normal from two displaced neighbours along the surface. // The surface is the sphere pushed out by h. Its normal tips against the slope of
// h, taken from two neighbours a small step along the surface.
vec3 t1 = normalize(cross(p, abs(p.y) < 0.99 ? vec3(0.0, 1.0, 0.0) : vec3(1.0, 0.0, 0.0))); vec3 t1 = normalize(cross(p, abs(p.y) < 0.99 ? vec3(0.0, 1.0, 0.0) : vec3(1.0, 0.0, 0.0)));
vec3 t2 = cross(p, t1); vec3 t2 = cross(p, t1);
const float e = 0.012; const float e = 0.012;
vec3 s = shape(p); float h = disp(p) * uMorph;
vec3 n = normalize(cross(shape(normalize(p + t1 * e)) - s, shape(normalize(p + t2 * e)) - s)); float g1 = (disp(p + t1 * e) * uMorph - h) / e;
if (dot(n, p) < 0.0) n = -n; float g2 = (disp(p + t2 * e) * uMorph - h) / e;
vec3 n = p * (1.0 + h) - g1 * t1 - g2 * t2;
// Flattened into the static artwork while it morphs in; the normal scales the other way.
float depth = mix(0.04, 1.0, uMorph);
n = normalize(vec3(n.xy, n.z / depth));
vec3 s = p * (1.0 + h);
s.z *= depth;
vPos = uRot * s * uScale + vec3(0.0, uLift, -uDist); vPos = uRot * s * uScale + vec3(0.0, uLift, -uDist);
vNormal = normalize(uRot * n / uScale); vNormal = normalize(uRot * n / uScale);
vObj = p; vObj = p;
@@ -173,8 +193,11 @@ uniform float uHue; // rim hue offset, radians
uniform float uIri; // how much the rim hue drifts uniform float uIri; // how much the rim hue drifts
uniform float uMetal; // sharper, brighter highlights uniform float uMetal; // sharper, brighter highlights
uniform float uHeat; // pulls the rim towards orange uniform float uHeat; // pulls the rim towards orange
uniform float uIcon; // 0 pause, 1 play; between them one fades into the other uniform float uIcon; // 0 pause, 1 play; between them one morphs into the other
uniform float uIconA; uniform float uIconA; // how much the icon shows
uniform float uBusy; // loading: a short arc runs round the ring instead
uniform float uProgress; // 0..1 played, on the ring
uniform float uClock; // s, wall clock
out vec4 outColor; out vec4 outColor;
@@ -215,17 +238,33 @@ void main() {
vec3 env = mix(vec3(0.015), uAccent * 0.5, pow(smoothstep(-0.4, 1.0, r.y), 2.0)); vec3 env = mix(vec3(0.015), uAccent * 0.5, pow(smoothstep(-0.4, 1.0, r.y), 2.0));
env += 0.5 * smoothstep(0.92, 0.985, dot(r, normalize(vec3(-0.5, 0.7, 0.5)))); env += 0.5 * smoothstep(0.92, 0.985, dot(r, normalize(vec3(-0.5, 0.7, 0.5))));
col += env * (0.1 + 0.5 * fres) * uMorph; col += env * (0.1 + 0.5 * fres) * uMorph;
// The icon sits on a dark glass disc, like the page's round buttons, painted onto // The icon sits on a disc of frosted glass, painted onto the surface so it turns
// the surface so it turns with the drop. Each shape grows in as it fades in. // with the drop: the artwork behind it blurred and dimmed, lit along its rim. A
// ring round it shows how much has played, or runs while the track loads.
vec2 ip = vObj.xy; vec2 ip = vObj.xy;
float front = smoothstep(0.35, 0.6, vObj.z) * uIconA; float front = smoothstep(0.35, 0.6, vObj.z) * uMorph;
float show = max(uIconA, uBusy) * front;
float disc = length(ip) - 0.36; float disc = length(ip) - 0.36;
col = mix(col, col * 0.3, front * (1.0 - smoothstep(-0.01, 0.01, disc))); float da = fwidth(disc);
float playIn = smoothstep(0.0, 1.0, uIcon); float inDisc = 1.0 - smoothstep(-da, da, disc);
float dPause = sdPause(ip / mix(1.0, 0.7, playIn)), dPlay = sdPlayIcon(ip / mix(0.7, 1.0, playIn)); vec3 frost = textureLod(uArt, 0.5 + vec2(ip.x, -ip.y) * 0.48, 5.0).rgb * 0.3 + uAccent * 0.03;
float ink = max((1.0 - smoothstep(-fwidth(dPause), fwidth(dPause), dPause)) * (1.0 - playIn), frost += 0.35 * (1.0 - smoothstep(0.0, 0.025, abs(disc + 0.01))) * smoothstep(-0.3, 0.35, ip.y);
(1.0 - smoothstep(-fwidth(dPlay), fwidth(dPlay), dPlay)) * playIn); col = mix(col, frost, show * inDisc);
col = mix(col, vec3(0.96), front * ink);
float ringD = abs(length(ip) - 0.41) - 0.011;
float ra = fwidth(ringD);
float ring = (1.0 - smoothstep(-ra, ra, ringD)) * front;
float turn = fract(atan(ip.x, ip.y) / 6.2831853 + 1.0); // 0 at the top, clockwise
float played = 1.0 - smoothstep(uProgress - 0.003, uProgress + 0.003, turn);
float seg = fract(turn - uClock * 0.9);
float arc = smoothstep(0.0, 0.04, seg) * (1.0 - smoothstep(0.18, 0.28, seg));
col = mix(col, mix(vec3(0.32), uAccent, played), ring * uIconA * (1.0 - uBusy));
col = mix(col, mix(vec3(0.25), vec3(0.96), arc), ring * uBusy);
float dIcon = sdPlayPause(ip, uIcon);
float ia = fwidth(dIcon);
float ink = 1.0 - smoothstep(-ia, ia, dIcon);
col = mix(col, vec3(0.96), front * uIconA * (1.0 - uBusy) * ink);
// The very edge fades out, so the drop sits in the page instead of on it. // The very edge fades out, so the drop sits in the page instead of on it.
float a = 1.0 - 0.9 * smoothstep(0.8, 1.0, fres) * uMorph; float a = 1.0 - 0.9 * smoothstep(0.8, 1.0, fres) * uMorph;
outColor = vec4(col * a, a); outColor = vec4(col * a, a);
+2 -1
View File
@@ -2,7 +2,7 @@
// and dropped again once it is far behind, so a long session stays light. // and dropped again once it is far behind, so a long session stays light.
import { type Track, artistImg, artistUrl, trackUrl } from "./api"; import { type Track, artistImg, artistUrl, trackUrl } from "./api";
import { accentFor } from "./color"; import { accentFor, accentForName } from "./color";
export interface CardActions { export interface CardActions {
like(card: Card): void; like(card: Card): void;
@@ -72,6 +72,7 @@ export class Card {
}); });
} else { } else {
$(".initial").textContent = (t.artist_name || "?")[0].toUpperCase(); $(".initial").textContent = (t.artist_name || "?")[0].toUpperCase();
el.style.setProperty("--card-accent", accentForName(t.artist_name || "?"));
} }
$(".why").textContent = this.whyText(); $(".why").textContent = this.whyText();
+7
View File
@@ -2,6 +2,13 @@
// well on the dark background. Images are same-origin (proxied), so the canvas can // well on the dark background. Images are same-origin (proxied), so the canvas can
// read them. // read them.
/// A steady accent for an artist without an image, from their name.
export function accentForName(name: string): string {
let h = 0;
for (const ch of name) h = (h * 31 + ch.codePointAt(0)!) >>> 0;
return `hsl(${h % 360} 70% 62%)`;
}
const cache = new Map<string, Promise<string | null>>(); const cache = new Map<string, Promise<string | null>>();
export function accentFor(src: string): Promise<string | null> { export function accentFor(src: string): Promise<string | null> {
+15 -3
View File
@@ -68,6 +68,9 @@ const DROP_RISE_DB = 2.5; // dB above the long average where a drop starts
const DROP_FULL_DB = 5; // dB above the start for a full drop const DROP_FULL_DB = 5; // dB above the start for a full drop
const DROP_ATTACK = 0.25; // s const DROP_ATTACK = 0.25; // s
const DROP_RELEASE = 2; // s const DROP_RELEASE = 2; // s
// Without an analyser the waveform stands in. Its steps are coarse, so a rise from
// one step to the next is the only "onset" there is.
const LOUD_FLOOR = 0.02; // loudness treated as silence, so a rise from nothing is finite
const ease = (dt: number, tau: number) => 1 - Math.exp(-dt / tau); const ease = (dt: number, tau: number) => 1 - Math.exp(-dt / tau);
const clamp01 = (x: number) => Math.min(1, Math.max(0, x)); const clamp01 = (x: number) => Math.min(1, Math.max(0, x));
@@ -130,6 +133,9 @@ export class FeatureTracker {
private prev: Float32Array<ArrayBuffer> | null = null; private prev: Float32Array<ArrayBuffer> | null = null;
private ranges = { sub: new Range(), low: new Range(), mid: new Range(), high: new Range() }; private ranges = { sub: new Range(), low: new Range(), mid: new Range(), high: new Range() };
private kick = new Onset(KICK_DECAY); private kick = new Onset(KICK_DECAY);
/// Kicks from the waveform's steps, where there is no analyser.
private loudKick = new Onset(KICK_DECAY * 1.5);
private lastLevel = 0;
private snare = new Onset(SNARE_DECAY); private snare = new Onset(SNARE_DECAY);
private bassRange = new Range(-60, -30, BASS_FOLLOW, BASS_FOLLOW, BASS_MIN_RANGE); private bassRange = new Range(-60, -30, BASS_FOLLOW, BASS_FOLLOW, BASS_MIN_RANGE);
private growlRange = new Range(7, 9, GROWL_FOLLOW, GROWL_FOLLOW, GROWL_MIN_RANGE); private growlRange = new Range(7, 9, GROWL_FOLLOW, GROWL_FOLLOW, GROWL_MIN_RANGE);
@@ -198,12 +204,18 @@ export class FeatureTracker {
} }
/// One frame where there is no analyser: only the loudness from the waveform. /// One frame where there is no analyser: only the loudness from the waveform.
fromLoudness(level: number, dt: number): Features { /// Jumps in it count as kicks, so the drop still lands on the drops.
fromLoudness(level: number, now: number, dt: number): Features {
const f = this.f; const f = this.f;
f.sub = f.low = f.mid = f.high = f.bass = level; f.sub = f.low = f.mid = f.high = f.bass = level;
f.growl = 0.3; f.growl = 0.3;
f.kick = f.snare = f.kickHit = f.snareHit = 0; const rise = 20 * Math.log10(Math.max(LOUD_FLOOR, level) / Math.max(LOUD_FLOOR, this.lastLevel));
f.kickAge = 99; this.lastLevel = level;
this.loudKick.update(Math.max(0, rise), now, dt);
f.kick = this.loudKick.env;
f.kickAge = this.loudKick.age;
f.kickHit = this.loudKick.hit;
f.snare = f.snareHit = 0;
this.track(level, level > 0 ? 20 * Math.log10(level) : SILENCE_DB, dt); this.track(level, level > 0 ? 20 * Math.log10(level) : SILENCE_DB, dt);
return f; return f;
} }
+13
View File
@@ -352,6 +352,7 @@ feedEl.addEventListener("click", (e) => {
if (lastTap.toggled) { if (lastTap.toggled) {
player.toggle(); player.toggle();
card.clearFlash(); card.clearFlash();
visual.cancelFlash();
} }
card.burst(e.clientX, e.clientY); card.burst(e.clientX, e.clientY);
setLike(card, true); setLike(card, true);
@@ -639,6 +640,7 @@ document.addEventListener("keydown", (e) => {
if ((e.target as Element).tagName === "INPUT" || e.ctrlKey || e.metaKey || e.altKey) return; if ((e.target as Element).tagName === "INPUT" || e.ctrlKey || e.metaKey || e.altKey) return;
const k = e.key.length === 1 ? e.key.toLowerCase() : e.key; const k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
if (k === "Escape") { if (k === "Escape") {
setZen(false);
closePanels(); closePanels();
hideHelp(); hideHelp();
station.close(); station.close();
@@ -666,9 +668,20 @@ document.addEventListener("keydown", (e) => {
else if (k === "m") { setMuted(!player.muted); toast(player.muted ? "muted" : "sound on"); } else if (k === "m") { setMuted(!player.muted); toast(player.muted ? "muted" : "sound on"); }
else if (k === "+" || k === "=") { setVolume(player.volume + 0.1); toast(`volume ${Math.round(player.volume * 100)}%`); } else if (k === "+" || k === "=") { setVolume(player.volume + 0.1); toast(`volume ${Math.round(player.volume * 100)}%`); }
else if (k === "-") { setVolume(player.volume - 0.1); toast(`volume ${Math.round(player.volume * 100)}%`); } else if (k === "-") { setVolume(player.volume - 0.1); toast(`volume ${Math.round(player.volume * 100)}%`); }
else if (k === "z") setZen(!document.body.classList.contains("zen"));
else if (k === "?") { helpEl.hidden ? showHelp() : hideHelp(); } else if (k === "?") { helpEl.hidden ? showHelp() : hideHelp(); }
}); });
/// Zen: nothing but the drop and the music.
function setZen(on: boolean): void {
if (document.body.classList.contains("zen") === on) return;
document.body.classList.toggle("zen", on);
if (on) {
closePanels();
toast("zen · z or esc to leave");
}
}
addEventListener("resize", () => current()?.drawWave(player.audio.duration ? player.audio.currentTime / player.audio.duration : 0, true)); addEventListener("resize", () => current()?.drawWave(player.audio.duration ? player.audio.currentTime / player.audio.duration : 0, true));
addEventListener("pagehide", finishActive); addEventListener("pagehide", finishActive);
+8 -1
View File
@@ -285,7 +285,8 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff;
} }
.card.liquid .content > :not(.art) { position: relative; z-index: 1; } .card.liquid .content > :not(.art) { position: relative; z-index: 1; }
.card.liquid .title { text-shadow: 0 2px 16px #000b; } .card.liquid .title { text-shadow: 0 2px 16px #000b; }
.card.liquid .initial, .card.liquid .paused-badge { display: none; } /* The drop shows loading as a ring of its own. */
.card.liquid .initial, .card.liquid .paused-badge, .card.liquid .spinner { display: none; }
/* Hidden, not removed: it takes itself away when its animation ends. */ /* Hidden, not removed: it takes itself away when its animation ends. */
.card.liquid .flash { visibility: hidden; } .card.liquid .flash { visibility: hidden; }
@media (pointer: fine) { @media (pointer: fine) {
@@ -738,6 +739,12 @@ button.primary svg { width: 1em; height: 1em; }
} }
} }
/* Zen: nothing but the drop, bigger, and the music. */
body.zen #bar, body.zen .actions, body.zen .chips, body.zen .desc, body.zen .counts,
body.zen .why, body.zen .byline button, body.zen #feed-status { display: none; }
body.zen .art { width: min(30rem, 62vh, 86vw); }
body.zen .content { gap: 0.3rem; }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
} }
+52 -16
View File
@@ -9,7 +9,7 @@ import { artistImg } from "./api";
import { LiquidBlob, SPILL } from "./blob/blob"; import { LiquidBlob, SPILL } from "./blob/blob";
import { lookFor } from "./blob/style"; import { lookFor } from "./blob/style";
import type { Card } from "./cards"; import type { Card } from "./cards";
import { accentFor } from "./color"; import { accentFor, accentForName } from "./color";
import { type Features, FeatureTracker } from "./features"; import { type Features, FeatureTracker } from "./features";
import type { Player } from "./player"; import type { Player } from "./player";
@@ -17,6 +17,7 @@ export type VisualsSetting = "liquid" | "simple";
const VISUALS_KEY = "pl8inf.visuals"; const VISUALS_KEY = "pl8inf.visuals";
const SLOP = 10; // px before a press on the drop turns it const SLOP = 10; // px before a press on the drop turns it
const REACH = 1.2; // how far past the art's radius the drop can be grabbed const REACH = 1.2; // how far past the art's radius the drop can be grabbed
const IDLE_FPS = 15; // a paused drop keeps breathing, slowly and at a low frame rate
export class Visualizer { export class Visualizer {
private raf = 0; private raf = 0;
@@ -31,6 +32,9 @@ export class Visualizer {
/// A press on the drop; `on` once it moved enough to turn it. /// A press on the drop; `on` once it moved enough to turn it.
private grab: { id: number; x: number; y: number; on: boolean } | null = null; private grab: { id: number; x: number; y: number; on: boolean } | null = null;
private spun = false; private spun = false;
/// A mouse rests on the drop.
private hover = false;
private idleTimer = 0;
constructor( constructor(
private player: Player, private player: Player,
@@ -70,14 +74,17 @@ export class Visualizer {
if (this.blob === undefined) { if (this.blob === undefined) {
this.blob = LiquidBlob.create(this.touch); this.blob = LiquidBlob.create(this.touch);
if (this.blob) { if (this.blob) {
this.blob.onLost = () => { // Plain art while the GPU is gone; the drop comes back with it.
this.blob?.canvas.remove(); this.blob.onLost = () => this.card?.el?.classList.remove("liquid");
this.blob = null; this.blob.onRestored = () => this.attach(this.card, true);
this.card?.el?.classList.remove("liquid");
};
} }
} }
return this.blob; return this.blob?.lost ? null : this.blob;
}
/// A play/pause from a tap was taken back by the second tap of a double-tap.
cancelFlash(): void {
this.blob?.settleIcon();
} }
/// Follow `card` from now on. /// Follow `card` from now on.
@@ -96,9 +103,11 @@ export class Visualizer {
card?.el?.classList.remove("liquid"); card?.el?.classList.remove("liquid");
} else { } else {
const img = artistImg(card.track); const img = artistImg(card.track);
blob.setAccent(null); const name = card.track.artist_name || "?";
const own = accentForName(name);
blob.setAccent(img ? null : own);
if (img) accentFor(img).then((c) => { if (this.card === card) blob.setAccent(c); }); if (img) accentFor(img).then((c) => { if (this.card === card) blob.setAccent(c); });
blob.mount(slot, lookFor(card.track), img, (card.track.artist_name || "?")[0].toUpperCase()).then((ok) => { blob.mount(slot, lookFor(card.track), img, name[0].toUpperCase(), own).then((ok) => {
if (ok && this.card === card) { if (ok && this.card === card) {
card.el?.classList.add("liquid"); card.el?.classList.add("liquid");
this.start(); this.start();
@@ -109,6 +118,8 @@ export class Visualizer {
} }
start(): void { start(): void {
clearTimeout(this.idleTimer);
this.idleTimer = 0;
if (!this.raf && !document.hidden) { if (!this.raf && !document.hidden) {
this.last = 0; this.last = 0;
this.raf = requestAnimationFrame(this.frame); this.raf = requestAnimationFrame(this.frame);
@@ -116,6 +127,8 @@ export class Visualizer {
} }
stop(): void { stop(): void {
clearTimeout(this.idleTimer);
this.idleTimer = 0;
cancelAnimationFrame(this.raf); cancelAnimationFrame(this.raf);
this.raf = 0; this.raf = 0;
} }
@@ -129,11 +142,22 @@ export class Visualizer {
return { x: r.left + r.width / 2, y: r.top + r.height / 2, radius: r.width / SPILL / 2 }; return { x: r.left + r.width / 2, y: r.top + r.height / 2, radius: r.width / SPILL / 2 };
} }
/// Pointer over the drop pushes a dent into it. /// Pointer over the drop pushes a dent into it; a mouse resting on it shows that
/// a click pauses.
private pointer(e: PointerEvent, on: boolean): void { private pointer(e: PointerEvent, on: boolean): void {
const at = this.where(); const at = this.where();
if (!at) return; if (!at) {
this.blob!.setPointer((e.clientX - at.x) / at.radius, -(e.clientY - at.y) / at.radius, on); this.hover = false;
return;
}
const x = (e.clientX - at.x) / at.radius, y = -(e.clientY - at.y) / at.radius;
this.blob!.setPointer(x, y, on);
const hover = on && e.pointerType === "mouse" && !this.grab?.on && Math.hypot(x, y) < 1
&& !(e.target as Element).closest?.("button, a, input, .wave-wrap, #bar, .panel");
if (hover !== this.hover) {
this.hover = hover;
this.card?.el?.classList.toggle("drop-hover", hover);
}
if (on) this.start(); if (on) this.start();
} }
@@ -211,16 +235,28 @@ export class Visualizer {
? this.features.idle(dt) ? this.features.idle(dt)
: mixed : mixed
? this.features.fromAnalyser(an, now / 1000, dt) ? this.features.fromAnalyser(an, now / 1000, dt)
: this.features.fromLoudness(card.loudnessAt(progress), dt); : this.features.fromLoudness(card.loudnessAt(progress), now / 1000, dt);
this.debug?.(f); this.debug?.(f);
let busy = playing; let busy = playing;
const liquid = !!this.blob && !!card.el?.classList.contains("liquid");
if (!this.reduced.matches) { if (!this.reduced.matches) {
// The kick drives the pulse; without an analyser there are no kicks, only loudness. // The kick drives the pulse; without an analyser the kicks come from the waveform.
const energy = Math.min(1, mixed ? 0.65 * f.kick + 0.35 * f.low : 0.7 * f.low); const energy = Math.min(1, mixed ? 0.65 * f.kick + 0.35 * f.low : 0.5 * f.low + 0.4 * f.kick);
card.el?.style.setProperty("--energy", energy.toFixed(3)); card.el?.style.setProperty("--energy", energy.toFixed(3));
busy ||= energy > 0.01; busy ||= energy > 0.01;
if (this.blob && card.el?.classList.contains("liquid")) busy = this.blob.frame(f, dt, playing) || busy; if (liquid) {
const buffering = !!card.el?.classList.contains("buffering");
busy = this.blob!.frame(f, dt, { playing, progress, buffering, hover: this.hover }) || busy;
}
} }
if (busy) this.raf = requestAnimationFrame(this.frame); if (busy) this.raf = requestAnimationFrame(this.frame);
// Paused, it breathes on at a low frame rate; not on phones, for the battery.
else if (liquid && !this.touch && !this.reduced.matches) {
// Not through start(): the frame should see the real time since the last one.
this.idleTimer = window.setTimeout(() => {
this.idleTimer = 0;
if (!this.raf && !document.hidden) this.raf = requestAnimationFrame(this.frame);
}, 1000 / IDLE_FPS);
}
}; };
} }