diff --git a/web/src/blob/blob.ts b/web/src/blob/blob.ts index bc7a8fc..87c44fb 100644 --- a/web/src/blob/blob.ts +++ b/web/src/blob/blob.ts @@ -307,12 +307,13 @@ export class LiquidBlob { return this.morph < 1 || moving || spinning || showing || this.pointer[3] > 0.01 || this.pointerGoal[3] > 0; } - /// Canvas pixels per CSS pixel at full quality. Phones stay at 1 for the battery. + /// Canvas pixels per CSS pixel at full quality. Phones stay at 1.5 at most, for + /// the battery; the quality steps take that down on phones that cannot keep up. /// Low-density desktop screens render at 2 and scale down, which smooths the /// edges and the icon that would otherwise fall on too few pixels. private density(): number { const dpr = devicePixelRatio || 1; - if (this.touch) return Math.min(dpr, 1); + if (this.touch) return Math.min(dpr, 1.5); return dpr < 1.5 ? 2 : 1.5; } diff --git a/web/src/blob/shaders.ts b/web/src/blob/shaders.ts index 25ca9ea..065dc0a 100644 --- a/web/src/blob/shaders.ts +++ b/web/src/blob/shaders.ts @@ -261,30 +261,34 @@ void main() { // The icon sits on a disc of frosted glass, painted onto the surface so it turns // with the drop: the artwork behind it blurred and dimmed, lit along its rim. A // ring round it shows how much has played, or runs while the track loads. - vec2 ip = vObj.xy; - float front = smoothstep(0.35, 0.6, vObj.z) * uMorph; - float show = max(uIconA, uBusy) * front; - float disc = length(ip) - 0.36; - float da = fwidth(disc); - float inDisc = 1.0 - smoothstep(-da, da, disc); - vec3 frost = textureLod(uArt, 0.5 + vec2(ip.x, -ip.y) * 0.48, 5.0).rgb * 0.3 + uAccent * 0.03; - frost += 0.35 * (1.0 - smoothstep(0.0, 0.025, abs(disc + 0.01))) * smoothstep(-0.3, 0.35, ip.y); - col = mix(col, frost, show * inDisc); + // Skipped while nothing of it shows; the test is on uniforms only, so every + // pixel takes the same branch and fwidth() inside it stays sound. + if (max(uIconA, uBusy) > 0.002) { + vec2 ip = vObj.xy; + float front = smoothstep(0.35, 0.6, vObj.z) * uMorph; + float show = max(uIconA, uBusy) * front; + float disc = length(ip) - 0.36; + float da = fwidth(disc); + float inDisc = 1.0 - smoothstep(-da, da, disc); + vec3 frost = textureLod(uArt, 0.5 + vec2(ip.x, -ip.y) * 0.48, 5.0).rgb * 0.3 + uAccent * 0.03; + frost += 0.35 * (1.0 - smoothstep(0.0, 0.025, abs(disc + 0.01))) * smoothstep(-0.3, 0.35, ip.y); + col = mix(col, frost, show * inDisc); - float ringD = abs(length(ip) - 0.41) - 0.011; - float ra = fwidth(ringD); - float ring = (1.0 - smoothstep(-ra, ra, ringD)) * front; - float turn = fract(atan(ip.x, ip.y) / 6.2831853 + 1.0); // 0 at the top, clockwise - float played = 1.0 - smoothstep(uProgress - 0.003, uProgress + 0.003, turn); - float seg = fract(turn - uClock * 0.9); - float arc = smoothstep(0.0, 0.04, seg) * (1.0 - smoothstep(0.18, 0.28, seg)); - col = mix(col, mix(vec3(0.32), uAccent, played), ring * uIconA * (1.0 - uBusy)); - col = mix(col, mix(vec3(0.25), vec3(0.96), arc), ring * uBusy); + float ringD = abs(length(ip) - 0.41) - 0.011; + float ra = fwidth(ringD); + float ring = (1.0 - smoothstep(-ra, ra, ringD)) * front; + float turn = fract(atan(ip.x, ip.y) / 6.2831853 + 1.0); // 0 at the top, clockwise + float played = 1.0 - smoothstep(uProgress - 0.003, uProgress + 0.003, turn); + float seg = fract(turn - uClock * 0.9); + float arc = smoothstep(0.0, 0.04, seg) * (1.0 - smoothstep(0.18, 0.28, seg)); + col = mix(col, mix(vec3(0.32), uAccent, played), ring * uIconA * (1.0 - uBusy)); + col = mix(col, mix(vec3(0.25), vec3(0.96), arc), ring * uBusy); - float dIcon = sdPlayPause(ip, uIcon); - float ia = fwidth(dIcon); - float ink = 1.0 - smoothstep(-ia, ia, dIcon); - col = mix(col, vec3(0.96), front * uIconA * (1.0 - uBusy) * ink); + float dIcon = sdPlayPause(ip, uIcon); + float ia = fwidth(dIcon); + float ink = 1.0 - smoothstep(-ia, ia, dIcon); + col = mix(col, vec3(0.96), front * uIconA * (1.0 - uBusy) * ink); + } // The very edge fades out, so the drop sits in the page instead of on it. float a = 1.0 - 0.9 * smoothstep(0.8, 1.0, fres) * uMorph; outColor = vec4(col * a, a); diff --git a/web/src/cards.ts b/web/src/cards.ts index 514496c..2bdf6ad 100644 --- a/web/src/cards.ts +++ b/web/src/cards.ts @@ -2,7 +2,7 @@ // and dropped again once it is far behind, so a long session stays light. import { type Track, artistImg, artistUrl, trackUrl } from "./api"; -import { accentFor, accentForName } from "./color"; +import { accentFor, accentForName, backdrop } from "./color"; export interface CardActions { like(card: Card): void; @@ -36,6 +36,8 @@ export class Card { private drawn = ""; private dragging = false; private accent: string | null = null; + /// The blurred background, once made. + private backdrop: string | null = null; /// `station`: the artist radio this card was picked for, if any. constructor( @@ -66,7 +68,17 @@ export class Card { const $ = (sel: string) => el.querySelector(sel) as T; const img = artistImg(t); if (img) { - $(".bg").style.backgroundImage = `url("${img}")`; + const bg = $(".bg"); + if (this.backdrop) this.showBackdrop(bg); + else { + // The plain image under a CSS blur until the blurred one is ready. + bg.style.backgroundImage = `url("${img}")`; + backdrop(img).then((b) => { + this.backdrop = b; + const bg = this.el?.querySelector(".bg"); + if (b && bg) this.showBackdrop(bg); + }, () => {}); + } $(".art").style.backgroundImage = `url("${img}")`; $(".artist-avatar").style.backgroundImage = `url("${img}")`; if (this.accent) el.style.setProperty("--card-accent", this.accent); @@ -129,6 +141,11 @@ export class Card { return el; } + private showBackdrop(bg: HTMLElement): void { + bg.style.backgroundImage = `url("${this.backdrop}")`; + bg.classList.add("baked"); + } + /// Where a radio track comes from, e.g. "Lower radio · via Motus". private whyText(): string { const { station } = this; diff --git a/web/src/color.ts b/web/src/color.ts index d00180b..0ee48a3 100644 --- a/web/src/color.ts +++ b/web/src/color.ts @@ -48,6 +48,57 @@ async function compute(src: string): Promise { return `hsl(${hue.toFixed(0)} ${s.toFixed(0)}% 62%)`; } +const BACKDROP = 64; // px square; it is shown blurred, so this is plenty +const BLUR = 2; // px each side, per box blur pass at that size +const DIM = 0.45; +const SATURATE = 1.3; + +/// The card's background from the artist image, as a data URL: cropped square, +/// blurred and darkened here, once, as `.bg`'s CSS filter would do on every frame. +export async function backdrop(src: string): Promise { + const img = new Image(); + img.src = src; + await img.decode(); + const c = document.createElement("canvas"); + c.width = c.height = BACKDROP; + const ctx = c.getContext("2d", { willReadFrequently: true }); + if (!ctx) return null; + const side = Math.min(img.naturalWidth, img.naturalHeight); + ctx.drawImage(img, (img.naturalWidth - side) / 2, (img.naturalHeight - side) / 2, side, side, 0, 0, BACKDROP, BACKDROP); + const data = ctx.getImageData(0, 0, BACKDROP, BACKDROP); + const px = data.data; + // Three box passes each way come close to a gaussian. + for (let pass = 0; pass < 3; pass++) { + box(px, false); + box(px, true); + } + for (let i = 0; i < px.length; i += 4) { + const l = 0.2126 * px[i] + 0.7152 * px[i + 1] + 0.0722 * px[i + 2]; + for (let ch = 0; ch < 3; ch++) px[i + ch] = (l + (px[i + ch] - l) * SATURATE) * DIM; + px[i + 3] = 255; + } + ctx.putImageData(data, 0, 0); + return c.toDataURL(); +} + +/// One box blur pass over the backdrop's pixels, along rows or columns; the +/// edges repeat outwards. +function box(px: Uint8ClampedArray, columns: boolean): void { + const src = px.slice(); + const n = BACKDROP; + const along = columns ? n * 4 : 4; + for (let line = 0; line < n; line++) { + const start = (columns ? line : line * n) * 4; + for (let i = 0; i < n; i++) { + for (let ch = 0; ch < 3; ch++) { + let sum = 0; + for (let k = -BLUR; k <= BLUR; k++) sum += src[start + Math.min(n - 1, Math.max(0, i + k)) * along + ch]; + px[start + i * along + ch] = sum / (2 * BLUR + 1); + } + } + } +} + function hsl(r: number, g: number, b: number): [number, number, number] { r /= 255; g /= 255; diff --git a/web/src/style.css b/web/src/style.css index 81af233..1f0bda0 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -397,6 +397,9 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff; background: #111 center / cover no-repeat; filter: blur(40px) brightness(0.45) saturate(1.3); } +/* Already blurred and darkened (color.ts): a CSS blur over the whole screen would + be worked out again whenever anything on top of it moves. */ +.bg.baked { filter: none; } .glow { position: absolute; @@ -404,6 +407,7 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff; pointer-events: none; background: radial-gradient(55% 40% at 50% 40%, color-mix(in oklab, var(--card-accent) 55%, transparent), transparent 70%); 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 { @@ -467,8 +471,12 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff; .card.liquid .art::before { content: ""; position: absolute; - inset: calc(-50% - var(--energy) * 15%); - background: radial-gradient(closest-side, color-mix(in oklab, var(--card-accent) calc(28% + var(--energy) * 55%), transparent), transparent); + inset: -65%; + background: radial-gradient(closest-side, color-mix(in oklab, var(--card-accent) 83%, transparent), transparent); + /* Pulses through transform and opacity only, so it never has to be painted again. */ + transform: scale(calc(0.87 + var(--energy) * 0.13)); + opacity: calc(0.34 + var(--energy) * 0.66); + will-change: transform, opacity; pointer-events: none; } .card.liquid .content > :not(.art, .chrome-layer) { position: relative; z-index: 1; } @@ -666,8 +674,7 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff; display: grid; place-items: center; border-radius: 50%; - background: rgba(255, 255, 255, 0.12); - backdrop-filter: blur(8px); + background: rgba(255, 255, 255, 0.16); transition: background var(--fast), transform var(--fast); } .act:hover .ring { background: rgba(255, 255, 255, 0.2); } @@ -766,6 +773,12 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff; transition: opacity var(--fast); } #up-next:hover { opacity: 1; } +/* Phones redo a backdrop blur on every frame the card under it pulses: a denser + fill instead. */ +@media (pointer: coarse) { + #mode-tabs { backdrop-filter: none; background: rgba(28, 28, 32, 0.72); } + #up-next { backdrop-filter: none; background: #000b; } +} .up-next-avatar { flex: none; width: 2rem; diff --git a/web/src/visual.ts b/web/src/visual.ts index ce6c542..594a668 100644 --- a/web/src/visual.ts +++ b/web/src/visual.ts @@ -44,6 +44,9 @@ interface Dressed { 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; @@ -80,13 +83,18 @@ 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; canvas: HTMLCanvasElement; plate: Plate; key: string } | 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 }; constructor( private player: Player, @@ -183,6 +191,7 @@ export class Visualizer { waveCanvas, title: chrome.plate("text"), wave: chrome.plate("wave"), + placed: false, layoutKey: "", titleKey: "", waveKey: "", @@ -196,6 +205,7 @@ export class Visualizer { 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(); @@ -248,6 +258,7 @@ export class Visualizer { 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; @@ -255,8 +266,14 @@ export class Visualizer { /// Fonts arrived: shapes are drawn again on the next frame. private redress(): void { - if (this.dressed) this.dressed.layoutKey = this.dressed.titleKey = this.dressed.waveKey = ""; - if (this.logo) this.logo.key = ""; + 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(); } @@ -265,12 +282,25 @@ export class Visualizer { const el = document.querySelector(".logo b"); if (!el) return; el.style.setProperty("--pad", `${LOGO_PAD}px`); - this.logo = { el, canvas: plateCanvas(el), plate: chrome.plate("text"), key: "" }; + 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. @@ -319,6 +349,7 @@ export class Visualizer { // 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; } @@ -331,10 +362,12 @@ export class Visualizer { /// Light the logo once, whenever its size changed. private shineLogo(chrome: Chrome): void { const logo = this.logo; - if (!logo) return; + if (!logo || logo.measured) return; const el = logo.el; const key = `${el.clientWidth}x${el.clientHeight}`; - if (key === logo.key || !el.clientWidth) return; + 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; @@ -365,6 +398,7 @@ export class Visualizer { 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; @@ -382,7 +416,7 @@ export class Visualizer { attach(card: Card | null, again = false): void { if (this.card === card && !again) return; if (this.card && this.card !== card) { - this.card.el?.style.setProperty("--energy", "0"); + this.energize(null, ""); this.card.el?.classList.remove("liquid", "turning"); this.grab = null; } @@ -409,6 +443,21 @@ export class Visualizer { this.start(); } + /// Set `--energy` on the art and the glow only: set on the card, everything in + /// it would be styled again on every frame. + private energize(el: HTMLElement | null, value: string): void { + let e = this.energized; + if (e && e.el !== el) { + for (const t of e.targets) t.style.removeProperty("--energy"); + e = this.energized = null; + } + if (!el) return; + e ??= this.energized = { el, targets: [...el.querySelectorAll(".art, .glow")], value: "" }; + if (value === e.value) return; + e.value = value; + for (const t of e.targets) t.style.setProperty("--energy", value); + } + start(): void { clearTimeout(this.idleTimer); this.idleTimer = 0; @@ -533,17 +582,26 @@ export class Visualizer { ? this.features.fromAnalyser(an, now / 1000, dt) : this.features.fromLoudness(card.loudnessAt(progress), now / 1000, dt); this.debug?.(f); - this.dropDebug?.([ - `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")); + const st = this.stats; + st.frames++; + st.worst = Math.max(st.worst, dt); + // Once a second: the readout itself asks the GPU things that stall it. + 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`, + `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; 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. const energy = Math.min(1, mixed ? 0.65 * f.kick + 0.35 * f.low : 0.5 * f.low + 0.4 * f.kick); - card.el?.style.setProperty("--energy", energy.toFixed(3)); + this.energize(card.el, energy.toFixed(2)); busy ||= energy > 0.01; if (liquid) { const buffering = !!card.el?.classList.contains("buffering");