diff --git a/web/src/lib/media/dominantColor.test.ts b/web/src/lib/media/dominantColor.test.ts new file mode 100644 index 00000000..e450d18a --- /dev/null +++ b/web/src/lib/media/dominantColor.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from 'vitest'; +import { dominantOf } from './dominantColor'; + +// Pixels as RGBA, n of each colour. +function pixels(...runs: [number, number, number, number][]): Uint8ClampedArray { + const out: number[] = []; + for (const [r, g, b, n] of runs) for (let i = 0; i < n; i++) out.push(r, g, b, 255); + return new Uint8ClampedArray(out); +} + +const near = (got: { r: number; g: number; b: number } | null, r: number, g: number, b: number) => + !!got && Math.abs(got.r - r) <= 8 && Math.abs(got.g - g) <= 8 && Math.abs(got.b - b) <= 8; + +describe('dominantOf', () => { + test('the colour covering the most of the cover wins, not a small bright accent', () => { + const got = dominantOf(pixels([40, 70, 140, 700], [250, 20, 60, 100], [30, 160, 60, 200])); + expect(near(got, 40, 70, 140)).toBe(true); + }); + + test('is not the mean: two halves of different colours give one of them', () => { + const got = dominantOf(pixels([200, 40, 40, 520], [40, 40, 200, 480])); + expect(near(got, 200, 40, 40)).toBe(true); + }); + + test('near black and near white are passed over, as Palette does', () => { + const got = dominantOf(pixels([0, 0, 0, 800], [255, 255, 255, 600], [60, 120, 90, 100])); + expect(near(got, 60, 120, 90)).toBe(true); + }); + + test('a cover of nothing but black and white has no colour', () => { + expect(dominantOf(pixels([0, 0, 0, 500], [255, 255, 255, 500]))).toBeNull(); + }); + + test('transparent pixels are not counted', () => { + const data = pixels([40, 70, 140, 100], [200, 40, 40, 300]); + for (let i = 100 * 4; i < data.length; i += 4) data[i + 3] = 0; + expect(near(dominantOf(data), 40, 70, 140)).toBe(true); + }); +}); diff --git a/web/src/lib/media/dominantColor.ts b/web/src/lib/media/dominantColor.ts index 16b76a13..72687480 100644 --- a/web/src/lib/media/dominantColor.ts +++ b/web/src/lib/media/dominantColor.ts @@ -1,19 +1,26 @@ -// Cheap dominant-color extractor: loads an image, draws it scaled -// to a 1x1 canvas, reads the resulting pixel. The browser's bilinear -// downsample produces an arithmetic-mean color, which approximates -// the "dominant" tone close enough for an ambient accent strip. -// Not aiming for ColorThief-grade clustering — that adds ~5KB and -// noticeable extraction latency. The user-visible feature is just -// "the page subtly takes on the current track's hue" and a mean -// color delivers that. +// The colour that covers the most of a cover, as Android takes it +// (player/ui/DominantColor.kt): androidx.palette's dominant swatch, not +// the mean and not the most vibrant. A mean turns a busy cover to mud; +// a vibrant pick lets a small bright accent tint the whole page. The +// dominant swatch matches what the eye sees most of (#5592). // -// Cover URLs are same-origin (/api/albums//cover), so no CORS -// dance is needed. Returns null on any failure (load error, decode -// error, opaque-canvas getImageData reject) so the caller can fall -// back to the static accent. +// Palette's method, kept close so the web and Android agree: shrink the +// image, quantise each pixel to 5 bits a channel, drop the colours its +// default filter drops (near black, near white, and the skin-tone band +// on the red "I line"), split what's left by median cut into at most 16 +// boxes, and take the box holding the most pixels. +// +// Cover URLs are same-origin (/api/albums//cover), so the canvas is +// readable. Returns null on any failure (load or decode error, no 2D +// context, a cover with nothing but black and white) and the caller +// keeps what it showed before. export type Rgb = { r: number; g: number; b: number }; +// Palette shrinks a bitmap to about 112×112 before quantising. +const SAMPLE = 112; +const MAX_COLORS = 16; + const cache = new Map>(); export function dominantColorFromUrl(url: string): Promise { @@ -32,13 +39,12 @@ function sample(url: string): Promise { img.onload = () => { try { const canvas = document.createElement('canvas'); - canvas.width = 1; - canvas.height = 1; + canvas.width = SAMPLE; + canvas.height = SAMPLE; const ctx = canvas.getContext('2d'); if (!ctx) return resolve(null); - ctx.drawImage(img, 0, 0, 1, 1); - const data = ctx.getImageData(0, 0, 1, 1).data; - resolve({ r: data[0], g: data[1], b: data[2] }); + ctx.drawImage(img, 0, 0, SAMPLE, SAMPLE); + resolve(dominantOf(ctx.getImageData(0, 0, SAMPLE, SAMPLE).data)); } catch { resolve(null); } @@ -48,6 +54,119 @@ function sample(url: string): Promise { }); } +type Color5 = { r: number; g: number; b: number; n: number }; + +// dominantOf picks the dominant colour of RGBA pixel data. +export function dominantOf(data: Uint8ClampedArray): Rgb | null { + const hist = new Map(); + for (let i = 0; i < data.length; i += 4) { + if (data[i + 3] < 128) continue; + const key = ((data[i] >> 3) << 10) | ((data[i + 1] >> 3) << 5) | (data[i + 2] >> 3); + hist.set(key, (hist.get(key) ?? 0) + 1); + } + const colors: Color5[] = []; + for (const [key, n] of hist) { + const c = { r: (key >> 10) & 31, g: (key >> 5) & 31, b: key & 31, n }; + if (!ignored(to8(c))) colors.push(c); + } + if (colors.length === 0) return null; + + let best: Rgb | null = null; + let bestN = 0; + for (const box of medianCut(colors, MAX_COLORS)) { + let n = 0, r = 0, g = 0, b = 0; + for (const c of box) { + n += c.n; + r += c.r * c.n; + g += c.g * c.n; + b += c.b * c.n; + } + const avg = to8({ r: r / n, g: g / n, b: b / n }); + if (n > bestN && !ignored(avg)) { + best = avg; + bestN = n; + } + } + return best; +} + +// medianCut splits the colours into at most max boxes, always splitting +// the box with the largest volume, at the population median of its +// longest side. +function medianCut(colors: Color5[], max: number): Color5[][] { + const boxes: Color5[][] = [colors]; + while (boxes.length < max) { + let pick = -1; + let pickVolume = 0; + boxes.forEach((box, i) => { + if (box.length < 2) return; + const v = volume(box); + if (v > pickVolume) { + pick = i; + pickVolume = v; + } + }); + if (pick < 0) break; + const box = boxes[pick]; + const side = longestSide(box); + box.sort((a, b) => a[side] - b[side]); + const total = box.reduce((s, c) => s + c.n, 0); + let run = 0; + let at = box.length - 1; + for (let i = 0; i < box.length - 1; i++) { + run += box[i].n; + if (run >= total / 2) { + at = i + 1; + break; + } + } + boxes.splice(pick, 1, box.slice(0, at), box.slice(at)); + } + return boxes; +} + +function range(box: Color5[], side: 'r' | 'g' | 'b'): number { + let lo = 31, hi = 0; + for (const c of box) { + lo = Math.min(lo, c[side]); + hi = Math.max(hi, c[side]); + } + return hi - lo + 1; +} + +function volume(box: Color5[]): number { + return range(box, 'r') * range(box, 'g') * range(box, 'b'); +} + +function longestSide(box: Color5[]): 'r' | 'g' | 'b' { + const r = range(box, 'r'), g = range(box, 'g'), b = range(box, 'b'); + return r >= g && r >= b ? 'r' : g >= b ? 'g' : 'b'; +} + +function to8(c: { r: number; g: number; b: number }): Rgb { + return { r: Math.round((c.r * 255) / 31), g: Math.round((c.g * 255) / 31), b: Math.round((c.b * 255) / 31) }; +} + +// Palette's default filter: near black, near white, and the band of +// hues around skin tones (10°–37°, not fully saturated). +function ignored({ r, g, b }: Rgb): boolean { + const [h, s, l] = hsl(r, g, b); + return l <= 0.05 || l >= 0.95 || (h >= 10 && h <= 37 && s <= 0.82); +} + +function hsl(r8: number, g8: number, b8: number): [number, number, number] { + const r = r8 / 255, g = g8 / 255, b = b8 / 255; + const max = Math.max(r, g, b), min = Math.min(r, g, b); + const l = (max + min) / 2; + const d = max - min; + if (d === 0) return [0, 0, l]; + const s = d / (1 - Math.abs(2 * l - 1)); + let h = max === r ? ((g - b) / d) % 6 : max === g ? (b - r) / d + 2 : (r - g) / d + 4; + h *= 60; + if (h < 0) h += 360; + return [h, s, l]; +} + export function rgbToCssString({ r, g, b }: Rgb): string { return `rgb(${r} ${g} ${b})`; } diff --git a/web/src/routes/now-playing/+page.svelte b/web/src/routes/now-playing/+page.svelte index e9c90166..4ef02562 100644 --- a/web/src/routes/now-playing/+page.svelte +++ b/web/src/routes/now-playing/+page.svelte @@ -14,6 +14,7 @@ } from '#lib/player/store.svelte.js'; import { formatDuration } from '#lib/media/duration.js'; import { FALLBACK_COVER, coverUrl } from '#lib/media/covers.js'; + import { dominantColorFromUrl, rgbToCssString } from '#lib/media/dominantColor.js'; import { useSmoothPosition } from '#lib/player/smoothPosition.svelte.js'; import LikeButton from '#lib/components/LikeButton.svelte'; import TrackMenu from '#lib/components/TrackMenu.svelte'; @@ -41,6 +42,22 @@ player.volume < 0.5 ? Volume1 : Volume2 ); + // The backdrop takes the cover's dominant colour, as Android's Now + // Playing does (#5592). The colour is held until the next cover's + // resolves, so a track change fades old to new rather than dipping to + // the plain background between them; a track with no cover clears it. + let tint = $state('transparent'); + $effect(() => { + const albumId = current?.album_id; + if (!albumId) { + tint = 'transparent'; + return; + } + dominantColorFromUrl(coverUrl(albumId)).then((rgb) => { + if (rgb && current?.album_id === albumId) tint = rgbToCssString(rgb); + }); + }); + function onSeekInput(e: Event) { const v = Number((e.currentTarget as HTMLInputElement).value); seekTo(v); @@ -66,7 +83,17 @@ {pageTitle(current ? `${current.title} · ${current.artist_name}` : 'Now playing')} -
+
+ +
+