diff --git a/web/src/lib/components/PlayerBar.svelte b/web/src/lib/components/PlayerBar.svelte
index 68caddcf..fc9bdc0a 100644
--- a/web/src/lib/components/PlayerBar.svelte
+++ b/web/src/lib/components/PlayerBar.svelte
@@ -13,6 +13,7 @@
} from '$lib/player/store.svelte';
import { formatDuration } from '$lib/media/duration';
import { FALLBACK_COVER, coverUrl } from '$lib/media/covers';
+ import { dominantColorFromUrl, rgbToCssString } from '$lib/media/dominantColor';
import LikeButton from './LikeButton.svelte';
import TrackMenu from './TrackMenu.svelte';
@@ -44,6 +45,24 @@
function onCoverError(e: Event) {
(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);
+ });
+ });
{#if current}
@@ -59,6 +78,16 @@
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.
-->
+
+
+
+
/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
>();
+
+export function dominantColorFromUrl(url: string): Promise {
+ const cached = cache.get(url);
+ if (cached) return cached;
+ const p = sample(url);
+ cache.set(url, p);
+ return p;
+}
+
+function sample(url: string): Promise {
+ 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})`;
+}
diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte
index 9654760f..80b8d6db 100644
--- a/web/src/routes/+page.svelte
+++ b/web/src/routes/+page.svelte
@@ -143,8 +143,9 @@
>
{#snippet item(rowItem: PlaylistRowItem)}
-
+ Sized so a typical viewport shows ~5-6 across instead
+ of cramming 8-9. -->
+
{#if rowItem.kind === 'real'}
{:else}
@@ -174,7 +175,7 @@
ariaLabel="Recently added"
>
{#snippet item(album: import('$lib/api/types').AlbumRef)}
-
+
{/snippet}
{/if}