feat(web): dominant-color accent strip on PlayerBar + bigger Home tiles
test-web / test (push) Successful in 36s

#9 — new lib/media/dominantColor.ts: load cover image, downsample to
1x1 canvas, read pixel. Approximates the dominant tone via the
browser's bilinear mean — close enough for an ambient accent without
the 5KB ColorThief dependency. Same-origin cover URLs so no CORS
dance. Result cached by URL so revisits are free.

PlayerBar samples the current track's cover and pipes the resulting
rgb into a 2px accent strip above both the compact and desktop
variants. Transparent until the first resolve; 300ms transition on
colour change so track-skips fade rather than snap.

#10 — slight bump to Home tile widths so a typical viewport shows
roughly 5-6 across instead of cramming 8-9: Playlists w-56, all
remaining AlbumCard rows w-48 (Recently Added + both Rediscover
album scrollers). Replaces the wave-2 sizes that were still showing
7-8 across on wider screens.
This commit is contained in:
2026-06-01 20:03:48 -04:00
parent 5b25f89c01
commit 5f297c4c21
3 changed files with 93 additions and 3 deletions
+36
View File
@@ -13,6 +13,7 @@
} from '$lib/player/store.svelte'; } from '$lib/player/store.svelte';
import { formatDuration } from '$lib/media/duration'; import { formatDuration } from '$lib/media/duration';
import { FALLBACK_COVER, coverUrl } from '$lib/media/covers'; import { FALLBACK_COVER, coverUrl } from '$lib/media/covers';
import { dominantColorFromUrl, rgbToCssString } from '$lib/media/dominantColor';
import LikeButton from './LikeButton.svelte'; import LikeButton from './LikeButton.svelte';
import TrackMenu from './TrackMenu.svelte'; import TrackMenu from './TrackMenu.svelte';
@@ -44,6 +45,24 @@
function onCoverError(e: Event) { function onCoverError(e: Event) {
(e.currentTarget as HTMLImageElement).src = FALLBACK_COVER; (e.currentTarget as HTMLImageElement).src = FALLBACK_COVER;
} }
// #9 — dominant-color accent. Sample the current track's cover and
// pipe it into a CSS custom property; a 2px strip above the bar
// takes on the colour, so the page subtly tracks what's playing.
// Cache-backed in dominantColorFromUrl so revisits are free.
// Default ('') keeps the strip transparent until the first cover
// resolves.
let accentRgb = $state('');
$effect(() => {
const cover = current?.album_id ? coverUrl(current.album_id) : null;
if (!cover) {
accentRgb = '';
return;
}
dominantColorFromUrl(cover).then((rgb) => {
if (rgb) accentRgb = rgbToCssString(rgb);
});
});
</script> </script>
{#if current} {#if current}
@@ -59,6 +78,16 @@
Play controls maintain 40/48/40 size; like+kebab and column-3 sub-rows Play controls maintain 40/48/40 size; like+kebab and column-3 sub-rows
are shorter so column 2's bottom edge aligns with column 3's bottom. are shorter so column 2's bottom edge aligns with column 3's bottom.
--> -->
<!-- Dominant-color accent strip — same colour for both compact
and desktop variants, only one renders at a time due to the
md: breakpoint switch below. Transparent until the first
cover resolves; 300ms tween on colour change so track-skips
fade rather than snap. -->
<div
aria-hidden="true"
class="md:hidden h-[2px] w-full transition-colors duration-300"
style={accentRgb ? `background-color: ${accentRgb};` : ''}
></div>
<div <div
data-testid="player-bar-compact" data-testid="player-bar-compact"
class="md:hidden flex flex-col gap-1.5 border-t border-border bg-surface px-3 py-2" class="md:hidden flex flex-col gap-1.5 border-t border-border bg-surface px-3 py-2"
@@ -224,6 +253,13 @@
the redundant PlayerOverflowMenu kebab is no longer mounted (volume, the redundant PlayerOverflowMenu kebab is no longer mounted (volume,
shuffle, repeat, queue all live inline in the right cluster). shuffle, repeat, queue all live inline in the right cluster).
--> -->
<!-- See compact-variant accent strip above; desktop has its own
so both responsive sizes get the same treatment. -->
<div
aria-hidden="true"
class="hidden md:block h-[2px] w-full transition-colors duration-300"
style={accentRgb ? `background-color: ${accentRgb};` : ''}
></div>
<div <div
data-testid="player-bar-desktop" data-testid="player-bar-desktop"
class="hidden md:flex md:flex-row md:min-h-[108px] md:items-center md:gap-4 border-t border-border bg-surface md:px-4 md:py-1.5" class="hidden md:flex md:flex-row md:min-h-[108px] md:items-center md:gap-4 border-t border-border bg-surface md:px-4 md:py-1.5"
+53
View File
@@ -0,0 +1,53 @@
// 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.
//
// 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.
export type Rgb = { r: number; g: number; b: number };
const cache = new Map<string, Promise<Rgb | null>>();
export function dominantColorFromUrl(url: string): Promise<Rgb | null> {
const cached = cache.get(url);
if (cached) return cached;
const p = sample(url);
cache.set(url, p);
return p;
}
function sample(url: string): Promise<Rgb | null> {
if (typeof window === 'undefined') return Promise.resolve(null);
return new Promise((resolve) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
try {
const canvas = document.createElement('canvas');
canvas.width = 1;
canvas.height = 1;
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] });
} catch {
resolve(null);
}
};
img.onerror = () => resolve(null);
img.src = url;
});
}
export function rgbToCssString({ r, g, b }: Rgb): string {
return `rgb(${r} ${g} ${b})`;
}
+4 -3
View File
@@ -143,8 +143,9 @@
> >
{#snippet item(rowItem: PlaylistRowItem)} {#snippet item(rowItem: PlaylistRowItem)}
<!-- Playlists at the page anchor: largest of the carousels. <!-- Playlists at the page anchor: largest of the carousels.
Recently added stays at w-44; Rediscover steps down. --> Sized so a typical viewport shows ~5-6 across instead
<div class="w-52"> of cramming 8-9. -->
<div class="w-56">
{#if rowItem.kind === 'real'} {#if rowItem.kind === 'real'}
<PlaylistCard playlist={rowItem.playlist} /> <PlaylistCard playlist={rowItem.playlist} />
{:else} {:else}
@@ -174,7 +175,7 @@
ariaLabel="Recently added" ariaLabel="Recently added"
> >
{#snippet item(album: import('$lib/api/types').AlbumRef)} {#snippet item(album: import('$lib/api/types').AlbumRef)}
<div class="w-44"><AlbumCard {album} /></div> <div class="w-48"><AlbumCard {album} /></div>
{/snippet} {/snippet}
</HorizontalScrollRow> </HorizontalScrollRow>
{/if} {/if}