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

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:
2026-10-10 23:05:05 -04:00
co-authored by Claude Opus 5.5
parent 2f2573dd07
commit c0c0f8a71f
3 changed files with 220 additions and 25 deletions
+39
View File
@@ -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);
});
});
+136 -17
View File
@@ -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})`;
}