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>s</dt><dd>share</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>?</dt><dd>this help</dd>
|
||||
</dl>
|
||||
|
||||
+162
-42
@@ -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<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);
|
||||
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<HTMLCanvasElement> {
|
||||
/// 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<HTMLCanvasElement> {
|
||||
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<HTMLCanvasE
|
||||
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);
|
||||
} 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);
|
||||
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";
|
||||
|
||||
+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
|
||||
// (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);
|
||||
|
||||
+2
-1
@@ -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();
|
||||
|
||||
@@ -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<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_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<ArrayBuffer> | 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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
+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 .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; }
|
||||
}
|
||||
+52
-16
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user