mirror of
https://github.com/narrrl/pl8list-rewind.git
synced 2026-11-04 16:47:00 +01:00
Make chrome cheap to draw and restyle it as dark metal
This commit is contained in:
1 parent
cfccfcaac6
commit
041ce73a3e
9 files changed
+666
-373
No files matched your search
+183
-103
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user