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