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.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable 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.base.PrimaryButton
import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer
import dev.krtirtho.spotube.core.ui.misc.shimmerApply 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.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
import dev.krtirtho.spotube.resources.iconsax.IconsaxEdit 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 @Composable
@androidx.compose.ui.tooling.preview.Preview @androidx.compose.ui.tooling.preview.Preview
fun CollectionDetailsPreview() { 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 package dev.krtirtho.spotube.core.ui.component
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
@ -154,8 +151,6 @@ fun TrackList(
simplified: Boolean = false, simplified: Boolean = false,
scrollable: Boolean = true, scrollable: Boolean = true,
state: LazyListState? = null, state: LazyListState? = null,
collapsedHeader: (@Composable () -> Unit)? = null,
isCollapsedHeaderShown: Boolean = false,
) { ) {
var filterQuery by rememberSaveable { mutableStateOf("") } var filterQuery by rememberSaveable { mutableStateOf("") }
var sortBy by rememberSaveable { mutableStateOf(TrackSortOption.None) } 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) { if (!useDropdownForOptions) {
selectedTrackForOptions?.let { track -> selectedTrackForOptions?.let { track ->
TrackOptionsBottomSheet( TrackOptionsBottomSheet(

View File

@ -17,18 +17,13 @@
package dev.krtirtho.spotube.modules.album 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer 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.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.share.ShareService import dev.krtirtho.spotube.core.share.ShareService
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import dev.krtirtho.spotube.core.ui.component.CollectionView
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.TrackOptionsAction import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
import dev.krtirtho.spotube.core.ui.component.TrackOptionsState 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.downloads.DownloadsViewModel
import dev.krtirtho.spotube.modules.library.LibraryRepository import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker 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 tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
var currentUserId by remember { mutableStateOf<String?>(null) } var currentUserId by remember { mutableStateOf<String?>(null) }
val listState = rememberLazyListState()
val isCollapsedHeaderShown by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0 ||
listState.firstVisibleItemScrollOffset > 400
}
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
currentUserId = libraryRepository.currentUser()?.id currentUserId = libraryRepository.currentUser()?.id
} }
@ -120,143 +102,77 @@ fun AlbumScreen(albumId: String) {
} }
} }
Scaffold( val dataState = state as? AlbumScreenState.Data
topBar = { ApplicationMainBar(backButton = !isCollapsedHeaderShown) } val album = dataState?.album
) { innerPadding -> val ownerName =
when (state) { album?.artists?.joinToString { it.name }.orEmpty().ifBlank { "Unknown artist" }
is AlbumScreenState.Loading -> { val ownerImageURL = album?.artists?.firstOrNull()?.thumbnails?.firstOrNull()?.url
TrackList( val firstArtistId = album?.artists?.firstOrNull()?.id
modifier = Modifier.padding(innerPadding), val artworkUrl = album?.thumbnails?.firstOrNull()?.url.orEmpty()
headerContent = { val isAlbumPlaying = currentCollectionEntry?.id == albumId &&
SkeletonTree(true) { playerState == PlayerState.PLAYING
CollectionDetails(
title = "Loading album...", val errorMessage = (state as? AlbumScreenState.Error)?.message
description = "", val isLoading = state is AlbumScreenState.Loading && (dataState == null || dataState.tracks.isEmpty())
imageURL = "",
ownerName = "Unknown artist", CollectionView(
ownerImageURL = null, title = album?.title ?: "Loading album...",
onOwnerClick = {}, description = album?.description ?: "${album?.albumType?.name.orEmpty()}${album?.releaseDate.orEmpty()}",
onPlay = {}, imageURL = artworkUrl,
onShufflePlay = {}, ownerName = ownerName,
onAddToQueue = {}, ownerImageURL = ownerImageURL,
isPlaying = false, onOwnerClick = {
isFollowing = false, firstArtistId?.let {
onFollowClick = {}, navigationCommands.navigateTo(Routes.Artist(it))
)
}
},
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 -> { onPlay = viewModel::playAlbum,
ErrorDisplay( onShufflePlay = {},
errorMessage = (state as AlbumScreenState.Error).message, onAddToQueue = viewModel::addAlbumToQueue,
onRetry = { viewModel.refresh() }, isPlaying = isAlbumPlaying,
modifier = Modifier.padding(innerPadding), isFollowing = savedAlbumIds.contains(albumId),
) onFollowClick = viewModel::toggleSavedAlbum,
} isLoading = isLoading,
error = errorMessage,
is AlbumScreenState.Data -> { onRetry = { viewModel.refresh() },
val dataState = state as AlbumScreenState.Data tracks = dataState?.tracks ?: emptyList(),
val album = dataState.album hasMore = dataState?.nextPagination != null,
val ownerName = isLoadingNextPage = state is AlbumScreenState.Data.LoadingMore,
album?.artists?.joinToString { it.name }.orEmpty().ifBlank { "Unknown artist" } currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id,
val ownerImageURL = album?.artists?.firstOrNull()?.thumbnails?.firstOrNull()?.url isCurrentTrackPlaying = playerState == PlayerState.PLAYING,
val firstArtistId = album?.artists?.firstOrNull()?.id onTrackClick = viewModel::playAlbumFromTrack,
val artworkUrl = album?.thumbnails?.firstOrNull()?.url.orEmpty() onLoadNextPage = viewModel::loadNextTracksPage,
val isAlbumPlaying = currentCollectionEntry?.id == albumId && onArtistClick = { navigationCommands.navigateTo(Routes.Artist(it.id)) },
playerState == PlayerState.PLAYING onAlbumClick = { navigationCommands.navigateTo(Routes.Album(it.id)) },
onTrackOptionsAction = ::handleTrackOptionsAction,
TrackList( trackOptionsState = ::getTrackOptionsState,
modifier = Modifier.padding(innerPadding), onBulkDownload = { tracks ->
state = listState, downloadsViewModel.downloadTracks(tracks)
collapsedHeader = { },
CollapsedCollectionHeader( onBulkAddToQueue = { tracks ->
title = album?.title ?: "Album", viewModel.addTracksToQueue(tracks)
imageURL = artworkUrl, },
isPlaying = isAlbumPlaying, onBulkPlayNext = { tracks ->
onPlay = viewModel::playAlbum, viewModel.playTracksNext(tracks)
onBack = { navigationCommands.pop() }, },
onBulkAddToPlaylist = { tracks ->
tracksToAddToPlaylist = tracks
showAddToPlaylistPicker = true
},
trailingContent = {
AddToPlaylistPicker(
visible = showAddToPlaylistPicker,
currentUserId = currentUserId,
onDismiss = { showAddToPlaylistPicker = false },
onPlaylistSelected = { playlistId ->
scope.launch {
libraryRepository.addTracksToPlaylist(
playlistId,
tracksToAddToPlaylist.map { it.id }
) )
}, }
isCollapsedHeaderShown = isCollapsedHeaderShown, },
headerContent = { )
CollectionDetails( },
title = album?.title ?: "Loading album...", )
description = album?.description ?: "${album?.albumType?.name.orEmpty()}${album?.releaseDate.orEmpty()}",
imageURL = artworkUrl,
ownerName = ownerName,
ownerImageURL = ownerImageURL,
onOwnerClick = {
firstArtistId?.let {
navigationCommands.navigateTo(
Routes.Artist(it)
)
}
},
onPlay = viewModel::playAlbum,
onShufflePlay = {},
onAddToQueue = viewModel::addAlbumToQueue,
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(),
isLoadingNextPage = state is AlbumScreenState.Data.LoadingMore,
currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id,
isCurrentTrackPlaying = playerState == PlayerState.PLAYING,
onTrackClick = viewModel::playAlbumFromTrack,
onLoadNextPage = viewModel::loadNextTracksPage,
onArtistClick = { navigationCommands.navigateTo(Routes.Artist(it.id)) },
onAlbumClick = { navigationCommands.navigateTo(Routes.Album(it.id)) },
onTrackOptionsAction = ::handleTrackOptionsAction,
trackOptionsState = ::getTrackOptionsState,
onBulkDownload = { tracks ->
downloadsViewModel.downloadTracks(tracks)
},
onBulkAddToQueue = { tracks ->
viewModel.addTracksToQueue(tracks)
},
onBulkPlayNext = { tracks ->
viewModel.playTracksNext(tracks)
},
onBulkAddToPlaylist = { tracks ->
tracksToAddToPlaylist = tracks
showAddToPlaylistPicker = true
},
)
}
}
AddToPlaylistPicker(
visible = showAddToPlaylistPicker,
currentUserId = currentUserId,
onDismiss = { showAddToPlaylistPicker = false },
onPlaylistSelected = { playlistId ->
scope.launch {
libraryRepository.addTracksToPlaylist(
playlistId,
tracksToAddToPlaylist.map { it.id }
)
}
},
)
}
} }

View File

@ -21,12 +21,9 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember 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.navigation.Routes
import dev.krtirtho.spotube.core.share.ShareService import dev.krtirtho.spotube.core.share.ShareService
import dev.krtirtho.spotube.core.ui.base.OutlineButton import dev.krtirtho.spotube.core.ui.base.OutlineButton
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import dev.krtirtho.spotube.core.ui.component.CollectionView
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.TrackOptionsAction import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
import dev.krtirtho.spotube.core.ui.component.TrackOptionsState 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.downloads.DownloadsViewModel
import dev.krtirtho.spotube.modules.library.LibraryRepository import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormData import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormData
import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormSheet 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.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -90,14 +81,6 @@ fun PlaylistScreen(playlistId: String) {
var showAddTracksDialog by remember { mutableStateOf(false) } var showAddTracksDialog by remember { mutableStateOf(false) }
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) } 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) { fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) {
viewModel.handleTrackOptionsAction(track, action) viewModel.handleTrackOptionsAction(track, action)
if (action is TrackOptionsAction.Share) { if (action is TrackOptionsAction.Share) {
@ -115,209 +98,144 @@ fun PlaylistScreen(playlistId: String) {
} }
} }
Scaffold( val dataState = state as? PlaylistScreenState.Data
topBar = { ApplicationMainBar(backButton = !isCollapsedHeaderShown) } val playlist = dataState?.playlist
) { innerPadding -> val owner = playlist?.owner
when (state) { val ownerName = owner?.displayName ?: owner?.username ?: "Unknown"
is PlaylistScreenState.Loading -> { val ownerImageURL = owner?.thumbnails?.firstOrNull()?.url
TrackList( val isOwner = currentUserId != null && playlist?.owner?.id == currentUserId
modifier = Modifier.padding(innerPadding), val artworkUrl = playlist?.thumbnails?.firstOrNull()?.url.orEmpty()
headerContent = { val isPlaying = currentCollectionEntry?.id == playlistId &&
SkeletonTree(true){ playerState == PlayerState.PLAYING
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 -> { val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
ErrorDisplay(
errorMessage = (state as PlaylistScreenState.Error).message,
onRetry = { viewModel.refresh() },
modifier = Modifier.padding(innerPadding),
)
}
is PlaylistScreenState.Data -> { fun getTrackOptionsState(track: MetadataTrack): TrackOptionsState {
val dataState = state as PlaylistScreenState.Data val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id
val playlist = dataState.playlist val queueTrackIds = queue.mapNotNull { entry ->
val owner = playlist?.owner (entry as? QueueEntry.StreamingTrack)?.track?.id
val ownerName = owner?.displayName ?: owner?.username ?: "Unknown" }.toSet()
val ownerImageURL = owner?.thumbnails?.firstOrNull()?.url return TrackOptionsState(
isInQueue = queueTrackIds.contains(track.id),
val isOwner = currentUserId != null && playlist?.owner?.id == currentUserId isCurrentlyPlaying = track.id == currentTrackId,
isFavorite = savedTrackIds.contains(track.id),
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle() isBlacklisted = false,
val artworkUrl = playlist?.thumbnails?.firstOrNull()?.url.orEmpty()
val isPlaying = currentCollectionEntry?.id == playlistId &&
playerState == PlayerState.PLAYING
fun getTrackOptionsState(track: MetadataTrack): TrackOptionsState {
val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id
val queueTrackIds = queue.mapNotNull { entry ->
(entry as? QueueEntry.StreamingTrack)?.track?.id
}.toSet()
return TrackOptionsState(
isInQueue = queueTrackIds.contains(track.id),
isCurrentlyPlaying = track.id == currentTrackId,
isFavorite = savedTrackIds.contains(track.id),
isBlacklisted = false,
)
}
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) {
{
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
horizontalArrangement = Arrangement.Center,
) {
OutlineButton(onClick = { showAddTracksDialog = true }) {
Icon(
imageVector = Iconsax.IconsaxAddSquare,
contentDescription = null,
)
Text("Add Tracks", modifier = Modifier.padding(start = 8.dp))
}
}
}
} else null,
tracks = dataState.tracks,
error = null,
hasMore = dataState.nextPagination != null,
isLoading = state is PlaylistScreenState.Loading && dataState.tracks.isEmpty(),
isLoadingNextPage = state is PlaylistScreenState.Data.LoadingMore,
currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id,
isCurrentTrackPlaying = playerState == PlayerState.PLAYING,
onTrackClick = viewModel::playPlaylistFromTrack,
onLoadNextPage = viewModel::loadNextTracksPage,
onArtistClick = { navigationCommands.navigateTo(Routes.Artist(it.id)) },
onAlbumClick = { navigationCommands.navigateTo(Routes.Album(it.id)) },
onTrackOptionsAction = ::handleTrackOptionsAction,
trackOptionsState = ::getTrackOptionsState,
onBulkDownload = { tracks ->
downloadsViewModel.downloadTracks(tracks)
},
onBulkAddToQueue = { tracks ->
viewModel.addTracksToQueue(tracks)
},
onBulkPlayNext = { tracks ->
viewModel.playTracksNext(tracks)
},
onBulkAddToPlaylist = { tracks ->
tracksToAddToPlaylist = tracks
showAddToPlaylistPicker = true
},
)
}
}
val dataState = state as? PlaylistScreenState.Data
val playlist = (dataState as? PlaylistScreenState.Data.Loaded)?.playlist
PlaylistFormSheet(
visible = showEditPlaylist,
onDismiss = { showEditPlaylist = false },
isEditing = true,
initialData = playlist?.let {
PlaylistFormData(
name = it.title,
description = it.description.orEmpty(),
isPublic = true,
isCollaborating = false,
imagePreviewUrl = it.thumbnails.firstOrNull()?.url,
)
},
onSubmit = { data ->
viewModel.updatePlaylist(
name = data.name,
description = data.description.ifBlank { null },
isPublic = data.isPublic,
isCollaborating = data.isCollaborating,
imageBase64 = data.imageBase64.ifBlank { null },
)
},
)
AddToPlaylistPicker(
visible = showAddToPlaylistPicker,
currentUserId = currentUserId,
onDismiss = { showAddToPlaylistPicker = false },
onPlaylistSelected = { selectedPlaylistId ->
scope.launch {
libraryRepository.addTracksToPlaylist(
selectedPlaylistId,
tracksToAddToPlaylist.map { it.id }
)
}
},
)
AddTracksToPlaylistDialog(
visible = showAddTracksDialog,
playlistId = playlistId,
onDismiss = {
showAddTracksDialog = false
viewModel.refresh()
},
) )
} }
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
.fillMaxWidth()
.padding(vertical = 16.dp),
horizontalArrangement = Arrangement.Center,
) {
OutlineButton(onClick = { showAddTracksDialog = true }) {
Icon(
imageVector = Iconsax.IconsaxAddSquare,
contentDescription = null,
)
Text("Add Tracks", modifier = Modifier.padding(start = 8.dp))
}
}
}
} 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,
onTrackClick = viewModel::playPlaylistFromTrack,
onLoadNextPage = viewModel::loadNextTracksPage,
onArtistClick = { navigationCommands.navigateTo(Routes.Artist(it.id)) },
onAlbumClick = { navigationCommands.navigateTo(Routes.Album(it.id)) },
onTrackOptionsAction = ::handleTrackOptionsAction,
trackOptionsState = ::getTrackOptionsState,
onBulkDownload = { tracks ->
downloadsViewModel.downloadTracks(tracks)
},
onBulkAddToQueue = { tracks ->
viewModel.addTracksToQueue(tracks)
},
onBulkPlayNext = { tracks ->
viewModel.playTracksNext(tracks)
},
onBulkAddToPlaylist = { tracks ->
tracksToAddToPlaylist = tracks
showAddToPlaylistPicker = true
},
footerContent = footerContent,
trailingContent = {
val loadedPlaylist = (dataState as? PlaylistScreenState.Data.Loaded)?.playlist
PlaylistFormSheet(
visible = showEditPlaylist,
onDismiss = { showEditPlaylist = false },
isEditing = true,
initialData = loadedPlaylist?.let {
PlaylistFormData(
name = it.title,
description = it.description.orEmpty(),
isPublic = true,
isCollaborating = false,
imagePreviewUrl = it.thumbnails.firstOrNull()?.url,
)
},
onSubmit = { data ->
viewModel.updatePlaylist(
name = data.name,
description = data.description.ifBlank { null },
isPublic = data.isPublic,
isCollaborating = data.isCollaborating,
imageBase64 = data.imageBase64.ifBlank { null },
)
},
)
AddToPlaylistPicker(
visible = showAddToPlaylistPicker,
currentUserId = currentUserId,
onDismiss = { showAddToPlaylistPicker = false },
onPlaylistSelected = { selectedPlaylistId ->
scope.launch {
libraryRepository.addTracksToPlaylist(
selectedPlaylistId,
tracksToAddToPlaylist.map { it.id }
)
}
},
)
AddTracksToPlaylistDialog(
visible = showAddTracksDialog,
playlistId = playlistId,
onDismiss = {
showAddTracksDialog = false
viewModel.refresh()
},
)
},
)
} }

View File

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