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
18 changed files with 730 additions and 88 deletions
@@ -13,6 +13,7 @@ import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.events.LiveEventsDispatcher
import com.fabledsword.minstrel.metadata.FreshnessSweeper
import com.fabledsword.minstrel.player.AudioPrefetcher
import com.fabledsword.minstrel.player.CoverPrefetcher
import com.fabledsword.minstrel.player.PlayEventsReporter
import com.fabledsword.minstrel.player.PlaybackErrorReporter
@@ -103,6 +104,15 @@ class MinstrelApplication :
*/
@Suppress("unused") @Inject lateinit var cacheIndexer: CacheIndexer
/**
* Same construct-the-singleton trick — AudioPrefetcher's init
* block subscribes to PlayerController.uiState +
* AuthStore.cacheSettings and pins the next-N tracks into
* SimpleCache via CacheWriter. Without this @Inject the
* prefetchWindow setting would be inert and skips would re-fetch.
*/
@Suppress("unused") @Inject lateinit var audioPrefetcher: AudioPrefetcher
/**
* Same construct-the-singleton trick — VersionCheckController's
* init block starts a 5-min poll loop against /healthz so the
@@ -101,7 +101,12 @@ import javax.inject.Inject
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
private const val PLAYLIST_FETCH_TIMEOUT_MS = 8_000L
private const val BOTTOM_PADDING_FOR_MINIPLAYER_DP = 140
private const val RECENTLY_ADDED_CHUNK = 25
// Recently Added is laid out in a multi-row LazyHorizontalGrid that
// scrolls as one panel (same pattern as Most Played). Two rows trades
// a tall block for fewer columns to scroll past — a 200dp tile × 2 =
// ~440dp section still leaves room for the rest of Home.
private const val RECENTLY_ADDED_GRID_ROWS = 2
private const val RECENTLY_ADDED_GRID_HEIGHT_DP = 440
// ─── State ───────────────────────────────────────────────────────────
@@ -367,8 +372,17 @@ fun HomeScreen(
onRefresh = { viewModel.refresh().join() },
modifier = Modifier.fillMaxSize().padding(inner),
) {
Crossfade(targetState = state, label = "home-state") { s ->
when (s) {
// Key Crossfade on the state CLASS, not the instance. Each
// section emission produces a new UiState.Success(data); if
// we keyed on `state` directly, every per-section
// hydration tick would re-run the 300ms crossfade, and
// first-sign-in (six sections cascading in) reads as
// continuous flicker. Keying on the class restricts the
// animation to Loading↔Success↔Empty↔Error transitions and
// lets normal Success→Success recompositions update the
// LazyColumn without a fade.
Crossfade(targetState = state::class, label = "home-state") { _ ->
when (val s = state) {
UiState.Loading -> HomeSkeletonContent()
UiState.Empty -> EmptyState(
title = "Welcome to Minstrel",
@@ -503,23 +517,75 @@ private fun LazyListScope.recentlyAddedSection(
}
return
}
// Chunk into stacked carousels of 25 so a large library reads as
// multiple rows instead of one unwieldy LazyRow. Only the first
// chunk carries the section title; the rest are continuation rows.
val chunks = albums.chunked(RECENTLY_ADDED_CHUNK)
itemsIndexed(
items = chunks,
key = { index, chunk -> "recent-$index-${chunk.first().id}" },
) { index, chunk ->
AlbumsRow(
title = if (index == 0) "Recently added" else "",
albums = chunk,
item {
RecentlyAddedGrid(
albums = albums,
onAlbumClick = onAlbumClick,
onPlayAlbum = onPlayAlbum,
)
}
}
/**
* Multi-row horizontal grid for Recently Added, mirroring the
* Most Played pattern: tiles laid out column-major across
* [RECENTLY_ADDED_GRID_ROWS] rows inside one LazyHorizontalGrid so
* every row scrolls together as one panel. Replaces the previous
* stacked-LazyRows layout which scrolled each chunk independently.
*/
@Composable
private fun RecentlyAddedGrid(
albums: List<HomeTile<AlbumRef>>,
onAlbumClick: (String) -> Unit,
onPlayAlbum: suspend (String) -> Unit,
) {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = "Recently added",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
// Re-flatten column-major to match Web's "ranks across the
// top row, then across the middle, then bottom" reading
// order. LazyHorizontalGrid otherwise fills column-major
// directly from a flat list and would scramble per-rank
// expectations.
val perRow = (albums.size + RECENTLY_ADDED_GRID_ROWS - 1) / RECENTLY_ADDED_GRID_ROWS
val rows = albums.chunked(perRow.coerceAtLeast(1))
val cols = rows.maxOfOrNull { it.size } ?: 0
val ordered = buildList {
for (c in 0 until cols) {
for (r in 0 until RECENTLY_ADDED_GRID_ROWS) {
rows.getOrNull(r)?.getOrNull(c)?.let { add(it) }
}
}
}
LazyHorizontalGrid(
rows = GridCells.Fixed(RECENTLY_ADDED_GRID_ROWS),
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.fillMaxWidth()
.height(RECENTLY_ADDED_GRID_HEIGHT_DP.dp),
) {
gridItems(items = ordered, key = { it.id }) { tile ->
val album = tile.value
if (album == null) {
SkeletonAlbumTile()
} else {
AlbumCard(
album = album,
onClick = { onAlbumClick(album.id) },
onPlay = { onPlayAlbum(album.id) },
)
}
}
}
}
}
private fun LazyListScope.rediscoverSection(
albums: List<HomeTile<AlbumRef>>,
artists: List<HomeTile<ArtistRef>>,
@@ -0,0 +1,143 @@
package com.fabledsword.minstrel.player
import android.net.Uri
import androidx.media3.datasource.DataSpec
import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.CacheWriter
import androidx.media3.datasource.okhttp.OkHttpDataSource
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.shared.resolveServerUrl
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.OkHttpClient
import javax.inject.Inject
import javax.inject.Singleton
/**
* Pre-downloads the next-N tracks in the queue into the shared Media3
* [androidx.media3.datasource.cache.SimpleCache] so a skip-forward or
* natural advance plays from disk instead of waiting on a fresh HTTP
* connection. Mirrors the Flutter `Prefetcher` (cache/prefetcher.dart):
* watches the player's current track, walks forward by
* [com.fabledsword.minstrel.cache.audiocache.CacheSettings.prefetchWindow]
* tracks, and pins each one. Idempotent — `CacheWriter` is a no-op when
* a track is already fully resident, so the reconcile is cheap on
* every queue/index change.
*
* Eager-constructed via the construct-the-singleton trick in
* [com.fabledsword.minstrel.MinstrelApplication]; the init block wires
* the observer.
*/
@Singleton
class AudioPrefetcher @Inject constructor(
@ApplicationScope private val scope: CoroutineScope,
private val playerController: PlayerController,
private val playerFactory: PlayerFactory,
private val authStore: AuthStore,
private val okHttpClient: OkHttpClient,
) {
// Reuse the same cache + upstream chain the player uses. Without the
// SimpleCache reference here the prefetcher would write to a
// disjoint store and playback would never read what we pinned.
private val cacheDataSourceFactory: CacheDataSource.Factory =
CacheDataSource.Factory()
.setCache(playerFactory.simpleCache)
.setUpstreamDataSourceFactory(OkHttpDataSource.Factory(okHttpClient))
// trackId -> in-flight job. Reconcile cancels jobs whose track has
// dropped out of the window (skip-prev, queue rebuild) so a stale
// prefetch doesn't keep the network busy.
private val activeJobs = mutableMapOf<String, Job>()
private val mutex = Mutex()
init {
scope.launch {
combine(
playerController.uiState.map { it.queue.map { t -> t.id to t.streamUrl } },
playerController.uiState.map { it.queueIndex },
authStore.cacheSettings.map { it.prefetchWindow },
) { queue, index, window -> Triple(queue, index, window) }
.distinctUntilChanged()
.collect { (queue, index, window) -> reconcile(queue, index, window) }
}
}
private suspend fun reconcile(
queue: List<Pair<String, String>>,
index: Int,
window: Int,
) {
mutex.withLock {
if (index < 0 || queue.isEmpty() || window <= 0) {
cancelAllLocked()
return
}
// Exclude the currently-playing track (it's loaded by the
// player itself) and walk `window` tracks forward.
val firstIdx = (index + 1).coerceAtMost(queue.size)
val lastIdx = (index + window).coerceAtMost(queue.size - 1)
if (firstIdx > lastIdx) {
cancelAllLocked()
return
}
val targets = queue.subList(firstIdx, lastIdx + 1)
val targetIds = targets.mapTo(mutableSetOf()) { it.first }
// Cancel jobs for tracks that have slid out of the window.
activeJobs.entries
.filter { it.key !in targetIds }
.toList()
.forEach { (id, job) ->
job.cancel()
activeJobs.remove(id)
}
// Start prefetches for new arrivals. Skip blank URLs (these
// come from minimal TrackRefs synthesized from playlist rows
// when the upstream track was removed from the library).
for ((trackId, streamUrl) in targets) {
if (trackId in activeJobs || streamUrl.isBlank()) continue
val job = scope.launch(Dispatchers.IO) {
runCatching { prefetchOne(trackId, streamUrl) }
mutex.withLock { activeJobs.remove(trackId) }
}
activeJobs[trackId] = job
}
}
}
private fun cancelAllLocked() {
activeJobs.values.forEach { it.cancel() }
activeJobs.clear()
}
private fun prefetchOne(trackId: String, streamUrl: String) {
// Match PlayerController.toMediaItem's resolveServerUrl +
// setCustomCacheKey(id) so the cached bytes are keyed exactly
// like a normal playback request — otherwise the player would
// miss them on read-through.
val resolved = resolveServerUrl(streamUrl) ?: streamUrl
val dataSpec = DataSpec.Builder()
.setUri(Uri.parse(resolved))
.setKey(trackId)
.build()
val writer = CacheWriter(
cacheDataSourceFactory.createDataSource(),
dataSpec,
/* temporaryBuffer = */ null,
/* progressListener = */ null,
)
// Blocking, but we're on Dispatchers.IO. Throws on network
// failure — outer runCatching swallows so a single failed
// prefetch doesn't trip the whole reconcile.
writer.cache()
}
}
@@ -20,8 +20,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -148,10 +147,19 @@ fun MiniPlayer(
tonalElevation = MINI_BAR_TONAL_ELEVATION_DP.dp,
) {
Column {
MiniSeekBar(
// Non-interactive fill bar — no scrubber thumb. Tapping
// the bar still expands to NowPlaying where scrubbing
// lives. The displayed fraction interpolates per-frame
// between PlayerController position ticks so the fill
// glides instead of stepping every 500ms.
val smoothPositionMs by rememberSmoothPositionMs(
positionMs = state.positionMs,
durationMs = state.durationMs,
onSeek = viewModel::seekTo,
isPlaying = state.isPlaying,
)
MiniProgressFill(
positionMs = smoothPositionMs,
durationMs = state.durationMs,
)
MiniRow(
track = track,
@@ -170,27 +178,25 @@ fun MiniPlayer(
}
@Composable
private fun MiniSeekBar(positionMs: Long, durationMs: Long, onSeek: (Long) -> Unit) {
private fun MiniProgressFill(positionMs: Long, durationMs: Long) {
val fraction = if (durationMs > 0) {
(positionMs.toFloat() / durationMs.toFloat()).coerceIn(0f, 1f)
} else {
0f
}
Slider(
value = fraction,
onValueChange = { v -> if (durationMs > 0) onSeek((v * durationMs).toLong()) },
Box(
modifier = Modifier
.fillMaxWidth()
.height(SCRUBBER_ROW_HEIGHT_DP.dp),
colors = SliderDefaults.colors(
thumbColor = MaterialTheme.colorScheme.primary,
activeTrackColor = MaterialTheme.colorScheme.primary,
// M3 default inactiveTrackColor is secondaryContainer, which the
// theme doesn't override — that's where the M3-baseline purple
// leaks in. Pin to surfaceVariant (= slate) for Flutter parity.
inactiveTrackColor = MaterialTheme.colorScheme.surfaceVariant,
),
)
.height(SCRUBBER_ROW_HEIGHT_DP.dp)
.background(MaterialTheme.colorScheme.surfaceVariant),
) {
Box(
modifier = Modifier
.fillMaxHeight()
.fillMaxWidth(fraction)
.background(MaterialTheme.colorScheme.primary),
)
}
}
@Composable
@@ -22,15 +22,20 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.unit.Velocity
import androidx.compose.material3.Scaffold
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Slider
import androidx.compose.ui.unit.DpSize
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
@@ -122,8 +127,11 @@ fun NowPlayingScreen(
return
}
val dominant = rememberDominantColor(track.coverUrl)
val dismissConnection = rememberDragDismissConnection(
onDismiss = { navController.popBackStack() },
)
Scaffold(
modifier = Modifier.fillMaxSize().nowPlayingDragDismiss(navController),
modifier = Modifier.fillMaxSize().nestedScroll(dismissConnection),
topBar = { NowPlayingTopBar(onClose = { navController.popBackStack() }) },
snackbarHost = { SnackbarHost(snackbarHostState) },
containerColor = Color.Transparent,
@@ -161,27 +169,51 @@ private const val DOMINANT_TINT_MID_STOP = 0.45f
/**
* Drag-down to dismiss. Mirrors Flutter's modal-page gesture: a
* downward drag past the threshold pops the screen, returning the
* user to whichever shell route launched NowPlaying. Tap, swipe-up,
* and horizontal drags pass through to the underlying content
* (slider scrub, etc.).
* downward over-scroll past the threshold pops the screen.
*
* Implementation note: the body Column uses verticalScroll, which
* eats every vertical drag delta before a top-level
* detectVerticalDragGestures can see it. NestedScroll is the
* right primitive here — verticalScroll offers its over-scroll
* deltas to the nearest ancestor connection, so we accumulate
* downward over-scroll (available.y > 0 means the scrollable
* couldn't consume it because it's at the top) and pop on
* threshold. The Slider keeps its own pointerInput and is
* unaffected.
*/
@Composable
private fun Modifier.nowPlayingDragDismiss(navController: NavHostController): Modifier =
this.pointerInput(Unit) {
var totalDragY = 0f
detectVerticalDragGestures(
onDragStart = { totalDragY = 0f },
onDragEnd = {
if (totalDragY > DRAG_DISMISS_THRESHOLD_PX) {
navController.popBackStack()
private fun rememberDragDismissConnection(
onDismiss: () -> Unit,
thresholdPx: Float = DRAG_DISMISS_THRESHOLD_PX,
): NestedScrollConnection = remember(onDismiss, thresholdPx) {
object : NestedScrollConnection {
private var accumulated = 0f
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource,
): Offset {
if (source != NestedScrollSource.UserInput) return Offset.Zero
return if (available.y > 0f) {
accumulated += available.y
if (accumulated >= thresholdPx) {
accumulated = 0f
onDismiss()
}
totalDragY = 0f
},
onDragCancel = { totalDragY = 0f },
onVerticalDrag = { _, delta -> totalDragY += delta },
)
Offset(0f, available.y)
} else {
if (consumed.y != 0f || available.y < 0f) accumulated = 0f
Offset.Zero
}
}
override suspend fun onPreFling(available: Velocity): Velocity {
accumulated = 0f
return Velocity.Zero
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -241,13 +273,18 @@ private fun NowPlayingBody(
onToggleShuffle = viewModel::toggleShuffle,
onCycleRepeat = viewModel::cycleRepeat,
)
Spacer(Modifier.height(8.dp))
ScrubberRow(
Spacer(Modifier.height(4.dp))
val smoothPositionMs by rememberSmoothPositionMs(
positionMs = state.positionMs,
durationMs = state.durationMs,
isPlaying = state.isPlaying,
)
ScrubberRow(
positionMs = smoothPositionMs,
durationMs = state.durationMs,
onSeek = viewModel::seekTo,
)
Spacer(Modifier.height(8.dp))
Spacer(Modifier.height(4.dp))
TransportRow(
isPlaying = state.isPlaying,
onPrev = viewModel::skipToPrevious,
@@ -396,6 +433,7 @@ private fun TrackHeader(title: String, artist: String, album: String) {
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ScrubberRow(positionMs: Long, durationMs: Long, onSeek: (Long) -> Unit) {
val accent = MaterialTheme.colorScheme.primary
@@ -404,19 +442,34 @@ private fun ScrubberRow(positionMs: Long, durationMs: Long, onSeek: (Long) -> Un
} else {
0f
}
val interactionSource = remember { MutableInteractionSource() }
val sliderColors = SliderDefaults.colors(
thumbColor = accent,
activeTrackColor = accent,
// M3 inactive default is secondaryContainer (purple baseline);
// pin to surfaceVariant (= slate) for Flutter parity.
inactiveTrackColor = MaterialTheme.colorScheme.surfaceVariant,
)
Slider(
value = fraction,
onValueChange = { newFraction ->
if (durationMs > 0) onSeek((newFraction * durationMs).toLong())
},
modifier = Modifier.fillMaxWidth(),
colors = SliderDefaults.colors(
thumbColor = accent,
activeTrackColor = accent,
// M3 inactive default is secondaryContainer (purple baseline);
// pin to surfaceVariant (= slate) for Flutter parity.
inactiveTrackColor = MaterialTheme.colorScheme.surfaceVariant,
),
colors = sliderColors,
interactionSource = interactionSource,
// Slim 10dp thumb shrinks the scrubber's visual weight. M3
// default is 20dp; halving keeps it tappable (Slider's own
// 48dp hit slop is unchanged) but lets it recede into the UI.
// Track left at default — the colour pin alone already
// matches Flutter's slate look.
thumb = {
SliderDefaults.Thumb(
interactionSource = interactionSource,
colors = sliderColors,
thumbSize = DpSize(10.dp, 10.dp),
)
},
)
Row(
modifier = Modifier.fillMaxWidth(),
@@ -0,0 +1,48 @@
package com.fabledsword.minstrel.player.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.produceState
import androidx.compose.runtime.withFrameMillis
import kotlinx.coroutines.isActive
/**
* Per-frame interpolated playhead. `positionMs` comes in at the
* polling cadence of [com.fabledsword.minstrel.player.PlayerController]
* (~500ms today) — using it directly for the scrubber paints a sudden
* 500ms jump on each tick. This wraps it in a [produceState] that
* resets to the canonical value on every emission and then advances
* forward at 1ms/ms while the player is playing, so the displayed
* position moves continuously at the natural playback rate.
*
* On track end / pause / seek the produceState's keys change and the
* coroutine restarts from the new canonical value — so when the
* canonical position changes by more than a frame can account for
* (skip, scrub, seek), we snap instead of slewing across the bar.
*
* For paused state the position stays at the canonical value with no
* extrapolation; for ended/durationMs<=0 the helper returns
* `positionMs` unchanged.
*/
@Composable
fun rememberSmoothPositionMs(
positionMs: Long,
durationMs: Long,
isPlaying: Boolean,
): State<Long> = produceState(
initialValue = positionMs,
key1 = positionMs,
key2 = isPlaying,
key3 = durationMs,
) {
value = positionMs
if (!isPlaying || durationMs <= 0L) return@produceState
val startFrame = withFrameMillis { it }
val startPos = positionMs
while (isActive) {
withFrameMillis { now ->
val advanced = startPos + (now - startFrame)
value = advanced.coerceAtMost(durationMs)
}
}
}
+7 -6
View File
@@ -28,12 +28,16 @@
}
</script>
<div class="card relative">
<div class="card group relative">
<a
href={`/albums/${album.id}`}
class="group block rounded focus-visible:ring-2 focus-visible:ring-accent"
class="block rounded focus-visible:ring-2 focus-visible:ring-accent"
>
<div class="art-wrap relative aspect-square w-full overflow-hidden rounded-md">
<div
class="art-wrap relative aspect-square w-full overflow-hidden rounded-md
shadow-sm transition-all duration-150
group-hover:shadow-lg group-hover:ring-1 group-hover:ring-accent/40"
>
<img
src={album.cover_url}
alt=""
@@ -54,9 +58,6 @@
{#if album.artist_name}
<div class="truncate text-xs text-text-secondary">{album.artist_name}</div>
{/if}
{#if album.year}
<div class="text-xs text-text-secondary">{album.year}</div>
{/if}
</a>
<CardActionCluster
likeEntityType="album"
+7 -3
View File
@@ -53,18 +53,22 @@ const album: AlbumRef = {
afterEach(() => vi.clearAllMocks());
describe('AlbumCard', () => {
test('renders cover, title, year inside a link to /albums/:id', () => {
test('renders cover, title, artist inside a link to /albums/:id', () => {
const { container } = render(AlbumCard, { props: { album } });
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/albums/xyz');
expect(link).toHaveTextContent('Kind of Blue');
expect(link).toHaveTextContent('1959');
expect(link).toHaveTextContent('Miles Davis');
// The year is intentionally omitted from the Home tile —
// dropped in the visual polish pass; the album detail page
// still shows it.
expect(link).not.toHaveTextContent('1959');
const img = container.querySelector('img') as HTMLImageElement;
expect(img.src).toContain('/api/albums/xyz/cover');
});
test('year is omitted when not present', () => {
test('year is not rendered on the tile even when present', () => {
render(AlbumCard, { props: { album: { ...album, year: undefined } } });
expect(screen.queryByText(/1959/)).not.toBeInTheDocument();
});
+2 -2
View File
@@ -42,10 +42,10 @@
}
</script>
<div class="card relative">
<div class="card group relative">
<a
href={`/artists/${artist.id}`}
class="group block rounded focus-visible:ring-2 focus-visible:ring-accent"
class="block rounded focus-visible:ring-2 focus-visible:ring-accent"
>
<div class="art-wrap relative mx-auto aspect-square w-full overflow-hidden rounded-full bg-surface-hover">
{#if artist.cover_url}
@@ -34,9 +34,17 @@
} = $props();
</script>
<!-- Like + Add to queue + Menu cluster. Hover-revealed so the
default tile state is just the cover artwork — controls fade in
when the user hovers the card or focuses any of the buttons
(focus-within keeps it visible during keyboard navigation). -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="absolute right-2 top-2 z-10 flex gap-1" onclick={(e) => e.stopPropagation()}>
<div
class="absolute right-2 top-2 z-10 flex gap-1 opacity-0 transition-opacity duration-150
group-hover:opacity-100 focus-within:opacity-100"
onclick={(e) => e.stopPropagation()}
>
<LikeButton entityType={likeEntityType} entityId={likeEntityId} />
<button
type="button"
@@ -50,6 +58,10 @@
</div>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="absolute right-2 bottom-2 z-10" onclick={(e) => e.stopPropagation()}>
<div
class="absolute right-2 bottom-2 z-10 opacity-0 transition-opacity duration-150
group-hover:opacity-100 focus-within:opacity-100"
onclick={(e) => e.stopPropagation()}
>
{@render menu()}
</div>
@@ -0,0 +1,75 @@
<script lang="ts">
import { Play } from 'lucide-svelte';
import { FALLBACK_COVER } from '$lib/media/covers';
// Shared shape for the two hero tiles at the top of Home (the
// "Today's pick" + "Just added" anchors). Wider than the regular
// grid tiles, cover on the left + meta + play button on the right,
// accent-tinted background so the eye lands here first.
let {
href,
coverUrl,
eyebrow,
title,
subtitle,
onPlay,
playDisabled = false
}: {
href: string;
coverUrl: string;
eyebrow: string;
title: string;
subtitle?: string;
onPlay: (e: MouseEvent) => void;
playDisabled?: boolean;
} = $props();
function onImgError(e: Event) {
(e.currentTarget as HTMLImageElement).src = FALLBACK_COVER;
}
</script>
<a
{href}
class="group flex items-stretch gap-4 overflow-hidden rounded-lg border border-border
bg-gradient-to-br from-accent/15 via-surface to-surface
shadow-sm transition-all duration-200
hover:from-accent/25 hover:shadow-lg hover:ring-1 hover:ring-accent/40
focus-visible:ring-2 focus-visible:ring-accent"
>
<div class="relative h-32 w-32 flex-shrink-0 overflow-hidden md:h-40 md:w-40">
<img
src={coverUrl}
alt=""
class="h-full w-full object-cover transition-transform duration-200 group-hover:scale-105"
loading="lazy"
onerror={onImgError}
/>
</div>
<div class="flex flex-1 flex-col justify-between gap-2 py-3 pr-4">
<div class="min-w-0">
<div class="text-xs font-medium uppercase tracking-wide text-accent">{eyebrow}</div>
<div class="font-display text-xl font-medium leading-tight text-text-primary
line-clamp-2 mt-1">
{title}
</div>
{#if subtitle}
<div class="mt-1 truncate text-sm text-text-secondary">{subtitle}</div>
{/if}
</div>
<button
type="button"
aria-label={`Play ${title}`}
onclick={onPlay}
disabled={playDisabled}
class="self-start inline-flex items-center gap-1.5 rounded-full
bg-accent px-4 py-1.5 text-sm font-medium text-text-primary
hover:opacity-90 focus-visible:ring-2 focus-visible:ring-accent
disabled:opacity-50"
>
<Play size={14} strokeWidth={1.5} fill="currentColor" />
Play
</button>
</div>
</a>
@@ -62,7 +62,14 @@
<div class="section" aria-label={ariaLabel}>
<header class="section-header">
{#if title}
<h2 class="font-display text-2xl font-medium text-text-primary">{title}</h2>
<!-- Section header: Fraunces (font-display) at 24px with an
inline accent rule that bleeds out from the title — gives
each shelf a clear chapter break without adding chrome. -->
<h2
class="font-display text-2xl font-medium text-text-primary
flex items-baseline gap-3 after:h-[2px] after:w-12 after:flex-shrink-0
after:rounded-full after:bg-accent/60 after:content-['']"
>{title}</h2>
{:else}
<span></span>
{/if}
+36
View File
@@ -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);
});
});
</script>
{#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.
-->
<!-- 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
data-testid="player-bar-compact"
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,
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
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"
+29 -4
View File
@@ -104,9 +104,14 @@
<svelte:window onclick={() => { menuOpen = false; }} />
<div class="card relative">
<div class="card group relative">
{#if refreshable}
<div class="kebab-wrap absolute right-1 top-1 z-10">
<!-- Kebab is hover/focus-revealed so the default tile state is
just the cover. Same pattern as CardActionCluster. -->
<div
class="kebab-wrap absolute right-1 top-1 z-10 transition-opacity duration-150
{menuOpen ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 focus-within:opacity-100'}"
>
<button
type="button"
aria-label="Playlist actions"
@@ -144,7 +149,11 @@
href={`/playlists/${playlist.id}`}
class="group flex w-full flex-col items-start gap-2 rounded-md p-2 text-left hover:bg-surface-hover"
>
<div class="art-wrap relative aspect-square w-full overflow-hidden rounded-md bg-surface-hover">
<div
class="art-wrap relative aspect-square w-full overflow-hidden rounded-md
bg-surface-hover shadow-sm transition-all duration-150
group-hover:shadow-lg group-hover:ring-1 group-hover:ring-accent/40"
>
{#if playlist.cover_url}
<img
src={playlist.cover_url}
@@ -156,6 +165,23 @@
<span class="text-xs">No tracks yet</span>
</div>
{/if}
<!-- Bottom-to-top gradient so the Fraunces title overlay below
is legible against any cover. The server's playlist cover
is often a 2x2 collage; the gradient mutes the collage so
the playlist NAME becomes the dominant element. -->
<div
class="pointer-events-none absolute inset-0 bg-gradient-to-t
from-black/85 via-black/40 to-transparent"
></div>
<!-- Title burned in at the bottom of the cover. Two lines max;
ellipsis. Fraunces (font-display) so it reads as
branded/curated content rather than just metadata. -->
<div class="pointer-events-none absolute inset-x-0 bottom-0 p-3">
<div
class="font-display text-base font-medium leading-tight text-text-primary
[display:-webkit-box] [-webkit-box-orient:vertical] [-webkit-line-clamp:2] overflow-hidden"
>{playlist.name}</div>
</div>
<button
type="button"
aria-label={`Play ${playlist.name}`}
@@ -167,7 +193,6 @@
</button>
</div>
<div class="w-full min-w-0">
<div class="truncate text-sm font-medium text-text-primary">{playlist.name}</div>
<div class="truncate text-xs text-text-muted">
{playlist.track_count} {playlist.track_count === 1 ? 'track' : 'tracks'}
{#if !isOwn}
+10 -1
View File
@@ -135,7 +135,16 @@
</div>
</header>
<main class="overflow-y-auto p-4">
<!-- Subtle 1200x600 radial highlight at the top of the scroll
area fades the slate tone (fs-iron with alpha) into the
background. Adds depth so the page doesn't read as a flat
slab. color-mix lets the gradient theme-track in case the
palette token ever shifts. -->
<main
class="overflow-y-auto p-4"
style="background-image: radial-gradient(ellipse 1200px 600px at 50% -100px,
color-mix(in srgb, var(--fs-iron) 60%, transparent), transparent 70%);"
>
{@render children?.()}
</main>
+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})`;
}
+89 -3
View File
@@ -3,6 +3,13 @@
import { createHomeQuery } from '$lib/api/home';
import HorizontalScrollRow from '$lib/components/HorizontalScrollRow.svelte';
import AlbumCard from '$lib/components/AlbumCard.svelte';
import HomeHeroCard from '$lib/components/HomeHeroCard.svelte';
import { systemShuffle, getPlaylist } from '$lib/api/playlists';
import { playlistTrackToRef } from '$lib/playlists/playlistTrackToRef';
import { playQueue } from '$lib/player/store.svelte';
import { api } from '$lib/api/client';
import { coverUrl as albumCoverUrl } from '$lib/media/covers';
import type { AlbumDetail, TrackRef as TrackRefType } from '$lib/api/types';
import ArtistCard from '$lib/components/ArtistCard.svelte';
import CompactTrackCard from '$lib/components/CompactTrackCard.svelte';
import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
@@ -75,6 +82,46 @@
const userPlaylists = $derived(userPlaylistsQ.data?.owned ?? []);
// Hero row: For-You + most-recently-added album. Anchors the page
// so the eye lands on these instead of cascading down a wall of
// identical tiles.
const latestAlbum = $derived(data?.recently_added_albums?.[0] ?? null);
function albumCoverFor(albumId: string | undefined | null): string {
return albumId ? albumCoverUrl(albumId) : '';
}
let heroStarting = $state(false);
async function playForYou(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
if (heroStarting || !forYouPlaylist?.system_variant) return;
heroStarting = true;
try {
const detail = await systemShuffle(forYouPlaylist.system_variant);
const refs = detail.tracks
.map((r) => playlistTrackToRef(r))
.filter((t): t is TrackRefType => t !== null);
if (refs.length > 0) playQueue(refs, 0, { source: forYouPlaylist.system_variant });
} finally {
heroStarting = false;
}
}
async function playLatestAlbum(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
if (heroStarting || !latestAlbum) return;
heroStarting = true;
try {
const detail = await api.get<AlbumDetail>(`/api/albums/${latestAlbum.id}`);
if (detail.tracks.length > 0) playQueue(detail.tracks, 0);
} finally {
heroStarting = false;
}
}
type PlaceholderVariant = 'building' | 'failed' | 'pending' | 'seed-needed';
function placeholderVariant(slot: 'for-you' | 'discover' | 'songs-like'): PlaceholderVariant {
const s = systemStatusQ.data;
@@ -134,6 +181,40 @@
<svelte:head><title>{pageTitle('Home')}</title></svelte:head>
<div class="space-y-8">
<!-- Hero anchors: For-You + most recently added album. Larger
cards on a tinted gradient so the page has a clear "land
here" point instead of opening on a wall of tiles. Only
rendered when both pieces of data exist; the page falls back
to the carousels cleanly when either is missing. -->
{#if forYouPlaylist || latestAlbum}
<section class="grid gap-4 md:grid-cols-2" aria-label="Featured">
{#if forYouPlaylist}
<HomeHeroCard
href={`/playlists/${forYouPlaylist.id}`}
coverUrl={forYouPlaylist.cover_url ?? ''}
eyebrow="Today's pick"
title={forYouPlaylist.name}
subtitle={forYouPlaylist.track_count > 0
? `${forYouPlaylist.track_count} tracks for you`
: 'Building your mix'}
onPlay={playForYou}
playDisabled={heroStarting || forYouPlaylist.track_count === 0}
/>
{/if}
{#if latestAlbum}
<HomeHeroCard
href={`/albums/${latestAlbum.id}`}
coverUrl={albumCoverFor(latestAlbum.id)}
eyebrow="Just added"
title={latestAlbum.title}
subtitle={latestAlbum.artist_name}
onPlay={playLatestAlbum}
playDisabled={heroStarting}
/>
{/if}
</section>
{/if}
<!-- Playlists: For-You + Songs-like + user playlists, single horizontal row -->
<section class="space-y-3">
<HorizontalScrollRow
@@ -142,7 +223,10 @@
ariaLabel="Playlists"
>
{#snippet item(rowItem: PlaylistRowItem)}
<div class="w-44">
<!-- Playlists at the page anchor: largest of the carousels.
Sized so a typical viewport shows ~5-6 across instead
of cramming 8-9. -->
<div class="w-56">
{#if rowItem.kind === 'real'}
<PlaylistCard playlist={rowItem.playlist} />
{:else}
@@ -172,7 +256,7 @@
ariaLabel="Recently added"
>
{#snippet item(album: import('$lib/api/types').AlbumRef)}
<div class="w-44"><AlbumCard {album} /></div>
<div class="w-48"><AlbumCard {album} /></div>
{/snippet}
</HorizontalScrollRow>
{/if}
@@ -195,7 +279,9 @@
ariaLabel="Rediscover albums"
>
{#snippet item(album: import('$lib/api/types').AlbumRef)}
<div class="w-44"><AlbumCard {album} /></div>
<!-- Rediscover steps down vs Recently added to make the
tile-size hierarchy read top-to-bottom. -->
<div class="w-40"><AlbumCard {album} /></div>
{/snippet}
</HorizontalScrollRow>
{/if}
+11 -3
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import { render, screen, within } from '@testing-library/svelte';
import { readable } from 'svelte/store';
import { emptyLikesMock } from '../test-utils/mocks/likes';
import type { Playlist } from '$lib/api/types';
@@ -103,7 +103,11 @@ describe('home Playlists section', () => {
: readable({ data: emptyPlaylistsResponse, isPending: false, isError: false })
);
render(Page);
expect(screen.getByText('For You')).toBeInTheDocument();
// For-You renders twice: once in the new hero card at the top of
// Home and once in the Playlists carousel. Scope the assertion
// to the Playlists section to avoid the hero collision.
const playlistsSection = screen.getByLabelText('Playlists');
expect(within(playlistsSection).getByText('For You')).toBeInTheDocument();
const placeholders = screen.queryAllByTestId('playlist-placeholder-card');
// 1 Discover + 3 Songs-like slots = 4 placeholders alongside the
// real For-You tile.
@@ -143,7 +147,11 @@ describe('home Playlists section', () => {
: readable({ data: emptyPlaylistsResponse, isPending: false, isError: false })
);
render(Page);
expect(screen.getByText('For You')).toBeInTheDocument();
// For-You also renders in the hero card; scope to the Playlists
// section. The secondary kinds aren't featured in the hero, so
// their getByText assertions can stay document-scoped.
const playlistsSection = screen.getByLabelText('Playlists');
expect(within(playlistsSection).getByText('For You')).toBeInTheDocument();
expect(screen.getByText('Deep cuts')).toBeInTheDocument();
expect(screen.getByText('New for you')).toBeInTheDocument();
});