diff --git a/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt b/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt index 522f6e84..9aa83efe 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt index e9048195..ae5a0ed9 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt @@ -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>, + 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>, artists: List>, diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/AudioPrefetcher.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/AudioPrefetcher.kt new file mode 100644 index 00000000..9ad8125e --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/AudioPrefetcher.kt @@ -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() + 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>, + 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() + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/MiniPlayer.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/MiniPlayer.kt index 8228d5bd..b2061931 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/MiniPlayer.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/MiniPlayer.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt index a1a7af98..96f24442 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt @@ -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(), diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/SmoothPosition.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/SmoothPosition.kt new file mode 100644 index 00000000..18003011 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/SmoothPosition.kt @@ -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 = 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) + } + } +} diff --git a/web/src/lib/components/AlbumCard.svelte b/web/src/lib/components/AlbumCard.svelte index 5575ccb1..69712910 100644 --- a/web/src/lib/components/AlbumCard.svelte +++ b/web/src/lib/components/AlbumCard.svelte @@ -28,12 +28,16 @@ } -
+
-
+
{album.artist_name}
{/if} - {#if album.year} -
{album.year}
- {/if}
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(); }); diff --git a/web/src/lib/components/ArtistCard.svelte b/web/src/lib/components/ArtistCard.svelte index f1e1003a..7af1d28d 100644 --- a/web/src/lib/components/ArtistCard.svelte +++ b/web/src/lib/components/ArtistCard.svelte @@ -42,10 +42,10 @@ } -
+
{#if artist.cover_url} diff --git a/web/src/lib/components/CardActionCluster.svelte b/web/src/lib/components/CardActionCluster.svelte index c92644b2..54d80130 100644 --- a/web/src/lib/components/CardActionCluster.svelte +++ b/web/src/lib/components/CardActionCluster.svelte @@ -34,9 +34,17 @@ } = $props(); + -
e.stopPropagation()}> + + diff --git a/web/src/lib/components/HorizontalScrollRow.svelte b/web/src/lib/components/HorizontalScrollRow.svelte index bdb91497..9565b5ba 100644 --- a/web/src/lib/components/HorizontalScrollRow.svelte +++ b/web/src/lib/components/HorizontalScrollRow.svelte @@ -62,7 +62,14 @@
{#if title} -

{title}

+ +

{title}

{:else} {/if} 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. --> + +
+ +
-
+ +
{@render children?.()}
diff --git a/web/src/lib/media/dominantColor.ts b/web/src/lib/media/dominantColor.ts new file mode 100644 index 00000000..16b76a13 --- /dev/null +++ b/web/src/lib/media/dominantColor.ts @@ -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//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 a6e0d6a7..60cf8d5a 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -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(`/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 @@ {pageTitle('Home')}
+ + {#if forYouPlaylist || latestAlbum} +
+ {#if forYouPlaylist} + 0 + ? `${forYouPlaylist.track_count} tracks for you` + : 'Building your mix'} + onPlay={playForYou} + playDisabled={heroStarting || forYouPlaylist.track_count === 0} + /> + {/if} + {#if latestAlbum} + + {/if} +
+ {/if} +
{#snippet item(rowItem: PlaylistRowItem)} -
+ +
{#if rowItem.kind === 'real'} {:else} @@ -172,7 +256,7 @@ ariaLabel="Recently added" > {#snippet item(album: import('$lib/api/types').AlbumRef)} -
+
{/snippet} {/if} @@ -195,7 +279,9 @@ ariaLabel="Rediscover albums" > {#snippet item(album: import('$lib/api/types').AlbumRef)} -
+ +
{/snippet} {/if} diff --git a/web/src/routes/page.test.ts b/web/src/routes/page.test.ts index 51df7407..70fbff51 100644 --- a/web/src/routes/page.test.ts +++ b/web/src/routes/page.test.ts @@ -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(); });