diff --git a/web/index.html b/web/index.html
index 89d6343..017aef1 100644
--- a/web/index.html
+++ b/web/index.html
@@ -190,6 +190,7 @@
xnot for me
sshare
vliquid / simple visuals
+ zzen: just the drop
m · + −mute · volume
?this help
diff --git a/web/src/blob/blob.ts b/web/src/blob/blob.ts
index 8949d59..fdf2855 100644
--- a/web/src/blob/blob.ts
+++ b/web/src/blob/blob.ts
@@ -30,30 +30,53 @@ const SNARE_RELEASE = 0.15; // s
// 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_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 SLOW_FRAME = 0.02; // s; frames slower than this for SLOW_FOR …
-const SLOW_FOR = 2; // … seconds step the quality down
+// Quality follows how fast frames come against what this display normally does, so
+// 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.
const QUALITY: [number, number][] = [[5, 1], [4, 1], [4, 0.6], [3, 0.5]];
const UNIFORMS = [
"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",
] 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 {
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;
+ onRestored: (() => void) | null = null;
/// Turning it by hand.
readonly spin = new Spin();
private gl: WebGL2RenderingContext;
private u = {} as Record<(typeof UNIFORMS)[number], WebGLUniformLocation | null>;
- private vao: WebGLVertexArrayObject;
+ private vao!: WebGLVertexArrayObject;
private buffers: WebGLBuffer[] = [];
private count = 0;
- private tex: WebGLTexture;
+ private tex!: WebGLTexture;
private quality: number;
private look: Look | null = null;
private accent: [number, number, number] = [1, 0.3, 0.43];
@@ -64,15 +87,26 @@ export class LiquidBlob {
private pointer = [0, 0, 1, 0];
private pointerGoal = [0, 0, 1, 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).
private jump = [0, 0];
private pump = [0, 0];
private snare = 0;
/// 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.
- private icon = [1, 1];
+ /// playing state is what it is. `iconFor` is null until the first frame of a track.
+ private iconShape = 1;
+ private iconAlpha = 0;
private iconFor: boolean | null = null;
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.
static create(touch: boolean): LiquidBlob | null {
@@ -89,10 +123,31 @@ export class LiquidBlob {
if (!gl) throw new Error("no WebGL2");
this.gl = gl;
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.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();
gl.attachShader(prog, this.shader(gl.VERTEX_SHADER, VERTEX));
gl.attachShader(prog, this.shader(gl.FRAGMENT_SHADER, FRAGMENT));
@@ -102,7 +157,7 @@ export class LiquidBlob {
gl.useProgram(prog);
for (const name of UNIFORMS) this.u[name] = gl.getUniformLocation(prog, name);
this.vao = gl.createVertexArray();
- this.quality = touch ? 1 : 0;
+ this.buffers = [];
this.buildMesh();
this.tex = gl.createTexture();
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
/// when the slot's own image can be hidden.
- async mount(slot: HTMLElement, look: Look, art: string | null, initial: string): Promise {
+ /// `tint` colours the stand-in when there is no artwork.
+ async mount(slot: HTMLElement, look: Look, art: string | null, initial: string, tint: string): Promise {
if (this.canvas.parentElement !== slot) slot.append(this.canvas);
const gen = ++this.gen;
this.look = look;
this.ready = false;
this.morph = 0;
- this.slow = 0;
+ this.slow = this.fast = 0;
this.spin.reset();
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;
const gl = this.gl;
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];
}
+ /// 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,
- /// settling, spinning, following the pointer).
- frame(f: Features, dt: number, playing: boolean): boolean {
+ /// settling, spinning, following the pointer, showing the icon).
+ frame(f: Features, dt: number, s: DropState): boolean {
const look = this.look;
if (!look || !this.ready) return false;
const gl = this.gl;
- this.watch(dt);
- const scale = Math.min(devicePixelRatio || 1, this.touch ? 1 : 1.5) * QUALITY[this.quality][1];
+ const { playing } = s;
+ 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);
if (!w || !h) return false;
if (this.canvas.width !== w || this.canvas.height !== h) {
@@ -202,11 +265,12 @@ export class LiquidBlob {
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;
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 u = this.u;
gl.uniform1f(u.uTime, this.time);
+ gl.uniform1f(u.uClock, (performance.now() / 1000) % 1000);
gl.uniform1f(u.uMorph, morph);
gl.uniform1f(u.uLevel, (f.sub + f.low) / 2);
gl.uniform1f(u.uWobble, f.wobble);
@@ -215,7 +279,7 @@ export class LiquidBlob {
gl.uniform1f(u.uDrop, f.drop);
gl.uniform1f(u.uBass, f.bass * look.throb);
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.uGain, look.gain);
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;
}
+ /// 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.
private bounce(f: Features, dt: number, look: Look): boolean {
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;
}
- /// Step the play/pause icon and set its uniforms. True while it still moves.
- private showIcon(playing: boolean, dt: number): boolean {
- const icon = this.icon;
+ /// Step the play/pause icon, the loading ring and the calm under them, and set
+ /// their uniforms. True while any of them still moves.
+ private showIcon(s: DropState, dt: number): boolean {
+ const { playing } = s;
if (this.iconFor === null) {
// 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;
} else if (this.iconFor !== playing) {
this.iconAge = 0;
- if (!playing) icon[0] = 0;
- icon[1] = 1;
+ // Pausing starts from the bars; jump to them only while nothing shows.
+ 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.iconAge += dt;
- const goal = playing ? [0, this.iconAge < ICON_FADE ? 1 : 0] : [this.iconAge < ICON_MELT ? 0 : 1, 1];
- const k = 1 - Math.exp(-dt / 0.1);
- for (let i = 0; i < 2; i++) icon[i] += (goal[i] - icon[i]) * k;
- this.gl.uniform1f(this.u.uIcon, icon[0]);
- this.gl.uniform1f(this.u.uIconA, icon[1]);
- return Math.abs(goal[0] - icon[0]) + Math.abs(goal[1] - icon[1]) > 0.005 || this.iconAge < ICON_MELT;
+ const [shape, alpha] = !playing
+ ? [this.iconAge < ICON_MELT ? 0 : 1, 1]
+ : [0, this.iconAge < ICON_FADE ? 1 : s.hover ? HOVER : 0];
+ // The shape moves at a steady pace; the shader eases it.
+ const step = dt / ICON_MORPH;
+ 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 {
- this.slow = dt > SLOW_FRAME ? this.slow + dt : Math.max(0, this.slow - dt);
- if (this.slow < SLOW_FOR || this.quality >= QUALITY.length - 1) return;
- this.slow = 0;
+ // The usual frame time drops quickly to what the display does and creeps up only slowly.
+ this.usual = Math.min(1 / 24, this.usual + (dt - this.usual) * (dt < this.usual ? 0.2 : 0.004));
+ 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];
- this.quality++;
- if (QUALITY[this.quality][0] !== before) this.buildMesh();
+ this.quality = q;
+ if (QUALITY[q][0] !== before) this.buildMesh();
}
}
-/// The artwork cropped square, or a gradient with the artist's initial.
-async function artwork(src: string | null, initial: string): Promise {
+/// The artwork cropped square, or a gradient in `tint` with the artist's initial.
+async function artwork(src: string | null, initial: string, tint: string): Promise {
const c = document.createElement("canvas");
c.width = c.height = TEXTURE_SIZE;
const ctx = c.getContext("2d")!;
@@ -315,12 +431,16 @@ async function artwork(src: string | null, initial: string): Promise tint.replace(/[\d.]+%\s*\)$/, `${l}%)`);
const g = ctx.createLinearGradient(0, 0, TEXTURE_SIZE, TEXTURE_SIZE);
- g.addColorStop(0, "#2a2a2e");
- g.addColorStop(1, "#111");
+ g.addColorStop(0, shade(42));
+ g.addColorStop(1, shade(14));
ctx.fillStyle = g;
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.textAlign = "center";
ctx.textBaseline = "middle";
diff --git a/web/src/blob/shaders.ts b/web/src/blob/shaders.ts
index f1ebdbe..3b609b5 100644
--- a/web/src/blob/shaders.ts
+++ b/web/src/blob/shaders.ts
@@ -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
-// (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 */ `
-float sdBox(vec2 p, vec2 b) {
- vec2 d = abs(p) - b;
- return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0);
+// Inigo Quilez's polygon distance, MIT licence, for four corners.
+float sdQuad(vec2 p, vec2 v0, vec2 v1, vec2 v2, vec2 v3) {
+ 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
-// moved right a little, so it looks centred.
-float sdPlay(vec2 p, float r) {
- p = vec2(-p.y, p.x - 0.035);
- const float k = sqrt(3.0);
- p.x = abs(p.x) - r;
- p.y = p.y + r / k;
- if (p.x + k * p.y > 0.0) p = vec2(p.x - k * p.y, -k * p.x - p.y) / 2.0;
- p.x -= clamp(p.x, -2.0 * r, 0.0);
- return -length(p) * sign(p.y);
-}
-
-float sdPause(vec2 p) {
- return sdBox(vec2(abs(p.x) - 0.1, p.y), vec2(0.035, 0.15)) - 0.025;
-}
-
-float sdPlayIcon(vec2 p) {
- return sdPlay(p, 0.15) - 0.03;
+// 0 pause, 1 play. Corners go bottom left, bottom right, top right, top left.
+float sdPlayPause(vec2 p, float t) {
+ float k = smoothstep(0.0, 1.0, t);
+ // A small swing in the middle of the morph, level again at both ends, so the
+ // change reads as a motion and not a dissolve.
+ float a = 0.35 * sin(3.14159 * k);
+ p = mat2(cos(a), sin(a), -sin(a), cos(a)) * p;
+ float left = sdQuad(p,
+ mix(vec2(-0.13, -0.16), vec2(-0.12, -0.17), k),
+ 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 right = sdQuad(p,
+ 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),
+ mix(vec2(0.05, 0.16), vec2(0.02, 0.091), k));
+ // 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 mat3 uRot; // turned by hand, object to view 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 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 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.
+ // 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 wave = exp(-pow((angle - uKickAge * 5.0) * 1.8, 2.0)) * exp(-uKickAge * 2.8) * 0.09 * uShock;
- float dent = -0.18 * uPointer.w * exp(-pow(distance(p, uPointer.xyz) * 2.2, 2.0));
+ float ahead = (angle - uKickAge * 5.0) * 1.8;
+ 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.
- return clamp((lobes + sway + ripples + spikes + wave) * uGain + dent, -0.3, 0.4) * (1.0 - 0.6 * uIconA);
-}
-
-vec3 shape(vec3 p) {
- vec3 s = p * (1.0 + disp(p) * uMorph);
- s.z *= mix(0.04, 1.0, uMorph);
- return s;
+ return clamp((lobes + sway + ripples + spikes + wave) * uGain + dent, -0.3, 0.4) * (1.0 - 0.6 * uCalm);
}
void main() {
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 t2 = cross(p, t1);
const float e = 0.012;
- vec3 s = shape(p);
- vec3 n = normalize(cross(shape(normalize(p + t1 * e)) - s, shape(normalize(p + t2 * e)) - s));
- if (dot(n, p) < 0.0) n = -n;
+ float h = disp(p) * uMorph;
+ float g1 = (disp(p + t1 * e) * uMorph - h) / e;
+ 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);
vNormal = normalize(uRot * n / uScale);
vObj = p;
@@ -173,8 +193,11 @@ uniform float uHue; // rim hue offset, radians
uniform float uIri; // how much the rim hue drifts
uniform float uMetal; // sharper, brighter highlights
uniform float uHeat; // pulls the rim towards orange
-uniform float uIcon; // 0 pause, 1 play; between them one fades into the other
-uniform float uIconA;
+uniform float uIcon; // 0 pause, 1 play; between them one morphs into the other
+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;
@@ -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));
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;
- // The icon sits on a dark glass disc, like the page's round buttons, painted onto
- // the surface so it turns with the drop. Each shape grows in as it fades in.
+ // The icon sits on a disc of frosted glass, painted onto the surface so it turns
+ // 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;
- 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;
- col = mix(col, col * 0.3, front * (1.0 - smoothstep(-0.01, 0.01, disc)));
- float playIn = smoothstep(0.0, 1.0, uIcon);
- float dPause = sdPause(ip / mix(1.0, 0.7, playIn)), dPlay = sdPlayIcon(ip / mix(0.7, 1.0, playIn));
- float ink = max((1.0 - smoothstep(-fwidth(dPause), fwidth(dPause), dPause)) * (1.0 - playIn),
- (1.0 - smoothstep(-fwidth(dPlay), fwidth(dPlay), dPlay)) * playIn);
- col = mix(col, vec3(0.96), front * ink);
+ float da = fwidth(disc);
+ float inDisc = 1.0 - smoothstep(-da, da, disc);
+ vec3 frost = textureLod(uArt, 0.5 + vec2(ip.x, -ip.y) * 0.48, 5.0).rgb * 0.3 + uAccent * 0.03;
+ frost += 0.35 * (1.0 - smoothstep(0.0, 0.025, abs(disc + 0.01))) * smoothstep(-0.3, 0.35, ip.y);
+ col = mix(col, frost, show * inDisc);
+
+ 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.
float a = 1.0 - 0.9 * smoothstep(0.8, 1.0, fres) * uMorph;
outColor = vec4(col * a, a);
diff --git a/web/src/cards.ts b/web/src/cards.ts
index e365511..06e0ea8 100644
--- a/web/src/cards.ts
+++ b/web/src/cards.ts
@@ -2,7 +2,7 @@
// and dropped again once it is far behind, so a long session stays light.
import { type Track, artistImg, artistUrl, trackUrl } from "./api";
-import { accentFor } from "./color";
+import { accentFor, accentForName } from "./color";
export interface CardActions {
like(card: Card): void;
@@ -72,6 +72,7 @@ export class Card {
});
} else {
$(".initial").textContent = (t.artist_name || "?")[0].toUpperCase();
+ el.style.setProperty("--card-accent", accentForName(t.artist_name || "?"));
}
$(".why").textContent = this.whyText();
diff --git a/web/src/color.ts b/web/src/color.ts
index 64e64cf..d00180b 100644
--- a/web/src/color.ts
+++ b/web/src/color.ts
@@ -2,6 +2,13 @@
// well on the dark background. Images are same-origin (proxied), so the canvas can
// 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>();
export function accentFor(src: string): Promise {
diff --git a/web/src/features.ts b/web/src/features.ts
index 28028e9..c8f36a1 100644
--- a/web/src/features.ts
+++ b/web/src/features.ts
@@ -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_ATTACK = 0.25; // 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 clamp01 = (x: number) => Math.min(1, Math.max(0, x));
@@ -130,6 +133,9 @@ export class FeatureTracker {
private prev: Float32Array | null = null;
private ranges = { sub: new Range(), low: new Range(), mid: new Range(), high: new Range() };
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 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);
@@ -198,12 +204,18 @@ export class FeatureTracker {
}
/// 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;
f.sub = f.low = f.mid = f.high = f.bass = level;
f.growl = 0.3;
- f.kick = f.snare = f.kickHit = f.snareHit = 0;
- f.kickAge = 99;
+ const rise = 20 * Math.log10(Math.max(LOUD_FLOOR, level) / Math.max(LOUD_FLOOR, this.lastLevel));
+ 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);
return f;
}
diff --git a/web/src/main.ts b/web/src/main.ts
index f17d994..4fc6848 100644
--- a/web/src/main.ts
+++ b/web/src/main.ts
@@ -352,6 +352,7 @@ feedEl.addEventListener("click", (e) => {
if (lastTap.toggled) {
player.toggle();
card.clearFlash();
+ visual.cancelFlash();
}
card.burst(e.clientX, e.clientY);
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;
const k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
if (k === "Escape") {
+ setZen(false);
closePanels();
hideHelp();
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 === "+" || 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(); }
});
+/// 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("pagehide", finishActive);
diff --git a/web/src/style.css b/web/src/style.css
index b3f2a02..f072f36 100644
--- a/web/src/style.css
+++ b/web/src/style.css
@@ -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 .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. */
.card.liquid .flash { visibility: hidden; }
@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) {
*, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
}
diff --git a/web/src/visual.ts b/web/src/visual.ts
index 3187ec4..bee1296 100644
--- a/web/src/visual.ts
+++ b/web/src/visual.ts
@@ -9,7 +9,7 @@ import { artistImg } from "./api";
import { LiquidBlob, SPILL } from "./blob/blob";
import { lookFor } from "./blob/style";
import type { Card } from "./cards";
-import { accentFor } from "./color";
+import { accentFor, accentForName } from "./color";
import { type Features, FeatureTracker } from "./features";
import type { Player } from "./player";
@@ -17,6 +17,7 @@ export type VisualsSetting = "liquid" | "simple";
const VISUALS_KEY = "pl8inf.visuals";
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 IDLE_FPS = 15; // a paused drop keeps breathing, slowly and at a low frame rate
export class Visualizer {
private raf = 0;
@@ -31,6 +32,9 @@ export class Visualizer {
/// 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 spun = false;
+ /// A mouse rests on the drop.
+ private hover = false;
+ private idleTimer = 0;
constructor(
private player: Player,
@@ -70,14 +74,17 @@ export class Visualizer {
if (this.blob === undefined) {
this.blob = LiquidBlob.create(this.touch);
if (this.blob) {
- this.blob.onLost = () => {
- this.blob?.canvas.remove();
- this.blob = null;
- this.card?.el?.classList.remove("liquid");
- };
+ // Plain art while the GPU is gone; the drop comes back with it.
+ this.blob.onLost = () => this.card?.el?.classList.remove("liquid");
+ this.blob.onRestored = () => this.attach(this.card, true);
}
}
- 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.
@@ -96,9 +103,11 @@ export class Visualizer {
card?.el?.classList.remove("liquid");
} else {
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); });
- 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) {
card.el?.classList.add("liquid");
this.start();
@@ -109,6 +118,8 @@ export class Visualizer {
}
start(): void {
+ clearTimeout(this.idleTimer);
+ this.idleTimer = 0;
if (!this.raf && !document.hidden) {
this.last = 0;
this.raf = requestAnimationFrame(this.frame);
@@ -116,6 +127,8 @@ export class Visualizer {
}
stop(): void {
+ clearTimeout(this.idleTimer);
+ this.idleTimer = 0;
cancelAnimationFrame(this.raf);
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 };
}
- /// 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 {
const at = this.where();
- if (!at) return;
- this.blob!.setPointer((e.clientX - at.x) / at.radius, -(e.clientY - at.y) / at.radius, on);
+ if (!at) {
+ 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();
}
@@ -211,16 +235,28 @@ export class Visualizer {
? this.features.idle(dt)
: mixed
? 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);
let busy = playing;
+ const liquid = !!this.blob && !!card.el?.classList.contains("liquid");
if (!this.reduced.matches) {
- // The kick drives the pulse; without an analyser there are no kicks, only loudness.
- const energy = Math.min(1, mixed ? 0.65 * f.kick + 0.35 * f.low : 0.7 * f.low);
+ // 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.5 * f.low + 0.4 * f.kick);
card.el?.style.setProperty("--energy", energy.toFixed(3));
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);
+ // 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);
+ }
};
}