feat: M3 weighted shuffle v1 — real /api/radio with scoring #21
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user