Android v1 polish + Web UI flavor pass #65
@@ -13,6 +13,7 @@ import com.fabledsword.minstrel.di.ApplicationScope
|
|||||||
import com.fabledsword.minstrel.events.EventsStream
|
import com.fabledsword.minstrel.events.EventsStream
|
||||||
import com.fabledsword.minstrel.events.LiveEventsDispatcher
|
import com.fabledsword.minstrel.events.LiveEventsDispatcher
|
||||||
import com.fabledsword.minstrel.metadata.FreshnessSweeper
|
import com.fabledsword.minstrel.metadata.FreshnessSweeper
|
||||||
|
import com.fabledsword.minstrel.player.AudioPrefetcher
|
||||||
import com.fabledsword.minstrel.player.CoverPrefetcher
|
import com.fabledsword.minstrel.player.CoverPrefetcher
|
||||||
import com.fabledsword.minstrel.player.PlayEventsReporter
|
import com.fabledsword.minstrel.player.PlayEventsReporter
|
||||||
import com.fabledsword.minstrel.player.PlaybackErrorReporter
|
import com.fabledsword.minstrel.player.PlaybackErrorReporter
|
||||||
@@ -103,6 +104,15 @@ class MinstrelApplication :
|
|||||||
*/
|
*/
|
||||||
@Suppress("unused") @Inject lateinit var cacheIndexer: CacheIndexer
|
@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
|
* Same construct-the-singleton trick — VersionCheckController's
|
||||||
* init block starts a 5-min poll loop against /healthz so the
|
* 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 SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||||
private const val PLAYLIST_FETCH_TIMEOUT_MS = 8_000L
|
private const val PLAYLIST_FETCH_TIMEOUT_MS = 8_000L
|
||||||
private const val BOTTOM_PADDING_FOR_MINIPLAYER_DP = 140
|
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 ───────────────────────────────────────────────────────────
|
// ─── State ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -367,8 +372,17 @@ fun HomeScreen(
|
|||||||
onRefresh = { viewModel.refresh().join() },
|
onRefresh = { viewModel.refresh().join() },
|
||||||
modifier = Modifier.fillMaxSize().padding(inner),
|
modifier = Modifier.fillMaxSize().padding(inner),
|
||||||
) {
|
) {
|
||||||
Crossfade(targetState = state, label = "home-state") { s ->
|
// Key Crossfade on the state CLASS, not the instance. Each
|
||||||
when (s) {
|
// 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.Loading -> HomeSkeletonContent()
|
||||||
UiState.Empty -> EmptyState(
|
UiState.Empty -> EmptyState(
|
||||||
title = "Welcome to Minstrel",
|
title = "Welcome to Minstrel",
|
||||||
@@ -503,23 +517,75 @@ private fun LazyListScope.recentlyAddedSection(
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Chunk into stacked carousels of 25 so a large library reads as
|
item {
|
||||||
// multiple rows instead of one unwieldy LazyRow. Only the first
|
RecentlyAddedGrid(
|
||||||
// chunk carries the section title; the rest are continuation rows.
|
albums = albums,
|
||||||
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,
|
|
||||||
onAlbumClick = onAlbumClick,
|
onAlbumClick = onAlbumClick,
|
||||||
onPlayAlbum = onPlayAlbum,
|
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(
|
private fun LazyListScope.rediscoverSection(
|
||||||
albums: List<HomeTile<AlbumRef>>,
|
albums: List<HomeTile<AlbumRef>>,
|
||||||
artists: List<HomeTile<ArtistRef>>,
|
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.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Slider
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.material3.SliderDefaults
|
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -148,10 +147,19 @@ fun MiniPlayer(
|
|||||||
tonalElevation = MINI_BAR_TONAL_ELEVATION_DP.dp,
|
tonalElevation = MINI_BAR_TONAL_ELEVATION_DP.dp,
|
||||||
) {
|
) {
|
||||||
Column {
|
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,
|
positionMs = state.positionMs,
|
||||||
durationMs = state.durationMs,
|
durationMs = state.durationMs,
|
||||||
onSeek = viewModel::seekTo,
|
isPlaying = state.isPlaying,
|
||||||
|
)
|
||||||
|
MiniProgressFill(
|
||||||
|
positionMs = smoothPositionMs,
|
||||||
|
durationMs = state.durationMs,
|
||||||
)
|
)
|
||||||
MiniRow(
|
MiniRow(
|
||||||
track = track,
|
track = track,
|
||||||
@@ -170,27 +178,25 @@ fun MiniPlayer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MiniSeekBar(positionMs: Long, durationMs: Long, onSeek: (Long) -> Unit) {
|
private fun MiniProgressFill(positionMs: Long, durationMs: Long) {
|
||||||
val fraction = if (durationMs > 0) {
|
val fraction = if (durationMs > 0) {
|
||||||
(positionMs.toFloat() / durationMs.toFloat()).coerceIn(0f, 1f)
|
(positionMs.toFloat() / durationMs.toFloat()).coerceIn(0f, 1f)
|
||||||
} else {
|
} else {
|
||||||
0f
|
0f
|
||||||
}
|
}
|
||||||
Slider(
|
Box(
|
||||||
value = fraction,
|
|
||||||
onValueChange = { v -> if (durationMs > 0) onSeek((v * durationMs).toLong()) },
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(SCRUBBER_ROW_HEIGHT_DP.dp),
|
.height(SCRUBBER_ROW_HEIGHT_DP.dp)
|
||||||
colors = SliderDefaults.colors(
|
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||||
thumbColor = MaterialTheme.colorScheme.primary,
|
) {
|
||||||
activeTrackColor = MaterialTheme.colorScheme.primary,
|
Box(
|
||||||
// M3 default inactiveTrackColor is secondaryContainer, which the
|
modifier = Modifier
|
||||||
// theme doesn't override — that's where the M3-baseline purple
|
.fillMaxHeight()
|
||||||
// leaks in. Pin to surfaceVariant (= slate) for Flutter parity.
|
.fillMaxWidth(fraction)
|
||||||
inactiveTrackColor = MaterialTheme.colorScheme.surfaceVariant,
|
.background(MaterialTheme.colorScheme.primary),
|
||||||
),
|
)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -22,15 +22,20 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
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.material3.Scaffold
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Slider
|
import androidx.compose.material3.Slider
|
||||||
|
import androidx.compose.ui.unit.DpSize
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
|
||||||
import androidx.compose.material3.SliderDefaults
|
import androidx.compose.material3.SliderDefaults
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
@@ -122,8 +127,11 @@ fun NowPlayingScreen(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
val dominant = rememberDominantColor(track.coverUrl)
|
val dominant = rememberDominantColor(track.coverUrl)
|
||||||
|
val dismissConnection = rememberDragDismissConnection(
|
||||||
|
onDismiss = { navController.popBackStack() },
|
||||||
|
)
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier.fillMaxSize().nowPlayingDragDismiss(navController),
|
modifier = Modifier.fillMaxSize().nestedScroll(dismissConnection),
|
||||||
topBar = { NowPlayingTopBar(onClose = { navController.popBackStack() }) },
|
topBar = { NowPlayingTopBar(onClose = { navController.popBackStack() }) },
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
containerColor = Color.Transparent,
|
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
|
* Drag-down to dismiss. Mirrors Flutter's modal-page gesture: a
|
||||||
* downward drag past the threshold pops the screen, returning the
|
* downward over-scroll past the threshold pops the screen.
|
||||||
* user to whichever shell route launched NowPlaying. Tap, swipe-up,
|
*
|
||||||
* and horizontal drags pass through to the underlying content
|
* Implementation note: the body Column uses verticalScroll, which
|
||||||
* (slider scrub, etc.).
|
* 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
|
@Composable
|
||||||
private fun Modifier.nowPlayingDragDismiss(navController: NavHostController): Modifier =
|
private fun rememberDragDismissConnection(
|
||||||
this.pointerInput(Unit) {
|
onDismiss: () -> Unit,
|
||||||
var totalDragY = 0f
|
thresholdPx: Float = DRAG_DISMISS_THRESHOLD_PX,
|
||||||
detectVerticalDragGestures(
|
): NestedScrollConnection = remember(onDismiss, thresholdPx) {
|
||||||
onDragStart = { totalDragY = 0f },
|
object : NestedScrollConnection {
|
||||||
onDragEnd = {
|
private var accumulated = 0f
|
||||||
if (totalDragY > DRAG_DISMISS_THRESHOLD_PX) {
|
|
||||||
navController.popBackStack()
|
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
|
Offset(0f, available.y)
|
||||||
},
|
} else {
|
||||||
onDragCancel = { totalDragY = 0f },
|
if (consumed.y != 0f || available.y < 0f) accumulated = 0f
|
||||||
onVerticalDrag = { _, delta -> totalDragY += delta },
|
Offset.Zero
|
||||||
)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun onPreFling(available: Velocity): Velocity {
|
||||||
|
accumulated = 0f
|
||||||
|
return Velocity.Zero
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -241,13 +273,18 @@ private fun NowPlayingBody(
|
|||||||
onToggleShuffle = viewModel::toggleShuffle,
|
onToggleShuffle = viewModel::toggleShuffle,
|
||||||
onCycleRepeat = viewModel::cycleRepeat,
|
onCycleRepeat = viewModel::cycleRepeat,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
ScrubberRow(
|
val smoothPositionMs by rememberSmoothPositionMs(
|
||||||
positionMs = state.positionMs,
|
positionMs = state.positionMs,
|
||||||
durationMs = state.durationMs,
|
durationMs = state.durationMs,
|
||||||
|
isPlaying = state.isPlaying,
|
||||||
|
)
|
||||||
|
ScrubberRow(
|
||||||
|
positionMs = smoothPositionMs,
|
||||||
|
durationMs = state.durationMs,
|
||||||
onSeek = viewModel::seekTo,
|
onSeek = viewModel::seekTo,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
TransportRow(
|
TransportRow(
|
||||||
isPlaying = state.isPlaying,
|
isPlaying = state.isPlaying,
|
||||||
onPrev = viewModel::skipToPrevious,
|
onPrev = viewModel::skipToPrevious,
|
||||||
@@ -396,6 +433,7 @@ private fun TrackHeader(title: String, artist: String, album: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun ScrubberRow(positionMs: Long, durationMs: Long, onSeek: (Long) -> Unit) {
|
private fun ScrubberRow(positionMs: Long, durationMs: Long, onSeek: (Long) -> Unit) {
|
||||||
val accent = MaterialTheme.colorScheme.primary
|
val accent = MaterialTheme.colorScheme.primary
|
||||||
@@ -404,19 +442,34 @@ private fun ScrubberRow(positionMs: Long, durationMs: Long, onSeek: (Long) -> Un
|
|||||||
} else {
|
} else {
|
||||||
0f
|
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(
|
Slider(
|
||||||
value = fraction,
|
value = fraction,
|
||||||
onValueChange = { newFraction ->
|
onValueChange = { newFraction ->
|
||||||
if (durationMs > 0) onSeek((newFraction * durationMs).toLong())
|
if (durationMs > 0) onSeek((newFraction * durationMs).toLong())
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
colors = SliderDefaults.colors(
|
colors = sliderColors,
|
||||||
thumbColor = accent,
|
interactionSource = interactionSource,
|
||||||
activeTrackColor = accent,
|
// Slim 10dp thumb shrinks the scrubber's visual weight. M3
|
||||||
// M3 inactive default is secondaryContainer (purple baseline);
|
// default is 20dp; halving keeps it tappable (Slider's own
|
||||||
// pin to surfaceVariant (= slate) for Flutter parity.
|
// 48dp hit slop is unchanged) but lets it recede into the UI.
|
||||||
inactiveTrackColor = MaterialTheme.colorScheme.surfaceVariant,
|
// 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(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,12 +28,16 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="card relative">
|
<div class="card group relative">
|
||||||
<a
|
<a
|
||||||
href={`/albums/${album.id}`}
|
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
|
<img
|
||||||
src={album.cover_url}
|
src={album.cover_url}
|
||||||
alt=""
|
alt=""
|
||||||
@@ -54,9 +58,6 @@
|
|||||||
{#if album.artist_name}
|
{#if album.artist_name}
|
||||||
<div class="truncate text-xs text-text-secondary">{album.artist_name}</div>
|
<div class="truncate text-xs text-text-secondary">{album.artist_name}</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if album.year}
|
|
||||||
<div class="text-xs text-text-secondary">{album.year}</div>
|
|
||||||
{/if}
|
|
||||||
</a>
|
</a>
|
||||||
<CardActionCluster
|
<CardActionCluster
|
||||||
likeEntityType="album"
|
likeEntityType="album"
|
||||||
|
|||||||
@@ -53,18 +53,22 @@ const album: AlbumRef = {
|
|||||||
afterEach(() => vi.clearAllMocks());
|
afterEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
describe('AlbumCard', () => {
|
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 { container } = render(AlbumCard, { props: { album } });
|
||||||
const link = screen.getByRole('link');
|
const link = screen.getByRole('link');
|
||||||
expect(link).toHaveAttribute('href', '/albums/xyz');
|
expect(link).toHaveAttribute('href', '/albums/xyz');
|
||||||
expect(link).toHaveTextContent('Kind of Blue');
|
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;
|
const img = container.querySelector('img') as HTMLImageElement;
|
||||||
expect(img.src).toContain('/api/albums/xyz/cover');
|
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 } } });
|
render(AlbumCard, { props: { album: { ...album, year: undefined } } });
|
||||||
expect(screen.queryByText(/1959/)).not.toBeInTheDocument();
|
expect(screen.queryByText(/1959/)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -42,10 +42,10 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="card relative">
|
<div class="card group relative">
|
||||||
<a
|
<a
|
||||||
href={`/artists/${artist.id}`}
|
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">
|
<div class="art-wrap relative mx-auto aspect-square w-full overflow-hidden rounded-full bg-surface-hover">
|
||||||
{#if artist.cover_url}
|
{#if artist.cover_url}
|
||||||
|
|||||||
@@ -34,9 +34,17 @@
|
|||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</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_click_events_have_key_events -->
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- 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} />
|
<LikeButton entityType={likeEntityType} entityId={likeEntityId} />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -50,6 +58,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- 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()}
|
{@render menu()}
|
||||||
</div>
|
</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}>
|
<div class="section" aria-label={ariaLabel}>
|
||||||
<header class="section-header">
|
<header class="section-header">
|
||||||
{#if title}
|
{#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}
|
{:else}
|
||||||
<span></span>
|
<span></span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -104,9 +104,14 @@
|
|||||||
|
|
||||||
<svelte:window onclick={() => { menuOpen = false; }} />
|
<svelte:window onclick={() => { menuOpen = false; }} />
|
||||||
|
|
||||||
<div class="card relative">
|
<div class="card group relative">
|
||||||
{#if refreshable}
|
{#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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Playlist actions"
|
aria-label="Playlist actions"
|
||||||
@@ -144,7 +149,11 @@
|
|||||||
href={`/playlists/${playlist.id}`}
|
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"
|
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}
|
{#if playlist.cover_url}
|
||||||
<img
|
<img
|
||||||
src={playlist.cover_url}
|
src={playlist.cover_url}
|
||||||
@@ -156,6 +165,23 @@
|
|||||||
<span class="text-xs">No tracks yet</span>
|
<span class="text-xs">No tracks yet</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`Play ${playlist.name}`}
|
aria-label={`Play ${playlist.name}`}
|
||||||
@@ -167,7 +193,6 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full min-w-0">
|
<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">
|
<div class="truncate text-xs text-text-muted">
|
||||||
{playlist.track_count} {playlist.track_count === 1 ? 'track' : 'tracks'}
|
{playlist.track_count} {playlist.track_count === 1 ? 'track' : 'tracks'}
|
||||||
{#if !isOwn}
|
{#if !isOwn}
|
||||||
|
|||||||
@@ -135,7 +135,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</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?.()}
|
{@render children?.()}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -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})`;
|
||||||
|
}
|
||||||
@@ -3,6 +3,13 @@
|
|||||||
import { createHomeQuery } from '$lib/api/home';
|
import { createHomeQuery } from '$lib/api/home';
|
||||||
import HorizontalScrollRow from '$lib/components/HorizontalScrollRow.svelte';
|
import HorizontalScrollRow from '$lib/components/HorizontalScrollRow.svelte';
|
||||||
import AlbumCard from '$lib/components/AlbumCard.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 ArtistCard from '$lib/components/ArtistCard.svelte';
|
||||||
import CompactTrackCard from '$lib/components/CompactTrackCard.svelte';
|
import CompactTrackCard from '$lib/components/CompactTrackCard.svelte';
|
||||||
import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
|
import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
|
||||||
@@ -75,6 +82,46 @@
|
|||||||
|
|
||||||
const userPlaylists = $derived(userPlaylistsQ.data?.owned ?? []);
|
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';
|
type PlaceholderVariant = 'building' | 'failed' | 'pending' | 'seed-needed';
|
||||||
function placeholderVariant(slot: 'for-you' | 'discover' | 'songs-like'): PlaceholderVariant {
|
function placeholderVariant(slot: 'for-you' | 'discover' | 'songs-like'): PlaceholderVariant {
|
||||||
const s = systemStatusQ.data;
|
const s = systemStatusQ.data;
|
||||||
@@ -134,6 +181,40 @@
|
|||||||
<svelte:head><title>{pageTitle('Home')}</title></svelte:head>
|
<svelte:head><title>{pageTitle('Home')}</title></svelte:head>
|
||||||
|
|
||||||
<div class="space-y-8">
|
<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 -->
|
<!-- Playlists: For-You + Songs-like + user playlists, single horizontal row -->
|
||||||
<section class="space-y-3">
|
<section class="space-y-3">
|
||||||
<HorizontalScrollRow
|
<HorizontalScrollRow
|
||||||
@@ -142,7 +223,10 @@
|
|||||||
ariaLabel="Playlists"
|
ariaLabel="Playlists"
|
||||||
>
|
>
|
||||||
{#snippet item(rowItem: PlaylistRowItem)}
|
{#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'}
|
{#if rowItem.kind === 'real'}
|
||||||
<PlaylistCard playlist={rowItem.playlist} />
|
<PlaylistCard playlist={rowItem.playlist} />
|
||||||
{:else}
|
{:else}
|
||||||
@@ -172,7 +256,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}
|
||||||
@@ -195,7 +279,9 @@
|
|||||||
ariaLabel="Rediscover albums"
|
ariaLabel="Rediscover albums"
|
||||||
>
|
>
|
||||||
{#snippet item(album: import('$lib/api/types').AlbumRef)}
|
{#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}
|
{/snippet}
|
||||||
</HorizontalScrollRow>
|
</HorizontalScrollRow>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
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 { readable } from 'svelte/store';
|
||||||
import { emptyLikesMock } from '../test-utils/mocks/likes';
|
import { emptyLikesMock } from '../test-utils/mocks/likes';
|
||||||
import type { Playlist } from '$lib/api/types';
|
import type { Playlist } from '$lib/api/types';
|
||||||
@@ -103,7 +103,11 @@ describe('home Playlists section', () => {
|
|||||||
: readable({ data: emptyPlaylistsResponse, isPending: false, isError: false })
|
: readable({ data: emptyPlaylistsResponse, isPending: false, isError: false })
|
||||||
);
|
);
|
||||||
render(Page);
|
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');
|
const placeholders = screen.queryAllByTestId('playlist-placeholder-card');
|
||||||
// 1 Discover + 3 Songs-like slots = 4 placeholders alongside the
|
// 1 Discover + 3 Songs-like slots = 4 placeholders alongside the
|
||||||
// real For-You tile.
|
// real For-You tile.
|
||||||
@@ -143,7 +147,11 @@ describe('home Playlists section', () => {
|
|||||||
: readable({ data: emptyPlaylistsResponse, isPending: false, isError: false })
|
: readable({ data: emptyPlaylistsResponse, isPending: false, isError: false })
|
||||||
);
|
);
|
||||||
render(Page);
|
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('Deep cuts')).toBeInTheDocument();
|
||||||
expect(screen.getByText('New for you')).toBeInTheDocument();
|
expect(screen.getByText('New for you')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user