Tap to listen, drop marks, bar-timed fades and solar flares

The first visit shows a welcome screen where one tap starts a guest
session; sign-in moves below it. Guests see a "sign in" pill, are
asked once to sign in after five likes or a first follow, and need
two taps to forget themselves.

Drops are found in the waveform and marked on it; "drop" and the d
key jump a bar before the next one. Long and dj-mix fades start on a
bar once the beat is locked. Radio sheets can be shared as /?r=handle.

The audio features now follow the bar, gaps before drops and the
wobble rate, and a new flare pass draws loops round the drop on each
kick, with a setting under visuals.
This commit is contained in:
nvrl committed 2026-11-02 22:03:14 +01:00
1 parent fbcff2266b
commit 62dde885aa
12 files changed
+836 -71

No files matched your search

+23 -10
View File
@@ -65,10 +65,7 @@
<div class="login-box"> <div class="login-box">
<h1><svg class="login-mark" aria-hidden="true"><use href="#i-rewind"/></svg>pl8list <b>rewind</b></h1> <h1><svg class="login-mark" aria-hidden="true"><use href="#i-rewind"/></svg>pl8list <b>rewind</b></h1>
<p class="tagline">endless riddim from pl8list. like it, and it comes back.</p> <p class="tagline">endless riddim from pl8list. like it, and it comes back.</p>
<div id="guest-start"> <p class="or">sign in with pl8list to bring your follows and likes along. What you did as a guest comes with you.</p>
<button type="button" id="guest-btn" class="primary"><svg><use href="#i-play"/></svg> start listening</button>
<p class="or">no account needed. or sign in with pl8list to bring your follows and likes along.</p>
</div>
<form id="email-form"> <form id="email-form">
<label for="email">sign in with your pl8list email</label> <label for="email">sign in with your pl8list email</label>
<input id="email" type="email" name="email" autocomplete="email" required placeholder="you@example.com"> <input id="email" type="email" name="email" autocomplete="email" required placeholder="you@example.com">
@@ -91,11 +88,10 @@
<button type="button" id="restart" class="link">use another email</button> <button type="button" id="restart" class="link">use another email</button>
</div> </div>
</form> </form>
<button type="button" id="login-cancel" class="link" hidden>cancel, keep using the app</button> <button type="button" id="login-cancel" class="link">cancel, keep listening</button>
<p id="login-error" role="alert"></p> <p id="login-error" role="alert"></p>
<p class="fineprint"> <p class="fineprint">
This site is not affiliated with pl8list. Without an account we keep what you play, like and This site is not affiliated with pl8list. Your email and code
follow under a random cookie, to pick tracks for you; signing out deletes it. Your email and code
go to pl8list, which signs this server in for you. Unless you choose to stay connected, we use go to pl8list, which signs this server in for you. Unless you choose to stay connected, we use
that pl8list login once to read your feed and library, then log it out; it is never stored. We that pl8list login once to read your feed and library, then log it out; it is never stored. We
keep your pl8list handle, the artists you follow and what you play and like. keep your pl8list handle, the artists you follow and what you play and like.
@@ -134,6 +130,12 @@
<label><input type="radio" name="visuals" value="liquid"> liquid</label> <label><input type="radio" name="visuals" value="liquid"> liquid</label>
<label><input type="radio" name="visuals" value="simple"> simple</label> <label><input type="radio" name="visuals" value="simple"> simple</label>
</fieldset> </fieldset>
<fieldset class="modes" id="flares-setting">
<legend>solar flares <small>on the liquid drop</small></legend>
<label title="on for riddim, tearout and 140 tracks"><input type="radio" name="flares" value="auto"> auto</label>
<label><input type="radio" name="flares" value="on"> on</label>
<label><input type="radio" name="flares" value="off"> off</label>
</fieldset>
<fieldset class="modes"> <fieldset class="modes">
<legend>crossfade</legend> <legend>crossfade</legend>
<label><input type="radio" name="crossfade" value="0"> cut</label> <label><input type="radio" name="crossfade" value="0"> cut</label>
@@ -199,9 +201,16 @@
<p id="feed-status" role="status" hidden></p> <p id="feed-status" role="status" hidden></p>
<div id="start" hidden> <div id="start" hidden>
<h1 class="start-logo"><svg aria-hidden="true"><use href="#i-rewind"/></svg>pl8list <b>rewind</b></h1>
<button type="button" class="start-play" aria-label="start playing"><svg><use href="#i-play"/></svg></button> <button type="button" class="start-play" aria-label="start playing"><svg><use href="#i-play"/></svg></button>
<p class="start-track"></p> <p class="start-track"></p>
<p class="start-hint">tap anywhere to start</p> <p class="start-hint">tap anywhere to start · no account needed</p>
<div class="start-welcome">
<button type="button" id="start-signin" class="link">or sign in with pl8list</button>
<p class="fineprint">Not affiliated with pl8list. Without an account we keep what you play, like and
follow under a random cookie, to pick tracks for you; "forget me" in the account menu deletes it.
<a href="/datenschutz">Datenschutz / privacy</a> · <a href="/impressum">Impressum</a></p>
</div>
</div> </div>
<button type="button" id="up-next" hidden> <button type="button" id="up-next" hidden>
@@ -228,6 +237,7 @@
<ol id="station-neighbours"></ol> <ol id="station-neighbours"></ol>
<p id="station-exploring" hidden>finding related artists…</p> <p id="station-exploring" hidden>finding related artists…</p>
<button type="button" id="station-start" class="primary"><svg><use href="#i-play"/></svg> start radio</button> <button type="button" id="station-start" class="primary"><svg><use href="#i-play"/></svg> start radio</button>
<button type="button" id="station-share" class="link">share this radio</button>
</section> </section>
</div> </div>
@@ -260,10 +270,13 @@
<dt>c</dt><dd>filters and settings</dd> <dt>c</dt><dd>filters and settings</dd>
<dt>h</dt><dd>history</dd> <dt>h</dt><dd>history</dd>
<dt>0–9 · , .</dt><dd>jump to 0–90 % · seek 5 s</dd> <dt>0–9 · , .</dt><dd>jump to 0–90 % · seek 5 s</dd>
<dt>d</dt><dd>jump to the next drop (the marks on the waveform)</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>
<p>Drag the liquid artwork sideways to spin it. Lock-screen and headphone buttons work too.</p> <p>Drag the liquid artwork sideways to spin it. On riddim the solar flares round it follow the
beat: a loop on each kick, the same crown every bar, a break on each snare. Lock-screen and
headphone buttons work too.</p>
<p class="pointer-only fineprint">Keys like r or x doing something else, such as reloading or <p class="pointer-only fineprint">Keys like r or x doing something else, such as reloading or
closing the tab? A vim-keys browser extension (Vimium and the like) takes them first: closing the tab? A vim-keys browser extension (Vimium and the like) takes them first:
turn it off for this site.</p> turn it off for this site.</p>
@@ -297,7 +310,7 @@
<canvas class="wave"></canvas> <canvas class="wave"></canvas>
<span class="wave-tip"></span> <span class="wave-tip"></span>
</div> </div>
<div class="time"><span class="cur">0:00</span><span class="counts"></span><span class="dur"></span></div> <div class="time"><span class="cur">0:00</span><button type="button" class="to-drop" title="jump to the drop (d)">drop <svg><use href="#i-down"/></svg></button><span class="counts"></span><span class="dur"></span></div>
</div> </div>
<nav class="actions"> <nav class="actions">
<button type="button" class="act like" title="like (l)" aria-label="like"><span class="ring"><svg><use href="#i-heart"/></svg></span><span class="n saves"></span></button> <button type="button" class="act like" title="like (l)" aria-label="like"><span class="ring"><svg><use href="#i-heart"/></svg></span><span class="n saves"></span></button>
+2
View File
@@ -139,6 +139,8 @@ export const trackUrl = (t: Track) => `${PL8}/${t.handle}/${t.slug}`;
/// A link that opens this app on the track. /// A link that opens this app on the track.
export const appTrackUrl = (t: Track) => `${location.origin}/?t=${encodeURIComponent(t.handle)}/${encodeURIComponent(t.slug)}`; export const appTrackUrl = (t: Track) => `${location.origin}/?t=${encodeURIComponent(t.handle)}/${encodeURIComponent(t.slug)}`;
export const artistUrl = (t: Track) => `${PL8}/${t.handle}`; export const artistUrl = (t: Track) => `${PL8}/${t.handle}`;
/// A link that opens this app on an artist's radio sheet.
export const appRadioUrl = (handle: string) => `${location.origin}/?r=${encodeURIComponent(handle)}`;
// Images come through our server, so pl8list never sees the visitor's address. // Images come through our server, so pl8list never sees the visitor's address.
export const imgUrl = (id: string | null) => (id ? `/img/${encodeURIComponent(id)}` : null); export const imgUrl = (id: string | null) => (id ? `/img/${encodeURIComponent(id)}` : null);
export const artistImg = (t: Track) => imgUrl(t.artist_img); export const artistImg = (t: Track) => imgUrl(t.artist_img);
+42 -4
View File
@@ -3,6 +3,7 @@
// page; cards around it show their artwork as a plain image. // page; cards around it show their artwork as a plain image.
import type { Features } from "../features"; import type { Features } from "../features";
import { Flares } from "./flares";
import { icosphere } from "./mesh"; import { icosphere } from "./mesh";
import { FRAGMENT, VERTEX } from "./shaders"; import { FRAGMENT, VERTEX } from "./shaders";
import { Spin } from "./spin"; import { Spin } from "./spin";
@@ -43,6 +44,11 @@ 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 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]];
/// Solar flare loops at most, per quality step.
const FLARE_LOOPS = [10, 8, 6, 4];
/// Solar flares: "auto" shows them where the track's look asks for them (style.ts).
export type FlareSetting = "auto" | "on" | "off";
const MAX_PIXELS = 2.6e6; // canvas pixels at full quality, about 1600 x 1600 const MAX_PIXELS = 2.6e6; // canvas pixels at full quality, about 1600 x 1600
const UNIFORMS = [ const UNIFORMS = [
@@ -75,6 +81,13 @@ export class LiquidBlob {
/// Turning it by hand. /// Turning it by hand.
readonly spin = new Spin(); readonly spin = new Spin();
private gl: WebGL2RenderingContext; private gl: WebGL2RenderingContext;
private prog!: WebGLProgram;
/// The loops round the drop; null if their shaders failed, which leaves the drop.
private flares: Flares | null = null;
flareSetting: FlareSetting = "auto";
private proj = perspective(FOV, 1, 0.1, 20);
private scaleNow: [number, number, number] = [1, 1, 1];
private liftNow = 0;
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[] = [];
@@ -159,6 +172,7 @@ export class LiquidBlob {
gl.linkProgram(prog); gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog) || "link failed"); if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog) || "link failed");
gl.useProgram(prog); gl.useProgram(prog);
this.prog = 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.buffers = []; this.buffers = [];
@@ -169,13 +183,20 @@ export class LiquidBlob {
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.uniform1i(this.u.uArt, 0); gl.uniform1i(this.u.uArt, 0);
gl.uniformMatrix4fv(this.u.uProj, false, perspective(FOV, 1, 0.1, 20)); gl.uniformMatrix4fv(this.u.uProj, false, this.proj);
gl.uniform1f(this.u.uDist, DIST); gl.uniform1f(this.u.uDist, DIST);
gl.enable(gl.DEPTH_TEST); gl.enable(gl.DEPTH_TEST);
// The edge fades into the page; the canvas is premultiplied. // The edge fades into the page; the canvas is premultiplied.
gl.enable(gl.BLEND); gl.enable(gl.BLEND);
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
gl.clearColor(0, 0, 0, 0); gl.clearColor(0, 0, 0, 0);
try {
this.flares = new Flares(gl, FLARE_LOOPS[this.quality]);
} catch (err) {
this.flares = null;
console.warn("no solar flares:", (err as Error).message);
}
gl.useProgram(prog);
} }
private shader(type: number, src: string): WebGLShader { private shader(type: number, src: string): WebGLShader {
@@ -215,6 +236,7 @@ export class LiquidBlob {
this.morph = 0; this.morph = 0;
this.slow = this.fast = 0; this.slow = this.fast = 0;
this.spin.reset(); this.spin.reset();
this.flares?.reset();
this.iconFor = null; this.iconFor = null;
const source = await artwork(art, initial, tint).catch(() => artwork(null, initial, tint)); 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;
@@ -251,6 +273,7 @@ export class LiquidBlob {
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;
gl.useProgram(this.prog);
const { playing } = s; const { playing } = s;
if (playing) this.watch(dt); if (playing) this.watch(dt);
const cw = this.canvas.clientWidth, ch = this.canvas.clientHeight; const cw = this.canvas.clientWidth, ch = this.canvas.clientHeight;
@@ -307,7 +330,18 @@ export class LiquidBlob {
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.bindVertexArray(this.vao); gl.bindVertexArray(this.vao);
gl.drawElements(gl.TRIANGLES, this.count, gl.UNSIGNED_SHORT, 0); gl.drawElements(gl.TRIANGLES, this.count, gl.UNSIGNED_SHORT, 0);
return this.morph < 1 || moving || spinning || showing || this.pointer[3] > 0.01 || this.pointerGoal[3] > 0; const strength = this.flareSetting === "off" ? 0 : this.flareSetting === "on" ? Math.max(1, look.flares) : look.flares;
// Not while the drop is still flat artwork, nor over the icon.
const flaring = !!this.flares && this.flares.frame(f, dt, look, playing && morph > 0.9, strength * (1 - this.calm), f.period, {
proj: this.proj,
dist: DIST,
rot: this.spin.matrix,
scale: this.scaleNow,
lift: this.liftNow,
accent: this.accent,
toObject: (x, y, z) => this.spin.toObject(x, y, z),
});
return this.morph < 1 || moving || spinning || showing || flaring || this.pointer[3] > 0.01 || this.pointerGoal[3] > 0;
} }
/// Canvas pixels per CSS pixel at full quality. Phones stay at 1.5 at most, for /// Canvas pixels per CSS pixel at full quality. Phones stay at 1.5 at most, for
@@ -344,8 +378,10 @@ export class LiquidBlob {
const whirl = Math.min(1, this.spin.speed / WHIRL); const whirl = Math.min(1, this.spin.speed / WHIRL);
const side = (size / Math.sqrt(stretch)) * (1 + 0.08 * whirl); const side = (size / Math.sqrt(stretch)) * (1 + 0.08 * whirl);
const u = this.u; const u = this.u;
this.gl.uniform3f(u.uScale, side, size * stretch * (1 - 0.06 * whirl), side); this.scaleNow = [side, size * stretch * (1 - 0.06 * whirl), side];
this.gl.uniform1f(u.uLift, Math.max(0, jump[0])); this.liftNow = Math.max(0, jump[0]);
this.gl.uniform3f(u.uScale, ...this.scaleNow);
this.gl.uniform1f(u.uLift, this.liftNow);
this.gl.uniform1f(u.uJelly, Math.min(1, Math.abs(jump[1]) * 0.4 + Math.abs(pump[1]) * 0.6 + 0.5 * whirl)); this.gl.uniform1f(u.uJelly, Math.min(1, Math.abs(jump[1]) * 0.4 + Math.abs(pump[1]) * 0.6 + 0.5 * whirl));
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;
} }
@@ -409,6 +445,7 @@ export class LiquidBlob {
`vertex highp ${hp ? hp.precision : "?"} bits`, `vertex highp ${hp ? hp.precision : "?"} bits`,
`ready ${this.ready} · lost ${gl.isContextLost()} · error ${gl.getError()}`, `ready ${this.ready} · lost ${gl.isContextLost()} · error ${gl.getError()}`,
`morph ${this.morph.toFixed(2)} · quality ${this.quality} · canvas ${this.canvas.width}x${this.canvas.height}`, `morph ${this.morph.toFixed(2)} · quality ${this.quality} · canvas ${this.canvas.width}x${this.canvas.height}`,
`flares ${this.flares ? this.flareSetting : "failed"} · look ${this.look?.flares ?? "-"}`,
`in page ${this.canvas.isConnected} · css ${this.canvas.clientWidth}x${this.canvas.clientHeight}`, `in page ${this.canvas.isConnected} · css ${this.canvas.clientWidth}x${this.canvas.clientHeight}`,
].join("\n"); ].join("\n");
} }
@@ -438,6 +475,7 @@ export class LiquidBlob {
const before = QUALITY[this.quality][0]; const before = QUALITY[this.quality][0];
this.quality = q; this.quality = q;
if (QUALITY[q][0] !== before) this.buildMesh(); if (QUALITY[q][0] !== before) this.buildMesh();
this.flares?.setMax(FLARE_LOOPS[q]);
} }
} }
+357
View File
@@ -0,0 +1,357 @@
// Solar flares round the drop: coronal loops, the magnetic arcs that leave a star at
// one point and land at another, played by the music.
//
// - Each kick raises a loop. Where it rises follows the beat, so a riddim, which
// puts the same kick on the same beat bar after bar, draws the same crown again
// every bar (`Look.crown` positions, `Look.scatter` off them).
// - The bass line's envelope lifts and drops every loop: each "womb" swells them.
// - Bright knots of plasma run along each loop once per womb.
// - The wobble's filter sets the heat: a dark "oo" glows deep red, a bright "wah"
// burns yellow to white, and twists the loops.
// - A snare breaks a loop at its top and throws the top out, like an eruption.
// - In a gap (the stop before a drop) the loops sink down and plasma rains back
// to their feet; the drop then sets everything off at once.
//
// The loops are drawn after the drop, in the same WebGL2 context, added onto it.
// They test against its depth, so the drop hides the parts behind it.
import type { Features } from "../features";
import type { Look } from "./style";
const SEGMENTS = 40; // along each loop
const FLOATS = 16; // per loop in the instance buffer
const RISE = 0.18; // s for a new loop to stand up
const FADE = 0.35; // s for an old one to go
const ERUPT = 0.7; // s from the break until the loop is gone
const KICK_MIN = 0.2; // weaker kicks raise no loop
const SPAN = [0.22, 0.42]; // half the distance between a loop's feet, rad
const WIDTH = 0.03; // across a loop, in radii
const HEAT_SMOOTH = 0.12; // s
const DROP_BURST = 0.55; // the drop rising through this sets off a burst of loops
interface Loop {
/// Feet, unit vectors in the drop's own space, so they turn with it.
a: [number, number, number];
b: [number, number, number];
age: number;
/// Seconds it lives, from the tempo.
life: number;
/// Seconds since it broke, or -1.
erupt: number;
seed: number;
/// Knots travelled, in loop lengths.
phase: number;
height: number;
}
export const FLARE_VERTEX = /* glsl */ `#version 300 es
precision highp float;
layout(location = 0) in vec2 aStrip; // along 0..1, across -1..1
layout(location = 1) in vec3 aA;
layout(location = 2) in vec3 aB;
layout(location = 3) in vec4 aP; // height, alpha, heat, seed
layout(location = 4) in vec4 aQ; // erupt 0..1, twist, width, phase
uniform mat4 uProj;
uniform mat3 uRot;
uniform vec3 uScale;
uniform float uLift, uDist, uTime;
out float vT;
out float vSide;
out float vAlpha;
out float vHeat;
out float vPhase;
out float vErupt;
// The loop's centre line at t, in view space.
vec3 along(float t) {
float om = acos(clamp(dot(aA, aB), -1.0, 1.0));
vec3 base = om < 0.001 ? aA : (sin((1.0 - t) * om) * aA + sin(t * om) * aB) / sin(om);
float arch = sin(3.14159265 * t);
float e = aQ.x;
// Broken, the legs sink and the top is thrown out.
float top = smoothstep(0.7, 0.98, arch);
float h = aP.x * arch * (1.0 - 0.6 * e) + e * (0.25 + 0.5 * e) * top;
vec3 p = base * (1.0 + h);
// A twist: the loop winds round its own line, more at the top.
vec3 side = normalize(cross(base, aB - aA + vec3(1e-4)));
p += side * aQ.y * 0.06 * sin(t * 12.566 + uTime * 3.0 + aP.w * 6.283) * arch;
return uRot * p * uScale + vec3(0.0, uLift, -uDist);
}
void main() {
float t = aStrip.x;
vec3 v = along(t);
vec3 ahead = along(min(1.0, t + 0.01)) - along(max(0.0, t - 0.01));
vec3 across = normalize(cross(ahead, v) + vec3(1e-5));
// Thicker legs, thin at the top, like the real ones look.
float arch = sin(3.14159265 * t);
v += across * aStrip.y * aQ.z * (1.0 - 0.35 * arch);
vT = t;
vSide = aStrip.y;
vAlpha = aP.y;
vHeat = aP.z;
vPhase = aQ.w;
vErupt = aQ.x;
gl_Position = uProj * vec4(v, 1.0);
}
`;
export const FLARE_FRAGMENT = /* glsl */ `#version 300 es
precision highp float;
in float vT;
in float vSide;
in float vAlpha;
in float vHeat;
in float vPhase;
in float vErupt;
uniform vec3 uAccent;
uniform float uTint;
uniform float uRain; // 0..1: plasma runs down to the feet instead of along
uniform float uFlash; // the feet light up on a drop
out vec4 outColor;
// Glowing plasma from cool to hot: deep red, orange, yellow, white.
vec3 heat(float h) {
vec3 c = mix(vec3(0.45, 0.02, 0.0), vec3(1.0, 0.3, 0.02), smoothstep(0.0, 0.35, h));
c = mix(c, vec3(1.0, 0.78, 0.25), smoothstep(0.35, 0.7, h));
return mix(c, vec3(1.0, 0.97, 0.9), smoothstep(0.7, 1.0, h));
}
void main() {
float across = 1.0 - abs(vSide);
float core = pow(across, 6.0);
float glow = pow(across, 1.5);
// Knots: along the loop, or from its top down both legs while it rains.
float s = mix(vT, 1.0 - abs(vT - 0.5) * 2.0, uRain);
float k = fract(s * 2.0 - vPhase);
float knot = smoothstep(0.0, 0.06, k) * (1.0 - smoothstep(0.06, 0.32, k));
float feet = 1.0 - smoothstep(0.0, 0.14, min(vT, 1.0 - vT));
vec3 hot = heat(vHeat);
vec3 col = hot * (0.35 * glow + 0.8 * core);
col += heat(min(1.0, vHeat + 0.35)) * knot * (0.3 * glow + 1.1 * core);
col += heat(min(1.0, vHeat + 0.5)) * feet * glow * (0.25 + 1.2 * uFlash);
// Leaning towards the card's colours, keeping the brightness.
col = mix(col, uAccent * dot(col, vec3(0.6)), uTint);
// A broken loop fades from the break outwards.
col *= vAlpha * (1.0 - vErupt * smoothstep(0.0, 0.5, 1.0 - abs(vT - 0.5) * 2.0) * 0.5);
// Added onto what is there; premultiplied, so alpha is the brightest channel.
outColor = vec4(col, max(col.r, max(col.g, col.b)));
}
`;
const FLARE_UNIFORMS = ["uProj", "uRot", "uScale", "uLift", "uDist", "uTime", "uAccent", "uTint", "uRain", "uFlash"] as const;
/// What the drop shares with its loops each frame.
export interface FlareFrame {
proj: Float32Array;
dist: number;
rot: Float32Array;
scale: [number, number, number];
lift: number;
accent: [number, number, number];
/// View direction to the drop's own space (spin.ts).
toObject: (x: number, y: number, z: number) => [number, number, number];
}
export class Flares {
private prog!: WebGLProgram;
private vao!: WebGLVertexArrayObject;
private strip!: WebGLBuffer;
private inst!: WebGLBuffer;
private u = {} as Record<(typeof FLARE_UNIFORMS)[number], WebGLUniformLocation | null>;
private data: Float32Array;
private loops: Loop[] = [];
private heat = 0.3;
private time = 0;
private lastDrop = 0;
/// Where on the crown the next loop goes, for tracks with scatter.
private turn = Math.random();
constructor(
private gl: WebGL2RenderingContext,
private max: number,
) {
this.data = new Float32Array(max * FLOATS);
this.init();
}
/// Everything on the GPU; again after a lost context comes back.
init(): void {
const gl = this.gl;
const prog = gl.createProgram();
for (const [type, src] of [[gl.VERTEX_SHADER, FLARE_VERTEX], [gl.FRAGMENT_SHADER, FLARE_FRAGMENT]] as const) {
const s = gl.createShader(type)!;
gl.shaderSource(s, src);
gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s) || "flare shader failed");
gl.attachShader(prog, s);
}
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog) || "flare link failed");
this.prog = prog;
for (const name of FLARE_UNIFORMS) this.u[name] = gl.getUniformLocation(prog, name);
this.vao = gl.createVertexArray();
gl.bindVertexArray(this.vao);
const strip = new Float32Array((SEGMENTS + 1) * 4);
for (let i = 0; i <= SEGMENTS; i++) strip.set([i / SEGMENTS, -1, i / SEGMENTS, 1], i * 4);
this.strip = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, this.strip);
gl.bufferData(gl.ARRAY_BUFFER, strip, gl.STATIC_DRAW);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
this.inst = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, this.inst);
gl.bufferData(gl.ARRAY_BUFFER, this.data.byteLength, gl.DYNAMIC_DRAW);
const stride = FLOATS * 4;
for (const [loc, size, offset] of [[1, 3, 0], [2, 3, 3], [3, 4, 6], [4, 4, 10]]) {
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc, size, gl.FLOAT, false, stride, offset * 4);
gl.vertexAttribDivisor(loc, 1);
}
gl.bindVertexArray(null);
}
/// A new track: no loops carried over.
reset(): void {
this.loops = [];
this.lastDrop = 0;
}
/// Fewer loops on slow devices.
setMax(max: number): void {
this.max = max;
if (this.loops.length > max) this.loops.length = max;
if (this.data.length < max * FLOATS) {
this.data = new Float32Array(max * FLOATS);
const gl = this.gl;
gl.bindBuffer(gl.ARRAY_BUFFER, this.inst);
gl.bufferData(gl.ARRAY_BUFFER, this.data.byteLength, gl.DYNAMIC_DRAW);
}
}
get active(): boolean {
return this.loops.length > 0;
}
/// Step the loops by `dt` and draw them. `strength` 0 lets them die out.
/// True while there are loops left.
frame(f: Features, dt: number, look: Look, playing: boolean, strength: number, period: number, view: FlareFrame): boolean {
const on = playing && strength > 0;
this.time += dt;
this.heat += (Math.min(1, 0.3 + 0.55 * f.growl * Math.max(0.4, f.bass) + 0.35 * f.drop) - this.heat) * (1 - Math.exp(-dt / HEAT_SMOOTH));
if (on && f.kickHit >= KICK_MIN && f.gap < 0.5) this.spawn(f, look, period, view);
// The drop: a burst all round.
if (on && f.drop > DROP_BURST && this.lastDrop <= DROP_BURST) {
for (let i = 0; i < Math.ceil(this.max / 2); i++) this.spawn(f, look, period, view, true);
}
this.lastDrop = f.drop;
if (on && f.snareHit >= look.erupt) {
const victim = this.loops.filter((l) => l.erupt < 0 && l.age > RISE).sort((x, y) => y.age - x.age)[0];
if (victim) victim.erupt = 0;
}
// Knots run once along a loop per womb, or with the beat while there is no wobble.
const rate = f.wobbleRate > 0.3 ? f.wobbleRate : 0.5 / period;
const want = look.arch * (0.3 + 0.55 * f.bass + 0.3 * f.drop) * (1 - 0.8 * f.gap);
const follow = 1 - Math.exp(-dt / 0.06);
this.loops = this.loops.filter((l) => {
l.age += dt;
l.phase += dt * rate * (1 - 0.5 * f.gap);
l.height += (want * (0.85 + 0.3 * l.seed) - l.height) * follow;
if (l.erupt >= 0) l.erupt += dt;
// Without music they go, quicker than they would.
if (!on) l.life = Math.min(l.life, l.age + FADE);
return l.erupt < ERUPT && l.age < l.life;
});
if (!this.loops.length) return false;
const d = this.data;
const n = Math.min(this.loops.length, this.max);
for (let i = 0; i < n; i++) {
const l = this.loops[i];
const rise = Math.min(1, l.age / RISE);
const fade = Math.min(1, (l.life - l.age) / FADE);
const alpha = rise * rise * (3 - 2 * rise) * Math.max(0, fade) * Math.max(strength, on ? 0 : 1);
const e = l.erupt < 0 ? 0 : l.erupt / ERUPT;
d.set([
...l.a, ...l.b,
l.height * rise, alpha * (1 - e * e), this.heat, l.seed,
e, 0.2 + 1.2 * f.growl * f.bass + 0.3 * f.wobble, WIDTH * (1 + 0.6 * f.drop), l.phase,
0, 0,
], i * FLOATS);
}
const gl = this.gl;
const u = this.u;
gl.useProgram(this.prog);
gl.uniformMatrix4fv(u.uProj, false, view.proj);
gl.uniformMatrix3fv(u.uRot, false, view.rot);
gl.uniform3f(u.uScale, ...view.scale);
gl.uniform1f(u.uLift, view.lift);
gl.uniform1f(u.uDist, view.dist);
gl.uniform1f(u.uTime, this.time);
gl.uniform3fv(u.uAccent, view.accent);
gl.uniform1f(u.uTint, look.tint);
gl.uniform1f(u.uRain, f.gap);
gl.uniform1f(u.uFlash, f.drop);
gl.bindVertexArray(this.vao);
gl.bindBuffer(gl.ARRAY_BUFFER, this.inst);
gl.bufferSubData(gl.ARRAY_BUFFER, 0, d, 0, n * FLOATS);
// Added on, tested against the drop but not hiding each other.
gl.depthMask(false);
gl.blendFunc(gl.ONE, gl.ONE);
gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, (SEGMENTS + 1) * 2, n);
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
gl.depthMask(true);
gl.bindVertexArray(null);
return true;
}
/// A new loop where the beat says: round the rim of the drop as seen, straddling
/// it, so it arcs out past the edge.
private spawn(f: Features, look: Look, period: number, view: FlareFrame, anywhere = false): void {
if (this.loops.length >= this.max) {
// The oldest that is not breaking makes room.
const oldest = this.loops.reduce((o, l) => (l.erupt < 0 && l.age > o.age ? l : o), this.loops[0]);
oldest.life = Math.min(oldest.life, oldest.age + FADE);
if (this.loops.length >= this.max + 2) return;
}
const crown = Math.max(1, Math.round(look.crown));
let slot: number;
if (anywhere) slot = Math.random() * crown;
else if (look.scatter === 0) slot = Math.round(f.bar * crown) % crown;
else {
this.turn = (this.turn + 1 + (Math.random() - 0.5) * look.scatter) % crown;
slot = this.turn;
}
// Upright crowns read best: the first slot sits at the top.
const angle = Math.PI / 2 - (slot / crown) * Math.PI * 2;
const z = -0.1 + Math.random() * 0.4;
const r = Math.sqrt(1 - z * z);
const c: [number, number, number] = [Math.cos(angle) * r, Math.sin(angle) * r, z];
const tx = -Math.sin(angle), ty = Math.cos(angle);
const span = SPAN[0] + Math.random() * (SPAN[1] - SPAN[0]);
const foot = (sgn: number) => {
const x = c[0] + tx * span * sgn, y = c[1] + ty * span * sgn, zz = c[2];
const len = Math.hypot(x, y, zz);
return view.toObject(x / len, y / len, zz / len);
};
this.loops.push({
a: foot(1),
b: foot(-1),
age: 0,
life: look.life * period * (0.9 + 0.2 * Math.random()),
erupt: -1,
seed: Math.random(),
phase: Math.random(),
height: 0,
});
}
}
+37 -8
View File
@@ -29,26 +29,55 @@ export interface Look {
heat: number; heat: number;
/// Rim hue offset from the key, radians. /// Rim hue offset from the key, radians.
hue: number; hue: number;
/// Solar flares (flares.ts): 0 off, 1 full. "auto" in the settings shows them
/// where this is above 0.
flares: number;
/// Loop positions round the drop: one per beat for a riddim (4), so each bar
/// draws the same crown again.
crown: number;
/// How far off those positions a loop may land, 0..1 of the gap between them.
scatter: number;
/// Loop height over the surface, in radii.
arch: number;
/// Beats a loop lives.
life: number;
/// Snares weaker than this break no loop.
erupt: number;
/// How far the loops' colour leans from the sun's towards the card's accent.
tint: number;
} }
const BASE: Look = { flow: 1, gain: 1, spike: 0.5, step: 0, throb: 1, bounce: 1, stiff: 90, twist: 0.15, ripple: 1, shock: 1, iri: 0.1, metal: 0.3, heat: 0, hue: 0 }; const BASE: Look = {
flow: 1, gain: 1, spike: 0.5, step: 0, throb: 1, bounce: 1, stiff: 90, twist: 0.15, ripple: 1, shock: 1, iri: 0.1, metal: 0.3, heat: 0, hue: 0,
flares: 0, crown: 6, scatter: 0.4, arch: 0.5, life: 4, erupt: 0.45, tint: 0.15,
};
const TAGS: Record<string, (l: Look) => void> = { const TAGS: Record<string, (l: Look) => void> = {
riddim: (l) => Object.assign(l, { step: 0.5, spike: 0.6, throb: 1.4, bounce: 1.3, stiff: 120, metal: 0.9, shock: 1.4, ripple: 1.2 }), // Riddim: the loops snap to the beat and the crown repeats every bar.
tearout: (l) => Object.assign(l, { spike: 1.3, bounce: 1.4, gain: l.gain * 1.15, heat: 0.7 }), riddim: (l) => Object.assign(l, {
deathstep: (l) => Object.assign(l, { spike: 1.4, bounce: 1.4, gain: l.gain * 1.15, heat: 0.9, metal: 0.7 }), step: 0.5, spike: 0.6, throb: 1.4, bounce: 1.3, stiff: 120, metal: 0.9, shock: 1.4, ripple: 1.2,
melodic: (l) => Object.assign(l, { iri: 0.8, spike: 0.15, throb: 0.7, bounce: 0.6, stiff: 55, gain: l.gain * 0.9, flow: l.flow * 0.8, metal: 0.1 }), flares: 1, crown: 4, scatter: 0, arch: 0.55, life: 4, erupt: 0.4,
colourbass: (l) => Object.assign(l, { iri: 1, spike: 0.25, bounce: 0.8, stiff: 70, metal: 0.15 }), }),
tearout: (l) => Object.assign(l, { spike: 1.3, bounce: 1.4, gain: l.gain * 1.15, heat: 0.7, flares: 1, crown: 6, scatter: 0.5, erupt: 0.25 }),
deathstep: (l) => Object.assign(l, { spike: 1.4, bounce: 1.4, gain: l.gain * 1.15, heat: 0.9, metal: 0.7, flares: 1, crown: 6, scatter: 0.6, erupt: 0.2 }),
melodic: (l) => Object.assign(l, {
iri: 0.8, spike: 0.15, throb: 0.7, bounce: 0.6, stiff: 55, gain: l.gain * 0.9, flow: l.flow * 0.8, metal: 0.1,
flares: 0, crown: 5, scatter: 0.3, arch: 0.8, life: 8, erupt: 0.8, tint: 0.5,
}),
colourbass: (l) => Object.assign(l, { iri: 1, spike: 0.25, bounce: 0.8, stiff: 70, metal: 0.15, tint: 0.6 }),
dnb: (l) => Object.assign(l, { ripple: 1.6 }), dnb: (l) => Object.assign(l, { ripple: 1.6 }),
"170": (l) => Object.assign(l, { ripple: 1.5 }), "170": (l) => Object.assign(l, { ripple: 1.5 }),
halftime: (l) => Object.assign(l, { flow: l.flow * 0.7, gain: l.gain * 1.15, stiff: 70 }), halftime: (l) => Object.assign(l, { flow: l.flow * 0.7, gain: l.gain * 1.15, stiff: 70, crown: 2, arch: 0.7 }),
wonk: (l) => Object.assign(l, { twist: 0.9 }), wonk: (l) => Object.assign(l, { twist: 0.9 }),
}; };
export function lookFor(t: Track): Look { export function lookFor(t: Track): Look {
// About one noise cycle per bar. // About one noise cycle per bar.
const l: Look = { ...BASE, flow: ((t.bpm || 140) / 240) * 0.9 }; const l: Look = { ...BASE, flow: ((t.bpm || 140) / 240) * 0.9 };
for (const tag of t.tags ?? []) TAGS[tag]?.(l); const tags = t.tags ?? [];
// Untagged dubstep tempo counts too: most riddim carries no riddim tag.
if (tags.includes("140")) l.flares = 1;
for (const tag of tags) TAGS[tag]?.(l);
l.hue = keyHue(t.key); l.hue = keyHue(t.key);
return l; return l;
} }
+52
View File
@@ -15,6 +15,8 @@ export interface CardActions {
canSeek(card: Card): boolean; canSeek(card: Card): boolean;
duration(card: Card): number; duration(card: Card): number;
seek(card: Card, seconds: number): void; seek(card: Card, seconds: number): void;
/// Jump to the next drop of the playing card.
toDrop(card: Card): void;
} }
// Uploads are often titled after their file name. // Uploads are often titled after their file name.
@@ -26,6 +28,39 @@ export const compact = (n: number) =>
const NEW_DAYS = 7; // uploads younger than this get a "new" chip const NEW_DAYS = 7; // uploads younger than this get a "new" chip
const BAR = 3; // px per waveform bar, gap included const BAR = 3; // px per waveform bar, gap included
const MIRROR = 0.28; // share of the waveform's height its reflection takes const MIRROR = 0.28; // share of the waveform's height its reflection takes
// Drops from the waveform: the level over a short window jumping well above the
// window before it, and loud. Tuned on riddim and dubstep, where the drop is the
// loudest, densest part and the build before it thins out.
const DROP_WINDOW = 0.035; // of the track, on each side of a drop
const DROP_RISE = 0.22; // the window after is this much louder (0..1 of the peak) …
const DROP_LOUD = 0.55; // … and at least this loud
const DROP_SKIP = 0.06; // no drop in the first bit: that is the track starting
const DROP_APART = 0.12; // of the track between two drops
const MAX_DROPS = 3;
/// Where the drops are, 0..1 of the track, from its waveform.
export function findDrops(data: number[] | null | undefined): number[] {
if (!data || data.length < 40) return [];
const max = Math.max(1, ...data);
const v = data.map((x) => x / max);
const n = v.length;
const w = Math.max(2, Math.round(n * DROP_WINDOW));
const sum = [0];
for (const x of v) sum.push(sum[sum.length - 1] + x);
const mean = (a: number, b: number) => (sum[b] - sum[a]) / (b - a);
const cands: { at: number; rise: number }[] = [];
for (let i = Math.max(w, Math.round(n * DROP_SKIP)); i <= n - w; i++) {
const after = mean(i, i + w), before = mean(i - w, i);
if (after - before >= DROP_RISE && after >= DROP_LOUD) cands.push({ at: i, rise: after - before });
}
cands.sort((a, b) => b.rise - a.rise);
const out: number[] = [];
for (const c of cands) {
if (out.length >= MAX_DROPS) break;
if (out.every((o) => Math.abs(o - c.at) >= n * DROP_APART)) out.push(c.at);
}
return out.sort((a, b) => a - b).map((i) => i / n);
}
const tpl = document.getElementById("card-tpl") as HTMLTemplateElement; const tpl = document.getElementById("card-tpl") as HTMLTemplateElement;
@@ -47,9 +82,12 @@ export class Card {
) { ) {
this.liked = liked; this.liked = liked;
this.station = station; this.station = station;
this.drops = findDrops(track.waveform);
} }
private station: string | undefined; private station: string | undefined;
/// Drops in the waveform, 0..1 of the track.
readonly drops: number[];
element(): HTMLElement { element(): HTMLElement {
return this.el ?? this.build(); return this.el ?? this.build();
@@ -131,6 +169,9 @@ export class Card {
$(".next").addEventListener("click", () => this.actions.next(this)); $(".next").addEventListener("click", () => this.actions.next(this));
$(".follow").addEventListener("click", () => this.actions.follow(this)); $(".follow").addEventListener("click", () => this.actions.follow(this));
$(".radio").addEventListener("click", () => this.actions.radio(this)); $(".radio").addEventListener("click", () => this.actions.radio(this));
const toDrop = $(".to-drop");
toDrop.hidden = !this.drops.length;
toDrop.addEventListener("click", () => this.actions.toDrop(this));
this.el = el; this.el = el;
this.canvas = $<HTMLCanvasElement>(".wave"); this.canvas = $<HTMLCanvasElement>(".wave");
@@ -243,6 +284,17 @@ export class Card {
ctx.fill(); ctx.fill();
} }
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
// A small mark over each drop.
for (const d of this.drops) {
const x = Math.round(d * bars) * BAR + (BAR - 1) / 2;
ctx.fillStyle = d < progress ? accent : "rgba(255,255,255,0.75)";
ctx.beginPath();
ctx.moveTo(x - 3.5, 0);
ctx.lineTo(x + 3.5, 0);
ctx.lineTo(x, 5);
ctx.closePath();
ctx.fill();
}
} }
/// Loudness at `progress` from the waveform, 0..1. Stands in for the analyser /// Loudness at `progress` from the waveform, 0..1. Stands in for the analyser
+4 -4
View File
@@ -4,7 +4,7 @@
import type { Features } from "./features"; import type { Features } from "./features";
const KEYS: (keyof Features)[] = ["sub", "low", "mid", "high", "bass", "growl", "kick", "snare", "wobble", "drop"]; const KEYS: (keyof Features)[] = ["sub", "low", "mid", "high", "bass", "growl", "kick", "snare", "wobble", "drop", "gap", "bar"];
/// Onsets counted since the overlay opened; also on `window.audioDebug` for scripts. /// Onsets counted since the overlay opened; also on `window.audioDebug` for scripts.
const counts = { kicks: 0, snares: 0, frames: 0 }; const counts = { kicks: 0, snares: 0, frames: 0 };
@@ -14,7 +14,7 @@ export function audioDebug(): (f: Features) => void {
let lastAge = 99, lastSnare = 0; let lastAge = 99, lastSnare = 0;
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
canvas.className = "audio-debug"; canvas.className = "audio-debug";
const w = (canvas.width = 300), h = (canvas.height = 124); const w = (canvas.width = 360), h = (canvas.height = 124);
document.body.append(canvas); document.body.append(canvas);
const ctx = canvas.getContext("2d")!; const ctx = canvas.getContext("2d")!;
ctx.font = "10px system-ui, sans-serif"; ctx.font = "10px system-ui, sans-serif";
@@ -27,10 +27,10 @@ export function audioDebug(): (f: Features) => void {
lastSnare = f.snare; lastSnare = f.snare;
ctx.clearRect(0, 0, w, h); ctx.clearRect(0, 0, w, h);
ctx.fillStyle = "#fff9"; ctx.fillStyle = "#fff9";
ctx.fillText(`kicks ${counts.kicks} · snares ${counts.snares}`, 4, 11); ctx.fillText(`kicks ${counts.kicks} · snares ${counts.snares} · beat ${f.beatNo + 1} · womb ${f.wobbleRate.toFixed(1)} Hz`, 4, 11);
KEYS.forEach((k, i) => { KEYS.forEach((k, i) => {
const v = Math.min(1, Math.max(0, f[k])); const v = Math.min(1, Math.max(0, f[k]));
ctx.fillStyle = k === "kick" || k === "snare" ? "#ff4d6d" : k === "drop" ? "#ffb02e" : "#7fd4ff"; ctx.fillStyle = k === "kick" || k === "snare" ? "#ff4d6d" : k === "drop" || k === "gap" ? "#ffb02e" : "#7fd4ff";
ctx.fillRect(i * bar + 3, h - 14 - v * (h - 30), bar - 6, v * (h - 30)); ctx.fillRect(i * bar + 3, h - 14 - v * (h - 30), bar - 6, v * (h - 30));
ctx.fillStyle = "#fff9"; ctx.fillStyle = "#fff9";
ctx.fillText(k, i * bar + 3, h - 3); ctx.fillText(k, i * bar + 3, h - 3);
+97 -2
View File
@@ -33,6 +33,21 @@ export interface Features {
growl: number; growl: number;
/// 0..1, high while the low end sits well above its recent average. /// 0..1, high while the low end sits well above its recent average.
drop: number; drop: number;
/// 0..1, high while the low end has dropped out after sounding: the stop before a
/// drop, the gaps a riddim leaves.
gap: number;
/// How often the bass line goes "womb", Hz; 0 until there were a few.
wobbleRate: number;
/// Where the music is in its bar, from the track's tempo locked onto the kicks:
/// `beat` 0..1 within the beat, `beatNo` 0..3, `bar` 0..1 within the bar.
beat: number;
beatNo: number;
bar: number;
/// Seconds per beat.
period: number;
/// How sure the bar is: the strength of recent kicks on the downbeat. Above
/// about 2 the downbeat has been hit several times in the last seconds.
lock: number;
} }
const BANDS = { sub: [30, 90], low: [90, 250], mid: [250, 2000], high: [2000, 10000] } as const; const BANDS = { sub: [30, 90], low: [90, 250], mid: [250, 2000], high: [2000, 10000] } as const;
@@ -68,6 +83,20 @@ 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
const GAP_DB = 12; // dB under the long average where the low end counts as gone …
const GAP_FULL_DB = 12; // … and this much more for a full gap
const GAP_ATTACK = 0.06; // s
const GAP_RELEASE = 0.12; // s
const GAP_HEARD = 2; // s of sound before a gap can be one
const WOMB_UP = 0.62; // the bass envelope crossing up through this is one "womb" …
const WOMB_DOWN = 0.38; // … once it fell below this since the last
const WOMB_MIN = 0.06; // s; closer wombs are one
const WOMB_MAX = 1.5; // s; further apart, the wobble stopped
const WOMB_SMOOTH = 0.25; // weight of each new interval
const BEATS_PER_BAR = 4;
const BEAT_PULL = 0.25; // how far a kick pulls the beat towards itself
const BEAT_MIN_HIT = 0.3; // kicks weaker than this leave the beat alone
const DOWNBEAT_MEMORY = 8; // s; the downbeat is the slot with the strongest kicks this recent
// Without an analyser the waveform stands in. Its steps are coarse, so a rise from // 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. // 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 LOUD_FLOOR = 0.02; // loudness treated as silence, so a rise from nothing is finite
@@ -128,7 +157,18 @@ class Onset {
} }
export class FeatureTracker { export class FeatureTracker {
readonly f: Features = { sub: 0, low: 0, mid: 0, high: 0, kick: 0, snare: 0, kickAge: 99, kickHit: 0, snareHit: 0, wobble: 0, bass: 0, growl: 0, drop: 0 }; readonly f: Features = {
sub: 0, low: 0, mid: 0, high: 0, kick: 0, snare: 0, kickAge: 99, kickHit: 0, snareHit: 0, wobble: 0, bass: 0, growl: 0,
drop: 0, gap: 0, wobbleRate: 0, beat: 0, beatNo: 0, bar: 0, period: 60 / 140, lock: 0,
};
/// Seconds per beat, from the track's BPM.
private period = 60 / 140;
/// Beats since the track started, kept in step with the kicks.
private phase = 0;
/// Kick strength per beat of the bar, fading; the strongest slot is the downbeat.
private slots = [0, 0, 0, 0];
private wombAt = -1;
private wombArmed = true;
private bins: Float32Array<ArrayBuffer> | null = null; private bins: Float32Array<ArrayBuffer> | null = null;
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() };
@@ -147,6 +187,19 @@ export class FeatureTracker {
/// music that starts in the middle of a drop is the baseline, not a drop. /// music that starts in the middle of a drop is the baseline, not a drop.
private heard = 0; private heard = 0;
/// A new track: its tempo, and nothing carried over about where its bar is.
/// Half-time notation (70 for a 140 tune) counts double.
setTempo(bpm: number | null): void {
let b = bpm && bpm >= 50 && bpm <= 220 ? bpm : 140;
if (b < 100) b *= 2;
this.period = this.f.period = 60 / b;
this.phase = 0;
this.slots = [0, 0, 0, 0];
this.wombAt = -1;
this.f.wobbleRate = 0;
this.f.lock = 0;
}
/// One frame from the analyser. /// One frame from the analyser.
fromAnalyser(an: AnalyserNode, now: number, dt: number): Features { fromAnalyser(an: AnalyserNode, now: number, dt: number): Features {
const n = an.frequencyBinCount; const n = an.frequencyBinCount;
@@ -200,6 +253,7 @@ export class FeatureTracker {
f.snare = this.snare.env; f.snare = this.snare.env;
f.snareHit = this.snare.hit; f.snareHit = this.snare.hit;
this.track(f.mid, Math.max(db.sub, db.low), dt); this.track(f.mid, Math.max(db.sub, db.low), dt);
this.rhythm(now, dt);
return f; return f;
} }
@@ -217,6 +271,7 @@ export class FeatureTracker {
f.kickHit = this.loudKick.hit; f.kickHit = this.loudKick.hit;
f.snare = f.snareHit = 0; 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);
this.rhythm(now, dt);
return f; return f;
} }
@@ -225,7 +280,7 @@ export class FeatureTracker {
const f = this.f; const f = this.f;
const k = Math.exp(-dt / 0.35); const k = Math.exp(-dt / 0.35);
f.sub *= k; f.low *= k; f.mid *= k; f.high *= k; f.sub *= k; f.low *= k; f.mid *= k; f.high *= k;
f.kick *= k; f.snare *= k; f.wobble *= k; f.drop *= k; f.bass *= k; f.growl *= k; f.kick *= k; f.snare *= k; f.wobble *= k; f.drop *= k; f.bass *= k; f.growl *= k; f.gap *= k;
f.kickAge += dt; f.kickAge += dt;
f.kickHit = f.snareHit = 0; f.kickHit = f.snareHit = 0;
return f; return f;
@@ -248,5 +303,45 @@ export class FeatureTracker {
this.long += (lowDb - this.long) * ease(dt, Math.min(DROP_LONG, DROP_SHORT + this.heard)); this.long += (lowDb - this.long) * ease(dt, Math.min(DROP_LONG, DROP_SHORT + this.heard));
const target = clamp01((this.short - this.long - DROP_RISE_DB) / DROP_FULL_DB); const target = clamp01((this.short - this.long - DROP_RISE_DB) / DROP_FULL_DB);
f.drop += (target - f.drop) * ease(dt, target > f.drop ? DROP_ATTACK : DROP_RELEASE); f.drop += (target - f.drop) * ease(dt, target > f.drop ? DROP_ATTACK : DROP_RELEASE);
// The gap is against this frame's level: a stop of half a bar is over before
// a smoothed level would notice it.
const gap = this.heard < GAP_HEARD ? 0 : clamp01((this.long - lowDb - GAP_DB) / GAP_FULL_DB);
f.gap += (gap - f.gap) * ease(dt, gap > f.gap ? GAP_ATTACK : GAP_RELEASE);
}
/// The beat and the bar, and the rate of the wobble.
private rhythm(now: number, dt: number): void {
const f = this.f;
this.phase += dt / this.period;
const fade = Math.exp(-dt / DOWNBEAT_MEMORY);
for (let i = 0; i < BEATS_PER_BAR; i++) this.slots[i] *= fade;
if (f.kickHit >= BEAT_MIN_HIT) {
// A kick lands on the nearest beat: pull the clock towards it.
const nearest = Math.round(this.phase);
this.phase -= (this.phase - nearest) * BEAT_PULL * f.kickHit;
const slot = ((nearest % BEATS_PER_BAR) + BEATS_PER_BAR) % BEATS_PER_BAR;
this.slots[slot] += f.kickHit;
}
f.lock = Math.max(...this.slots);
const down = this.slots.indexOf(f.lock);
const inBar = (((this.phase - down) % BEATS_PER_BAR) + BEATS_PER_BAR) % BEATS_PER_BAR;
f.beatNo = Math.floor(inBar);
f.beat = inBar - f.beatNo;
f.bar = inBar / BEATS_PER_BAR;
// Each "womb" of the bass is its envelope rising through WOMB_UP.
if (f.bass < WOMB_DOWN) this.wombArmed = true;
if (this.wombArmed && f.bass > WOMB_UP) {
this.wombArmed = false;
const since = this.wombAt < 0 ? Infinity : now - this.wombAt;
if (since >= WOMB_MIN) {
if (since <= WOMB_MAX) {
const rate = 1 / since;
f.wobbleRate = f.wobbleRate ? f.wobbleRate + (rate - f.wobbleRate) * WOMB_SMOOTH : rate;
}
this.wombAt = now;
}
}
if (this.wombAt >= 0 && now - this.wombAt > WOMB_MAX * 2) f.wobbleRate *= Math.exp(-dt / 0.5);
} }
} }
+154 -37
View File
@@ -9,6 +9,7 @@ import { Pager } from "./pager";
import { Player } from "./player"; import { Player } from "./player";
import { Sheet } from "./sheet"; import { Sheet } from "./sheet";
import { Station } from "./station"; import { Station } from "./station";
import type { FlareSetting } from "./blob/blob";
import { type VisualsSetting, Visualizer } from "./visual"; import { type VisualsSetting, Visualizer } from "./visual";
const PREFETCH_AHEAD = 4; // fetch more when this close to the end const PREFETCH_AHEAD = 4; // fetch more when this close to the end
@@ -23,6 +24,8 @@ const BPM_RANGE = [60, 200]; // the BPM slider's ends, which mean "no limit"
const FILTERS_KEY = "pl8inf.filters"; const FILTERS_KEY = "pl8inf.filters";
const MODE_KEY = "pl8inf.mode"; const MODE_KEY = "pl8inf.mode";
const HINT_KEY = "pl8inf.hintShown"; const HINT_KEY = "pl8inf.hintShown";
const NUDGE_KEY = "pl8inf.signInNudged";
const NUDGE_LIKES = 5; // a guest is asked to sign in after this many likes, or a first follow
const $ = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T; const $ = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
@@ -66,6 +69,7 @@ let reported = false; // final event already sent for the active card
let advanced = false; // already mixed into the next track let advanced = false; // already mixed into the next track
let remembered = false; // the active card is in the history let remembered = false; // the active card is in the history
let preloadTimer = 0; let preloadTimer = 0;
let barTimer = 0; // a long crossfade waiting for the next bar
let filters: Filters = loadFilters(); let filters: Filters = loadFilters();
let mode: Mode = loadMode(); let mode: Mode = loadMode();
let stats: Stats | null = null; let stats: Stats | null = null;
@@ -124,7 +128,7 @@ async function loadMore(): Promise<void> {
if (err instanceof api.HttpError && err.status === 401) { if (err instanceof api.HttpError && err.status === 401) {
loading = false; loading = false;
signedInUser = null; signedInUser = null;
showLogin(); showWelcome();
return; return;
} }
setStatus(`failed to load: ${(err as Error).message}`); setStatus(`failed to load: ${(err as Error).message}`);
@@ -189,6 +193,8 @@ function activate(idx: number): void {
reported = false; reported = false;
advanced = false; advanced = false;
remembered = false; remembered = false;
clearTimeout(barTimer);
barTimer = 0;
const card = cards[idx]; const card = cards[idx];
if (pager.index !== idx) pager.go(idx, { instant: true }); if (pager.index !== idx) pager.go(idx, { instant: true });
card.element().classList.add("current"); card.element().classList.add("current");
@@ -257,14 +263,35 @@ const actions: CardActions = {
canSeek: (c) => c === current() && !!player.audio.duration, canSeek: (c) => c === current() && !!player.audio.duration,
duration: (c) => (c === current() && player.audio.duration) || c.track.duration || 0, duration: (c) => (c === current() && player.audio.duration) || c.track.duration || 0,
seek: (_, s) => player.seek(s), seek: (_, s) => player.seek(s),
toDrop: (c) => toDrop(c),
}; };
/// Seek to the next drop of the playing card, a bar early so it lands; past the
/// last one, back to the first.
function toDrop(card: Card): void {
const a = player.audio;
if (card !== current() || !a.duration || !card.drops.length) return;
const lead = 4 * beatSeconds();
const times = card.drops.map((d) => Math.max(0, d * a.duration - lead));
const next = times.find((t) => t > a.currentTime + 1) ?? times[0];
player.seek(next);
toast(`drop ${times.indexOf(next) + 1} of ${times.length}`);
}
function setLike(card: Card, liked: boolean): void { function setLike(card: Card, liked: boolean): void {
if (card.liked === liked) return; if (card.liked === liked) return;
card.liked = liked; card.liked = liked;
card.showLike(true); card.showLike(true);
if (stats) { stats.liked += liked ? 1 : -1; showStats(); } if (stats) { stats.liked += liked ? 1 : -1; showStats(); }
api.sendEvent(card.track, liked ? "like" : "unlike"); api.sendEvent(card.track, liked ? "like" : "unlike");
if (liked && (stats?.liked ?? 0) >= NUDGE_LIKES) nudgeSignIn();
}
/// Once per device, a guest who has started collecting is asked to sign in.
function nudgeSignIn(): void {
if (!signedInUser?.guest || localStorage.getItem(NUDGE_KEY)) return;
localStorage.setItem(NUDGE_KEY, "1");
toast("sign in with pl8list to keep these, and bring your library along", 8000, { label: "sign in", run: () => showLogin() });
} }
function dislike(card: Card): void { function dislike(card: Card): void {
@@ -293,7 +320,8 @@ async function setFollow(card: Card, follow: boolean): Promise<void> {
update(follow); update(follow);
try { try {
await api.postJSON("/local/follow", { handle, follow }); await api.postJSON("/local/follow", { handle, follow });
toast(follow ? `following ${artist_name}` : `unfollowed ${artist_name}`); if (follow && signedInUser?.guest && !localStorage.getItem(NUDGE_KEY)) nudgeSignIn();
else toast(follow ? `following ${artist_name}` : `unfollowed ${artist_name}`);
} catch (err) { } catch (err) {
update(!follow); update(!follow);
toast((err as Error).message, 4000); toast((err as Error).message, 4000);
@@ -301,13 +329,17 @@ async function setFollow(card: Card, follow: boolean): Promise<void> {
} }
/// Shares a link that opens this app on the track, with pl8list's own link in the text. /// Shares a link that opens this app on the track, with pl8list's own link in the text.
async function share(card: Card): Promise<void> { function share(card: Card): Promise<void> {
const { track } = card; const { track } = card;
const url = api.appTrackUrl(track);
const title = cleanTitle(track.title); const title = cleanTitle(track.title);
return shareLink(api.appTrackUrl(track), title, `${title} by ${track.artist_name} · ${api.trackUrl(track)}`);
}
/// The share sheet on phones, the clipboard elsewhere.
async function shareLink(url: string, title: string, text = title): Promise<void> {
if (navigator.share && isTouch) { if (navigator.share && isTouch) {
try { try {
await navigator.share({ title, text: `${title} by ${track.artist_name} · ${api.trackUrl(track)}`, url }); await navigator.share({ title, text, url });
} catch { /* dismissed */ } } catch { /* dismissed */ }
return; return;
} }
@@ -336,12 +368,43 @@ player.on("timeupdate", () => {
} }
// Where tracks can be mixed, start the next one so they overlap by the crossfade. // Where tracks can be mixed, start the next one so they overlap by the crossfade.
const lead = Math.max(1, player.crossfade); const lead = Math.max(1, player.crossfade);
if (player.mixing && !advanced && !document.hidden && a.duration && a.duration - t < lead && cards[active + 1] && !sleepAtEnd) { const left = a.duration - t;
if (player.mixing && !advanced && !document.hidden && a.duration && cards[active + 1] && !sleepAtEnd) {
// Long fades start on a bar, like a DJ would bring the next one in; up to a
// bar early, never late.
const toBar = player.crossfade >= BAR_FADE && !barTimer ? visual.secondsToBar() : null;
if (toBar !== null && left - toBar >= lead * 0.75 && left - toBar <= lead + 2 * 4 * beatSeconds()) {
const card = current();
barTimer = window.setTimeout(() => {
barTimer = 0;
if (current() === card) mixNext();
}, toBar * 1000);
} else if (left < lead) mixNext();
}
// Seeked back from a bar-timed fade: it waits for the end again.
if (barTimer && left > lead + 8) {
clearTimeout(barTimer);
barTimer = 0;
}
});
/// Seconds per beat of the playing card.
function beatSeconds(): number {
const bpm = current()?.track.bpm || 140;
return 60 / (bpm < 100 ? bpm * 2 : bpm);
}
/// Long crossfades at least this long start on a bar.
const BAR_FADE = 4;
function mixNext(): void {
clearTimeout(barTimer);
barTimer = 0;
if (advanced) return;
advanced = true; advanced = true;
if (!reported) { api.sendEvent(cards[active].track, "complete", listened); reported = true; } if (!reported) { api.sendEvent(cards[active].track, "complete", listened); reported = true; }
go(active + 1); go(active + 1);
} }
});
player.on("ended", () => { player.on("ended", () => {
const card = current(); const card = current();
@@ -510,8 +573,26 @@ function showStart(card: Card): void {
startEl.querySelector(".start-track")!.textContent = `${cleanTitle(t.title)} · ${t.artist_name}`; startEl.querySelector(".start-track")!.textContent = `${cleanTitle(t.title)} · ${t.artist_name}`;
} }
/// A tap on the overlay. Someone new becomes a guest with it: one tap, and it plays.
async function tapToStart(): Promise<void> {
if (signedInUser) {
start();
return;
}
if (startEl.classList.contains("busy")) return;
startEl.classList.add("busy");
try {
const { user } = await api.postJSON<{ user: User }>("/auth/guest");
signedIn(user);
start();
} catch (err) {
toast((err as Error).message, 4000);
}
startEl.classList.remove("busy");
}
function start(): void { function start(): void {
if (started) return; if (started || !signedInUser) return;
started = true; started = true;
startEl.hidden = true; startEl.hidden = true;
player.unlock(); player.unlock();
@@ -524,7 +605,12 @@ function start(): void {
: "click to pause · double-click to like · ? for shortcuts", 5000); : "click to pause · double-click to like · ? for shortcuts", 5000);
} }
} }
startEl.addEventListener("click", start); startEl.addEventListener("click", (e) => {
// The sign-in link and the legal links are not a tap to start.
if ((e.target as Element).closest(".start-welcome")) return;
tapToStart();
});
$("start-signin").addEventListener("click", () => showLogin());
/// Start playing the current card after `start()`; activate() did not, as nothing played yet. /// Start playing the current card after `start()`; activate() did not, as nothing played yet.
function activateAgain(card: Card): void { function activateAgain(card: Card): void {
@@ -759,11 +845,13 @@ function showSetting(name: string, value: string): void {
function setVisuals(v: VisualsSetting): void { function setVisuals(v: VisualsSetting): void {
visual.visuals = v; visual.visuals = v;
showSetting("visuals", v); showSetting("visuals", v);
$("flares-setting").hidden = v !== "liquid";
} }
filtersForm.addEventListener("change", (e) => { filtersForm.addEventListener("change", (e) => {
const input = e.target as HTMLInputElement; const input = e.target as HTMLInputElement;
if (input.name === "visuals") setVisuals(input.value as VisualsSetting); if (input.name === "visuals") setVisuals(input.value as VisualsSetting);
else if (input.name === "flares") visual.flares = input.value as FlareSetting;
else if (input.name === "crossfade") player.crossfade = Number(input.value); else if (input.name === "crossfade") player.crossfade = Number(input.value);
else if (input.name === "bass") player.bassBoost = input.checked; else if (input.name === "bass") player.bassBoost = input.checked;
else if (input.name === "sleep") setSleep(input.value); else if (input.name === "sleep") setSleep(input.value);
@@ -777,6 +865,8 @@ $("filters-clear").addEventListener("click", () => {
function showSettings(): void { function showSettings(): void {
showSetting("visuals", visual.visuals); showSetting("visuals", visual.visuals);
showSetting("flares", visual.flares);
$("flares-setting").hidden = visual.visuals !== "liquid";
// A saved crossfade that is not one of the choices shows as the nearest one. // A saved crossfade that is not one of the choices shows as the nearest one.
const fades = [...filtersForm.querySelectorAll<HTMLInputElement>("input[name=crossfade]")].map((i) => Number(i.value)); const fades = [...filtersForm.querySelectorAll<HTMLInputElement>("input[name=crossfade]")].map((i) => Number(i.value));
const fade = fades.reduce((a, b) => (Math.abs(b - player.crossfade) < Math.abs(a - player.crossfade) ? b : a)); const fade = fades.reduce((a, b) => (Math.abs(b - player.crossfade) < Math.abs(a - player.crossfade) ? b : a));
@@ -900,7 +990,7 @@ const station = new Station((m) => {
if (!started) start(); if (!started) start();
jumpOnLoad = true; jumpOnLoad = true;
setMode(m); setMode(m);
}); }, (url, title) => shareLink(url, title));
/// On the last card of a radio that has played everything known around its artist. /// On the last card of a radio that has played everything known around its artist.
function updateRadioEnd(): void { function updateRadioEnd(): void {
@@ -938,7 +1028,7 @@ document.addEventListener("keydown", (e) => {
if (k === "?") { sheets.help.toggle(); return; } if (k === "?") { sheets.help.toggle(); return; }
if (station.isOpen || Sheet.current || !loginEl.hidden) return; if (station.isOpen || Sheet.current || !loginEl.hidden) return;
if (!started) { if (!started) {
if (k !== "Tab" && k !== "Shift") { e.preventDefault(); start(); } if (k !== "Tab" && k !== "Shift") { e.preventDefault(); tapToStart(); }
return; return;
} }
const card = current(); const card = current();
@@ -951,6 +1041,7 @@ document.addEventListener("keydown", (e) => {
else if (k === ".") player.seek(a.currentTime + 5); else if (k === ".") player.seek(a.currentTime + 5);
else if (k === ",") player.seek(a.currentTime - 5); else if (k === ",") player.seek(a.currentTime - 5);
else if (/^[0-9]$/.test(k) && a.duration) player.seek((Number(k) / 10) * a.duration); else if (/^[0-9]$/.test(k) && a.duration) player.seek((Number(k) / 10) * a.duration);
else if (k === "d" && card) toDrop(card);
else if (k === "l" && card) setLike(card, !card.liked); else if (k === "l" && card) setLike(card, !card.liked);
else if (k === "f" && card) setFollow(card, !card.track.followed); else if (k === "f" && card) setFollow(card, !card.track.followed);
else if (k === "r" && card) actions.radio(card); else if (k === "r" && card) actions.radio(card);
@@ -997,28 +1088,24 @@ function showLogin({ resync = false } = {}): void {
loginEl.hidden = false; loginEl.hidden = false;
emailForm.hidden = false; emailForm.hidden = false;
codeForm.hidden = true; codeForm.hidden = true;
// Someone already listening is here only to sign in with pl8list.
guestStart.hidden = !!signedInUser;
loginCancel.hidden = !signedInUser;
loginError.textContent = resync ? "sign in with pl8list again to sync your library" : ""; loginError.textContent = resync ? "sign in with pl8list again to sync your library" : "";
if (signedInUser) $("email").focus(); $("email").focus();
} }
loginCancel.addEventListener("click", () => { if (signedInUser) signedIn(signedInUser); }); /// Nobody signed in on this device: the overlay welcomes them, and one tap plays.
function showWelcome(): void {
Sheet.closeAll();
loginEl.hidden = true;
$("bar").hidden = true;
upNext.hidden = true;
started = false;
player.pause();
startEl.classList.add("welcome");
startEl.querySelector(".start-track")!.textContent = "endless riddim from pl8list. like it, and it comes back.";
startEl.hidden = false;
}
const guestStart = $("guest-start"); loginCancel.addEventListener("click", () => { if (signedInUser) signedIn(signedInUser); else showWelcome(); });
$("guest-btn").addEventListener("click", async (e) => {
const btn = e.currentTarget as HTMLButtonElement;
btn.disabled = true;
loginError.textContent = "";
try {
const { user } = await api.postJSON<{ user: User }>("/auth/guest");
signedIn(user);
} catch (err) {
loginError.textContent = (err as Error).message;
}
btn.disabled = false;
});
async function withForm(form: HTMLFormElement, fn: () => Promise<void>): Promise<void> { async function withForm(form: HTMLFormElement, fn: () => Promise<void>): Promise<void> {
const buttons = form.querySelectorAll("button"); const buttons = form.querySelectorAll("button");
@@ -1119,10 +1206,28 @@ disconnectBtn.addEventListener("click", async () => {
} }
}); });
/// A guest's sign-out deletes everything they did: it takes a second tap.
let forgetArmed = false;
let forgetTimer = 0;
for (const [id, url] of [["logout-btn", "/auth/logout"], ["logout-all-btn", "/account/logout-all"]]) { for (const [id, url] of [["logout-btn", "/auth/logout"], ["logout-all-btn", "/account/logout-all"]]) {
$(id).addEventListener("click", async () => { $(id).addEventListener("click", async (e) => {
const btn = e.currentTarget as HTMLButtonElement;
if (id === "logout-btn" && signedInUser?.guest && !forgetArmed) {
forgetArmed = true;
btn.textContent = "tap again: delete your likes, follows and history here";
btn.classList.add("danger");
clearTimeout(forgetTimer);
forgetTimer = window.setTimeout(() => {
forgetArmed = false;
btn.textContent = "forget me";
btn.classList.remove("danger");
}, 5000);
return;
}
try { try {
await api.postJSON(url); await api.postJSON(url);
localStorage.removeItem(NUDGE_KEY);
location.reload(); location.reload();
} catch (err) { } catch (err) {
toast((err as Error).message, 4000); toast((err as Error).message, 4000);
@@ -1140,7 +1245,7 @@ deleteForm.addEventListener("submit", async (e) => {
e.preventDefault(); e.preventDefault();
try { try {
await api.postJSON("/account/delete", { confirm: deleteConfirm.value.trim() }); await api.postJSON("/account/delete", { confirm: deleteConfirm.value.trim() });
for (const key of [FILTERS_KEY, MODE_KEY, HINT_KEY]) localStorage.removeItem(key); for (const key of [FILTERS_KEY, MODE_KEY, HINT_KEY, NUDGE_KEY]) localStorage.removeItem(key);
heard.clear(); heard.clear();
location.reload(); location.reload();
} catch (err) { } catch (err) {
@@ -1154,15 +1259,18 @@ function signedIn(user: User, pl8list?: Pl8Status): void {
showSync(pl8list); showSync(pl8list);
loginEl.hidden = true; loginEl.hidden = true;
$("bar").hidden = false; $("bar").hidden = false;
accountBtn.textContent = guest ? "?" : (user.name || user.handle)[0]?.toUpperCase() ?? "?"; startEl.classList.remove("welcome");
accountBtn.style.backgroundColor = guest ? "#ffffff30" : accentForName(user.handle); accountBtn.textContent = guest ? "sign in" : (user.name || user.handle)[0]?.toUpperCase() ?? "?";
accountBtn.title = guest ? "guest" : `@${user.handle}`; accountBtn.classList.toggle("guest", guest);
accountBtn.style.backgroundColor = guest ? "var(--glass-strong)" : accentForName(user.handle);
accountBtn.title = guest ? "listening as a guest: sign in, or forget me" : `@${user.handle}`;
$("account-handle").textContent = guest ? "listening as a guest" : `@${user.handle}`; $("account-handle").textContent = guest ? "listening as a guest" : `@${user.handle}`;
$("delete-handle").textContent = user.handle; $("delete-handle").textContent = user.handle;
// A guest has one session and nothing on pl8list: signing out forgets them. // A guest has one session and nothing on pl8list: signing out forgets them.
$("sync-title").textContent = guest ? "pl8list" : "pl8list sync"; $("sync-title").textContent = guest ? "pl8list" : "pl8list sync";
$("sync-btn").textContent = guest ? "sign in with pl8list" : "resync with pl8list"; $("sync-btn").textContent = guest ? "sign in with pl8list" : "resync with pl8list";
$("logout-btn").textContent = guest ? "sign out and forget me" : "sign out"; $("logout-btn").textContent = guest ? "forget me" : "sign out";
forgetArmed = false;
for (const id of ["logout-all-btn", "delete-open"]) $(id).hidden = guest; for (const id of ["logout-all-btn", "delete-open"]) $(id).hidden = guest;
// Typing the login code counts as a user gesture, so playback may start right away. // Typing the login code counts as a user gesture, so playback may start right away.
if (navigator.userActivation?.hasBeenActive) start(); if (navigator.userActivation?.hasBeenActive) start();
@@ -1171,7 +1279,16 @@ function signedIn(user: User, pl8list?: Pl8Status): void {
} }
/// `/?t=handle/slug` opens on that track; the feed carries on after it. /// `/?t=handle/slug` opens on that track; the feed carries on after it.
/// `/?r=handle` opens that artist's radio sheet over the feed.
async function openLinkOrFeed(): Promise<void> { async function openLinkOrFeed(): Promise<void> {
const radio = new URLSearchParams(location.search).get("r");
if (radio) {
history.replaceState(null, "", location.pathname);
api.artist(radio).then(
({ artist }) => station.open(artist.handle, artist.name, api.imgUrl(artist.img)),
(err) => toast(`couldn't open that radio: ${(err as Error).message}`, 4000),
);
}
const link = new URLSearchParams(location.search).get("t")?.split("/"); const link = new URLSearchParams(location.search).get("t")?.split("/");
if (link?.length === 2) { if (link?.length === 2) {
history.replaceState(null, "", location.pathname); history.replaceState(null, "", location.pathname);
@@ -1187,5 +1304,5 @@ async function openLinkOrFeed(): Promise<void> {
api.me().then( api.me().then(
(me) => signedIn(me.user, me.pl8list), (me) => signedIn(me.user, me.pl8list),
() => showLogin(), () => showWelcome(),
); );
+7 -1
View File
@@ -36,7 +36,13 @@ export class Station {
private timer = 0; private timer = 0;
private known = new Set<string>(); private known = new Set<string>();
constructor(private start: (m: Mode) => void) { constructor(
private start: (m: Mode) => void,
share: (url: string, title: string) => void,
) {
$("station-share").addEventListener("click", () => {
if (this.shown) share(api.appRadioUrl(this.shown.handle), `${this.shown.name} radio`);
});
$("station-close").addEventListener("click", () => this.close()); $("station-close").addEventListener("click", () => this.close());
this.root.addEventListener("click", (e) => { if (e.target === this.root) this.close(); }); this.root.addEventListener("click", (e) => { if (e.target === this.root) this.close(); });
this.backBtn.addEventListener("click", () => { this.backBtn.addEventListener("click", () => {
+36 -4
View File
@@ -230,6 +230,7 @@ body.zen #bar { display: none; }
color: var(--muted); color: var(--muted);
} }
#sleep-left { text-transform: none; letter-spacing: 0; color: var(--accent); } #sleep-left { text-transform: none; letter-spacing: 0; color: var(--accent); }
#flares-setting legend small { text-transform: none; letter-spacing: 0; color: var(--muted); font-weight: 400; }
.sheet-actions { display: flex; align-items: center; gap: 1rem; } .sheet-actions { display: flex; align-items: center; gap: 1rem; }
.sheet-tools { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; } .sheet-tools { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; }
.sheet-tools input[type=search] { .sheet-tools input[type=search] {
@@ -646,6 +647,20 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff;
.wave-wrap.hover .wave-tip { opacity: 1; } .wave-wrap.hover .wave-tip { opacity: 1; }
.time { width: 100%; display: flex; justify-content: space-between; gap: 1rem; color: var(--muted); font-size: 0.75rem; font-variant-numeric: tabular-nums; } .time { width: 100%; display: flex; justify-content: space-between; gap: 1rem; color: var(--muted); font-size: 0.75rem; font-variant-numeric: tabular-nums; }
.counts { opacity: 0.8; } .counts { opacity: 0.8; }
.to-drop {
display: inline-flex;
align-items: center;
gap: 0.2em;
margin-right: auto;
padding: 0.05em 0.6em;
border: 1px solid var(--line-strong);
border-radius: 999px;
background: none;
color: var(--fg);
font-size: inherit;
}
.to-drop svg { width: 0.9em; height: 0.9em; }
.to-drop:hover { border-color: var(--card-accent, var(--accent)); color: var(--card-accent, var(--accent)); }
.actions { .actions {
position: absolute; position: absolute;
@@ -839,6 +854,26 @@ body.zen #up-next { display: none; }
@keyframes beckon { to { box-shadow: 0 0 0 2rem transparent; } } @keyframes beckon { to { box-shadow: 0 0 0 2rem transparent; } }
.start-track { margin: 0.4rem 0 0; font-weight: 700; font-size: 1.1rem; text-align: center; padding: 0 1rem; } .start-track { margin: 0.4rem 0 0; font-weight: 700; font-size: 1.1rem; text-align: center; padding: 0 1rem; }
.start-hint { margin: 0; color: var(--muted); font-size: 0.85rem; } .start-hint { margin: 0; color: var(--muted); font-size: 0.85rem; }
/* First visit: the overlay is the welcome, and the logo and the way to sign in show. */
.start-logo, .start-welcome { display: none; }
#start.welcome .start-logo { display: flex; align-items: center; gap: 0.3em; margin: 0 0 1rem; font-size: 2rem; }
.start-logo svg { width: 1em; height: 1em; }
.start-logo b { color: var(--accent); }
#start.welcome .start-welcome {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.6rem;
position: absolute;
bottom: max(1.5rem, env(safe-area-inset-bottom));
max-width: 32rem;
padding: 0 1.2rem;
text-align: center;
cursor: default;
}
.start-welcome a { color: inherit; }
/* A guest's account button says what it is for. */
.avatar-btn.guest { width: auto; padding: 0 0.9rem; border-radius: 999px; font-weight: 600; font-size: 0.85rem; color: var(--fg); }
/* Radio sheet: a bottom sheet on phones, a centred box elsewhere. */ /* Radio sheet: a bottom sheet on phones, a centred box elsewhere. */
#station { #station {
@@ -1001,10 +1036,7 @@ button.primary:disabled { opacity: 0.35; cursor: default; }
#code { font-family: ui-monospace, monospace; font-size: 1.4rem; letter-spacing: 0.5em; text-align: center; } #code { font-family: ui-monospace, monospace; font-size: 1.4rem; letter-spacing: 0.5em; text-align: center; }
.login-box button[type=submit] { background: var(--accent); border: none; color: #fff; padding: 0.7em; font-weight: 600; } .login-box button[type=submit] { background: var(--accent); border: none; color: #fff; padding: 0.7em; font-weight: 600; }
/* Listening without an account comes first; signing in is the quieter way below. */ /* Listening without an account comes first; signing in is the quieter way below. */
#guest-start { display: flex; flex-direction: column; gap: 0.6rem; padding-bottom: 0.4rem; border-bottom: 1px solid var(--line); } .login-box .or { margin: 0; color: var(--muted); font-size: 0.85rem; }
#guest-btn { width: 100%; font-size: 1.05rem; padding: 0.8em; }
#guest-start .or { margin: 0 0 0.5rem; color: var(--muted); font-size: 0.85rem; text-align: center; }
#guest-start:not([hidden]) ~ #email-form button[type=submit] { background: none; border: 1px solid var(--line-strong); color: var(--fg); }
.login-box button[disabled] { opacity: 0.6; cursor: wait; } .login-box button[disabled] { opacity: 0.6; cursor: wait; }
.login-links { display: flex; justify-content: space-between; } .login-links { display: flex; justify-content: space-between; }
.login-box label.check { display: flex; gap: 0.6rem; align-items: flex-start; color: var(--fg); cursor: pointer; } .login-box label.check { display: flex; gap: 0.6rem; align-items: flex-start; color: var(--fg); cursor: pointer; }
+25 -1
View File
@@ -6,7 +6,7 @@
// Web Audio, otherwise from the track's waveform at the current position. // Web Audio, otherwise from the track's waveform at the current position.
import { artistImg } from "./api"; import { artistImg } from "./api";
import { LiquidBlob, SPILL } from "./blob/blob"; import { type FlareSetting, 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, accentForName } from "./color"; import { accentFor, accentForName } from "./color";
@@ -15,9 +15,11 @@ import type { Player } from "./player";
export type VisualsSetting = "liquid" | "simple"; export type VisualsSetting = "liquid" | "simple";
const VISUALS_KEY = "pl8inf.visuals"; const VISUALS_KEY = "pl8inf.visuals";
const FLARES_KEY = "pl8inf.flares";
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 const IDLE_FPS = 15; // a paused drop keeps breathing, slowly and at a low frame rate
const BAR_LOCK = 2; // kick strength on the downbeat before the bar counts as known
export class Visualizer { export class Visualizer {
private raf = 0; private raf = 0;
@@ -28,6 +30,7 @@ export class Visualizer {
private blob: LiquidBlob | null | undefined; private blob: LiquidBlob | null | undefined;
private reduced = matchMedia("(prefers-reduced-motion: reduce)"); private reduced = matchMedia("(prefers-reduced-motion: reduce)");
private setting: VisualsSetting = localStorage.getItem(VISUALS_KEY) === "simple" ? "simple" : "liquid"; private setting: VisualsSetting = localStorage.getItem(VISUALS_KEY) === "simple" ? "simple" : "liquid";
private flareSetting: FlareSetting = (["on", "off"] as const).find((v) => v === localStorage.getItem(FLARES_KEY)) ?? "auto";
private debug: ((f: Features) => void) | null = null; private debug: ((f: Features) => void) | null = null;
private dropDebug: ((text: string) => void) | null = null; private dropDebug: ((text: string) => void) | null = null;
/// 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.
@@ -73,12 +76,24 @@ export class Visualizer {
this.attach(this.card, true); this.attach(this.card, true);
} }
get flares(): FlareSetting {
return this.flareSetting;
}
/// Solar flares round the drop: auto, on or off.
set flares(v: FlareSetting) {
this.flareSetting = v;
localStorage.setItem(FLARES_KEY, v);
if (this.blob) this.blob.flareSetting = v;
}
/// Whether the liquid drop runs: wanted, motion allowed, and WebGL2 works. /// Whether the liquid drop runs: wanted, motion allowed, and WebGL2 works.
private liquid(): LiquidBlob | null { private liquid(): LiquidBlob | null {
if (this.setting !== "liquid" || this.reduced.matches || this.blob === null) return null; if (this.setting !== "liquid" || this.reduced.matches || this.blob === null) return null;
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.flareSetting = this.flareSetting;
// Plain art while the GPU is gone; the drop comes back with it. // Plain art while the GPU is gone; the drop comes back with it.
this.blob.onLost = () => this.card?.el?.classList.remove("liquid"); this.blob.onLost = () => this.card?.el?.classList.remove("liquid");
this.blob.onRestored = () => this.attach(this.card, true); this.blob.onRestored = () => this.attach(this.card, true);
@@ -87,6 +102,14 @@ export class Visualizer {
return this.blob?.lost ? null : this.blob; return this.blob?.lost ? null : this.blob;
} }
/// Seconds until the next bar starts, where the analyser hears the music and the
/// bar is locked onto its kicks; null otherwise.
secondsToBar(): number | null {
const f = this.features.f;
if (!this.player.mixing || this.player.audio.paused || f.lock < BAR_LOCK) return null;
return (1 - f.bar) * 4 * f.period;
}
/// A play/pause from a tap was taken back by the second tap of a double-tap. /// A play/pause from a tap was taken back by the second tap of a double-tap.
cancelFlash(): void { cancelFlash(): void {
this.blob?.settleIcon(); this.blob?.settleIcon();
@@ -100,6 +123,7 @@ export class Visualizer {
this.card.el?.classList.remove("liquid", "turning"); this.card.el?.classList.remove("liquid", "turning");
this.grab = null; this.grab = null;
} }
if (this.card !== card) this.features.setTempo(card?.track.bpm ?? null);
this.card = card; this.card = card;
const blob = this.liquid(); const blob = this.liquid();
const slot = card?.el?.querySelector<HTMLElement>(".art"); const slot = card?.el?.querySelector<HTMLElement>(".art");