release v2026.05.11.1: Flutter caching, navigation, and player polish #39
@@ -1,3 +1,5 @@
|
|||||||
|
@file:Suppress("TooManyFunctions") // Compose screen + private helper composables
|
||||||
|
|
||||||
package com.fabledsword.minstrel.library.ui
|
package com.fabledsword.minstrel.library.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
@@ -120,7 +122,8 @@ private fun AlbumDetailStateContent(
|
|||||||
) {
|
) {
|
||||||
Crossfade(targetState = state, label = "album-detail") { s ->
|
Crossfade(targetState = state, label = "album-detail") { s ->
|
||||||
when (s) {
|
when (s) {
|
||||||
is AlbumDetailUiState.Loading -> SkeletonTrackList()
|
is AlbumDetailUiState.Loading ->
|
||||||
|
if (s.seed != null) SeededAlbumLoading(s.seed) else SkeletonTrackList()
|
||||||
is AlbumDetailUiState.Error -> EmptyState(
|
is AlbumDetailUiState.Error -> EmptyState(
|
||||||
title = "Couldn't load album",
|
title = "Couldn't load album",
|
||||||
body = s.message,
|
body = s.message,
|
||||||
@@ -370,6 +373,53 @@ private fun SkeletonTrackList() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loading body that renders the album header from the navigation seed
|
||||||
|
* (cover + title + artist + count) so the screen reads as itself
|
||||||
|
* immediately, with skeleton track rows below until the fetch lands.
|
||||||
|
* Action buttons are omitted — they need the real track list.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun SeededAlbumLoading(seed: AlbumRef) {
|
||||||
|
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
|
item {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
AlbumCover(seed)
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = seed.title,
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
if (seed.artistName.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = seed.artistName,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = trackCountLine(seed),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item { HorizontalDivider() }
|
||||||
|
items(SKELETON_TRACK_ROWS) { SkeletonTrackRow() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private const val SECONDS_PER_MINUTE = 60
|
private const val SECONDS_PER_MINUTE = 60
|
||||||
|
|
||||||
private fun formatDuration(seconds: Int): String {
|
private fun formatDuration(seconds: Int): String {
|
||||||
|
|||||||
+50
-1
@@ -89,7 +89,12 @@ fun ArtistDetailScreen(
|
|||||||
) {
|
) {
|
||||||
Crossfade(targetState = state, label = "artist-detail") { s ->
|
Crossfade(targetState = state, label = "artist-detail") { s ->
|
||||||
when (s) {
|
when (s) {
|
||||||
is ArtistDetailUiState.Loading -> SkeletonArtistAlbumsGrid()
|
is ArtistDetailUiState.Loading ->
|
||||||
|
if (s.seed != null) {
|
||||||
|
SeededArtistLoading(s.seed)
|
||||||
|
} else {
|
||||||
|
SkeletonArtistAlbumsGrid()
|
||||||
|
}
|
||||||
is ArtistDetailUiState.Error -> EmptyState(
|
is ArtistDetailUiState.Error -> EmptyState(
|
||||||
title = "Couldn't load artist",
|
title = "Couldn't load artist",
|
||||||
body = s.message,
|
body = s.message,
|
||||||
@@ -258,3 +263,47 @@ private fun SkeletonArtistAlbumsGrid() {
|
|||||||
items(SKELETON_ARTIST_GRID_TILES) { SkeletonAlbumTile() }
|
items(SKELETON_ARTIST_GRID_TILES) { SkeletonAlbumTile() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loading body that renders the artist header from the navigation
|
||||||
|
* seed (avatar + name + album count) with a skeleton album grid
|
||||||
|
* below until the fetch lands. Play / Like omitted — they need the
|
||||||
|
* loaded detail.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun SeededArtistLoading(seed: ArtistRef) {
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Adaptive(minSize = 176.dp),
|
||||||
|
contentPadding = PaddingValues(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
ArtistAvatar(seed)
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = seed.name,
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
if (seed.albumCount > 0) {
|
||||||
|
Text(
|
||||||
|
text = albumCountLine(seed),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items(SKELETON_ARTIST_GRID_TILES) { SkeletonAlbumTile() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+49
-1
@@ -299,7 +299,8 @@ private fun PlaylistDetailContent(
|
|||||||
navController: NavHostController,
|
navController: NavHostController,
|
||||||
) {
|
) {
|
||||||
Crossfade(targetState = state, label = "playlist-detail") { s -> when (s) {
|
Crossfade(targetState = state, label = "playlist-detail") { s -> when (s) {
|
||||||
is PlaylistDetailUiState.Loading -> SkeletonPlaylistTrackList()
|
is PlaylistDetailUiState.Loading ->
|
||||||
|
s.seed?.let { SeededPlaylistLoading(it) } ?: SkeletonPlaylistTrackList()
|
||||||
is PlaylistDetailUiState.Error -> ErrorBlock(s.message, viewModel::refresh)
|
is PlaylistDetailUiState.Error -> ErrorBlock(s.message, viewModel::refresh)
|
||||||
is PlaylistDetailUiState.Success -> {
|
is PlaylistDetailUiState.Success -> {
|
||||||
val likedTrackIds by viewModel.likedTrackIds.collectAsState()
|
val likedTrackIds by viewModel.likedTrackIds.collectAsState()
|
||||||
@@ -553,6 +554,53 @@ private fun SkeletonPlaylistTrackList() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loading body that renders the playlist header from the navigation
|
||||||
|
* seed (cover + name + description + count) with skeleton track rows
|
||||||
|
* below. Play / Shuffle / Regenerate omitted — they need the loaded
|
||||||
|
* track list.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun SeededPlaylistLoading(seed: PlaylistRef) {
|
||||||
|
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
|
item {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
PlaylistCover(seed)
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = seed.name,
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
if (seed.description.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = seed.description,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 3,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = "${seed.trackCount} tracks",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item { HorizontalDivider() }
|
||||||
|
items(SKELETON_PLAYLIST_ROWS) { SkeletonTrackRow() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ErrorBlock(message: String, onRetry: () -> Unit) {
|
private fun ErrorBlock(message: String, onRetry: () -> Unit) {
|
||||||
var retried by remember { mutableStateOf(false) }
|
var retried by remember { mutableStateOf(false) }
|
||||||
|
|||||||
Reference in New Issue
Block a user