/** * Dominant colour of an album cover, used as the page accent. * * Deezer's CDN sends `access-control-allow-origin: *`, so the artwork can be * drawn into a canvas without tainting it and read back. That is the whole * reason this works client-side with no image proxy and no server CPU — it * was checked before the feature was designed around it. */ const cache = new Map(); export function dominant(url: string | null): Promise<[number, number, number] | null> { if (!url) return Promise.resolve(null); const hit = cache.get(url); if (hit) return Promise.resolve(hit); return new Promise((resolve) => { const img = new Image(); img.crossOrigin = "anonymous"; img.onload = () => { try { const n = 32; const cv = document.createElement("canvas"); cv.width = cv.height = n; const cx = cv.getContext("2d", { willReadFrequently: true }); if (!cx) return resolve(null); cx.drawImage(img, 0, 0, n, n); const d = cx.getImageData(0, 0, n, n).data; // Coarse 4-bit-per-channel histogram, weighted by saturation. Most // album art is dominated by greys and near-blacks in raw pixel count, // which say nothing about its palette — the weighting is what keeps // the accent off them. const bins = new Map(); for (let i = 0; i < d.length; i += 4) { if (d[i + 3] < 128) continue; const r = d[i], g = d[i + 1], b = d[i + 2]; const mx = Math.max(r, g, b), mn = Math.min(r, g, b); if (mx < 32 || mx > 246) continue; // near-black / blown out const sat = mx ? (mx - mn) / mx : 0; const key = (r >> 4) * 256 + (g >> 4) * 16 + (b >> 4); const cur = bins.get(key) ?? { w: 0, r: 0, g: 0, b: 0, n: 0 }; cur.w += 1 + sat * 5; cur.r += r; cur.g += g; cur.b += b; cur.n++; bins.set(key, cur); } let best: { w: number; r: number; g: number; b: number; n: number } | null = null; for (const v of bins.values()) if (!best || v.w > best.w) best = v; if (!best) return resolve(null); const [r0, g0, b0] = [best.r / best.n, best.g / best.n, best.b / best.n]; // Clamp luminance so the accent stays readable as text on the dark // shell AND dark enough to sit behind the near-black label on solid // accent buttons. Album art happily supplies both #0a0a0a and #fffef8. const lum = (0.2126 * r0 + 0.7152 * g0 + 0.0722 * b0) / 255; const k = lum < 0.45 ? 0.45 / Math.max(lum, 0.04) : lum > 0.82 ? 0.82 / lum : 1; const rgb = [r0, g0, b0].map((v) => Math.max(0, Math.min(255, Math.round(v * k))), ) as [number, number, number]; cache.set(url, rgb); resolve(rgb); } catch { resolve(null); } }; img.onerror = () => resolve(null); img.src = url; }); } let token = 0; /** Apply an artwork's colour as the live accent. Last call wins. */ export async function tint(url: string | null) { const mine = ++token; const rgb = await dominant(url); if (!rgb || mine !== token) return; // a newer view won the race const s = document.documentElement.style; s.setProperty("--a-r", String(rgb[0])); s.setProperty("--a-g", String(rgb[1])); s.setProperty("--a-b", String(rgb[2])); }