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 @@
x
not for me
s
share
v
liquid / simple visuals
+
z
zen: 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); + } }; }