fix(web): /register reachable for bootstrap admin (closes #376) #32

Merged
bvandeusen merged 262 commits from dev into main 2026-05-08 18:01:39 -04:00
3 changed files with 150 additions and 3 deletions
Showing only changes of commit 38102df929 - Show all commits
@@ -1,3 +1,5 @@
@file:Suppress("TooManyFunctions") // Compose screen + private helper composables
package com.fabledsword.minstrel.library.ui
import androidx.compose.foundation.background
@@ -120,7 +122,8 @@ private fun AlbumDetailStateContent(
) {
Crossfade(targetState = state, label = "album-detail") { s ->
when (s) {
is AlbumDetailUiState.Loading -> SkeletonTrackList()
is AlbumDetailUiState.Loading ->
if (s.seed != null) SeededAlbumLoading(s.seed) else SkeletonTrackList()
is AlbumDetailUiState.Error -> EmptyState(
title = "Couldn't load album",
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 fun formatDuration(seconds: Int): String {
@@ -89,7 +89,12 @@ fun ArtistDetailScreen(
) {
Crossfade(targetState = state, label = "artist-detail") { s ->
when (s) {
is ArtistDetailUiState.Loading -> SkeletonArtistAlbumsGrid()
is ArtistDetailUiState.Loading ->
if (s.seed != null) {
SeededArtistLoading(s.seed)
} else {
SkeletonArtistAlbumsGrid()
}
is ArtistDetailUiState.Error -> EmptyState(
title = "Couldn't load artist",
body = s.message,
@@ -258,3 +263,47 @@ private fun SkeletonArtistAlbumsGrid() {
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() }
}
}
@@ -299,7 +299,8 @@ private fun PlaylistDetailContent(
navController: NavHostController,
) {
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.Success -> {
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
private fun ErrorBlock(message: String, onRetry: () -> Unit) {
var retried by remember { mutableStateOf(false) }