feat: M3 weighted shuffle v1 — real /api/radio with scoring #21

Merged
bvandeusen merged 262 commits from dev into main 2026-04-27 11:00:29 -04:00
2 changed files with 210 additions and 21 deletions
Showing only changes of commit 43ee8f9a39 - Show all commits
@@ -1,5 +1,8 @@
@file:Suppress("TooManyFunctions") // Compose screen + section helper composables + skeletons
package com.fabledsword.minstrel.home.ui
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
@@ -12,9 +15,9 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -54,6 +57,9 @@ import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile
import com.fabledsword.minstrel.shared.widgets.SkeletonArtistTile
import com.fabledsword.minstrel.shared.widgets.SkeletonSectionHeader
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.SharingStarted
@@ -180,32 +186,72 @@ fun HomeScreen(
onRefresh = { viewModel.refresh().join() },
modifier = Modifier.fillMaxSize().padding(inner),
) {
when (val s = state) {
HomeUiState.Loading -> LoadingCentered()
HomeUiState.Empty -> EmptyState(
title = "Welcome to Minstrel",
body = "Nothing to show yet — scan a folder in your server settings, " +
"then come back here for system playlists and recommendations.",
)
is HomeUiState.Error -> EmptyState(
title = "Couldn't load home",
body = s.message,
)
is HomeUiState.Success -> HomeSuccessContent(
sections = s.sections,
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
onArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) },
)
Crossfade(targetState = state, label = "home-state") { s ->
when (s) {
HomeUiState.Loading -> HomeSkeletonContent()
HomeUiState.Empty -> EmptyState(
title = "Welcome to Minstrel",
body = "Nothing to show yet — scan a folder in your server " +
"settings, then come back here for system playlists " +
"and recommendations.",
)
is HomeUiState.Error -> EmptyState(
title = "Couldn't load home",
body = s.message,
)
is HomeUiState.Success -> HomeSuccessContent(
sections = s.sections,
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
onArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) },
)
}
}
}
}
}
/**
* Skeleton stand-in shown during cold-load. Mirrors the success
* layout's section structure (Playlists carousel → Recently added
* carousel → Rediscover albums carousel → Last played artists
* carousel) so the Crossfade reveal doesn't reflow.
*/
@Composable
private fun LoadingCentered() {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
private fun HomeSkeletonContent() {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp),
) {
item { SkeletonSectionHeader() }
item { SkeletonAlbumRow() }
item { SkeletonSectionHeader() }
item { SkeletonAlbumRow() }
item { SkeletonSectionHeader() }
item { SkeletonArtistRow() }
item { Spacer(Modifier.height(BOTTOM_PADDING_FOR_MINIPLAYER_DP.dp)) }
}
}
private const val SKELETON_TILES_PER_ROW = 5
@Composable
private fun SkeletonAlbumRow() {
LazyRow(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 8.dp),
) {
items(SKELETON_TILES_PER_ROW) { SkeletonAlbumTile() }
}
}
@Composable
private fun SkeletonArtistRow() {
LazyRow(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 8.dp),
) {
items(SKELETON_TILES_PER_ROW) { SkeletonArtistTile() }
}
}
@@ -0,0 +1,143 @@
package com.fabledsword.minstrel.shared.widgets
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.dp
import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
/**
* Skeleton placeholders for cold-load states. Mirror Flutter's
* `SkeletonAlbumTile` / `SkeletonArtistTile` / `_CompactTrackSkeleton`
* — same geometry as the real cards so the layout doesn't reflow when
* real data lands and the screen-level Crossfade reveals.
*/
private const val PULSE_MIN_ALPHA = 0.3f
private const val PULSE_MAX_ALPHA = 0.55f
private const val PULSE_DURATION_MS = 800
/**
* Solid-color box with a slow alpha pulse. Building block for the
* tile-shape composites below. Mirrors the surfaceVariant fill the
* real cards use as their cover-loading background.
*/
@Composable
fun SkeletonBox(
modifier: Modifier = Modifier,
shape: Shape = RoundedCornerShape(FabledSwordFlatTokens.radiusSm),
) {
val transition = rememberInfiniteTransition(label = "skeleton-pulse")
val alpha by transition.animateFloat(
initialValue = PULSE_MIN_ALPHA,
targetValue = PULSE_MAX_ALPHA,
animationSpec = infiniteRepeatable(
animation = tween(PULSE_DURATION_MS, easing = LinearEasing),
repeatMode = RepeatMode.Reverse,
),
label = "skeleton-alpha",
)
Box(
modifier = modifier
.clip(shape)
.background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha)),
)
}
/**
* Stand-in for AlbumCard / PlaylistCard (same 176dp×144dp cover +
* 2 caption lines geometry).
*/
@Composable
fun SkeletonAlbumTile(modifier: Modifier = Modifier) {
Column(
modifier = modifier
.width(176.dp)
.padding(horizontal = 8.dp),
) {
SkeletonBox(modifier = Modifier.size(144.dp))
Spacer(Modifier.height(8.dp))
SkeletonBox(modifier = Modifier.fillMaxWidth().height(14.dp))
Spacer(Modifier.height(4.dp))
SkeletonBox(modifier = Modifier.width(96.dp).height(12.dp))
}
}
/**
* Stand-in for ArtistCard (128dp circular cover + 1 name line).
*/
@Composable
fun SkeletonArtistTile(modifier: Modifier = Modifier) {
Column(
modifier = modifier
.width(144.dp)
.padding(horizontal = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
SkeletonBox(
modifier = Modifier.size(128.dp),
shape = CircleShape,
)
Spacer(Modifier.height(8.dp))
SkeletonBox(modifier = Modifier.width(96.dp).height(14.dp))
}
}
/**
* Stand-in for a compact track row (28dp cover thumb + title + subtitle).
*/
@Composable
fun SkeletonTrackRow(modifier: Modifier = Modifier) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
SkeletonBox(modifier = Modifier.size(40.dp))
Column(
modifier = Modifier
.weight(1f, fill = true),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
SkeletonBox(modifier = Modifier.fillMaxWidth().height(14.dp))
SkeletonBox(modifier = Modifier.width(120.dp).height(12.dp))
}
}
}
/** Section-heading skeleton (~40% width title bar). */
@Composable
fun SkeletonSectionHeader(modifier: Modifier = Modifier) {
Box(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
) {
SkeletonBox(modifier = Modifier.width(160.dp).height(20.dp))
}
}