Android v1 polish + Web UI flavor pass #65

Merged
bvandeusen merged 13 commits from dev into main 2026-06-01 20:17:49 -04:00
3 changed files with 93 additions and 3 deletions
Showing only changes of commit 5f297c4c21 - Show all commits
+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}