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,58 +102,8 @@ fun AlbumScreen(albumId: String) {
} }
} }
Scaffold( val dataState = state as? AlbumScreenState.Data
topBar = { ApplicationMainBar(backButton = !isCollapsedHeaderShown) } val album = dataState?.album
) { 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 ownerName = val ownerName =
album?.artists?.joinToString { it.name }.orEmpty().ifBlank { "Unknown artist" } album?.artists?.joinToString { it.name }.orEmpty().ifBlank { "Unknown artist" }
val ownerImageURL = album?.artists?.firstOrNull()?.thumbnails?.firstOrNull()?.url val ownerImageURL = album?.artists?.firstOrNull()?.thumbnails?.firstOrNull()?.url
@ -180,21 +112,10 @@ fun AlbumScreen(albumId: String) {
val isAlbumPlaying = currentCollectionEntry?.id == albumId && val isAlbumPlaying = currentCollectionEntry?.id == albumId &&
playerState == PlayerState.PLAYING playerState == PlayerState.PLAYING
TrackList( val errorMessage = (state as? AlbumScreenState.Error)?.message
modifier = Modifier.padding(innerPadding), val isLoading = state is AlbumScreenState.Loading && (dataState == null || dataState.tracks.isEmpty())
state = listState,
collapsedHeader = { CollectionView(
CollapsedCollectionHeader(
title = album?.title ?: "Album",
imageURL = artworkUrl,
isPlaying = isAlbumPlaying,
onPlay = viewModel::playAlbum,
onBack = { navigationCommands.pop() },
)
},
isCollapsedHeaderShown = isCollapsedHeaderShown,
headerContent = {
CollectionDetails(
title = album?.title ?: "Loading album...", title = album?.title ?: "Loading album...",
description = album?.description ?: "${album?.albumType?.name.orEmpty()}${album?.releaseDate.orEmpty()}", description = album?.description ?: "${album?.albumType?.name.orEmpty()}${album?.releaseDate.orEmpty()}",
imageURL = artworkUrl, imageURL = artworkUrl,
@ -202,9 +123,7 @@ fun AlbumScreen(albumId: String) {
ownerImageURL = ownerImageURL, ownerImageURL = ownerImageURL,
onOwnerClick = { onOwnerClick = {
firstArtistId?.let { firstArtistId?.let {
navigationCommands.navigateTo( navigationCommands.navigateTo(Routes.Artist(it))
Routes.Artist(it)
)
} }
}, },
onPlay = viewModel::playAlbum, onPlay = viewModel::playAlbum,
@ -213,12 +132,11 @@ fun AlbumScreen(albumId: String) {
isPlaying = isAlbumPlaying, isPlaying = isAlbumPlaying,
isFollowing = savedAlbumIds.contains(albumId), isFollowing = savedAlbumIds.contains(albumId),
onFollowClick = viewModel::toggleSavedAlbum, onFollowClick = viewModel::toggleSavedAlbum,
) isLoading = isLoading,
}, error = errorMessage,
tracks = dataState.tracks, onRetry = { viewModel.refresh() },
error = null, tracks = dataState?.tracks ?: emptyList(),
hasMore = dataState.nextPagination != null, hasMore = dataState?.nextPagination != null,
isLoading = state is AlbumScreenState.Loading && dataState.tracks.isEmpty(),
isLoadingNextPage = state is AlbumScreenState.Data.LoadingMore, isLoadingNextPage = state is AlbumScreenState.Data.LoadingMore,
currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id, currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id,
isCurrentTrackPlaying = playerState == PlayerState.PLAYING, isCurrentTrackPlaying = playerState == PlayerState.PLAYING,
@ -241,10 +159,7 @@ fun AlbumScreen(albumId: String) {
tracksToAddToPlaylist = tracks tracksToAddToPlaylist = tracks
showAddToPlaylistPicker = true showAddToPlaylistPicker = true
}, },
) trailingContent = {
}
}
AddToPlaylistPicker( AddToPlaylistPicker(
visible = showAddToPlaylistPicker, visible = showAddToPlaylistPicker,
currentUserId = currentUserId, 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.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,70 +98,18 @@ fun PlaylistScreen(playlistId: String) {
} }
} }
Scaffold( val dataState = state as? PlaylistScreenState.Data
topBar = { ApplicationMainBar(backButton = !isCollapsedHeaderShown) } val playlist = dataState?.playlist
) { 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 owner = playlist?.owner val owner = playlist?.owner
val ownerName = owner?.displayName ?: owner?.username ?: "Unknown" val ownerName = owner?.displayName ?: owner?.username ?: "Unknown"
val ownerImageURL = owner?.thumbnails?.firstOrNull()?.url val ownerImageURL = owner?.thumbnails?.firstOrNull()?.url
val isOwner = currentUserId != null && playlist?.owner?.id == currentUserId val isOwner = currentUserId != null && playlist?.owner?.id == currentUserId
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
val artworkUrl = playlist?.thumbnails?.firstOrNull()?.url.orEmpty() val artworkUrl = playlist?.thumbnails?.firstOrNull()?.url.orEmpty()
val isPlaying = currentCollectionEntry?.id == playlistId && val isPlaying = currentCollectionEntry?.id == playlistId &&
playerState == PlayerState.PLAYING playerState == PlayerState.PLAYING
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
fun getTrackOptionsState(track: MetadataTrack): TrackOptionsState { fun getTrackOptionsState(track: MetadataTrack): TrackOptionsState {
val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id
val queueTrackIds = queue.mapNotNull { entry -> val queueTrackIds = queue.mapNotNull { entry ->
@ -192,38 +123,10 @@ fun PlaylistScreen(playlistId: String) {
) )
} }
TrackList( val errorMessage = (state as? PlaylistScreenState.Error)?.message
modifier = Modifier.padding(innerPadding), val isLoading = state is PlaylistScreenState.Loading && (dataState == null || dataState.tracks.isEmpty())
state = listState,
collapsedHeader = { val footerContent: (@Composable () -> Unit)? = if (isOwner && dataState != null) {
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( Row(
modifier = Modifier modifier = Modifier
@ -240,11 +143,28 @@ fun PlaylistScreen(playlistId: String) {
} }
} }
} }
} else null, } else null
tracks = dataState.tracks,
error = null, CollectionView(
hasMore = dataState.nextPagination != null, title = playlist?.title ?: "Loading playlist...",
isLoading = state is PlaylistScreenState.Loading && dataState.tracks.isEmpty(), 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, isLoadingNextPage = state is PlaylistScreenState.Data.LoadingMore,
currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id, currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id,
isCurrentTrackPlaying = playerState == PlayerState.PLAYING, isCurrentTrackPlaying = playerState == PlayerState.PLAYING,
@ -267,17 +187,14 @@ fun PlaylistScreen(playlistId: String) {
tracksToAddToPlaylist = tracks tracksToAddToPlaylist = tracks
showAddToPlaylistPicker = true showAddToPlaylistPicker = true
}, },
) footerContent = footerContent,
} trailingContent = {
} val loadedPlaylist = (dataState as? PlaylistScreenState.Data.Loaded)?.playlist
val dataState = state as? PlaylistScreenState.Data
val playlist = (dataState as? PlaylistScreenState.Data.Loaded)?.playlist
PlaylistFormSheet( PlaylistFormSheet(
visible = showEditPlaylist, visible = showEditPlaylist,
onDismiss = { showEditPlaylist = false }, onDismiss = { showEditPlaylist = false },
isEditing = true, isEditing = true,
initialData = playlist?.let { initialData = loadedPlaylist?.let {
PlaylistFormData( PlaylistFormData(
name = it.title, name = it.title,
description = it.description.orEmpty(), description = it.description.orEmpty(),
@ -319,5 +236,6 @@ fun PlaylistScreen(playlistId: String) {
viewModel.refresh() 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,81 +103,16 @@ fun SavedTracksScreen() {
} }
} }
Scaffold( val dataState = state as? SavedTracksScreenState.Data
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 isPlaying = currentCollectionEntry is QueueCollectionEntry.SavedTracks && val isPlaying = currentCollectionEntry is QueueCollectionEntry.SavedTracks &&
playerState == PlayerState.PLAYING playerState == PlayerState.PLAYING
TrackList( val errorMessage = (state as? SavedTracksScreenState.Error)?.message
modifier = Modifier.padding(innerPadding), val isLoading = state is SavedTracksScreenState.Loading && (dataState == null || dataState.tracks.isEmpty())
state = listState,
collapsedHeader = { CollectionView(
CollapsedCollectionHeader(
title = "Saved Tracks", title = "Saved Tracks",
imageURL = "", description = dataState?.let { "${it.totalCount} tracks" } ?: "",
imageResource = Res.drawable.liked_tracks,
isPlaying = isPlaying,
onPlay = viewModel::playSavedTracks,
onBack = { navigationCommands.pop() },
)
},
isCollapsedHeaderShown = isCollapsedHeaderShown,
headerContent = {
CollectionDetails(
title = "Saved Tracks",
description = "${dataState.totalCount} tracks",
imageURL = "", imageURL = "",
imageResource = Res.drawable.liked_tracks, imageResource = Res.drawable.liked_tracks,
ownerName = "You", ownerName = "You",
@ -209,12 +125,11 @@ fun SavedTracksScreen() {
isFollowing = false, isFollowing = false,
onFollowClick = {}, onFollowClick = {},
showFollowButton = false, showFollowButton = false,
) isLoading = isLoading,
}, error = errorMessage,
tracks = dataState.tracks, onRetry = { viewModel.refresh() },
error = null, tracks = dataState?.tracks ?: emptyList(),
hasMore = dataState.nextPagination != null, hasMore = dataState?.nextPagination != null,
isLoading = state is SavedTracksScreenState.Loading && dataState.tracks.isEmpty(),
isLoadingNextPage = state is SavedTracksScreenState.Data.LoadingMore, isLoadingNextPage = state is SavedTracksScreenState.Data.LoadingMore,
currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id, currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id,
isCurrentTrackPlaying = playerState == PlayerState.PLAYING, isCurrentTrackPlaying = playerState == PlayerState.PLAYING,
@ -237,10 +152,7 @@ fun SavedTracksScreen() {
tracksToAddToPlaylist = tracks tracksToAddToPlaylist = tracks
showAddToPlaylistPicker = true showAddToPlaylistPicker = true
}, },
) trailingContent = {
}
}
AddToPlaylistPicker( AddToPlaylistPicker(
visible = showAddToPlaylistPicker, visible = showAddToPlaylistPicker,
currentUserId = currentUserId, currentUserId = currentUserId,
@ -254,5 +166,6 @@ fun SavedTracksScreen() {
} }
}, },
) )
} },
)
} }