Android v1 polish + Web UI flavor pass #65
@@ -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"
|
||||||
|
|||||||
@@ -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})`;
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user