Drop the chrome title, waveform and logo, the grain and the Unbounded font

Back to the plain bold title, the drawn waveform and the system font from
before the metal look. The spiky drop and its glow, the pre-blurred
backgrounds, the --energy fix, ?debug=drop and the desktop layout stay.
This commit is contained in:
nvrl committed 2026-11-02 13:06:30 +01:00
1 parent 92ac36bc67
commit 6e7311bb9d
10 files changed
+22 -1121

No files matched your search

-5
View File
@@ -413,11 +413,6 @@ export class LiquidBlob {
].join("\n");
}
/// Stepped down to the lowest quality: the GPU had trouble keeping up.
get struggling(): boolean {
return this.quality === QUALITY.length - 1;
}
/// Step the quality down when frames keep coming in slow, and back up once they
/// have been on time for a while.
private watch(dt: number): void {
+1 -36
View File
@@ -193,11 +193,6 @@ export class Card {
if (this.dragging && !force) return;
this.lastProgress = progress;
this.lastBuffered = buffered;
if (this.onWave) {
this.onWave(progress, buffered);
this.drawn = ""; // drawn here again once chrome lets go
return;
}
const canvas = this.canvas;
if (!canvas) return;
const w = canvas.clientWidth, h = canvas.clientHeight;
@@ -221,7 +216,7 @@ export class Card {
const accent = getComputedStyle(canvas).getPropertyValue("--card-accent").trim() || "#ff4d6d";
const base = Math.round(h * (1 - MIRROR)) - 1;
const below = h - base - 2;
const level = (i: number) => this.level(i, bars, data, max);
const level = (i: number) => (data.length ? data[Math.floor((i / bars) * data.length)] / max : 0.15);
const grad = ctx.createLinearGradient(0, base, 0, 0);
grad.addColorStop(0, accent);
grad.addColorStop(0.65, accent);
@@ -250,36 +245,6 @@ export class Card {
ctx.globalAlpha = 1;
}
/// Set while the waveform is chrome (chrome.ts via visual.ts), which then draws it.
onWave: ((progress: number, buffered: number) => void) | null = null;
get waveCanvas(): HTMLCanvasElement | null {
return this.canvas;
}
private level(i: number, bars: number, data: number[], max: number): number {
return data.length ? data[Math.floor((i / bars) * data.length)] / max : 0.15;
}
/// The bars alone, as a mask for chrome, in CSS px of a `w` x `h` canvas, `bar`
/// px apart. Returns the line they stand on and, per bar, where its top is.
waveMask(ctx: CanvasRenderingContext2D, w: number, h: number, bar: number): { base: number; tops: number[]; width: number } {
const data = this.track.waveform || [];
const max = Math.max(1, ...data);
const bars = Math.max(1, Math.floor(w / bar));
const width = bar * 0.7;
const base = Math.round(h * (1 - MIRROR)) - 1;
const tops: number[] = [];
for (let i = 0; i < bars; i++) {
const top = Math.max(width, this.level(i, bars, data, max) * (base - 2));
tops.push(base - top);
ctx.beginPath();
ctx.roundRect(i * bar, base - top, width, top, width / 2);
ctx.fill();
}
return { base, tops, width };
}
/// Loudness at `progress` from the waveform, 0..1. Stands in for the analyser
/// where there is no Web Audio graph.
loudnessAt(progress: number): number {
-155
View File
@@ -1,155 +0,0 @@
// The CPU half of chrome (chrome.ts): a shape's mask baked into the texture its
// shader lights. Runs in a worker (chrome-worker.ts), or inline where there is none.
/// A run of the mask that forms one line: its face runs sky to floor over `height`.
export interface Line {
left: number;
top: number;
width: number;
height: number;
}
/// A mask to bake, in device px.
export interface BakeJob {
alpha: Uint8ClampedArray;
w: number;
h: number;
bevel: number;
/// How far the side wall reaches down; 0 for none.
depth: number;
/// How far the shadow falls and spreads; 0 for none.
shadow: number;
lines: Line[];
}
const INF = 1e20;
/// The mask's alpha to the texture the shader lights: RG the surface normal, B
/// the coverage of the metal (face and side wall), A the shadow under it.
export function bake({ alpha, w, h, bevel, depth, shadow: fall, lines }: BakeJob): Uint8Array<ArrayBuffer> {
const n = w * h;
const cover = new Float32Array(n);
// Squared distance from inside the shape to its edge, with sub-pixel edges.
const grid = new Float64Array(n);
for (let i = 0; i < n; i++) {
const a = alpha[i * 4 + 3] / 255;
cover[i] = a;
grid[i] = a === 1 ? INF : a === 0 ? 0 : Math.max(0, a - 0.5) ** 2;
}
edt(grid, w, h);
const height = new Float32Array(n);
for (let i = 0; i < n; i++) {
const t = Math.min(1, Math.sqrt(grid[i]) / bevel);
// Mostly a flat chamfer, rounded a little at the top.
height[i] = 0.6 * t + 0.4 * (1 - (1 - t) * (1 - t));
}
// How far down its line each pixel is; each line is a plate curved top to
// bottom, so its face runs from the bright sky through the horizon to the upper
// floor, never out to the dark ends of the studio.
const run = new Float32Array(n).fill(0.5);
for (const l of lines) {
const x0 = Math.max(0, Math.floor(l.left)), x1 = Math.min(w, Math.ceil(l.left + l.width));
const y0 = Math.max(0, Math.floor(l.top)), y1 = Math.min(h, Math.ceil(l.top + l.height + depth));
for (let y = y0; y < y1; y++) {
const v = Math.min(1, (y - l.top) / Math.max(1, l.height));
for (let x = x0; x < x1; x++) run[y * w + x] = v;
}
}
// Slope per px times the bevel width: a bevel at about 55 degrees.
const k = 0.5 * bevel * 1.4;
const shadow = fall ? shadowOf(cover, w, h, depth, fall) : null;
const out = new Uint8Array(n * 4);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const i = y * w + x;
const hx = (x + 1 < w ? height[i + 1] : 0) - (x > 0 ? height[i - 1] : 0);
const hy = (y + 1 < h ? height[i + w] : 0) - (y > 0 ? height[i - w] : 0);
let nx = -hx * k, ny = hy * k + 0.25 - run[i] * 0.5, nz = 1;
// The side wall: the nearest face above, up to `depth` px; the further
// down from it, the more it faces the floor.
let m = cover[i];
let up = 0;
for (let d = 1; m < 1 && d <= depth && y - d >= 0; d++) {
if (cover[i - d * w] > 0.5) {
up = d;
break;
}
}
if (up) {
const wall = 1 - m;
const t = up / depth;
nx *= m;
ny = ny * m + wall * -(0.4 + 1.2 * t);
m = 1;
}
const len = Math.sqrt(nx * nx + ny * ny + nz * nz);
out[i * 4] = Math.round((nx / len) * 127.5 + 127.5);
out[i * 4 + 1] = Math.round((ny / len) * 127.5 + 127.5);
out[i * 4 + 2] = Math.round(m * 255);
out[i * 4 + 3] = shadow ? Math.round(shadow[i] * 255) : 0;
}
}
return out;
}
/// The coverage moved down past the side wall and blurred: the shadow it casts.
function shadowOf(cover: Float32Array, w: number, h: number, depth: number, fall: number): Float32Array {
const drop = depth + Math.max(1, Math.round(fall * 0.5));
const r = Math.max(1, Math.round(fall * 0.8));
let a: Float32Array<ArrayBuffer> = new Float32Array(cover.length);
for (let y = drop; y < h; y++) a.set(cover.subarray((y - drop) * w, (y - drop + 1) * w), y * w);
// Two box blurs each way are close enough to a gaussian.
for (let pass = 0; pass < 2; pass++) {
a = boxBlur(a, w, h, r, 1, w);
a = boxBlur(a, h, w, r, w, 1);
}
return a;
}
/// Blur runs of `len` values `step` apart, `count` of them `stride` apart.
function boxBlur(src: Float32Array, len: number, count: number, r: number, step: number, stride: number): Float32Array<ArrayBuffer> {
const dst = new Float32Array(src.length);
const norm = 1 / (2 * r + 1);
for (let j = 0; j < count; j++) {
const o = j * stride;
let sum = 0;
for (let i = -r; i <= r; i++) sum += src[o + Math.min(len - 1, Math.max(0, i)) * step];
for (let i = 0; i < len; i++) {
dst[o + i * step] = sum * norm;
sum += src[o + Math.min(len - 1, i + r + 1) * step] - src[o + Math.max(0, i - r) * step];
}
}
return dst;
}
/// Felzenszwalb and Huttenlocher's distance transform, in place: columns, then rows.
function edt(grid: Float64Array, w: number, h: number): void {
const size = Math.max(w, h);
const f = new Float64Array(size), z = new Float64Array(size + 1), v = new Uint16Array(size);
for (let x = 0; x < w; x++) edt1d(grid, x, w, h, f, v, z);
for (let y = 0; y < h; y++) edt1d(grid, y * w, 1, w, f, v, z);
}
function edt1d(grid: Float64Array, offset: number, stride: number, length: number, f: Float64Array, v: Uint16Array, z: Float64Array): void {
v[0] = 0;
z[0] = -INF;
z[1] = INF;
f[0] = grid[offset];
for (let q = 1, k = 0, s = 0; q < length; q++) {
f[q] = grid[offset + q * stride];
const q2 = q * q;
do {
const r = v[k];
s = (f[q] - f[r] + q2 - r * r) / (q - r) / 2;
} while (s <= z[k] && --k > -1);
k++;
v[k] = q;
z[k] = s;
z[k + 1] = INF;
}
for (let q = 0, k = 0; q < length; q++) {
while (z[k + 1] < q) k++;
const r = v[k], qr = q - r;
grid[offset + q * stride] = f[r] + qr * qr;
}
}
-8
View File
@@ -1,8 +0,0 @@
// Bakes chrome shapes off the main thread; see chrome-bake.ts.
import { bake, type BakeJob } from "./chrome-bake";
self.onmessage = (e: MessageEvent<BakeJob & { id: number }>) => {
const pixels = bake(e.data);
(self as unknown as Worker).postMessage({ id: e.data.id, pixels }, [pixels.buffer]);
};
-506
View File
@@ -1,506 +0,0 @@
// Chrome: the playing card's title, its waveform and the logo as heavy, polished
// metal, after chrome lettering on a dark flyer. Drawn by WebGL2, not by CSS.
//
// A shape (glyphs or waveform bars) is drawn as a mask on a 2D canvas and baked
// once, on the CPU and off the main thread (chrome-bake.ts), into a texture: a distance transform gives every edge a bevel
// of a fixed width, the bevel's slope gives the surface normal, a side wall is
// pushed out below the shape and a soft shadow falls under it. Lighting it is
// then one texture fetch and a few lookups into a small painted studio: mostly
// black, a sharp horizon through every letter's face, one white strip above it
// and the card's colour as the floor. Colours split at the steep edges, more on
// kicks, and a highlight follows the pointer.
//
// One WebGL2 context draws straight into a canvas laid over the playing card, so
// nothing is copied per frame. Only the logo, which never moves, is read back
// once into its own canvas.
import { bake, type BakeJob, type Line } from "./chrome-bake";
const MAX_DPR = 2;
const ENV_W = 64;
const ENV_H = 256; // tall, so the horizon stays sharp
const ART_SIZE = 8; // the art is only ever seen blurred, in the floor
/// What a plate shows.
export type Kind = "text" | "wave";
/// A shape on the GPU, drawn at a place on the layer.
export interface Plate {
kind: Kind;
/// Place on the layer and size, in device px.
x: number;
y: number;
w: number;
h: number;
/// The baked shape as uploaded (RG normal, B coverage, A shadow).
pixels: Uint8Array | null;
/// Counts shapes asked for, so a late bake of an old one is dropped.
job: number;
tex: WebGLTexture | null;
/// Waveform: the y of the line the bars stand on, 0..1 from the top.
floor: number;
}
/// Per-render light and state.
export interface Light {
/// Card colour as rgb 0..1.
tint: [number, number, number];
/// Where the light comes from, -1..1 each way; follows the pointer.
tilt: [number, number];
/// Onset envelope, 0..1.
kick: number;
/// A highlight sweeping across, 0..1 along the plate; outside that, none.
sweep: number;
}
export interface WaveState {
progress: number;
buffered: number;
}
const VERTEX = `#version 300 es
out vec2 vUv;
void main() {
// One triangle over the viewport.
vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
vUv = p;
gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
}`;
const FRAGMENT = `#version 300 es
precision highp float;
uniform sampler2D uShape;
uniform sampler2D uEnv;
uniform vec2 uSize;
uniform int uKind;
uniform vec3 uTint;
uniform vec2 uTilt;
uniform float uKick;
uniform float uSweep;
uniform float uFloor;
uniform vec2 uWave; // progress, buffered
in vec2 vUv;
out vec4 outColor;
// The studio seen along the reflection of a ray from the viewer off normal n.
vec3 env(vec3 n) {
vec3 r = reflect(vec3(0.0, 0.0, -1.0), n);
vec2 at = vec2(0.5 + 0.5 * clamp(r.x * 0.8 + uTilt.x * 0.3, -1.0, 1.0), 0.5 - 0.5 * clamp(r.y + uTilt.y * 0.2, -1.0, 1.0));
return texture(uEnv, at).rgb;
}
void main() {
vec2 uv = vec2(vUv.x, 1.0 - vUv.y);
vec2 px = 1.0 / uSize;
vec2 at = uv;
float fade = 1.0;
bool wave = uKind == 1;
if (wave && uv.y > uFloor) {
// Under the line the bars stand on: their reflection, fading out.
at.y = 2.0 * uFloor - uv.y;
fade = 0.18 * (1.0 - smoothstep(0.0, 1.0 - uFloor, uv.y - uFloor));
}
vec4 s = texture(uShape, at);
float m = s.b * fade;
float shadow = s.a * fade;
// Waveform: a thin, hot playhead from the floor up, and a hairline floor.
float head = 0.0;
float floorLine = 0.0;
if (wave) {
if (uWave.x > 0.0 && uWave.x < 1.0) head = smoothstep(1.5 * px.x, 0.0, abs(uv.x - uWave.x)) * step(uv.y, uFloor) * smoothstep(0.0, 0.6, uv.y);
floorLine = smoothstep(1.2 * px.y, 0.0, abs(uv.y - uFloor - px.y)) * 0.35;
}
if (m < 0.002 && shadow < 0.002 && head + floorLine < 0.002) discard;
vec3 n = vec3(s.rg * 2.0 - 1.0, 0.0);
n.z = sqrt(max(0.0, 1.0 - dot(n.xy, n.xy)));
// Split the colours at the steep edges, like a cheap lens; a kick pushes it.
// Text gets a third of it, so small glyphs keep clean outlines.
float split = (0.015 + (1.0 - n.z) * (0.05 + 0.3 * uKick)) * (wave ? 1.0 : 0.35);
vec3 c;
c.r = env(normalize(vec3(n.xy * (1.0 + split), n.z))).r;
c.g = env(n).g;
c.b = env(normalize(vec3(n.xy * (1.0 - split), n.z))).b;
// A key light that follows the pointer; kicks make it flare.
vec3 l = normalize(vec3(uTilt.x * 0.9 - 0.25, -uTilt.y * 0.7 + 0.55, 1.0));
float spec = pow(max(dot(reflect(-l, n), vec3(0.0, 0.0, 1.0)), 0.0), 64.0);
c += spec * (0.7 + 1.8 * uKick) * mix(vec3(1.0), uTint, 0.3);
// The sweep: a narrow bright band slanting across.
float band = (uv.x - uSweep) * 12.0 + (uv.y - 0.5) * 2.0;
c += 1.4 * exp(-band * band) * step(-0.5, uSweep) * step(uSweep, 1.5);
if (wave) {
// Played bars are chrome; the rest is dark gunmetal, lighter where loaded.
float played = step(uv.x, uWave.x);
float loaded = step(uv.x, uWave.y);
float grey = dot(c, vec3(0.3, 0.59, 0.11));
vec3 gun = vec3(grey) * mix(0.45, 0.65, loaded) * vec3(0.92, 0.93, 1.0);
c = mix(gun, c, played);
m *= mix(mix(0.75, 0.9, loaded), 1.0, played);
}
vec3 col = c * m;
float a = m;
vec3 hot = mix(uTint, vec3(1.0), 0.25) * 1.4;
col = mix(col, hot, head);
a = max(a, head);
col += vec3(0.8) * floorLine * (1.0 - a);
a += floorLine * (1.0 - a);
a += shadow * 0.65 * (1.0 - a);
outColor = vec4(min(col, vec3(a)), a);
}`;
const UNIFORMS = ["uShape", "uEnv", "uSize", "uKind", "uTint", "uTilt", "uKick", "uSweep", "uFloor", "uWave"] as const;
export class Chrome {
/// The layer laid over the playing card.
readonly canvas: HTMLCanvasElement;
/// Called once the GPU gave the context back; everything should be drawn again.
onRestored: (() => void) | null = null;
readonly dpr = Math.min(MAX_DPR, window.devicePixelRatio || 1);
private gl: WebGL2RenderingContext;
private u: Record<(typeof UNIFORMS)[number], WebGLUniformLocation | null> = {} as never;
/// The studio as the playing card colours it, and as the logo sees it.
private env: WebGLTexture | null = null;
private baseEnv: WebGLTexture | null = null;
private art: { img: HTMLImageElement | null; color: string } = { img: null, color: "#ff4d6d" };
private plates = new Set<Plate>();
private baker = new Baker();
private constructor(gl: WebGL2RenderingContext) {
this.gl = gl;
this.canvas = gl.canvas as HTMLCanvasElement;
this.canvas.addEventListener("webglcontextlost", (e) => e.preventDefault());
this.canvas.addEventListener("webglcontextrestored", () => {
this.init();
for (const p of this.plates) {
p.tex = null;
if (p.pixels) this.upload(p);
}
this.onRestored?.();
});
this.init();
}
static create(): Chrome | null {
try {
const canvas = document.createElement("canvas");
canvas.className = "chrome-layer";
canvas.setAttribute("aria-hidden", "true");
const gl = canvas.getContext("webgl2", { premultipliedAlpha: true, antialias: false, alpha: true, depth: false, stencil: false });
return gl ? new Chrome(gl) : null;
} catch {
return null;
}
}
get lost(): boolean {
return this.gl.isContextLost();
}
private init(): void {
const gl = this.gl;
const prog = gl.createProgram();
for (const [type, src] of [[gl.VERTEX_SHADER, VERTEX], [gl.FRAGMENT_SHADER, 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) || "compile failed");
gl.attachShader(prog, s);
}
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog) || "link failed");
gl.useProgram(prog);
for (const name of UNIFORMS) this.u[name] = gl.getUniformLocation(prog, name);
gl.uniform1i(this.u.uShape, 0);
gl.uniform1i(this.u.uEnv, 1);
gl.bindVertexArray(gl.createVertexArray());
gl.clearColor(0, 0, 0, 0);
this.baseEnv = this.paintEnv(gl.createTexture(), null, "#ff4d6d");
this.env = this.paintEnv(gl.createTexture(), this.art.img, this.art.color);
}
/// What the floor of the studio shows: the card's art, or its colour.
setArt(img: HTMLImageElement | null, color: string): void {
this.art = { img, color };
this.env = this.paintEnv(this.env, img, color);
}
/// Paint the studio into `tex`. Rows run from straight up to straight down.
private paintEnv(tex: WebGLTexture | null, img: HTMLImageElement | null, color: string): WebGLTexture | null {
const gl = this.gl;
const c = document.createElement("canvas");
c.width = ENV_W;
c.height = ENV_H;
const ctx = c.getContext("2d")!;
const half = ENV_H / 2;
// The sky: mid grey overhead, rising to white at the horizon, so no part of a
// face sinks into the dark page.
const sky = ctx.createLinearGradient(0, 0, 0, half);
sky.addColorStop(0, "#5c626e");
sky.addColorStop(0.35, "#7c8290");
sky.addColorStop(0.75, "#d6dae2");
sky.addColorStop(0.95, "#ffffff");
sky.addColorStop(1, "#ffffff");
ctx.fillStyle = sky;
ctx.fillRect(0, 0, ENV_W, half);
// One softbox strip high up, and a faint narrow one to the side.
ctx.fillStyle = "#fff";
ctx.fillRect(0, half * 0.38, ENV_W, 3);
ctx.fillStyle = "rgba(255,255,255,0.35)";
ctx.fillRect(ENV_W * 0.74, 0, 2, half * 0.9);
// The floor: a thin dark horizon, then the card's colour and art.
ctx.fillStyle = color;
ctx.fillRect(0, half, ENV_W, half);
if (img) {
// Drawn tiny and stretched up: a blur for free.
const small = document.createElement("canvas");
small.width = small.height = ART_SIZE;
small.getContext("2d")!.drawImage(img, 0, 0, ART_SIZE, ART_SIZE);
ctx.globalAlpha = 0.35;
ctx.drawImage(small, 0, half, ENV_W, half);
ctx.globalAlpha = 1;
}
const dark = ctx.createLinearGradient(0, half, 0, ENV_H);
dark.addColorStop(0, "rgba(0,0,0,0.9)");
dark.addColorStop(0.06, "rgba(0,0,0,0.6)");
dark.addColorStop(0.25, "rgba(0,0,0,0.1)");
dark.addColorStop(1, "rgba(0,0,0,0)");
ctx.fillStyle = dark;
ctx.fillRect(0, half, ENV_W, half);
tex ??= gl.createTexture();
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, c);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
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.activeTexture(gl.TEXTURE0);
return tex;
}
/// Size the layer to `w` x `h` CSS px. Clears it.
layout(w: number, h: number): void {
const W = Math.max(1, Math.round(w * this.dpr)), H = Math.max(1, Math.round(h * this.dpr));
if (this.canvas.width !== W) this.canvas.width = W;
if (this.canvas.height !== H) this.canvas.height = H;
}
plate(kind: Kind): Plate {
const p: Plate = { kind, x: 0, y: 0, w: 0, h: 0, pixels: null, job: 0, tex: null, floor: 1 };
this.plates.add(p);
return p;
}
drop(p: Plate): void {
if (p.tex) this.gl.deleteTexture(p.tex);
p.tex = null;
p.pixels = null;
p.job++;
this.plates.delete(p);
}
/// Put `p` at `x`, `y` CSS px from the layer's top left.
place(p: Plate, x: number, y: number): void {
p.x = Math.round(x * this.dpr);
p.y = Math.round(y * this.dpr);
}
/// Give `p` its shape: `draw` paints the mask in CSS px onto a canvas of
/// `w` x `h` CSS px and returns, per painted region, the line it belongs to
/// (for the face's sky-to-floor run). `bevel`, `depth` (of the side wall) and
/// `shadow` (how far it falls; 0 for none) are in CSS px. Resolves once the
/// shape is on the GPU, or false if it was dropped or asked for again in the
/// meantime.
async shape(p: Plate, w: number, h: number, bevel: number, depth: number, shadow: number, draw: (ctx: CanvasRenderingContext2D) => Line[]): Promise<boolean> {
const dpr = this.dpr;
const W = Math.max(1, Math.round(w * dpr)), H = Math.max(1, Math.round(h * dpr));
const mask = document.createElement("canvas");
mask.width = W;
mask.height = H;
const ctx = mask.getContext("2d", { willReadFrequently: true })!;
ctx.scale(dpr, dpr);
ctx.fillStyle = "#fff";
const lines = draw(ctx);
const alpha = ctx.getImageData(0, 0, W, H).data;
const scaled = lines.map((l) => ({ top: l.top * dpr, height: l.height * dpr, left: l.left * dpr, width: l.width * dpr }));
const job = ++p.job;
const pixels = await this.baker.bake({ alpha, w: W, h: H, bevel: bevel * dpr, depth: Math.round(depth * dpr), shadow: Math.round(shadow * dpr), lines: scaled });
if (job !== p.job) return false;
p.w = W;
p.h = H;
p.pixels = pixels;
this.upload(p);
return true;
}
private upload(p: Plate): void {
const gl = this.gl;
p.tex ??= gl.createTexture();
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, p.tex);
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, p.w, p.h, 0, gl.RGBA, gl.UNSIGNED_BYTE, p.pixels);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
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);
}
/// Empty the layer, before its plates are drawn again.
clear(): void {
if (this.lost) return;
this.gl.viewport(0, 0, this.canvas.width, this.canvas.height);
this.gl.clear(this.gl.COLOR_BUFFER_BIT);
}
/// Light `p` at its place on the layer.
render(p: Plate, light: Light, wave?: WaveState): void {
this.draw(p, p.x, this.canvas.height - p.y - p.h, light, this.env, wave);
}
/// Light `p` once, off screen, and keep the picture in `target`: for a plate
/// that never moves.
still(p: Plate, target: HTMLCanvasElement, light: Light): void {
const gl = this.gl;
if (!p.tex || gl.isContextLost()) return;
const tex = gl.createTexture();
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA8, p.w, p.h);
const fb = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
this.draw(p, 0, 0, light, this.baseEnv);
const px = new Uint8Array(p.w * p.h * 4);
gl.readPixels(0, 0, p.w, p.h, gl.RGBA, gl.UNSIGNED_BYTE, px);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.deleteFramebuffer(fb);
gl.deleteTexture(tex);
// GL's rows run bottom up and its colour is premultiplied; ImageData's are not.
const img = new ImageData(p.w, p.h);
const row = p.w * 4;
for (let y = 0; y < p.h; y++) {
for (let i = 0, from = (p.h - 1 - y) * row, to = y * row; i < row; i += 4) {
const a = px[from + i + 3];
const k = a ? 255 / a : 0;
img.data[to + i] = px[from + i] * k;
img.data[to + i + 1] = px[from + i + 1] * k;
img.data[to + i + 2] = px[from + i + 2] * k;
img.data[to + i + 3] = a;
}
}
target.width = p.w;
target.height = p.h;
target.getContext("2d")!.putImageData(img, 0, 0);
}
private draw(p: Plate, x: number, y: number, light: Light, env: WebGLTexture | null, wave?: WaveState): boolean {
const gl = this.gl;
if (!p.tex || gl.isContextLost()) return false;
gl.viewport(x, y, p.w, p.h);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, env);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, p.tex);
gl.uniform2f(this.u.uSize, p.w, p.h);
gl.uniform1i(this.u.uKind, p.kind === "wave" ? 1 : 0);
gl.uniform3fv(this.u.uTint, light.tint);
gl.uniform2fv(this.u.uTilt, light.tilt);
gl.uniform1f(this.u.uKick, light.kick);
gl.uniform1f(this.u.uSweep, light.sweep);
gl.uniform1f(this.u.uFloor, p.floor);
gl.uniform2f(this.u.uWave, wave?.progress ?? 0, wave?.buffered ?? 0);
gl.enable(gl.BLEND);
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
gl.drawArrays(gl.TRIANGLES, 0, 3);
return true;
}
}
/// Bakes shapes in a worker; inline where the worker cannot start.
class Baker {
private worker: Worker | null = null;
private next = 1;
private waiting = new Map<number, { job: BakeJob; done: (pixels: Uint8Array) => void }>();
constructor() {
try {
this.worker = new Worker(new URL("./chrome-worker.ts", import.meta.url), { type: "module" });
this.worker.onmessage = (e: MessageEvent<{ id: number; pixels: Uint8Array }>) => {
this.waiting.get(e.data.id)?.done(e.data.pixels);
this.waiting.delete(e.data.id);
};
this.worker.onerror = () => this.inline();
} catch {
this.worker = null;
}
}
bake(job: BakeJob): Promise<Uint8Array> {
if (!this.worker) return Promise.resolve(bake(job));
const id = this.next++;
return new Promise((done) => {
this.waiting.set(id, { job, done });
this.worker!.postMessage({ ...job, id });
});
}
/// The worker failed: bake what it was given here, and everything after.
private inline(): void {
this.worker?.terminate();
this.worker = null;
for (const { job, done } of this.waiting.values()) done(bake(job));
this.waiting.clear();
}
}
/// Paint `el`'s text, character by character where the page laid it out, into
/// a mask whose origin is `pad` CSS px up and left of `el`. Returns its lines.
export function textMask(el: HTMLElement, pad: number, ctx: CanvasRenderingContext2D): Line[] {
const box = el.getBoundingClientRect();
const cs = getComputedStyle(el);
ctx.font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`;
ctx.textBaseline = "alphabetic";
const m = ctx.measureText("Hg");
const range = document.createRange();
const lines = new Map<number, Line>();
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
const text = node.textContent || "";
for (let i = 0; i < text.length; i++) {
// Surrogate pairs stay together.
const len = text.codePointAt(i)! > 0xffff ? 2 : 1;
const ch = text.slice(i, i + len);
range.setStart(node, i);
range.setEnd(node, i + len);
i += len - 1;
if (!ch.trim()) continue;
const r = range.getClientRects()[0];
if (!r) continue;
const left = r.left - box.left + pad, top = r.top - box.top + pad;
const ascent = m.fontBoundingBoxAscent ?? r.height * 0.8;
ctx.fillText(ch, left, top + ascent);
const key = Math.round(top);
const line = lines.get(key);
if (line) {
line.width = left + r.width - line.left;
} else {
lines.set(key, { left, top, width: r.width, height: r.height });
}
}
}
return [...lines.values()];
}
let probe: CanvasRenderingContext2D | null = null;
/// "#rrggbb", "rgb(...)" or "hsl(...)" to rgb 0..1, through the page's own parser.
export function rgb(color: string): [number, number, number] {
probe ??= document.createElement("canvas").getContext("2d", { willReadFrequently: true })!;
probe.clearRect(0, 0, 1, 1);
probe.fillStyle = color;
probe.fillRect(0, 0, 1, 1);
const d = probe.getImageData(0, 0, 1, 1).data;
return [d[0] / 255, d[1] / 255, d[2] / 255];
}
-21
View File
@@ -1,5 +1,4 @@
import "./style.css";
import "@fontsource-variable/unbounded";
import * as api from "./api";
import type { Filters, Mode, Pl8Status, Stats, Track, User } from "./api";
@@ -56,8 +55,6 @@ const sheets = {
if ("serviceWorker" in navigator && import.meta.env.PROD) {
navigator.serviceWorker.register("/sw.js", { scope: "/" }).catch((err) => console.warn("no service worker:", err));
}
paintGrain();
const cards: Card[] = [];
let active = -1;
let loading = false;
@@ -1143,24 +1140,6 @@ function signedIn(user: User, pl8list?: Pl8Status): void {
if (!cards.length) openLinkOrFeed();
}
/// The tile of film grain the stylesheet lays under the page's content: faint
/// specks, each a little lighter or darker than what is under it.
function paintGrain(): void {
const size = 128;
const c = document.createElement("canvas");
c.width = c.height = size;
const ctx = c.getContext("2d");
if (!ctx) return;
const img = ctx.createImageData(size, size);
for (let i = 0; i < img.data.length; i += 4) {
const v = Math.random() < 0.5 ? 0 : 255;
img.data[i] = img.data[i + 1] = img.data[i + 2] = v;
img.data[i + 3] = Math.random() * 30;
}
ctx.putImageData(img, 0, 0);
document.documentElement.style.setProperty("--grain", `url(${c.toDataURL()})`);
}
/// `/?t=handle/slug` opens on that track; the feed carries on after it.
async function openLinkOrFeed(): Promise<void> {
const link = new URLSearchParams(location.search).get("t")?.split("/");
+14 -44
View File
@@ -18,8 +18,7 @@
--radius-l: 18px;
--shadow: 0 16px 40px #000a;
--font: system-ui, -apple-system, "Segoe UI", sans-serif;
/* Heavy and wide, so chrome has broad faces to show its horizon on. */
--font-display: "Unbounded Variable", var(--font);
--font-display: var(--font);
--ease-out: cubic-bezier(0.2, 0.8, 0.2, 1);
--fast: 0.15s;
}
@@ -29,14 +28,13 @@
html, body {
margin: 0;
height: 100%;
/* Film grain (main.ts paints the tile) under everything; cards lay their own
over their background, below their content. */
background: var(--grain, none), var(--bg);
background: var(--bg);
color: var(--fg);
font: 15px/1.4 var(--font);
overflow: hidden;
-webkit-tap-highlight-color: transparent;
}
a { color: inherit; }
button, input {
@@ -75,8 +73,8 @@ button {
}
body.zen #bar { display: none; }
.logo { position: relative; font: 800 0.95rem var(--font-display); letter-spacing: 0.03em; display: inline-flex; align-items: center; gap: 0.4em; white-space: nowrap; justify-self: start; }
.logo b { position: relative; color: var(--accent); }
.logo { font-weight: 600; display: inline-flex; align-items: center; gap: 0.4em; white-space: nowrap; justify-self: start; }
.logo b { color: var(--accent); font-weight: 700; }
.logo svg { width: 1.3em; height: 1.3em; color: var(--accent); flex: none; }
.bar-end { justify-self: end; display: flex; align-items: center; gap: 0.4rem; }
@@ -220,7 +218,7 @@ body.zen #bar { display: none; }
.sheet:focus-visible { outline: none; }
@keyframes drawer { from { transform: translateX(100%); } }
.sheet-handle { display: none; }
.sheet h2 { margin: 0; font: 700 1.15rem/1.2 var(--font-display); }
.sheet h2 { margin: 0; font: 800 1.25rem/1.2 var(--font-display); }
.sheet fieldset { margin: 0; padding: 0; border: 0; display: flex; flex-direction: column; gap: 0.6rem; }
.sheet legend, .sheet h3 {
padding: 0;
@@ -440,15 +438,6 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff;
opacity: calc(0.18 + var(--energy) * 0.55);
will-change: opacity;
}
/* Grain over the card's background only: content and actions sit above it. */
.card::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: var(--grain, none);
}
.card > .content, .card > .actions { z-index: 1; }
.content {
position: relative;
@@ -510,7 +499,7 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff;
will-change: transform, opacity;
pointer-events: none;
}
.card.liquid .content > :not(.art, .chrome-layer) { position: relative; z-index: 1; }
.card.liquid .content > :not(.art) { position: relative; z-index: 1; }
.card.liquid .title { text-shadow: 0 2px 16px #000b; }
/* The drop shows loading as a ring of its own. */
.card.liquid .initial, .card.liquid .paused-badge, .card.liquid .spinner { display: none; }
@@ -570,31 +559,12 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff;
@keyframes spin { to { transform: rotate(360deg); } }
.title {
position: relative;
z-index: 1; /* over the drop's spill */
font: 700 clamp(1.1rem, 3vw, 1.5rem)/1.2 var(--font-display);
letter-spacing: 0.02em;
font: 800 clamp(1.3rem, 4.2vw, 1.9rem)/1.1 var(--font-display);
letter-spacing: -0.01em;
margin: 0;
overflow-wrap: anywhere;
text-wrap: balance;
}
/* Chrome (chrome.ts): one canvas over the playing card's title and waveform
draws them as metal, placed and sized by visual.ts; the text stays for
selection and screen readers, but transparent. The logo, which never moves,
gets a canvas of its own that reaches past the text for its shadow. */
.content > .chrome-layer { position: absolute; z-index: 2; pointer-events: none; }
.chrome-plate {
position: absolute;
inset: calc(-1 * var(--pad, 16px));
width: calc(100% + 2 * var(--pad, 16px));
height: calc(100% + 2 * var(--pad, 16px));
pointer-events: none;
opacity: 0;
transition: opacity 0.35s;
}
.chromed { color: transparent !important; text-shadow: none !important; }
.chromed > .chrome-plate { opacity: 1; }
.why {
margin: 0;
min-height: 0;
@@ -867,7 +837,7 @@ body.zen #up-next { display: none; }
}
.start-play svg { width: 2.6rem; height: 2.6rem; margin-left: 0.3rem; }
@keyframes beckon { to { box-shadow: 0 0 0 2rem transparent; } }
.start-track { margin: 0.4rem 0 0; font: 700 1.05rem/1.25 var(--font-display); 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; }
/* Radio sheet: a bottom sheet on phones, a centred box elsewhere. */
@@ -901,7 +871,7 @@ body.zen #up-next { display: none; }
.icon-btn { width: 2.2rem; height: 2.2rem; padding: 0; display: grid; place-items: center; }
.icon-btn svg { width: 1rem; height: 1rem; }
.station-head { display: flex; gap: 1rem; align-items: center; }
.station-head h2 { margin: 0; font: 700 1.25rem/1.15 var(--font-display); overflow-wrap: anywhere; }
.station-head h2 { margin: 0; font: 800 1.4rem/1.1 var(--font-display); overflow-wrap: anywhere; }
.station-kicker { margin: 0 0 0.2rem; font-size: 0.7rem; letter-spacing: 0.12em; text-transform: uppercase; color: var(--accent); }
#station-sub { margin: 0.3rem 0 0.4rem; color: var(--muted); font-size: 0.85rem; }
#station-tags { justify-content: flex-start; }
@@ -1014,7 +984,7 @@ button.primary:disabled { opacity: 0.35; cursor: default; }
flex-direction: column;
gap: 0.9rem;
}
.login-box h1 { margin: 0; font: 800 2.1rem/1.1 var(--font-display); letter-spacing: -0.01em; display: flex; align-items: center; gap: 0.3em; }
.login-box h1 { margin: 0; font-size: 2.4rem; display: flex; align-items: center; gap: 0.3em; }
.login-box h1 b { color: var(--accent); }
/* A wheel up: the plate is pulled back once, then rests. */
.login-mark { width: 1.2em; height: 1.2em; color: var(--accent); flex: none; animation: wheel-up 1.1s 0.3s cubic-bezier(0.3, 0, 0.2, 1) both; }
@@ -1110,7 +1080,7 @@ body.zen .content { gap: 0.3rem; }
1.5rem around 2000x1100, at most 1.6rem; the shorter side decides. */
@media (min-width: 900px) and (pointer: fine) {
#bar { padding: 1rem 1.6rem; }
.logo { font-size: 1.15rem; }
.logo { font-size: 1.1rem; }
#mode-tabs { padding: 0.25rem; }
#mode-tabs button { font-size: 0.95rem; padding: 0.45em 1.15em; }
/* The controls as one glass capsule, a match for the tabs. */
@@ -1134,7 +1104,7 @@ body.zen .content { gap: 0.3rem; }
}
.content { width: min(calc(30 * var(--u)), 56vw); gap: calc(0.6 * var(--u)); }
.art { width: min(calc(16 * var(--u)), 46vh); margin-bottom: calc(1.2 * var(--u)); }
.title { font-size: calc(1.45 * var(--u)); }
.title { font-size: calc(1.75 * var(--u)); }
.why { font-size: 0.8rem; }
.byline { gap: 0.6rem; font-size: 1.1rem; }
.artist-avatar { width: 2rem; height: 2rem; font-size: 0.85rem; }
+7 -335
View File
@@ -1,7 +1,6 @@
// Per-frame visuals of the playing card: waveform progress, the liquid artwork drop
// (blob/), the chrome title, waveform and logo (chrome.ts), and `--energy` (0..1)
// for the stylesheet's glow and pulse, which is all there is where the drop is off
// or WebGL2 is missing.
// (blob/), and `--energy` (0..1) for the stylesheet's glow and pulse, which is all
// there is where the drop is off or WebGL2 is missing.
//
// What drives them comes from features.ts: from the analyser where audio runs through
// Web Audio, otherwise from the track's waveform at the current position.
@@ -10,7 +9,6 @@ import { artistImg } from "./api";
import { LiquidBlob, SPILL } from "./blob/blob";
import { lookFor } from "./blob/style";
import type { Card } from "./cards";
import { Chrome, type Light, type Plate, rgb, textMask } from "./chrome";
import { accentFor, accentForName } from "./color";
import { type Features, FeatureTracker } from "./features";
import type { Player } from "./player";
@@ -20,48 +18,6 @@ const VISUALS_KEY = "pl8inf.visuals";
const SLOP = 10; // px before a press on the drop turns it
const REACH = 1.2; // how far past the art's radius the drop can be grabbed
const IDLE_FPS = 15; // a paused drop keeps breathing, slowly and at a low frame rate
// Chrome is drawn only when something on it changes: the light moving, a kick, a
// bar of the waveform played, the sweep over a new title.
const CHROME_FPS = 30; // at most, 20 on phones
const TOUCH_CHROME_FPS = 20;
const PAD = 16; // CSS px the chrome reaches past the text, for its side wall and shadow
const BEVEL = 0.05; // of the font size
const DEPTH = 0.05; // of the font size: how far the side wall reaches down …
const WALL_MIN = 20; // … on text from this many CSS px; smaller text would clog up
const SHADOW = 0.09; // of the font size: how far the shadow falls
const SWEEP = 0.9; // s for the highlight to sweep across a new title
const AIM = 4; // 1/s: how fast the light follows the pointer
const KICK_STEP = 0.02; // a change in the kick envelope worth a frame
const CHROME_BAR = 5; // CSS px per chrome waveform bar, gap included: wide enough to read as metal
const LOGO_PAD = 10;
const LOGO_TINT: [number, number, number] = [1, 0.3, 0.43];
const REST_TILT: [number, number] = [0, -0.3];
/// The card in chrome: its title and waveform plates, and what they show.
interface Dressed {
card: Card;
titleEl: HTMLElement;
waveCanvas: HTMLCanvasElement;
title: Plate;
wave: Plate;
/// Placed as the layout now is; cleared when something may have moved, as
/// reading the layout on every frame would force it to be worked out each time.
placed: boolean;
layoutKey: string;
titleKey: string;
waveKey: string;
/// The waveform's width in CSS px and its number of bars.
waveW: number;
bars: number;
/// Bars played and loaded, as drawn.
played: number;
loaded: number;
tint: [number, number, number];
sweepFrom: number;
progress: number;
buffered: number;
observer: ResizeObserver;
}
export class Visualizer {
private raf = 0;
@@ -80,21 +36,10 @@ export class Visualizer {
/// A mouse rests on the drop.
private hover = false;
private idleTimer = 0;
/// Undefined until first needed; null where WebGL2 failed.
private chrome: Chrome | null | undefined;
private dressed: Dressed | null = null;
/// `measured` as for `Dressed.placed`.
private logo: { el: HTMLElement; canvas: HTMLCanvasElement; plate: Plate; key: string; measured: boolean; observer: ResizeObserver } | null = null;
/// Where the light comes from, and where the pointer wants it.
private tilt: [number, number] = [...REST_TILT];
private aim: [number, number] = [...REST_TILT];
private chromeAt = 0;
private chromeDirty = true;
private kickDrawn = 0;
/// The playing card's art and glow, and the `--energy` they were last given.
private energized: { el: HTMLElement; targets: HTMLElement[]; value: string } | null = null;
/// Frames and chrome draws counted for `?debug=drop`, shown once a second.
private stats = { from: 0, frames: 0, worst: 0, chrome: 0 };
/// Frames counted for `?debug=drop`, shown once a second.
private stats = { from: 0, frames: 0, worst: 0 };
constructor(
private player: Player,
@@ -104,7 +49,6 @@ export class Visualizer {
this.reduced.addEventListener("change", () => this.attach(this.card, true));
// Capturing, so a turn can keep its moves from the pager.
const on = { capture: true, passive: true };
document.fonts?.addEventListener("loadingdone", () => this.redress());
document.addEventListener("pointerdown", (e) => this.down(e), on);
document.addEventListener("pointermove", (e) => this.move(e), on);
for (const ev of ["pointerup", "pointercancel"]) document.addEventListener(ev, (e) => this.up(e as PointerEvent), on);
@@ -148,264 +92,6 @@ export class Visualizer {
this.blob?.settleIcon();
}
/// Chrome is part of the liquid look, and needs WebGL2 too.
private chromeOn(): Chrome | null {
if (this.setting !== "liquid" || this.chrome === null) return null;
if (this.chrome === undefined) {
this.chrome = Chrome.create();
if (this.chrome) this.chrome.onRestored = () => {
this.chromeDirty = true;
this.start();
};
}
if (this.chrome && !this.logo) this.dressLogo(this.chrome);
return this.chrome;
}
/// Put `card`'s title and waveform in chrome: the layer moves into the card.
private dress(card: Card | null): void {
const old = this.dressed;
if (old && (old.card !== card || !old.card.el)) this.strip(old);
const chrome = this.chromeOn();
if (!chrome) {
this.undress();
return;
}
const titleEl = card?.el?.querySelector<HTMLElement>(".title");
const waveCanvas = card?.waveCanvas;
if (!card || !titleEl || !waveCanvas || this.dressed) return;
titleEl.parentElement!.append(chrome.canvas);
chrome.clear();
// The waveform's own canvas stays, for seeking, but empty.
waveCanvas.getContext("2d")?.clearRect(0, 0, waveCanvas.width, waveCanvas.height);
const color = getComputedStyle(card.el!).getPropertyValue("--card-accent").trim() || "#ff4d6d";
const d: Dressed = {
card,
titleEl,
waveCanvas,
title: chrome.plate("text"),
wave: chrome.plate("wave"),
placed: false,
layoutKey: "",
titleKey: "",
waveKey: "",
waveW: 0,
bars: 0,
played: -1,
loaded: -1,
tint: rgb(color),
sweepFrom: performance.now(),
progress: 0,
buffered: 0,
// Text loading around the title moves it; the layer follows.
observer: new ResizeObserver(() => {
d.placed = false;
d.layoutKey = "";
this.chromeDirty = true;
this.start();
}),
};
for (const el of [titleEl.parentElement!, titleEl, waveCanvas]) d.observer.observe(el);
this.dressed = d;
card.onWave = (progress, buffered) => {
d.progress = progress;
d.buffered = buffered;
const played = Math.floor(progress * d.bars), loaded = Math.floor(buffered * d.bars);
if (played === d.played && loaded === d.loaded) return;
d.played = played;
d.loaded = loaded;
this.chromeDirty = true;
this.start();
};
// The studio's floor shows the art, in the colour taken from it.
chrome.setArt(null, color);
const img = artistImg(card.track);
if (img) {
const pic = new Image();
pic.src = img;
Promise.all([pic.decode(), accentFor(img)]).then(([, c]) => {
if (this.dressed !== d) return;
chrome.setArt(pic, c ?? color);
if (c) d.tint = rgb(c);
this.chromeDirty = true;
this.start();
}, () => {});
}
this.chromeDirty = true;
}
/// Take the chrome off `d`'s card: plain text and the drawn waveform again.
private strip(d: Dressed): void {
d.card.onWave = null;
d.observer.disconnect();
d.titleEl.classList.remove("chromed");
this.chrome?.drop(d.title);
this.chrome?.drop(d.wave);
this.chrome?.canvas.remove();
d.card.drawWave(d.progress, true, d.buffered);
this.dressed = null;
}
/// Back to plain text and the drawn waveform everywhere.
private undress(): void {
if (this.dressed) this.strip(this.dressed);
for (const el of document.querySelectorAll(".chromed")) el.classList.remove("chromed");
if (this.logo) {
this.logo.canvas.remove();
this.logo.observer.disconnect();
this.chrome?.drop(this.logo.plate);
}
this.logo = null;
}
/// Fonts arrived: shapes are drawn again on the next frame.
private redress(): void {
if (this.dressed) {
this.dressed.placed = false;
this.dressed.layoutKey = this.dressed.titleKey = this.dressed.waveKey = "";
}
if (this.logo) {
this.logo.key = "";
this.logo.measured = false;
}
this.chromeDirty = true;
this.start();
}
private dressLogo(chrome: Chrome): void {
const el = document.querySelector<HTMLElement>(".logo b");
if (!el) return;
el.style.setProperty("--pad", `${LOGO_PAD}px`);
const logo = {
el,
canvas: plateCanvas(el),
plate: chrome.plate("text"),
key: "",
measured: false,
observer: new ResizeObserver(() => {
logo.measured = false;
this.redraw();
}),
};
logo.observer.observe(el);
this.logo = logo;
}
/// Size and place the layer over the title and the waveform, and draw their
/// shapes again where their size or text changed.
private reshape(chrome: Chrome, d: Dressed): boolean {
if (d.placed) return true;
const el = d.titleEl, c = d.waveCanvas, wrap = c.parentElement!;
if (!el.clientWidth || !c.clientWidth) return false;
// Layout offsets, not client rects: the card scales as it leaves.
const top = el.offsetTop - PAD;
const waveTop = wrap.offsetTop + c.offsetTop;
const width = el.parentElement!.clientWidth + 2 * PAD;
const height = waveTop + c.clientHeight + PAD - top;
const key = `${width}x${height}@${top}`;
if (key !== d.layoutKey) {
d.layoutKey = key;
const s = chrome.canvas.style;
s.left = `${-PAD}px`;
s.top = `${top}px`;
s.width = `${width}px`;
s.height = `${height}px`;
chrome.layout(width, height);
}
const tkey = `${el.clientWidth}x${el.clientHeight}:${el.textContent}`;
if (tkey !== d.titleKey) {
d.titleKey = tkey;
const size = parseFloat(getComputedStyle(el).fontSize);
const wall = size < WALL_MIN ? 0 : size * DEPTH;
chrome.shape(d.title, el.clientWidth + 2 * PAD, el.clientHeight + 2 * PAD, size * BEVEL, wall, size * SHADOW, (ctx) => textMask(el, PAD, ctx)).then((ok) => {
if (!ok) return;
// The sweep runs over the title once it is metal.
if (!el.classList.contains("chromed")) d.sweepFrom = performance.now();
el.classList.add("chromed");
this.redraw();
});
}
const wkey = `${c.clientWidth}x${c.clientHeight}`;
if (wkey !== d.waveKey) {
d.waveKey = wkey;
const [w, h] = [c.clientWidth, c.clientHeight];
chrome.shape(d.wave, w, h, CHROME_BAR * 0.25, 0, 0, (ctx) => {
const { base, tops } = d.card.waveMask(ctx, w, h, CHROME_BAR);
d.waveW = w;
d.bars = tops.length;
d.played = Math.floor(d.progress * d.bars);
d.loaded = Math.floor(d.buffered * d.bars);
d.wave.floor = base / h;
// One horizon runs through all the bars, as through one sheet of metal.
return [{ left: 0, top: Math.min(...tops), width: tops.length * CHROME_BAR, height: base - Math.min(...tops) }];
}).then((ok) => ok && this.redraw());
}
// The title's plate starts PAD up and left of it, as the layer does.
chrome.place(d.title, el.offsetLeft, 0);
chrome.place(d.wave, wrap.offsetLeft + c.offsetLeft + PAD, waveTop - top);
d.placed = true;
return true;
}
/// A shape arrived from the baker: draw again.
private redraw(): void {
this.chromeDirty = true;
this.start();
}
/// Light the logo once, whenever its size changed.
private shineLogo(chrome: Chrome): void {
const logo = this.logo;
if (!logo || logo.measured) return;
const el = logo.el;
const key = `${el.clientWidth}x${el.clientHeight}`;
if (!el.clientWidth) return;
logo.measured = true;
if (key === logo.key) return;
logo.key = key;
chrome.shape(logo.plate, el.clientWidth + 2 * LOGO_PAD, el.clientHeight + 2 * LOGO_PAD, 1, 0, 1.5, (ctx) => textMask(el, LOGO_PAD, ctx)).then((ok) => {
if (!ok || this.logo !== logo) return;
chrome.still(logo.plate, logo.canvas, { tint: LOGO_TINT, tilt: REST_TILT, kick: 0, sweep: -1 });
el.classList.add("chromed");
});
}
/// Light the chrome where something on it changed. Returns whether it wants
/// more frames.
private shine(now: number, dt: number, kick: number): boolean {
const chrome = this.chrome;
if (!chrome || chrome.lost) return false;
const d = this.dressed;
// Where the drop had to step down to its lowest quality, the GPU is slow:
// chrome then holds still and is drawn only when what it shows changes.
const frozen = !!this.blob?.struggling;
const calm = frozen || this.reduced.matches;
const k = 1 - Math.exp(-dt * AIM);
this.tilt[0] += (this.aim[0] - this.tilt[0]) * k;
this.tilt[1] += (this.aim[1] - this.tilt[1]) * k;
const moving = !frozen && Math.abs(this.aim[0] - this.tilt[0]) + Math.abs(this.aim[1] - this.tilt[1]) > 0.003;
if (calm) kick = 0;
const kicking = Math.abs(kick - this.kickDrawn) > KICK_STEP;
const t = d && !calm ? (now - d.sweepFrom) / 1000 / SWEEP : 2;
const sweeping = t <= 1;
const want = this.chromeDirty || moving || kicking || sweeping;
if (!want || now - this.chromeAt < 1000 / (this.touch ? TOUCH_CHROME_FPS : CHROME_FPS)) return want;
this.chromeAt = now;
this.chromeDirty = false;
this.stats.chrome++;
this.kickDrawn = kick;
this.shineLogo(chrome);
if (!d || !this.reshape(chrome, d)) return want;
const light: Light = { tint: d.tint, tilt: this.tilt, kick, sweep: sweeping ? -0.3 + 1.6 * t : -1 };
chrome.clear();
chrome.render(d.title, light);
// The playhead sits in the gap after the last bar played.
const gap = (CHROME_BAR * 0.3) / 2;
const at = (bars: number) => (bars > 0 ? (bars * CHROME_BAR - gap) / d.waveW : 0);
chrome.render(d.wave, { ...light, sweep: -1 }, { progress: at(d.played), buffered: at(d.loaded) });
return want;
}
/// Follow `card` from now on.
attach(card: Card | null, again = false): void {
if (this.card === card && !again) return;
@@ -415,7 +101,6 @@ export class Visualizer {
this.grab = null;
}
this.card = card;
this.dress(card);
const blob = this.liquid();
const slot = card?.el?.querySelector<HTMLElement>(".art");
if (!card || !slot || !blob) {
@@ -509,10 +194,6 @@ export class Visualizer {
}
private move(e: PointerEvent): void {
if (e.pointerType === "mouse" && this.chrome) {
this.aim = [(e.clientX / innerWidth) * 2 - 1, (e.clientY / innerHeight) * 2 - 1];
this.start();
}
this.pointer(e, true);
const g = this.grab;
const at = this.where();
@@ -583,14 +264,14 @@ export class Visualizer {
if (this.dropDebug && now - st.from >= 1000) {
const secs = (now - st.from) / 1000;
this.dropDebug([
`fps ${(st.frames / secs).toFixed(0)} · worst ${(st.worst * 1000).toFixed(0)} ms · chrome ${(st.chrome / secs).toFixed(0)}/s`,
`fps ${(st.frames / secs).toFixed(0)} · worst ${(st.worst * 1000).toFixed(0)} ms`,
`setting ${this.setting} · reduced motion ${this.reduced.matches} · touch ${this.touch} · mixing ${mixed}`,
`blob ${this.blob === undefined ? "not asked" : this.blob === null ? `failed: ${LiquidBlob.failure}` : "made"} · card liquid ${!!card.el?.classList.contains("liquid")}`,
this.blob?.describe() ?? "",
].join("\n"));
}
if (now - st.from >= 1000) this.stats = { from: now, frames: 0, worst: 0, chrome: 0 };
let busy = this.shine(now, dt, f.kick) || playing;
if (now - st.from >= 1000) this.stats = { from: now, frames: 0, worst: 0 };
let busy = playing;
const liquid = !!this.blob && !!card.el?.classList.contains("liquid");
if (!this.reduced.matches) {
// The kick drives the pulse; without an analyser the kicks come from the waveform.
@@ -614,15 +295,6 @@ export class Visualizer {
};
}
/// A canvas laid over `el` for its chrome.
function plateCanvas(el: HTMLElement): HTMLCanvasElement {
const c = document.createElement("canvas");
c.className = "chrome-plate";
c.setAttribute("aria-hidden", "true");
el.append(c);
return c;
}
/// How far the audio is loaded from where it plays now, in seconds.
function bufferedTo(a: HTMLMediaElement): number {
const b = a.buffered;