feat: implement CollectionView component and refactor Album, Playlist, SavedTracks screens for improved UI consistency

This commit is contained in:
Kingkor Roy Tirtho 2026-07-20 17:40:30 +06:00
parent b22ab3d43e
commit c10c9bb3fc
6 changed files with 529 additions and 639 deletions

View File

@ -32,7 +32,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -51,7 +50,6 @@ import dev.krtirtho.spotube.core.ui.base.OutlineButton
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer
import dev.krtirtho.spotube.core.ui.misc.shimmerApply
import dev.krtirtho.spotube.resources.iconsax.ArrowLeft3
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
import dev.krtirtho.spotube.resources.iconsax.IconsaxEdit
@ -319,102 +317,6 @@ fun CollectionDetails(
}
}
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun CollapsedCollectionHeader(
title: String,
imageURL: String,
imageResource: DrawableResource? = null,
isPlaying: Boolean,
onPlay: () -> Unit,
modifier: Modifier = Modifier,
onBack: (() -> Unit)? = null,
sharedElementKey: String? = null,
) {
val sharedTransitionScope = LocalSharedTransitionScope.current
val animatedVisibilityScope = LocalAnimatedVisibilityScope.current
Card(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
if (onBack != null) {
IconButton(
onClick = onBack,
modifier = Modifier.size(36.dp),
) {
Icon(
imageVector = Iconsax.ArrowLeft3,
contentDescription = "Back",
modifier = Modifier.size(20.dp),
)
}
}
Box(
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(6.dp))
.sharedElementOrNone(
key = sharedElementKey,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
)
.shimmerApply(),
contentAlignment = Alignment.Center,
) {
if (imageURL.isNotBlank()) {
AsyncImage(
model = imageURL,
contentDescription = title,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
} else if (imageResource != null) {
androidx.compose.foundation.Image(
painter = painterResource(imageResource),
contentDescription = title,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
} else {
Text(
text = title.take(1).ifBlank { "?" }.uppercase(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
TextWithShimmer(
text = title,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
IconButton(
onClick = onPlay,
modifier = Modifier.size(36.dp),
) {
Icon(
imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2,
contentDescription = if (isPlaying) "Pause" else "Play",
modifier = Modifier.size(24.dp),
)
}
}
}
}
@Composable
@androidx.compose.ui.tooling.preview.Preview
fun CollectionDetailsPreview() {

View File

@ -0,0 +1,257 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.core.ui.component
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer
import dev.krtirtho.spotube.core.ui.misc.shimmerApply
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxPauseCircle
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlayCircle2
import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.painterResource
@Composable
fun CollectionView(
title: String,
description: String,
imageURL: String,
imageResource: DrawableResource? = null,
ownerName: String,
ownerImageURL: String?,
onOwnerClick: () -> Unit,
onPlay: () -> Unit,
onShufflePlay: () -> Unit,
onAddToQueue: () -> Unit,
isPlaying: Boolean = false,
isFollowing: Boolean = false,
onFollowClick: () -> Unit = {},
showFollowButton: Boolean = true,
onEdit: (() -> Unit)? = null,
sharedElementKey: String? = null,
isLoading: Boolean = false,
error: String? = null,
onRetry: (() -> Unit)? = null,
tracks: List<MetadataTrack> = emptyList(),
hasMore: Boolean = false,
isLoadingNextPage: Boolean = false,
currentTrackId: String? = null,
isCurrentTrackPlaying: Boolean = false,
onTrackClick: (MetadataTrack) -> Unit = {},
onLoadNextPage: () -> Unit = {},
onTrackOptionsAction: (MetadataTrack, TrackOptionsAction) -> Unit = { _, _ -> },
onArtistClick: (MetadataArtist.Basic) -> Unit = {},
onAlbumClick: (MetadataAlbum.Detailed) -> Unit = {},
onArtistsOverflowClick: (MetadataTrack) -> Unit = {},
onBulkDownload: (List<MetadataTrack>) -> Unit = {},
onBulkAddToQueue: (List<MetadataTrack>) -> Unit = {},
onBulkPlayNext: (List<MetadataTrack>) -> Unit = {},
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit = {},
trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() },
footerContent: (@Composable () -> Unit)? = null,
trailingContent: @Composable () -> Unit = {},
) {
val listState = rememberLazyListState()
val isCollapsed by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0 ||
listState.firstVisibleItemScrollOffset > 400
}
}
Scaffold(
topBar = {
ApplicationMainBar(
backButton = true,
title = if (isCollapsed) {
{
CollapsedCollectionTitle(
title = title,
imageURL = imageURL,
imageResource = imageResource,
)
}
} else {
{}
},
actions = if (isCollapsed) {
{
IconButton(onClick = onPlay) {
Icon(
imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2,
contentDescription = if (isPlaying) "Pause" else "Play",
)
}
}
} else {
{}
},
)
}
) { innerPadding ->
if (error != null && tracks.isEmpty() && !isLoading) {
ErrorDisplay(
errorMessage = error,
onRetry = onRetry ?: {},
modifier = Modifier.padding(innerPadding),
)
} else {
TrackList(
modifier = Modifier.padding(innerPadding),
state = listState,
headerContent = {
if (isLoading && tracks.isEmpty()) {
dev.krtirtho.spotube.core.ui.misc.SkeletonTree(true) {
CollectionDetails(
title = title.ifBlank { "Loading..." },
description = description,
imageURL = imageURL,
imageResource = imageResource,
ownerName = ownerName.ifBlank { "Unknown" },
ownerImageURL = ownerImageURL,
onOwnerClick = onOwnerClick,
onPlay = onPlay,
onShufflePlay = onShufflePlay,
onAddToQueue = onAddToQueue,
isPlaying = isPlaying,
isFollowing = isFollowing,
onFollowClick = onFollowClick,
showFollowButton = showFollowButton,
onEdit = onEdit,
sharedElementKey = sharedElementKey,
)
}
} else {
CollectionDetails(
title = title,
description = description,
imageURL = imageURL,
imageResource = imageResource,
ownerName = ownerName,
ownerImageURL = ownerImageURL,
onOwnerClick = onOwnerClick,
onPlay = onPlay,
onShufflePlay = onShufflePlay,
onAddToQueue = onAddToQueue,
isPlaying = isPlaying,
isFollowing = isFollowing,
onFollowClick = onFollowClick,
showFollowButton = showFollowButton,
onEdit = onEdit,
sharedElementKey = sharedElementKey,
)
}
},
footerContent = footerContent,
tracks = tracks,
error = error,
hasMore = hasMore,
isLoading = isLoading,
isLoadingNextPage = isLoadingNextPage,
currentTrackId = currentTrackId,
isCurrentTrackPlaying = isCurrentTrackPlaying,
onTrackClick = onTrackClick,
onLoadNextPage = onLoadNextPage,
onTrackOptionsAction = onTrackOptionsAction,
onArtistClick = onArtistClick,
onAlbumClick = onAlbumClick,
onArtistsOverflowClick = onArtistsOverflowClick,
onBulkDownload = onBulkDownload,
onBulkAddToQueue = onBulkAddToQueue,
onBulkPlayNext = onBulkPlayNext,
onBulkAddToPlaylist = onBulkAddToPlaylist,
trackOptionsState = trackOptionsState,
)
}
trailingContent()
}
}
@Composable
private fun CollapsedCollectionTitle(
title: String,
imageURL: String,
imageResource: DrawableResource? = null,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(6.dp))
.shimmerApply(),
contentAlignment = Alignment.Center,
) {
if (imageURL.isNotBlank()) {
AsyncImage(
model = imageURL,
contentDescription = title,
modifier = Modifier.size(36.dp),
contentScale = ContentScale.Crop,
)
} else if (imageResource != null) {
androidx.compose.foundation.Image(
painter = painterResource(imageResource),
contentDescription = title,
modifier = Modifier.size(36.dp),
contentScale = ContentScale.Crop,
)
} else {
Text(
text = title.take(1).ifBlank { "?" }.uppercase(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
TextWithShimmer(
text = title,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = 10.dp),
)
}
}

View File

@ -18,11 +18,8 @@
package dev.krtirtho.spotube.core.ui.component
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
@ -154,8 +151,6 @@ fun TrackList(
simplified: Boolean = false,
scrollable: Boolean = true,
state: LazyListState? = null,
collapsedHeader: (@Composable () -> Unit)? = null,
isCollapsedHeaderShown: Boolean = false,
) {
var filterQuery by rememberSaveable { mutableStateOf("") }
var sortBy by rememberSaveable { mutableStateOf(TrackSortOption.None) }
@ -491,17 +486,6 @@ fun TrackList(
}
}
if (collapsedHeader != null) {
AnimatedVisibility(
visible = isCollapsedHeaderShown,
enter = fadeIn() + slideInVertically { -it },
exit = fadeOut() + slideOutVertically { -it },
modifier = Modifier.align(Alignment.TopCenter).padding(horizontal = if (isCompact) 6.dp else 16.dp),
) {
collapsedHeader()
}
}
if (!useDropdownForOptions) {
selectedTrackForOptions?.let { track ->
TrackOptionsBottomSheet(

View File

@ -17,18 +17,13 @@
package dev.krtirtho.spotube.modules.album
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
@ -38,14 +33,9 @@ import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.share.ShareService
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
import dev.krtirtho.spotube.core.ui.component.CollapsedCollectionHeader
import dev.krtirtho.spotube.core.ui.component.CollectionDetails
import dev.krtirtho.spotube.core.ui.component.ErrorDisplay
import dev.krtirtho.spotube.core.ui.component.TrackList
import dev.krtirtho.spotube.core.ui.component.CollectionView
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
@ -78,14 +68,6 @@ fun AlbumScreen(albumId: String) {
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
var currentUserId by remember { mutableStateOf<String?>(null) }
val listState = rememberLazyListState()
val isCollapsedHeaderShown by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0 ||
listState.firstVisibleItemScrollOffset > 400
}
}
LaunchedEffect(Unit) {
currentUserId = libraryRepository.currentUser()?.id
}
@ -120,58 +102,8 @@ fun AlbumScreen(albumId: String) {
}
}
Scaffold(
topBar = { ApplicationMainBar(backButton = !isCollapsedHeaderShown) }
) { innerPadding ->
when (state) {
is AlbumScreenState.Loading -> {
TrackList(
modifier = Modifier.padding(innerPadding),
headerContent = {
SkeletonTree(true) {
CollectionDetails(
title = "Loading album...",
description = "",
imageURL = "",
ownerName = "Unknown artist",
ownerImageURL = null,
onOwnerClick = {},
onPlay = {},
onShufflePlay = {},
onAddToQueue = {},
isPlaying = false,
isFollowing = false,
onFollowClick = {},
)
}
},
tracks = emptyList(),
error = null,
hasMore = false,
isLoading = true,
isLoadingNextPage = false,
currentTrackId = null,
isCurrentTrackPlaying = false,
onTrackClick = {},
onLoadNextPage = {},
onArtistClick = { navigationCommands.navigateTo(Routes.Artist(it.id)) },
onAlbumClick = { navigationCommands.navigateTo(Routes.Album(it.id)) },
onTrackOptionsAction = { _, _ -> },
trackOptionsState = { TrackOptionsState() },
)
}
is AlbumScreenState.Error -> {
ErrorDisplay(
errorMessage = (state as AlbumScreenState.Error).message,
onRetry = { viewModel.refresh() },
modifier = Modifier.padding(innerPadding),
)
}
is AlbumScreenState.Data -> {
val dataState = state as AlbumScreenState.Data
val album = dataState.album
val dataState = state as? AlbumScreenState.Data
val album = dataState?.album
val ownerName =
album?.artists?.joinToString { it.name }.orEmpty().ifBlank { "Unknown artist" }
val ownerImageURL = album?.artists?.firstOrNull()?.thumbnails?.firstOrNull()?.url
@ -180,21 +112,10 @@ fun AlbumScreen(albumId: String) {
val isAlbumPlaying = currentCollectionEntry?.id == albumId &&
playerState == PlayerState.PLAYING
TrackList(
modifier = Modifier.padding(innerPadding),
state = listState,
collapsedHeader = {
CollapsedCollectionHeader(
title = album?.title ?: "Album",
imageURL = artworkUrl,
isPlaying = isAlbumPlaying,
onPlay = viewModel::playAlbum,
onBack = { navigationCommands.pop() },
)
},
isCollapsedHeaderShown = isCollapsedHeaderShown,
headerContent = {
CollectionDetails(
val errorMessage = (state as? AlbumScreenState.Error)?.message
val isLoading = state is AlbumScreenState.Loading && (dataState == null || dataState.tracks.isEmpty())
CollectionView(
title = album?.title ?: "Loading album...",
description = album?.description ?: "${album?.albumType?.name.orEmpty()}${album?.releaseDate.orEmpty()}",
imageURL = artworkUrl,
@ -202,9 +123,7 @@ fun AlbumScreen(albumId: String) {
ownerImageURL = ownerImageURL,
onOwnerClick = {
firstArtistId?.let {
navigationCommands.navigateTo(
Routes.Artist(it)
)
navigationCommands.navigateTo(Routes.Artist(it))
}
},
onPlay = viewModel::playAlbum,
@ -213,12 +132,11 @@ fun AlbumScreen(albumId: String) {
isPlaying = isAlbumPlaying,
isFollowing = savedAlbumIds.contains(albumId),
onFollowClick = viewModel::toggleSavedAlbum,
)
},
tracks = dataState.tracks,
error = null,
hasMore = dataState.nextPagination != null,
isLoading = state is AlbumScreenState.Loading && dataState.tracks.isEmpty(),
isLoading = isLoading,
error = errorMessage,
onRetry = { viewModel.refresh() },
tracks = dataState?.tracks ?: emptyList(),
hasMore = dataState?.nextPagination != null,
isLoadingNextPage = state is AlbumScreenState.Data.LoadingMore,
currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id,
isCurrentTrackPlaying = playerState == PlayerState.PLAYING,
@ -241,10 +159,7 @@ fun AlbumScreen(albumId: String) {
tracksToAddToPlaylist = tracks
showAddToPlaylistPicker = true
},
)
}
}
trailingContent = {
AddToPlaylistPicker(
visible = showAddToPlaylistPicker,
currentUserId = currentUserId,
@ -258,5 +173,6 @@ fun AlbumScreen(albumId: String) {
}
},
)
}
},
)
}

View File

@ -21,12 +21,9 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -44,20 +41,14 @@ import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.share.ShareService
import dev.krtirtho.spotube.core.ui.base.OutlineButton
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
import dev.krtirtho.spotube.core.ui.component.CollapsedCollectionHeader
import dev.krtirtho.spotube.core.ui.component.CollectionDetails
import dev.krtirtho.spotube.core.ui.component.ErrorDisplay
import dev.krtirtho.spotube.core.ui.component.TrackList
import dev.krtirtho.spotube.core.ui.component.CollectionView
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormData
import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormSheet
import dev.krtirtho.spotube.modules.playlist.AddTracksToPlaylistDialog
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
import kotlinx.coroutines.launch
@ -90,14 +81,6 @@ fun PlaylistScreen(playlistId: String) {
var showAddTracksDialog by remember { mutableStateOf(false) }
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
val listState = rememberLazyListState()
val isCollapsedHeaderShown by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0 ||
listState.firstVisibleItemScrollOffset > 400
}
}
fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) {
viewModel.handleTrackOptionsAction(track, action)
if (action is TrackOptionsAction.Share) {
@ -115,70 +98,18 @@ fun PlaylistScreen(playlistId: String) {
}
}
Scaffold(
topBar = { ApplicationMainBar(backButton = !isCollapsedHeaderShown) }
) { innerPadding ->
when (state) {
is PlaylistScreenState.Loading -> {
TrackList(
modifier = Modifier.padding(innerPadding),
headerContent = {
SkeletonTree(true){
CollectionDetails(
title = "Loading playlist...",
description = "",
imageURL = "",
ownerName = "Unknown",
ownerImageURL = null,
onOwnerClick = {},
onPlay = {},
onShufflePlay = {},
onAddToQueue = {},
isPlaying = false,
isFollowing = false,
onFollowClick = {},
)
}
},
tracks = emptyList(),
error = null,
hasMore = false,
isLoading = true,
isLoadingNextPage = false,
currentTrackId = null,
isCurrentTrackPlaying = false,
onTrackClick = {},
onLoadNextPage = {},
onArtistClick = { navigationCommands.navigateTo(Routes.Artist(it.id)) },
onAlbumClick = { navigationCommands.navigateTo(Routes.Album(it.id)) },
onTrackOptionsAction = { _, _ -> },
trackOptionsState = { TrackOptionsState() },
)
}
is PlaylistScreenState.Error -> {
ErrorDisplay(
errorMessage = (state as PlaylistScreenState.Error).message,
onRetry = { viewModel.refresh() },
modifier = Modifier.padding(innerPadding),
)
}
is PlaylistScreenState.Data -> {
val dataState = state as PlaylistScreenState.Data
val playlist = dataState.playlist
val dataState = state as? PlaylistScreenState.Data
val playlist = dataState?.playlist
val owner = playlist?.owner
val ownerName = owner?.displayName ?: owner?.username ?: "Unknown"
val ownerImageURL = owner?.thumbnails?.firstOrNull()?.url
val isOwner = currentUserId != null && playlist?.owner?.id == currentUserId
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
val artworkUrl = playlist?.thumbnails?.firstOrNull()?.url.orEmpty()
val isPlaying = currentCollectionEntry?.id == playlistId &&
playerState == PlayerState.PLAYING
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
fun getTrackOptionsState(track: MetadataTrack): TrackOptionsState {
val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id
val queueTrackIds = queue.mapNotNull { entry ->
@ -192,38 +123,10 @@ fun PlaylistScreen(playlistId: String) {
)
}
TrackList(
modifier = Modifier.padding(innerPadding),
state = listState,
collapsedHeader = {
CollapsedCollectionHeader(
title = playlist?.title ?: "Playlist",
imageURL = artworkUrl,
isPlaying = isPlaying,
onPlay = viewModel::playPlaylist,
onBack = { navigationCommands.pop() },
)
},
isCollapsedHeaderShown = isCollapsedHeaderShown,
headerContent = {
CollectionDetails(
title = playlist?.title ?: "Loading playlist...",
description = playlist?.description.orEmpty(),
imageURL = artworkUrl,
ownerName = ownerName,
ownerImageURL = ownerImageURL,
onOwnerClick = {},
onPlay = viewModel::playPlaylist,
onShufflePlay = {},
onAddToQueue = viewModel::addPlaylistToQueue,
isPlaying = isPlaying,
isFollowing = savedPlaylistIds.contains(playlistId),
onFollowClick = viewModel::toggleSavedPlaylist,
showFollowButton = playlistId != "saved_tracks",
onEdit = if (isOwner) { { showEditPlaylist = true } } else null,
)
},
footerContent = if (isOwner) {
val errorMessage = (state as? PlaylistScreenState.Error)?.message
val isLoading = state is PlaylistScreenState.Loading && (dataState == null || dataState.tracks.isEmpty())
val footerContent: (@Composable () -> Unit)? = if (isOwner && dataState != null) {
{
Row(
modifier = Modifier
@ -240,11 +143,28 @@ fun PlaylistScreen(playlistId: String) {
}
}
}
} else null,
tracks = dataState.tracks,
error = null,
hasMore = dataState.nextPagination != null,
isLoading = state is PlaylistScreenState.Loading && dataState.tracks.isEmpty(),
} else null
CollectionView(
title = playlist?.title ?: "Loading playlist...",
description = playlist?.description.orEmpty(),
imageURL = artworkUrl,
ownerName = ownerName,
ownerImageURL = ownerImageURL,
onOwnerClick = {},
onPlay = viewModel::playPlaylist,
onShufflePlay = {},
onAddToQueue = viewModel::addPlaylistToQueue,
isPlaying = isPlaying,
isFollowing = savedPlaylistIds.contains(playlistId),
onFollowClick = viewModel::toggleSavedPlaylist,
showFollowButton = playlistId != "saved_tracks",
onEdit = if (isOwner) { { showEditPlaylist = true } } else null,
isLoading = isLoading,
error = errorMessage,
onRetry = { viewModel.refresh() },
tracks = dataState?.tracks ?: emptyList(),
hasMore = dataState?.nextPagination != null,
isLoadingNextPage = state is PlaylistScreenState.Data.LoadingMore,
currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id,
isCurrentTrackPlaying = playerState == PlayerState.PLAYING,
@ -267,17 +187,14 @@ fun PlaylistScreen(playlistId: String) {
tracksToAddToPlaylist = tracks
showAddToPlaylistPicker = true
},
)
}
}
val dataState = state as? PlaylistScreenState.Data
val playlist = (dataState as? PlaylistScreenState.Data.Loaded)?.playlist
footerContent = footerContent,
trailingContent = {
val loadedPlaylist = (dataState as? PlaylistScreenState.Data.Loaded)?.playlist
PlaylistFormSheet(
visible = showEditPlaylist,
onDismiss = { showEditPlaylist = false },
isEditing = true,
initialData = playlist?.let {
initialData = loadedPlaylist?.let {
PlaylistFormData(
name = it.title,
description = it.description.orEmpty(),
@ -319,5 +236,6 @@ fun PlaylistScreen(playlistId: String) {
viewModel.refresh()
},
)
}
},
)
}

View File

@ -17,18 +17,13 @@
package dev.krtirtho.spotube.modules.saved_tracks
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
@ -39,19 +34,13 @@ import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.share.ShareService
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
import dev.krtirtho.spotube.core.ui.component.CollapsedCollectionHeader
import dev.krtirtho.spotube.core.ui.component.CollectionDetails
import dev.krtirtho.spotube.core.ui.component.ErrorDisplay
import dev.krtirtho.spotube.core.ui.component.TrackList
import dev.krtirtho.spotube.core.ui.component.CollectionView
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.DrawableResource
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
@ -80,14 +69,6 @@ fun SavedTracksScreen() {
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
var currentUserId by remember { mutableStateOf<String?>(null) }
val listState = rememberLazyListState()
val isCollapsedHeaderShown by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0 ||
listState.firstVisibleItemScrollOffset > 400
}
}
LaunchedEffect(Unit) {
currentUserId = libraryRepository.currentUser()?.id
}
@ -122,81 +103,16 @@ fun SavedTracksScreen() {
}
}
Scaffold(
topBar = { ApplicationMainBar(backButton = !isCollapsedHeaderShown) }
) { innerPadding ->
when (state) {
is SavedTracksScreenState.Loading -> {
TrackList(
modifier = Modifier.padding(innerPadding),
headerContent = {
SkeletonTree(true) {
CollectionDetails(
title = "Loading saved tracks...",
description = "",
imageURL = "",
imageResource = Res.drawable.liked_tracks,
ownerName = "You",
ownerImageURL = null,
onOwnerClick = {},
onPlay = {},
onShufflePlay = {},
onAddToQueue = {},
isPlaying = false,
isFollowing = false,
onFollowClick = {},
showFollowButton = false,
)
}
},
tracks = emptyList(),
error = null,
hasMore = false,
isLoading = true,
isLoadingNextPage = false,
currentTrackId = null,
isCurrentTrackPlaying = false,
onTrackClick = {},
onLoadNextPage = {},
onArtistClick = { navigationCommands.navigateTo(Routes.Artist(it.id)) },
onAlbumClick = { navigationCommands.navigateTo(Routes.Album(it.id)) },
onTrackOptionsAction = { _, _ -> },
trackOptionsState = { TrackOptionsState() },
)
}
is SavedTracksScreenState.Error -> {
ErrorDisplay(
errorMessage = (state as SavedTracksScreenState.Error).message,
onRetry = { viewModel.refresh() },
modifier = Modifier.padding(innerPadding),
)
}
is SavedTracksScreenState.Data -> {
val dataState = state as SavedTracksScreenState.Data
val dataState = state as? SavedTracksScreenState.Data
val isPlaying = currentCollectionEntry is QueueCollectionEntry.SavedTracks &&
playerState == PlayerState.PLAYING
TrackList(
modifier = Modifier.padding(innerPadding),
state = listState,
collapsedHeader = {
CollapsedCollectionHeader(
val errorMessage = (state as? SavedTracksScreenState.Error)?.message
val isLoading = state is SavedTracksScreenState.Loading && (dataState == null || dataState.tracks.isEmpty())
CollectionView(
title = "Saved Tracks",
imageURL = "",
imageResource = Res.drawable.liked_tracks,
isPlaying = isPlaying,
onPlay = viewModel::playSavedTracks,
onBack = { navigationCommands.pop() },
)
},
isCollapsedHeaderShown = isCollapsedHeaderShown,
headerContent = {
CollectionDetails(
title = "Saved Tracks",
description = "${dataState.totalCount} tracks",
description = dataState?.let { "${it.totalCount} tracks" } ?: "",
imageURL = "",
imageResource = Res.drawable.liked_tracks,
ownerName = "You",
@ -209,12 +125,11 @@ fun SavedTracksScreen() {
isFollowing = false,
onFollowClick = {},
showFollowButton = false,
)
},
tracks = dataState.tracks,
error = null,
hasMore = dataState.nextPagination != null,
isLoading = state is SavedTracksScreenState.Loading && dataState.tracks.isEmpty(),
isLoading = isLoading,
error = errorMessage,
onRetry = { viewModel.refresh() },
tracks = dataState?.tracks ?: emptyList(),
hasMore = dataState?.nextPagination != null,
isLoadingNextPage = state is SavedTracksScreenState.Data.LoadingMore,
currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id,
isCurrentTrackPlaying = playerState == PlayerState.PLAYING,
@ -237,10 +152,7 @@ fun SavedTracksScreen() {
tracksToAddToPlaylist = tracks
showAddToPlaylistPicker = true
},
)
}
}
trailingContent = {
AddToPlaylistPicker(
visible = showAddToPlaylistPicker,
currentUserId = currentUserId,
@ -254,5 +166,6 @@ fun SavedTracksScreen() {
}
},
)
}
},
)
}