Make chrome cheap to draw and restyle it as dark metal

This commit is contained in:
nvrl committed 2026-11-01 20:45:45 +01:00
1 parent cfccfcaac6
commit 041ce73a3e
9 files changed
+666 -373

No files matched your search

+183 -103
View File
@@ -20,28 +20,42 @@ 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
const CHROME_FPS = 30; // chrome is redrawn at most this often, 24 on phones
const PAD = 16; // CSS px the chrome canvas reaches past the text, for glow and shadow
const SWEEP = 1.4; // s for the highlight to sweep across a new title
// 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.09; // of the font size: how far the side wall reaches down
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 TILT_DEG = 7; // how far the chrome title turns towards the pointer
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;
layoutKey: string;
titleKey: string;
waveKey: string;
/// Per bar, how far down its top is, 0..1.
tops: number[];
/// 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];
tintAt: number;
sweepFrom: number;
progress: number;
buffered: number;
observer: ResizeObserver;
}
export class Visualizer {
@@ -63,12 +77,13 @@ export class Visualizer {
/// Undefined until first needed; null where WebGL2 failed.
private chrome: Chrome | null | undefined;
private dressed: Dressed | null = null;
private logo: { el: HTMLElement; plate: Plate; key: string } | null = null;
private logo: { el: HTMLElement; canvas: HTMLCanvasElement; plate: Plate; key: string } | null = null;
/// Where the light comes from, and where the pointer wants it.
private tilt: [number, number] = [0, -0.3];
private aim: [number, number] = [0, -0.3];
private tilt: [number, number] = [...REST_TILT];
private aim: [number, number] = [...REST_TILT];
private chromeAt = 0;
private chromeDirty = true;
private kickDrawn = 0;
constructor(
private player: Player,
@@ -131,21 +146,21 @@ export class Visualizer {
/// Chrome is part of the liquid look, and needs WebGL2 too.
private chromeOn(): Chrome | null {
if (this.setting !== "liquid" || this.chrome === null) return null;
this.chrome ??= Chrome.create();
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 card before keeps its last
/// picture; only the playing card's chrome moves.
/// 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)) {
old.card.onWave = null;
this.chrome?.drop(old.title);
this.chrome?.drop(old.wave);
this.dressed = null;
}
if (old && (old.card !== card || !old.card.el)) this.strip(old);
const chrome = this.chromeOn();
if (!chrome) {
this.undress();
@@ -154,54 +169,90 @@ export class Visualizer {
const titleEl = card?.el?.querySelector<HTMLElement>(".title");
const waveCanvas = card?.waveCanvas;
if (!card || !titleEl || !waveCanvas || this.dressed) return;
const canvas = titleEl.querySelector<HTMLCanvasElement>(".chrome-plate") ?? plateCanvas(titleEl);
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,
title: chrome.plate(canvas, "text"),
wave: chrome.plate(waveCanvas, "wave"),
waveCanvas,
title: chrome.plate("text"),
wave: chrome.plate("wave"),
layoutKey: "",
titleKey: "",
waveKey: "",
tops: [],
tint: rgb(getComputedStyle(card.el!).getPropertyValue("--card-accent") || "#ff4d6d"),
tintAt: 0,
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.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) => {
if (progress === d.progress && buffered === d.buffered) return;
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.
// The studio's floor shows the art, in the colour taken from it.
chrome.setArt(null, color);
const img = artistImg(card.track);
const own = accentForName(card.track.artist_name || "?");
chrome.setArt(null, own);
if (img) {
const pic = new Image();
pic.src = img;
pic.decode().then(() => { if (this.dressed === d) chrome.setArt(pic, own); }, () => {});
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");
for (const c of document.querySelectorAll(".chrome-plate")) c.remove();
if (this.dressed) this.dressed.card.onWave = null;
this.dressed = null;
if (this.logo) this.chrome?.drop(this.logo.plate);
if (this.logo) {
this.logo.canvas.remove();
this.chrome?.drop(this.logo.plate);
}
this.logo = null;
}
/// Fonts arrived or the layout changed: shapes are drawn again on the next frame.
/// Fonts arrived: shapes are drawn again on the next frame.
private redress(): void {
if (this.dressed) this.dressed.titleKey = this.dressed.waveKey = "";
if (this.dressed) this.dressed.layoutKey = this.dressed.titleKey = this.dressed.waveKey = "";
if (this.logo) this.logo.key = "";
this.chromeDirty = true;
this.start();
@@ -210,87 +261,116 @@ export class Visualizer {
private dressLogo(chrome: Chrome): void {
const el = document.querySelector<HTMLElement>(".logo b");
if (!el) return;
el.style.setProperty("--pad", "10px");
this.logo = { el, plate: chrome.plate(plateCanvas(el), "text"), key: "" };
el.style.setProperty("--pad", `${LOGO_PAD}px`);
this.logo = { el, canvas: plateCanvas(el), plate: chrome.plate("text"), key: "" };
}
/// Draw the shapes again where their size or text changed.
private reshape(chrome: Chrome): void {
const d = this.dressed;
if (d) {
const el = d.titleEl;
const key = `${el.clientWidth}x${el.clientHeight}:${el.textContent}`;
if (key !== d.titleKey && el.clientWidth) {
d.titleKey = key;
const bevel = parseFloat(getComputedStyle(el).fontSize) * 0.075;
chrome.shape(d.title, el.clientWidth + 2 * PAD, el.clientHeight + 2 * PAD, bevel, (ctx) => textMask(el, PAD, ctx));
el.classList.add("chromed");
}
const c = d.wave.canvas;
const wkey = `${c.clientWidth}x${c.clientHeight}`;
if (wkey !== d.waveKey && c.clientWidth) {
d.waveKey = wkey;
const [w, h] = [c.clientWidth, c.clientHeight];
chrome.shape(d.wave, w, h, CHROME_BAR * 0.36, (ctx) => {
const { base, tops, width } = d.card.waveMask(ctx, w, h, CHROME_BAR);
d.tops = tops.map((t) => t / h);
d.wave.floor = base / h;
// Each bar is its own little pillar of metal, sky at its top.
return tops.map((top, i) => ({ left: i * CHROME_BAR, top, width, height: base - top }));
});
}
/// 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 {
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);
chrome.shape(d.title, el.clientWidth + 2 * PAD, el.clientHeight + 2 * PAD, size * BEVEL, size * DEPTH, (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, (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);
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) {
const el = logo.el;
const key = `${el.clientWidth}x${el.clientHeight}`;
if (key !== logo.key && el.clientWidth) {
logo.key = key;
chrome.shape(logo.plate, el.clientWidth + 20, el.clientHeight + 20, 1, (ctx) => textMask(el, 10, ctx));
el.classList.add("chromed");
}
}
if (!logo) return;
const el = logo.el;
const key = `${el.clientWidth}x${el.clientHeight}`;
if (key === logo.key || !el.clientWidth) return;
logo.key = key;
chrome.shape(logo.plate, el.clientWidth + 2 * LOGO_PAD, el.clientHeight + 2 * LOGO_PAD, 1, 1, (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. Returns whether it wants more frames.
private shine(now: number, dt: number, kick: number, playing: boolean): boolean {
/// 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;
// A mouse moves the light; on phones it drifts on its own.
if (this.touch && !this.reduced.matches) this.aim = [Math.sin(now / 2500) * 0.6, Math.cos(now / 3300) * 0.35 - 0.2];
// 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 = Math.abs(this.aim[0] - this.tilt[0]) + Math.abs(this.aim[1] - this.tilt[1]) > 0.003;
const t = d && !this.reduced.matches ? (now - d.sweepFrom) / 1000 / SWEEP : 2;
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 || sweeping || playing;
if (!want || now - this.chromeAt < 1000 / (this.touch ? 24 : CHROME_FPS)) return want;
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.reshape(chrome);
const light: Light = {
tint: [1, 0.3, 0.43],
tilt: this.tilt,
time: this.reduced.matches ? 0 : now / 1000,
kick: this.reduced.matches ? 0 : kick,
sweep: sweeping ? -0.3 + 1.6 * t : -1,
};
if (d) {
// The card's colour can arrive late, from its art.
if (now - d.tintAt > 500 && d.card.el) {
d.tintAt = now;
d.tint = rgb(getComputedStyle(d.card.el).getPropertyValue("--card-accent") || "#ff4d6d");
}
const lit = { ...light, tint: d.tint };
chrome.render(d.title, lit);
// The title turns a little towards the light, as a solid thing would.
d.title.canvas.style.transform = `perspective(700px) rotateY(${(this.tilt[0] * TILT_DEG).toFixed(2)}deg) rotateX(${(-this.tilt[1] * TILT_DEG * 0.6).toFixed(2)}deg)`;
const bar = Math.min(d.tops.length - 1, Math.floor(d.progress * d.tops.length));
chrome.render(d.wave, lit, { progress: d.progress, buffered: d.buffered, top: d.tops[Math.max(0, bar)] ?? 1 });
}
if (this.logo) chrome.render(this.logo.plate, { ...light, sweep: -1, kick: 0 });
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;
}
@@ -449,7 +529,7 @@ export class Visualizer {
? this.features.fromAnalyser(an, now / 1000, dt)
: this.features.fromLoudness(card.loudnessAt(progress), now / 1000, dt);
this.debug?.(f);
let busy = this.shine(now, dt, f.kick, playing) || playing;
let busy = this.shine(now, dt, f.kick) || 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.