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 a15b6929..7e80094d 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 @@ -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() } } } 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 b1c78e3e..bd8a23a1 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 @@ -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() } } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryScreen.kt index bba5b2a8..18f12538 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryScreen.kt @@ -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() } } } 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 ff052941..f7b15674 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 @@ -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() } } }