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:
+37
-26
@@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+30
-18
@@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
+16
-12
@@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user