diff --git a/web/package-lock.json b/web/package-lock.json index 68b284c..710ff40 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -6,7 +6,6 @@ "": { "name": "pl8inf-web", "dependencies": { - "@fontsource-variable/unbounded": "^5.3.0", "hls.js": "1.7.4" }, "devDependencies": { @@ -14,15 +13,6 @@ "vite": "8.3.5" } }, - "node_modules/@fontsource-variable/unbounded": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@fontsource-variable/unbounded/-/unbounded-5.3.0.tgz", - "integrity": "sha512-ea565Ncn4r7gPdBEHcmaF31V11hDfkL7b/JCklLobQbMfbu2ZZQrVWNgdeEFWFLqNh36yhieZWOgxdAnMTzrLg==", - "license": "OFL-1.1", - "funding": { - "url": "https://github.com/sponsors/ayuhito" - } - }, "node_modules/@oxc-project/types": { "version": "0.156.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.156.0.tgz", diff --git a/web/package.json b/web/package.json index f2b4cf4..c9937d9 100644 --- a/web/package.json +++ b/web/package.json @@ -8,7 +8,6 @@ "check": "tsc --noEmit" }, "dependencies": { - "@fontsource-variable/unbounded": "^5.3.0", "hls.js": "1.7.4" }, "devDependencies": { diff --git a/web/src/blob/blob.ts b/web/src/blob/blob.ts index 0a21053..54506c9 100644 --- a/web/src/blob/blob.ts +++ b/web/src/blob/blob.ts @@ -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 { diff --git a/web/src/cards.ts b/web/src/cards.ts index 2bdf6ad..d4c7dd7 100644 --- a/web/src/cards.ts +++ b/web/src/cards.ts @@ -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 { diff --git a/web/src/chrome-bake.ts b/web/src/chrome-bake.ts deleted file mode 100644 index e87b7ef..0000000 --- a/web/src/chrome-bake.ts +++ /dev/null @@ -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 { - 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 = 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 { - 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; - } -} diff --git a/web/src/chrome-worker.ts b/web/src/chrome-worker.ts deleted file mode 100644 index 993490b..0000000 --- a/web/src/chrome-worker.ts +++ /dev/null @@ -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) => { - const pixels = bake(e.data); - (self as unknown as Worker).postMessage({ id: e.data.id, pixels }, [pixels.buffer]); -}; diff --git a/web/src/chrome.ts b/web/src/chrome.ts deleted file mode 100644 index dcd8160..0000000 --- a/web/src/chrome.ts +++ /dev/null @@ -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(); - 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 { - 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 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 { - 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(); - 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]; -} diff --git a/web/src/main.ts b/web/src/main.ts index 46ba9a0..a4ef52b 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -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 { const link = new URLSearchParams(location.search).get("t")?.split("/"); diff --git a/web/src/style.css b/web/src/style.css index 21811df..331af4a 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -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; } diff --git a/web/src/visual.ts b/web/src/visual.ts index d05585d..058c9ed 100644 --- a/web/src/visual.ts +++ b/web/src/visual.ts @@ -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(".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(".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(".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;