mirror of
https://github.com/narrrl/pl8list-rewind.git
synced 2026-11-03 16:41:26 +01:00
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:
1 parent
bcdca34244
commit
2dcca0bc94
9 files changed
+346
-110
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user