feat(android): skeleton bodies on Library + detail screens (audit v2 #16, slice 2)

Wraps each cold-load branch in a Crossfade and replaces the
centered spinner with skeleton tile grids/lists that match the
real layout's geometry so the reveal doesn't reflow:

* LibraryScreen Artists tab → SkeletonArtistsGrid (12 SkeletonArtistTile
  in adaptive 144dp grid).
* LibraryScreen Albums tab → SkeletonAlbumsGrid (12 SkeletonAlbumTile
  in adaptive 176dp grid).
* AlbumDetail → SkeletonTrackList (8 SkeletonTrackRow).
* ArtistDetail → SkeletonArtistAlbumsGrid (9 SkeletonAlbumTile,
  176dp adaptive grid mirroring ArtistBody's albums grid).
* PlaylistDetail → SkeletonPlaylistTrackList (8 SkeletonTrackRow).

Drops now-unused LoadingCentered helpers from Library / AlbumDetail /
ArtistDetail / PlaylistDetail per the no-dead-code rule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 14:31:29 -04:00
parent 43ee8f9a39
commit 89203fc4a1
4 changed files with 140 additions and 83 deletions
@@ -13,13 +13,13 @@ 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.animation.Crossfade
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
@@ -57,6 +57,7 @@ import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.LikeButton
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import com.fabledsword.minstrel.shared.widgets.SkeletonTrackRow
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsViewModel
import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
@@ -94,29 +95,37 @@ fun AlbumDetailScreen(
onRefresh = { viewModel.refresh().join() },
modifier = Modifier.fillMaxSize().padding(inner),
) {
when (val s = state) {
is AlbumDetailUiState.Loading -> LoadingCentered()
is AlbumDetailUiState.Error -> EmptyState(
title = "Couldn't load album",
body = s.message,
)
is AlbumDetailUiState.Success -> {
val albumLiked by viewModel.albumLiked.collectAsStateWithLifecycle()
val likedTrackIds by viewModel.likedTrackIds.collectAsStateWithLifecycle()
val playerState by playerViewModel.uiState.collectAsStateWithLifecycle()
AlbumBody(
detail = s.detail,
albumLiked = albumLiked,
likedTrackIds = likedTrackIds,
playingTrackId = playerState.currentTrack?.id,
onPlayAll = { viewModel.play(startTrackId = null) },
onShuffleAll = viewModel::shuffle,
onTrackClick = { id -> viewModel.play(startTrackId = id) },
onToggleAlbumLike = viewModel::toggleLikeAlbum,
onToggleTrackLike = viewModel::toggleLikeTrack,
onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) },
onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) },
Crossfade(targetState = state, label = "album-detail") { s ->
when (s) {
is AlbumDetailUiState.Loading -> SkeletonTrackList()
is AlbumDetailUiState.Error -> EmptyState(
title = "Couldn't load album",
body = s.message,
)
is AlbumDetailUiState.Success -> {
val albumLiked by viewModel.albumLiked.collectAsStateWithLifecycle()
val likedTrackIds by viewModel.likedTrackIds
.collectAsStateWithLifecycle()
val playerState by playerViewModel.uiState
.collectAsStateWithLifecycle()
AlbumBody(
detail = s.detail,
albumLiked = albumLiked,
likedTrackIds = likedTrackIds,
playingTrackId = playerState.currentTrack?.id,
onPlayAll = { viewModel.play(startTrackId = null) },
onShuffleAll = viewModel::shuffle,
onTrackClick = { id -> viewModel.play(startTrackId = id) },
onToggleAlbumLike = viewModel::toggleLikeAlbum,
onToggleTrackLike = viewModel::toggleLikeTrack,
onNavigateToAlbum = { id ->
navController.navigate(AlbumDetail(id))
},
onNavigateToArtist = { id ->
navController.navigate(ArtistDetail(id))
},
)
}
}
}
}
@@ -337,10 +346,12 @@ private fun TrackRow(
}
}
private const val SKELETON_TRACK_ROWS = 8
@Composable
private fun LoadingCentered() {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
private fun SkeletonTrackList() {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(SKELETON_TRACK_ROWS) { SkeletonTrackRow() }
}
}
@@ -1,5 +1,6 @@
package com.fabledsword.minstrel.library.ui
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -19,7 +20,6 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -50,6 +50,7 @@ import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.LikeButton
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -75,21 +76,24 @@ fun ArtistDetailScreen(
onRefresh = { viewModel.refresh().join() },
modifier = Modifier.fillMaxSize().padding(inner),
) {
when (val s = state) {
is ArtistDetailUiState.Loading -> LoadingCentered()
is ArtistDetailUiState.Error -> EmptyState(
title = "Couldn't load artist",
body = s.message,
)
is ArtistDetailUiState.Success -> {
val artistLiked by viewModel.artistLiked.collectAsStateWithLifecycle()
ArtistBody(
detail = s.detail,
artistLiked = artistLiked,
onPlay = viewModel::playArtist,
onToggleLike = viewModel::toggleLikeArtist,
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
Crossfade(targetState = state, label = "artist-detail") { s ->
when (s) {
is ArtistDetailUiState.Loading -> SkeletonArtistAlbumsGrid()
is ArtistDetailUiState.Error -> EmptyState(
title = "Couldn't load artist",
body = s.message,
)
is ArtistDetailUiState.Success -> {
val artistLiked by viewModel.artistLiked
.collectAsStateWithLifecycle()
ArtistBody(
detail = s.detail,
artistLiked = artistLiked,
onPlay = viewModel::playArtist,
onToggleLike = viewModel::toggleLikeArtist,
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
)
}
}
}
}
@@ -229,9 +233,17 @@ private fun EmptyAlbumsHint() {
}
}
private const val SKELETON_ARTIST_GRID_TILES = 9
@Composable
private fun LoadingCentered() {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
private fun SkeletonArtistAlbumsGrid() {
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 176.dp),
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxSize(),
) {
items(SKELETON_ARTIST_GRID_TILES) { SkeletonAlbumTile() }
}
}
@@ -1,5 +1,8 @@
@file:Suppress("TooManyFunctions") // Compose screen + tab helpers + skeleton helpers
package com.fabledsword.minstrel.library.ui
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -9,7 +12,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -27,7 +29,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
@@ -49,6 +50,8 @@ import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
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.trackactions.TrackActionsViewModel
/**
@@ -146,17 +149,20 @@ private fun ArtistsTab(
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
PullToRefreshScaffold(onRefresh = { viewModel.refresh().join() }) {
when (val s = state) {
LibraryUiState.Loading -> LoadingCentered()
LibraryUiState.Empty -> EmptyState(
title = "No artists yet",
body = "Scan a folder in your server settings to populate the library.",
)
is LibraryUiState.Error -> ErrorRetry(message = s.message, onRetry = {})
is LibraryUiState.Success -> ArtistsGrid(
artists = s.artists,
onArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
)
Crossfade(targetState = state, label = "library-artists") { s ->
when (s) {
LibraryUiState.Loading -> SkeletonArtistsGrid()
LibraryUiState.Empty -> EmptyState(
title = "No artists yet",
body = "Scan a folder in your server settings to " +
"populate the library.",
)
is LibraryUiState.Error -> ErrorRetry(message = s.message, onRetry = {})
is LibraryUiState.Success -> ArtistsGrid(
artists = s.artists,
onArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
)
}
}
}
}
@@ -168,17 +174,20 @@ private fun AlbumsTab(
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
PullToRefreshScaffold(onRefresh = { viewModel.refresh().join() }) {
when (val s = state) {
LibraryUiState.Loading -> LoadingCentered()
LibraryUiState.Empty -> EmptyState(
title = "No albums yet",
body = "Scan a folder in your server settings to populate the library.",
)
is LibraryUiState.Error -> ErrorRetry(message = s.message, onRetry = {})
is LibraryUiState.Success -> AlbumsGrid(
albums = s.albums,
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
)
Crossfade(targetState = state, label = "library-albums") { s ->
when (s) {
LibraryUiState.Loading -> SkeletonAlbumsGrid()
LibraryUiState.Empty -> EmptyState(
title = "No albums yet",
body = "Scan a folder in your server settings to " +
"populate the library.",
)
is LibraryUiState.Error -> ErrorRetry(message = s.message, onRetry = {})
is LibraryUiState.Success -> AlbumsGrid(
albums = s.albums,
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
)
}
}
}
}
@@ -220,9 +229,30 @@ private fun AlbumsGrid(
}
}
private const val SKELETON_GRID_TILES = 12
@Composable
private fun LoadingCentered() {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
private fun SkeletonArtistsGrid() {
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 144.dp),
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxSize(),
) {
items(SKELETON_GRID_TILES) { SkeletonArtistTile() }
}
}
@Composable
private fun SkeletonAlbumsGrid() {
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 176.dp),
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxSize(),
) {
items(SKELETON_GRID_TILES) { SkeletonAlbumTile() }
}
}
@@ -2,6 +2,7 @@
package com.fabledsword.minstrel.playlists.ui
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -21,7 +22,6 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
@@ -73,6 +73,7 @@ import com.fabledsword.minstrel.playlists.data.PlaylistDetailRef
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
import com.fabledsword.minstrel.shared.widgets.LikeButton
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import com.fabledsword.minstrel.shared.widgets.SkeletonTrackRow
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsViewModel
@@ -296,30 +297,30 @@ private fun PlaylistDetailContent(
playerViewModel: com.fabledsword.minstrel.player.ui.PlayerViewModel,
navController: NavHostController,
) {
when (state) {
is PlaylistDetailUiState.Loading -> LoadingCentered()
is PlaylistDetailUiState.Error -> ErrorBlock(state.message, viewModel::refresh)
Crossfade(targetState = state, label = "playlist-detail") { s -> when (s) {
is PlaylistDetailUiState.Loading -> SkeletonPlaylistTrackList()
is PlaylistDetailUiState.Error -> ErrorBlock(s.message, viewModel::refresh)
is PlaylistDetailUiState.Success -> {
val likedTrackIds by viewModel.likedTrackIds.collectAsState()
val playerState by playerViewModel.uiState.collectAsState()
PlaylistDetailBody(
detail = state.detail,
detail = s.detail,
likedTrackIds = likedTrackIds,
playingTrackId = playerState.currentTrack?.id,
onPlayAll = { viewModel.play(state.detail.tracks, startTrackId = null) },
onPlayAll = { viewModel.play(s.detail.tracks, startTrackId = null) },
onShuffleAll = {
viewModel.play(state.detail.tracks.shuffled(), startTrackId = null)
viewModel.play(s.detail.tracks.shuffled(), startTrackId = null)
},
onRegenerate = viewModel::regenerate,
onTrackClick = { id ->
viewModel.play(state.detail.tracks, startTrackId = id)
viewModel.play(s.detail.tracks, startTrackId = id)
},
onToggleTrackLike = viewModel::toggleLikeTrack,
onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) },
onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) },
)
}
}
} }
}
private fun currentTitle(state: PlaylistDetailUiState): String = when (state) {
@@ -543,9 +544,12 @@ private fun TrackRow(
}
@Composable
private fun LoadingCentered() {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
private const val SKELETON_PLAYLIST_ROWS = 8
@Composable
private fun SkeletonPlaylistTrackList() {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(SKELETON_PLAYLIST_ROWS) { SkeletonTrackRow() }
}
}