diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailScreen.kt index 939a3756..f1c43d2b 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailScreen.kt @@ -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 { diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailScreen.kt index 7de9db13..7f3fa839 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailScreen.kt @@ -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() } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt index bb4c0d27..148b0539 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt @@ -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) }