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})`;
}
+45 -8
View File
@@ -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 @@
<svelte:head><title>{pageTitle(current ? `${current.title} · ${current.artist_name}` : 'Now playing')}</title></svelte:head>
<div class="flex h-screen flex-col bg-background text-text-primary">
<div class="relative isolate flex h-screen flex-col bg-background text-text-primary">
<!-- Android's gradient (NowPlayingScreen.dominantGradient): the cover's
colour at 55% at the top, 18% at 45% down, then the page. Drawn as a
background colour under a mask so the colour itself can fade between
tracks over 600ms, as Android's tween does. -->
<div
aria-hidden="true"
data-testid="now-playing-tint"
class="pointer-events-none absolute inset-0 -z-10 transition-colors duration-[600ms] motion-reduce:transition-none"
style="background-color: {tint}; mask-image: linear-gradient(to bottom, rgb(0 0 0 / 0.55) 0%, rgb(0 0 0 / 0.18) 45%, transparent 100%);"
></div>
<header class="flex items-center px-4 py-3">
<button
type="button"
@@ -106,8 +133,17 @@
the bottom row). Wide-screen viewers get queue context without
opening a drawer; mobile keeps the focused single-column flow. -->
<main class="flex flex-1 overflow-hidden">
<section class="flex flex-1 flex-col items-center justify-center gap-6 px-4 pb-6 overflow-y-auto">
<div class="aspect-square w-full max-w-md overflow-hidden rounded-lg bg-surface-hover shadow-lg">
<section class="flex flex-1 flex-col items-center justify-center px-4 pb-6 overflow-y-auto">
<!-- The column is as wide as the cover, and the cover as big as the
screen allows: the window's height less what the title and
controls beneath it need (26rem), never under 16rem, never over
52rem, never wider than the pane (#5592). -->
<div
class="flex flex-col items-center gap-6"
style="width: min(100%, max(16rem, calc(100dvh - 26rem)), 52rem);"
data-testid="now-playing-column"
>
<div class="aspect-square w-full overflow-hidden rounded-lg bg-surface-hover shadow-lg">
<img
src={coverUrl(current.album_id)}
alt=""
@@ -116,11 +152,11 @@
/>
</div>
<div class="w-full max-w-md text-center">
<h1 class="font-display text-2xl font-medium leading-tight">
<div class="w-full text-center">
<h1 class="font-display text-2xl font-medium leading-tight lg:text-3xl">
{current.title}
</h1>
<p class="mt-1 text-sm">
<p class="mt-1 text-sm lg:text-base">
<a
href={`/artists/${current.artist_id}`}
class="text-text-secondary hover:text-text-primary hover:underline"
@@ -139,7 +175,7 @@
</p>
</div>
<div class="w-full max-w-md">
<div class="w-full">
<input
type="range"
min="0"
@@ -227,7 +263,7 @@
</button>
</div>
<div class="flex w-full max-w-md items-center justify-between gap-4">
<div class="flex w-full items-center justify-between gap-4">
<div class="flex items-center gap-2">
<LikeButton entityType="track" entityId={current.id} />
</div>
@@ -259,6 +295,7 @@
<span class="text-sm tabular-nums">{player.queue.length}</span>
</button>
</div>
</div>
</section>
<aside
aria-label="Queue"