feat: Now Playing's cover grows to fill the screen, and the page takes the cover's dominant colour as Android does (#5592)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m45s
release / go (push) Successful in 2m9s
release / integration (push) Successful in 5m44s
release / android (push) Successful in 5m49s
release / Build signed APK (releases and dev) (push) Successful in 6m9s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m45s
release / go (push) Successful in 2m9s
release / integration (push) Successful in 5m44s
release / android (push) Successful in 5m49s
release / Build signed APK (releases and dev) (push) Successful in 6m9s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
The web colour helper now picks Palette's dominant swatch (5-bit quantise, Palette's default filter, median cut to 16, the most-populated box) instead of the 1x1 mean, so the web and Android agree; PlayerBar's accent strip shares it. Now Playing draws Android's gradient (55% at the top, 18% at 45%, then the page) as a masked background colour that fades over 600ms between tracks, holding the old colour until the new resolves. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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/<id>/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/<id>/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<string, Promise<Rgb | null>>();
|
||||
|
||||
export function dominantColorFromUrl(url: string): Promise<Rgb | null> {
|
||||
@@ -32,13 +39,12 @@ function sample(url: string): Promise<Rgb | null> {
|
||||
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<Rgb | null> {
|
||||
});
|
||||
}
|
||||
|
||||
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<number, number>();
|
||||
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})`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user