mirror of
https://github.com/narrrl/pl8list-rewind.git
synced 2026-11-03 16:41:26 +01:00
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:
1 parent
fbcff2266b
commit
62dde885aa
12 files changed
+837
-72
No files matched your search
+23
-10
@@ -65,10 +65,7 @@
|
||||
<div class="login-box">
|
||||
<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>
|
||||
<div id="guest-start">
|
||||
<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>
|
||||
<p class="or">sign in with pl8list to bring your follows and likes along. What you did as a guest comes with you.</p>
|
||||
<form id="email-form">
|
||||
<label for="email">sign in with your pl8list email</label>
|
||||
<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>
|
||||
</div>
|
||||
</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 class="fineprint">
|
||||
This site is not affiliated with pl8list. Without an account we keep what you play, like and
|
||||
follow under a random cookie, to pick tracks for you; signing out deletes it. Your email and code
|
||||
This site is not affiliated with pl8list. Your email and code
|
||||
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
|
||||
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="simple"> simple</label>
|
||||
</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">
|
||||
<legend>crossfade</legend>
|
||||
<label><input type="radio" name="crossfade" value="0"> cut</label>
|
||||
@@ -199,9 +201,16 @@
|
||||
<p id="feed-status" role="status" hidden></p>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<button type="button" id="up-next" hidden>
|
||||
@@ -228,6 +237,7 @@
|
||||
<ol id="station-neighbours"></ol>
|
||||
<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-share" class="link">share this radio</button>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -260,10 +270,13 @@
|
||||
<dt>c</dt><dd>filters and settings</dd>
|
||||
<dt>h</dt><dd>history</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>?</dt><dd>this help</dd>
|
||||
</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
|
||||
closing the tab? A vim-keys browser extension (Vimium and the like) takes them first:
|
||||
turn it off for this site.</p>
|
||||
@@ -297,7 +310,7 @@
|
||||
<canvas class="wave"></canvas>
|
||||
<span class="wave-tip"></span>
|
||||
</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>
|
||||
<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>
|
||||
|
||||
@@ -139,6 +139,8 @@ export const trackUrl = (t: Track) => `${PL8}/${t.handle}/${t.slug}`;
|
||||
/// 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 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.
|
||||
export const imgUrl = (id: string | null) => (id ? `/img/${encodeURIComponent(id)}` : null);
|
||||
export const artistImg = (t: Track) => imgUrl(t.artist_img);
|
||||
|
||||
+42
-4
@@ -3,6 +3,7 @@
|
||||
// page; cards around it show their artwork as a plain image.
|
||||
|
||||
import type { Features } from "../features";
|
||||
import { Flares } from "./flares";
|
||||
import { icosphere } from "./mesh";
|
||||
import { FRAGMENT, VERTEX } from "./shaders";
|
||||
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
|
||||
/// Quality steps: icosphere subdivisions and resolution scale.
|
||||
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 UNIFORMS = [
|
||||
@@ -75,6 +81,13 @@ export class LiquidBlob {
|
||||
/// Turning it by hand.
|
||||
readonly spin = new Spin();
|
||||
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 vao!: WebGLVertexArrayObject;
|
||||
private buffers: WebGLBuffer[] = [];
|
||||
@@ -159,6 +172,7 @@ export class LiquidBlob {
|
||||
gl.linkProgram(prog);
|
||||
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog) || "link failed");
|
||||
gl.useProgram(prog);
|
||||
this.prog = prog;
|
||||
for (const name of UNIFORMS) this.u[name] = gl.getUniformLocation(prog, name);
|
||||
this.vao = gl.createVertexArray();
|
||||
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_T, gl.CLAMP_TO_EDGE);
|
||||
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.enable(gl.DEPTH_TEST);
|
||||
// The edge fades into the page; the canvas is premultiplied.
|
||||
gl.enable(gl.BLEND);
|
||||
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
|
||||
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 {
|
||||
@@ -215,6 +236,7 @@ export class LiquidBlob {
|
||||
this.morph = 0;
|
||||
this.slow = this.fast = 0;
|
||||
this.spin.reset();
|
||||
this.flares?.reset();
|
||||
this.iconFor = null;
|
||||
const source = await artwork(art, initial, tint).catch(() => artwork(null, initial, tint));
|
||||
if (gen !== this.gen || this.gl.isContextLost()) return false;
|
||||
@@ -251,6 +273,7 @@ export class LiquidBlob {
|
||||
const look = this.look;
|
||||
if (!look || !this.ready) return false;
|
||||
const gl = this.gl;
|
||||
gl.useProgram(this.prog);
|
||||
const { playing } = s;
|
||||
if (playing) this.watch(dt);
|
||||
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.bindVertexArray(this.vao);
|
||||
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
|
||||
@@ -344,8 +378,10 @@ export class LiquidBlob {
|
||||
const whirl = Math.min(1, this.spin.speed / WHIRL);
|
||||
const side = (size / Math.sqrt(stretch)) * (1 + 0.08 * whirl);
|
||||
const u = this.u;
|
||||
this.gl.uniform3f(u.uScale, side, size * stretch * (1 - 0.06 * whirl), side);
|
||||
this.gl.uniform1f(u.uLift, Math.max(0, jump[0]));
|
||||
this.scaleNow = [side, size * stretch * (1 - 0.06 * whirl), side];
|
||||
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));
|
||||
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`,
|
||||
`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}`,
|
||||
`flares ${this.flares ? this.flareSetting : "failed"} · look ${this.look?.flares ?? "-"}`,
|
||||
`in page ${this.canvas.isConnected} · css ${this.canvas.clientWidth}x${this.canvas.clientHeight}`,
|
||||
].join("\n");
|
||||
}
|
||||
@@ -438,6 +475,7 @@ export class LiquidBlob {
|
||||
const before = QUALITY[this.quality][0];
|
||||
this.quality = q;
|
||||
if (QUALITY[q][0] !== before) this.buildMesh();
|
||||
this.flares?.setMax(FLARE_LOOPS[q]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -29,26 +29,55 @@ export interface Look {
|
||||
heat: number;
|
||||
/// Rim hue offset from the key, radians.
|
||||
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> = {
|
||||
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 }),
|
||||
tearout: (l) => Object.assign(l, { spike: 1.3, bounce: 1.4, gain: l.gain * 1.15, heat: 0.7 }),
|
||||
deathstep: (l) => Object.assign(l, { spike: 1.4, bounce: 1.4, gain: l.gain * 1.15, heat: 0.9, metal: 0.7 }),
|
||||
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 }),
|
||||
colourbass: (l) => Object.assign(l, { iri: 1, spike: 0.25, bounce: 0.8, stiff: 70, metal: 0.15 }),
|
||||
// Riddim: the loops snap to the beat and the crown repeats every bar.
|
||||
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,
|
||||
flares: 1, crown: 4, scatter: 0, arch: 0.55, life: 4, erupt: 0.4,
|
||||
}),
|
||||
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 }),
|
||||
"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 }),
|
||||
};
|
||||
|
||||
export function lookFor(t: Track): Look {
|
||||
// About one noise cycle per bar.
|
||||
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);
|
||||
return l;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface CardActions {
|
||||
canSeek(card: Card): boolean;
|
||||
duration(card: Card): number;
|
||||
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.
|
||||
@@ -26,6 +28,39 @@ export const compact = (n: number) =>
|
||||
const NEW_DAYS = 7; // uploads younger than this get a "new" chip
|
||||
const BAR = 3; // px per waveform bar, gap included
|
||||
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;
|
||||
|
||||
@@ -47,9 +82,12 @@ export class Card {
|
||||
) {
|
||||
this.liked = liked;
|
||||
this.station = station;
|
||||
this.drops = findDrops(track.waveform);
|
||||
}
|
||||
|
||||
private station: string | undefined;
|
||||
/// Drops in the waveform, 0..1 of the track.
|
||||
readonly drops: number[];
|
||||
|
||||
element(): HTMLElement {
|
||||
return this.el ?? this.build();
|
||||
@@ -131,6 +169,9 @@ export class Card {
|
||||
$(".next").addEventListener("click", () => this.actions.next(this));
|
||||
$(".follow").addEventListener("click", () => this.actions.follow(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.canvas = $<HTMLCanvasElement>(".wave");
|
||||
@@ -243,6 +284,17 @@ export class Card {
|
||||
ctx.fill();
|
||||
}
|
||||
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
|
||||
|
||||
+4
-4
@@ -4,7 +4,7 @@
|
||||
|
||||
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.
|
||||
const counts = { kicks: 0, snares: 0, frames: 0 };
|
||||
@@ -14,7 +14,7 @@ export function audioDebug(): (f: Features) => void {
|
||||
let lastAge = 99, lastSnare = 0;
|
||||
const canvas = document.createElement("canvas");
|
||||
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);
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.font = "10px system-ui, sans-serif";
|
||||
@@ -27,10 +27,10 @@ export function audioDebug(): (f: Features) => void {
|
||||
lastSnare = f.snare;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
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) => {
|
||||
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.fillStyle = "#fff9";
|
||||
ctx.fillText(k, i * bar + 3, h - 3);
|
||||
|
||||
+97
-2
@@ -33,6 +33,21 @@ export interface Features {
|
||||
growl: number;
|
||||
/// 0..1, high while the low end sits well above its recent average.
|
||||
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;
|
||||
@@ -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_ATTACK = 0.25; // 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
|
||||
// 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
|
||||
@@ -128,7 +157,18 @@ class Onset {
|
||||
}
|
||||
|
||||
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 prev: Float32Array<ArrayBuffer> | null = null;
|
||||
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.
|
||||
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.
|
||||
fromAnalyser(an: AnalyserNode, now: number, dt: number): Features {
|
||||
const n = an.frequencyBinCount;
|
||||
@@ -200,6 +253,7 @@ export class FeatureTracker {
|
||||
f.snare = this.snare.env;
|
||||
f.snareHit = this.snare.hit;
|
||||
this.track(f.mid, Math.max(db.sub, db.low), dt);
|
||||
this.rhythm(now, dt);
|
||||
return f;
|
||||
}
|
||||
|
||||
@@ -217,6 +271,7 @@ export class FeatureTracker {
|
||||
f.kickHit = this.loudKick.hit;
|
||||
f.snare = f.snareHit = 0;
|
||||
this.track(level, level > 0 ? 20 * Math.log10(level) : SILENCE_DB, dt);
|
||||
this.rhythm(now, dt);
|
||||
return f;
|
||||
}
|
||||
|
||||
@@ -225,7 +280,7 @@ export class FeatureTracker {
|
||||
const f = this.f;
|
||||
const k = Math.exp(-dt / 0.35);
|
||||
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.kickHit = f.snareHit = 0;
|
||||
return f;
|
||||
@@ -248,5 +303,45 @@ export class FeatureTracker {
|
||||
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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
+155
-38
@@ -9,6 +9,7 @@ import { Pager } from "./pager";
|
||||
import { Player } from "./player";
|
||||
import { Sheet } from "./sheet";
|
||||
import { Station } from "./station";
|
||||
import type { FlareSetting } from "./blob/blob";
|
||||
import { type VisualsSetting, Visualizer } from "./visual";
|
||||
|
||||
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 MODE_KEY = "pl8inf.mode";
|
||||
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;
|
||||
|
||||
@@ -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 remembered = false; // the active card is in the history
|
||||
let preloadTimer = 0;
|
||||
let barTimer = 0; // a long crossfade waiting for the next bar
|
||||
let filters: Filters = loadFilters();
|
||||
let mode: Mode = loadMode();
|
||||
let stats: Stats | null = null;
|
||||
@@ -124,7 +128,7 @@ async function loadMore(): Promise<void> {
|
||||
if (err instanceof api.HttpError && err.status === 401) {
|
||||
loading = false;
|
||||
signedInUser = null;
|
||||
showLogin();
|
||||
showWelcome();
|
||||
return;
|
||||
}
|
||||
setStatus(`failed to load: ${(err as Error).message}`);
|
||||
@@ -189,6 +193,8 @@ function activate(idx: number): void {
|
||||
reported = false;
|
||||
advanced = false;
|
||||
remembered = false;
|
||||
clearTimeout(barTimer);
|
||||
barTimer = 0;
|
||||
const card = cards[idx];
|
||||
if (pager.index !== idx) pager.go(idx, { instant: true });
|
||||
card.element().classList.add("current");
|
||||
@@ -257,14 +263,35 @@ const actions: CardActions = {
|
||||
canSeek: (c) => c === current() && !!player.audio.duration,
|
||||
duration: (c) => (c === current() && player.audio.duration) || c.track.duration || 0,
|
||||
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 {
|
||||
if (card.liked === liked) return;
|
||||
card.liked = liked;
|
||||
card.showLike(true);
|
||||
if (stats) { stats.liked += liked ? 1 : -1; showStats(); }
|
||||
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 {
|
||||
@@ -293,7 +320,8 @@ async function setFollow(card: Card, follow: boolean): Promise<void> {
|
||||
update(follow);
|
||||
try {
|
||||
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) {
|
||||
update(!follow);
|
||||
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.
|
||||
async function share(card: Card): Promise<void> {
|
||||
function share(card: Card): Promise<void> {
|
||||
const { track } = card;
|
||||
const url = api.appTrackUrl(track);
|
||||
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) {
|
||||
try {
|
||||
await navigator.share({ title, text: `${title} by ${track.artist_name} · ${api.trackUrl(track)}`, url });
|
||||
await navigator.share({ title, text, url });
|
||||
} catch { /* dismissed */ }
|
||||
return;
|
||||
}
|
||||
@@ -336,13 +368,44 @@ player.on("timeupdate", () => {
|
||||
}
|
||||
// Where tracks can be mixed, start the next one so they overlap by the crossfade.
|
||||
const lead = Math.max(1, player.crossfade);
|
||||
if (player.mixing && !advanced && !document.hidden && a.duration && a.duration - t < lead && cards[active + 1] && !sleepAtEnd) {
|
||||
advanced = true;
|
||||
if (!reported) { api.sendEvent(cards[active].track, "complete", listened); reported = true; }
|
||||
go(active + 1);
|
||||
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;
|
||||
if (!reported) { api.sendEvent(cards[active].track, "complete", listened); reported = true; }
|
||||
go(active + 1);
|
||||
}
|
||||
|
||||
player.on("ended", () => {
|
||||
const card = current();
|
||||
if (card && !reported) {
|
||||
@@ -510,8 +573,26 @@ function showStart(card: Card): void {
|
||||
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 {
|
||||
if (started) return;
|
||||
if (started || !signedInUser) return;
|
||||
started = true;
|
||||
startEl.hidden = true;
|
||||
player.unlock();
|
||||
@@ -524,7 +605,12 @@ function start(): void {
|
||||
: "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.
|
||||
function activateAgain(card: Card): void {
|
||||
@@ -759,11 +845,13 @@ function showSetting(name: string, value: string): void {
|
||||
function setVisuals(v: VisualsSetting): void {
|
||||
visual.visuals = v;
|
||||
showSetting("visuals", v);
|
||||
$("flares-setting").hidden = v !== "liquid";
|
||||
}
|
||||
|
||||
filtersForm.addEventListener("change", (e) => {
|
||||
const input = e.target as HTMLInputElement;
|
||||
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 === "bass") player.bassBoost = input.checked;
|
||||
else if (input.name === "sleep") setSleep(input.value);
|
||||
@@ -777,6 +865,8 @@ $("filters-clear").addEventListener("click", () => {
|
||||
|
||||
function showSettings(): void {
|
||||
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.
|
||||
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));
|
||||
@@ -900,7 +990,7 @@ const station = new Station((m) => {
|
||||
if (!started) start();
|
||||
jumpOnLoad = true;
|
||||
setMode(m);
|
||||
});
|
||||
}, (url, title) => shareLink(url, title));
|
||||
|
||||
/// On the last card of a radio that has played everything known around its artist.
|
||||
function updateRadioEnd(): void {
|
||||
@@ -938,7 +1028,7 @@ document.addEventListener("keydown", (e) => {
|
||||
if (k === "?") { sheets.help.toggle(); return; }
|
||||
if (station.isOpen || Sheet.current || !loginEl.hidden) return;
|
||||
if (!started) {
|
||||
if (k !== "Tab" && k !== "Shift") { e.preventDefault(); start(); }
|
||||
if (k !== "Tab" && k !== "Shift") { e.preventDefault(); tapToStart(); }
|
||||
return;
|
||||
}
|
||||
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 (/^[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 === "f" && card) setFollow(card, !card.track.followed);
|
||||
else if (k === "r" && card) actions.radio(card);
|
||||
@@ -997,28 +1088,24 @@ function showLogin({ resync = false } = {}): void {
|
||||
loginEl.hidden = false;
|
||||
emailForm.hidden = false;
|
||||
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" : "";
|
||||
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");
|
||||
$("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;
|
||||
});
|
||||
loginCancel.addEventListener("click", () => { if (signedInUser) signedIn(signedInUser); else showWelcome(); });
|
||||
|
||||
async function withForm(form: HTMLFormElement, fn: () => Promise<void>): Promise<void> {
|
||||
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"]]) {
|
||||
$(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 {
|
||||
await api.postJSON(url);
|
||||
localStorage.removeItem(NUDGE_KEY);
|
||||
location.reload();
|
||||
} catch (err) {
|
||||
toast((err as Error).message, 4000);
|
||||
@@ -1140,7 +1245,7 @@ deleteForm.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
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();
|
||||
location.reload();
|
||||
} catch (err) {
|
||||
@@ -1154,15 +1259,18 @@ function signedIn(user: User, pl8list?: Pl8Status): void {
|
||||
showSync(pl8list);
|
||||
loginEl.hidden = true;
|
||||
$("bar").hidden = false;
|
||||
accountBtn.textContent = guest ? "?" : (user.name || user.handle)[0]?.toUpperCase() ?? "?";
|
||||
accountBtn.style.backgroundColor = guest ? "#ffffff30" : accentForName(user.handle);
|
||||
accountBtn.title = guest ? "guest" : `@${user.handle}`;
|
||||
startEl.classList.remove("welcome");
|
||||
accountBtn.textContent = guest ? "sign in" : (user.name || user.handle)[0]?.toUpperCase() ?? "?";
|
||||
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}`;
|
||||
$("delete-handle").textContent = user.handle;
|
||||
// A guest has one session and nothing on pl8list: signing out forgets them.
|
||||
$("sync-title").textContent = guest ? "pl8list" : "pl8list sync";
|
||||
$("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;
|
||||
// Typing the login code counts as a user gesture, so playback may start right away.
|
||||
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.
|
||||
/// `/?r=handle` opens that artist's radio sheet over the feed.
|
||||
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("/");
|
||||
if (link?.length === 2) {
|
||||
history.replaceState(null, "", location.pathname);
|
||||
@@ -1187,5 +1304,5 @@ async function openLinkOrFeed(): Promise<void> {
|
||||
|
||||
api.me().then(
|
||||
(me) => signedIn(me.user, me.pl8list),
|
||||
() => showLogin(),
|
||||
() => showWelcome(),
|
||||
);
|
||||
+7
-1
@@ -36,7 +36,13 @@ export class Station {
|
||||
private timer = 0;
|
||||
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());
|
||||
this.root.addEventListener("click", (e) => { if (e.target === this.root) this.close(); });
|
||||
this.backBtn.addEventListener("click", () => {
|
||||
|
||||
+36
-4
@@ -230,6 +230,7 @@ body.zen #bar { display: none; }
|
||||
color: var(--muted);
|
||||
}
|
||||
#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-tools { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; }
|
||||
.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; }
|
||||
.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; }
|
||||
.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 {
|
||||
position: absolute;
|
||||
@@ -839,6 +854,26 @@ body.zen #up-next { display: none; }
|
||||
@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-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. */
|
||||
#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; }
|
||||
.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. */
|
||||
#guest-start { display: flex; flex-direction: column; gap: 0.6rem; padding-bottom: 0.4rem; border-bottom: 1px solid var(--line); }
|
||||
#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 .or { margin: 0; color: var(--muted); font-size: 0.85rem; }
|
||||
.login-box button[disabled] { opacity: 0.6; cursor: wait; }
|
||||
.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; }
|
||||
|
||||
+25
-1
@@ -6,7 +6,7 @@
|
||||
// Web Audio, otherwise from the track's waveform at the current position.
|
||||
|
||||
import { artistImg } from "./api";
|
||||
import { LiquidBlob, SPILL } from "./blob/blob";
|
||||
import { type FlareSetting, LiquidBlob, SPILL } from "./blob/blob";
|
||||
import { lookFor } from "./blob/style";
|
||||
import type { Card } from "./cards";
|
||||
import { accentFor, accentForName } from "./color";
|
||||
@@ -15,9 +15,11 @@ import type { Player } from "./player";
|
||||
|
||||
export type VisualsSetting = "liquid" | "simple";
|
||||
const VISUALS_KEY = "pl8inf.visuals";
|
||||
const FLARES_KEY = "pl8inf.flares";
|
||||
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
|
||||
const BAR_LOCK = 2; // kick strength on the downbeat before the bar counts as known
|
||||
|
||||
export class Visualizer {
|
||||
private raf = 0;
|
||||
@@ -28,6 +30,7 @@ export class Visualizer {
|
||||
private blob: LiquidBlob | null | undefined;
|
||||
private reduced = matchMedia("(prefers-reduced-motion: reduce)");
|
||||
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 dropDebug: ((text: string) => void) | null = null;
|
||||
/// 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);
|
||||
}
|
||||
|
||||
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.
|
||||
private liquid(): LiquidBlob | null {
|
||||
if (this.setting !== "liquid" || this.reduced.matches || this.blob === null) return null;
|
||||
if (this.blob === undefined) {
|
||||
this.blob = LiquidBlob.create(this.touch);
|
||||
if (this.blob) {
|
||||
this.blob.flareSetting = this.flareSetting;
|
||||
// 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);
|
||||
@@ -87,6 +102,14 @@ export class Visualizer {
|
||||
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.
|
||||
cancelFlash(): void {
|
||||
this.blob?.settleIcon();
|
||||
@@ -100,6 +123,7 @@ export class Visualizer {
|
||||
this.card.el?.classList.remove("liquid", "turning");
|
||||
this.grab = null;
|
||||
}
|
||||
if (this.card !== card) this.features.setTempo(card?.track.bpm ?? null);
|
||||
this.card = card;
|
||||
const blob = this.liquid();
|
||||
const slot = card?.el?.querySelector<HTMLElement>(".art");
|
||||
|
||||
Reference in new issue
Block a user