Put a 3D play/pause on the liquid drop and keep page swipes working

The flat play/pause badge and the flash icon sat on top of the drop as two separate
symbols. With the drop on, both are gone: the icon is stamped out of the drop's front
instead, turns with it and catches the light. Pausing pops up the bars, which melt
into the play triangle; playing melts it back into the bars as they fade.

On touch only a clearly sideways drag turns the drop now, so diagonal swipes turn the
page again. A mouse released outside the window, or after turning the drop, no longer
leaves the pager dragging. The halo behind the drop drew a hard ring at the art's edge;
it is a soft radial glow now.
This commit is contained in:
nvrl committed 2026-11-01 15:39:21 +01:00
1 parent 482cfc299b
commit ca23cf5b7c
5 files changed
+104 -13

No files matched your search

+35 -2
View File
@@ -26,6 +26,10 @@ const THROB = 0.09; // size swing with the bass line's envelope
const WHIRL = 15; // rad/s of hand spin at which it bulges and sloshes the most const WHIRL = 15; // rad/s of hand spin at which it bulges and sloshes the most
const SNARE_ATTACK = 0.03; // s; spikes grow in rather than pop const SNARE_ATTACK = 0.03; // s; spikes grow in rather than pop
const SNARE_RELEASE = 0.15; // s const SNARE_RELEASE = 0.15; // s
// The play/pause icon: pausing pops up the bars, which melt into the play triangle;
// playing melts 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 TEXTURE_SIZE = 512; const TEXTURE_SIZE = 512;
const SLOW_FRAME = 0.02; // s; frames slower than this for SLOW_FOR … const SLOW_FRAME = 0.02; // s; frames slower than this for SLOW_FOR …
const SLOW_FOR = 2; // … seconds step the quality down const SLOW_FOR = 2; // … seconds step the quality down
@@ -34,7 +38,7 @@ 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", "uGain", "uSpike", "uStep", "uTwist", "uRipple", "uShock", "uScale", "uLift", "uJelly", "uPointer", "uRot", "uFront", "uIcon", "uIconA",
"uArt", "uAccent", "uHue", "uIri", "uMetal", "uHeat", "uArt", "uAccent", "uHue", "uIri", "uMetal", "uHeat",
] as const; ] as const;
@@ -64,6 +68,11 @@ export class LiquidBlob {
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
/// playing state is what it is. Null until the first frame of a track.
private icon = [1, 1];
private iconFor: boolean | null = null;
private iconAge = 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 {
@@ -146,6 +155,7 @@ export class LiquidBlob {
this.morph = 0; this.morph = 0;
this.slow = 0; this.slow = 0;
this.spin.reset(); this.spin.reset();
this.iconFor = null;
const source = await artwork(art, initial).catch(() => artwork(null, initial)); const source = await artwork(art, initial).catch(() => artwork(null, initial));
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;
@@ -192,6 +202,7 @@ 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 moving = this.bounce(f, dt, look); const moving = this.bounce(f, dt, look);
const u = this.u; const u = this.u;
@@ -226,7 +237,7 @@ export class LiquidBlob {
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.bindVertexArray(this.vao); gl.bindVertexArray(this.vao);
gl.drawElements(gl.TRIANGLES, this.count, gl.UNSIGNED_SHORT, 0); gl.drawElements(gl.TRIANGLES, this.count, gl.UNSIGNED_SHORT, 0);
return this.morph < 1 || moving || spinning || 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;
} }
/// 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.
@@ -259,6 +270,28 @@ 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.
private showIcon(playing: boolean, dt: number): boolean {
const icon = this.icon;
if (this.iconFor === null) {
// A new track: no pop, just what holds now.
icon[0] = icon[1] = playing ? 0 : 1;
this.iconAge = 1;
} else if (this.iconFor !== playing) {
this.iconAge = 0;
if (!playing) icon[0] = 0;
icon[1] = 1;
}
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;
}
/// Step the quality down when frames keep coming in slow. /// Step the quality down when frames keep coming in slow.
private watch(dt: number): void { private watch(dt: number): void {
this.slow = dt > SLOW_FRAME ? this.slow + dt : Math.max(0, this.slow - dt); this.slow = dt > SLOW_FRAME ? this.slow + dt : Math.max(0, this.slow - dt);
+41 -1
View File
@@ -54,6 +54,32 @@ float snoise(vec3 v) {
} }
`; `;
// The play/pause icon on the front of the drop, as a distance in units of the radius
// (negative inside). `shape` 0 is the pause bars, 1 the play triangle; between them
// one melts into the 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 equilateral triangle, MIT licence; turned to point right.
float sdPlay(vec2 p, float r) {
p = vec2(-p.y, p.x + 0.08);
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 icon(vec2 p, float shape) {
float bars = sdBox(vec2(abs(p.x) - 0.15, p.y), vec2(0.07, 0.24));
return mix(bars, sdPlay(p, 0.24), shape) - 0.03;
}
`;
export const VERTEX = /* glsl */ `#version 300 es export const VERTEX = /* glsl */ `#version 300 es
precision highp float; precision highp float;
@@ -74,12 +100,15 @@ 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 uIcon; // play/pause, see ICON
uniform float uIconA; // how much it shows
out vec3 vPos; // view space out vec3 vPos; // view space
out vec3 vNormal; // view space out vec3 vNormal; // view space
out vec3 vObj; // the resting sphere; its front projection carries the artwork out vec3 vObj; // the resting sphere; its front projection carries the artwork
${NOISE} ${NOISE}
${ICON}
// Every layer keeps a floor, so the drop never goes still and nothing snaps in from // Every layer keeps a floor, so the drop never goes still and nothing snaps in from
// nothing; the music only turns each layer up and down. // nothing; the music only turns each layer up and down.
@@ -103,7 +132,9 @@ float disp(vec3 p) {
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 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 dent = -0.18 * uPointer.w * exp(-pow(distance(p, uPointer.xyz) * 2.2, 2.0));
return clamp((lobes + sway + ripples + spikes + wave) * uGain + dent, -0.3, 0.4); // The icon stands out of the front like a stamp, and turns with the drop.
float stamp = (1.0 - smoothstep(-0.02, 0.05, icon(p.xy, uIcon))) * smoothstep(0.3, 0.6, p.z) * uIconA;
return clamp((lobes + sway + ripples + spikes + wave) * uGain + dent, -0.3, 0.4) * (1.0 - 0.6 * uIconA) + 0.09 * stamp;
} }
vec3 shape(vec3 p) { vec3 shape(vec3 p) {
@@ -142,9 +173,12 @@ 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, uIconA;
out vec4 outColor; out vec4 outColor;
${ICON}
vec3 hueRotate(vec3 c, float a) { vec3 hueRotate(vec3 c, float a) {
const vec3 k = vec3(0.57735); const vec3 k = vec3(0.57735);
float ca = cos(a); float ca = cos(a);
@@ -180,6 +214,12 @@ 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, crisp per pixel on top of the stamp, with a soft shadow round it.
float d = icon(vObj.xy, uIcon);
float aa = fwidth(d);
float front = smoothstep(0.3, 0.6, vObj.z) * uIconA;
col *= 1.0 - 0.5 * front * (1.0 - smoothstep(0.0, 0.12, d));
col = mix(col, vec3(0.96) + 0.4 * spec, front * (1.0 - smoothstep(-aa, aa, d)));
// 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);
+6
View File
@@ -151,6 +151,12 @@ export class Pager {
private move(e: PointerEvent): void { private move(e: PointerEvent): void {
const d = this.drag; const d = this.drag;
if (!d || e.pointerId !== d.id) return; if (!d || e.pointerId !== d.id) return;
// The button came up where we did not see it (outside the window, or while
// the drop was being turned).
if (e.pointerType === "mouse" && !(e.buttons & 1)) {
this.up(e, true);
return;
}
const dx = e.clientX - d.x0; const dx = e.clientX - d.x0;
const dy = e.clientY - d.y0; const dy = e.clientY - d.y0;
if (!d.active) { if (!d.active) {
+13 -4
View File
@@ -266,18 +266,27 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff;
height: 200%; height: 200%;
pointer-events: none; pointer-events: none;
} }
/* The halo stays behind the drop and pulses with it; the text sits on top where /* A halo stays behind the drop and pulses with it; the text sits on top where the
the drop spills over it. */ drop spills over it. The drop shows play and pause on itself (blob/shaders.ts). */
.card.liquid .art { .card.liquid .art {
z-index: 0; z-index: 0;
background-image: none !important; background-image: none !important;
background-color: transparent; background-color: transparent;
box-shadow: 0 0 calc(30px + var(--energy) * 80px) color-mix(in oklab, var(--card-accent) calc(18% + var(--energy) * 40%), transparent); box-shadow: none;
scale: 1; scale: 1;
} }
.card.liquid .art::before {
content: "";
position: absolute;
inset: -40%;
background: radial-gradient(closest-side, color-mix(in oklab, var(--card-accent) calc(20% + var(--energy) * 40%), transparent), transparent);
pointer-events: none;
}
.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 { display: none; } .card.liquid .initial, .card.liquid .paused-badge { display: none; }
/* Hidden, not removed: it takes itself away when its animation ends. */
.card.liquid .flash { visibility: hidden; }
@media (pointer: fine) { @media (pointer: fine) {
.card.liquid .art { cursor: grab; } .card.liquid .art { cursor: grab; }
.card.turning, .card.turning .art { cursor: grabbing; } .card.turning, .card.turning .art { cursor: grabbing; }
+9 -6
View File
@@ -15,7 +15,7 @@ import type { Player } from "./player";
export type VisualsSetting = "liquid" | "simple"; export type VisualsSetting = "liquid" | "simple";
const VISUALS_KEY = "pl8inf.visuals"; const VISUALS_KEY = "pl8inf.visuals";
const SLOP = 8; // 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
export class Visualizer { export class Visualizer {
@@ -137,8 +137,8 @@ export class Visualizer {
if (on) this.start(); if (on) this.start();
} }
// Dragging the drop turns it. On touch only a sideways drag does, as up and // Dragging the drop turns it. On touch only a clearly sideways drag does, as up
// down turn the page; a mouse can turn it any way. // and down turn the page; a mouse can turn it any way (the wheel turns pages).
private down(e: PointerEvent): void { private down(e: PointerEvent): void {
this.pointer(e, true); this.pointer(e, true);
@@ -158,16 +158,19 @@ export class Visualizer {
this.grab = null; this.grab = null;
return; return;
} }
// The button came up outside the window.
if (e.pointerType === "mouse" && !(e.buttons & 1)) {
this.up(e);
return;
}
const dx = e.clientX - g.x, dy = e.clientY - g.y; const dx = e.clientX - g.x, dy = e.clientY - g.y;
if (!g.on) { if (!g.on) {
if (Math.hypot(dx, dy) < SLOP) return; if (Math.hypot(dx, dy) < SLOP) return;
if (e.pointerType !== "mouse" && Math.abs(dy) >= Math.abs(dx)) { if (e.pointerType !== "mouse" && Math.abs(dx) < 2 * Math.abs(dy)) {
this.grab = null; this.grab = null;
return; return;
} }
g.on = true; g.on = true;
// Keep the moves when the mouse leaves the window.
document.documentElement.setPointerCapture(e.pointerId);
this.blob!.spin.hold(true); this.blob!.spin.hold(true);
this.card?.el?.classList.add("turning"); this.card?.el?.classList.add("turning");
} }