feat: enhance UI with shared element transitions and improve state management in album and playlist screens

This commit is contained in:
Kingkor Roy Tirtho 2026-07-17 17:59:48 +06:00
parent 16d4adf59c
commit abeae2144c
19 changed files with 394 additions and 82 deletions

5
.gitignore vendored
View File

@ -108,3 +108,8 @@ tm.json
android/build
android/app/.cxx
/node_modules
/package.json
/package-lock.json
/bun.lock

View File

@ -25,6 +25,7 @@ import android.content.Intent
import android.os.Build
import android.util.Log
import androidx.annotation.OptIn
import androidx.core.app.NotificationCompat
import androidx.media3.common.MediaItem as Media3MediaItem
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
@ -76,6 +77,16 @@ class PlaybackService : MediaLibraryService(), KoinComponent {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Spotube")
.setContentText("Playback service")
.setSmallIcon(android.R.drawable.ic_media_play)
.setContentIntent(sessionActivity)
.setOngoing(true)
.build()
startForeground(NOTIFICATION_ID, notification)
librarySession = MediaLibrarySession.Builder(this, audioPlayer.player, LibrarySessionCallback())
.setSessionActivity(sessionActivity)
.build()
@ -388,5 +399,6 @@ class PlaybackService : MediaLibraryService(), KoinComponent {
companion object {
private const val TAG = "PlaybackService"
private const val CHANNEL_ID = "spotube_playback"
private const val NOTIFICATION_ID = 1
}
}

View File

@ -17,6 +17,8 @@
package dev.krtirtho.spotube
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
@ -34,6 +36,7 @@ import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES
import dev.krtirtho.spotube.core.navigation.rememberNavigationState
import dev.krtirtho.spotube.core.navigation.toEntries
import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope
import dev.krtirtho.spotube.core.ui.theming.SpotubeTheme
import dev.krtirtho.spotube.modules.settings.SettingsRepository
import dev.krtirtho.spotube.modules.settings.UserSettings
@ -86,7 +89,7 @@ val tabs = listOf(
)
)
@OptIn(KoinExperimentalAPI::class, ExperimentalCoroutinesApi::class)
@OptIn(KoinExperimentalAPI::class, ExperimentalCoroutinesApi::class, ExperimentalSharedTransitionApi::class)
@Composable
fun App(
content: @Composable () -> Unit = {}
@ -106,12 +109,18 @@ fun App(
val baseUITheme = rememberBaseUITheme()
CompositionLocalProvider(LocalBaseUITheme provides baseUITheme) {
AppShell(navigator, navigationState) {
Column {
NavDisplay(
modifier = Modifier.fillMaxSize(),
onBack = navigator::pop,
entries = navigationState.toEntries(koinEntryProvider())
)
SharedTransitionLayout {
CompositionLocalProvider(
LocalSharedTransitionScope provides this@SharedTransitionLayout,
) {
Column {
NavDisplay(
modifier = Modifier.fillMaxSize(),
onBack = navigator::pop,
entries = navigationState.toEntries(koinEntryProvider())
)
}
}
}
}
content()

View File

@ -60,5 +60,6 @@ fun AlbumCard(
scope.launch { playbackHelper.addAlbumToQueue(album.id) }
},
modifier = modifier.width(160.dp),
sharedElementKey = "album_art_${album.id}",
)
}

View File

@ -17,6 +17,7 @@
package dev.krtirtho.spotube.core.ui.component
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@ -30,9 +31,8 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
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
@ -41,16 +41,17 @@ 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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import dev.krtirtho.spotube.core.ui.base.ButtonGroup
import dev.krtirtho.spotube.core.ui.base.ButtonGroupDivider
import dev.krtirtho.spotube.core.ui.base.Card
import dev.krtirtho.spotube.core.ui.base.GroupIconButton
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
@ -64,6 +65,7 @@ import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.painterResource
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun CollectionDetails(
modifier: Modifier = Modifier,
@ -82,12 +84,14 @@ fun CollectionDetails(
onFollowClick: () -> Unit = { },
showFollowButton: Boolean = true,
onEdit: (() -> Unit)? = null,
sharedElementKey: String? = null,
) {
BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
val isCompact = maxWidth < 600.dp
val sharedTransitionScope = LocalSharedTransitionScope.current
val animatedVisibilityScope = LocalAnimatedVisibilityScope.current
val playPauseButton = @Composable {
Row(
@ -207,6 +211,11 @@ fun CollectionDetails(
modifier = Modifier
.size(if (isCompact) 100.dp else 200.dp)
.clip(RoundedCornerShape(12.dp))
.sharedElementOrNone(
key = sharedElementKey,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
)
.shimmerApply(),
contentAlignment = Alignment.Center,
) {
@ -260,10 +269,6 @@ fun CollectionDetails(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
shape = RoundedCornerShape(20.dp),
) {
if (isCompact) {
Column(
@ -314,8 +319,104 @@ fun CollectionDetails(
}
}
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
@Preview
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() {
CollectionDetails(
title = "My Playlist",
@ -328,4 +429,4 @@ fun CollectionDetailsPreview() {
onShufflePlay = {},
onAddToQueue = {},
)
}
}

View File

@ -18,6 +18,7 @@
package dev.krtirtho.spotube.core.ui.component
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.clickable
@ -55,6 +56,9 @@ import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry
import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
import dev.krtirtho.spotube.core.ui.component.LocalAnimatedVisibilityScope
import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope
import dev.krtirtho.spotube.core.ui.component.sharedElementOrNone
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
import dev.krtirtho.spotube.resources.iconsax.IconsaxPauseCircle
@ -66,6 +70,7 @@ import org.koin.compose.koinInject
import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.liked_tracks
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun LikedTracksCard(
modifier: Modifier = Modifier,
@ -79,6 +84,9 @@ fun LikedTracksCard(
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
val isPlaying = currentCollectionEntry is QueueCollectionEntry.SavedTracks
val sharedTransitionScope = LocalSharedTransitionScope.current
val animatedVisibilityScope = LocalAnimatedVisibilityScope.current
Box(
modifier = modifier
.clip(RoundedCornerShape(8.dp))
@ -91,8 +99,15 @@ fun LikedTracksCard(
androidx.compose.foundation.Image(
painter = painterResource(Res.drawable.liked_tracks),
contentDescription = "Liked Tracks",
modifier = Modifier.fillMaxWidth().aspectRatio(1f)
.clip(RoundedCornerShape(8.dp)),
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.clip(RoundedCornerShape(8.dp))
.sharedElementOrNone(
key = "saved_tracks_art",
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
),
contentScale = ContentScale.Crop,
)

View File

@ -57,6 +57,7 @@ fun PlaylistCard(
onAddToQueue = {
scope.launch { playbackHelper.addPlaylistToQueue(playlist.id) }
},
modifier = modifier
modifier = modifier,
sharedElementKey = "playlist_art_${playlist.id}",
)
}

View File

@ -0,0 +1,47 @@
/*
* 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.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.Modifier
@OptIn(ExperimentalSharedTransitionApi::class)
val LocalSharedTransitionScope = compositionLocalOf<SharedTransitionScope?> { null }
@OptIn(ExperimentalSharedTransitionApi::class)
val LocalAnimatedVisibilityScope = compositionLocalOf<AnimatedVisibilityScope?> { null }
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun Modifier.sharedElementOrNone(
key: String?,
sharedTransitionScope: SharedTransitionScope?,
animatedVisibilityScope: AnimatedVisibilityScope?,
): Modifier {
if (sharedTransitionScope == null || animatedVisibilityScope == null || key == null) return this
with(sharedTransitionScope) {
return this@sharedElementOrNone.sharedElement(
sharedContentState = rememberSharedContentState(key = key),
animatedVisibilityScope = animatedVisibilityScope,
)
}
}

View File

@ -18,8 +18,11 @@
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
@ -42,6 +45,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
@ -149,6 +153,9 @@ fun TrackList(
contentPadding: PaddingValues = PaddingValues(bottom = LocalAppShellBottomInset.current),
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) }
@ -183,7 +190,7 @@ fun TrackList(
}
}
val listState = rememberLazyListState()
val listState = state ?: rememberLazyListState()
val density = LocalDensity.current
val shouldLoadMore = remember(density) {
derivedStateOf {
@ -484,6 +491,17 @@ 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

@ -18,6 +18,7 @@
package dev.krtirtho.spotube.core.ui.component.cards
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.clickable
@ -53,6 +54,9 @@ import dev.krtirtho.spotube.core.ui.base.BaseUITheme
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
import dev.krtirtho.spotube.core.ui.base.PrimaryIconButton
import dev.krtirtho.spotube.core.ui.base.SecondaryIconButton
import dev.krtirtho.spotube.core.ui.component.LocalAnimatedVisibilityScope
import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope
import dev.krtirtho.spotube.core.ui.component.sharedElementOrNone
import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer
import dev.krtirtho.spotube.core.ui.misc.shimmerApply
import dev.krtirtho.spotube.resources.iconsax.Iconsax
@ -60,6 +64,7 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
import dev.krtirtho.spotube.resources.iconsax.IconsaxPause
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun PlayableCard(
title: String,
@ -70,8 +75,11 @@ fun PlayableCard(
onPlay: (() -> Unit)? = null,
onAddToQueue: (() -> Unit)? = null,
modifier: Modifier = Modifier,
sharedElementKey: String? = null,
) {
val interactionSource = remember { MutableInteractionSource() }
val sharedTransitionScope = LocalSharedTransitionScope.current
val animatedVisibilityScope = LocalAnimatedVisibilityScope.current
Box(
modifier = modifier
@ -85,8 +93,15 @@ fun PlayableCard(
AsyncImage(
model = imageURL,
contentDescription = title,
modifier = Modifier.fillMaxWidth().aspectRatio(1f)
.clip(RoundedCornerShape(8.dp)),
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.clip(RoundedCornerShape(8.dp))
.sharedElementOrNone(
key = sharedElementKey,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
),
contentScale = ContentScale.Crop,
)
@ -98,7 +113,6 @@ fun PlayableCard(
val isHovered by interactionSource.collectIsHoveredAsState()
this@Column.AnimatedVisibility(
visible = isHovered,
//fade in/out animation when hovered
enter = fadeIn(),
exit = fadeOut(),
) {
@ -186,4 +200,4 @@ private fun PlayableCardPreview() {
onAddToQueue = {}
)
}
}
}

View File

@ -52,12 +52,17 @@ class AlbumRepository(
.filterNotNull()
.flatMapLatest { it.loggedInFlow }
.distinctUntilChanged()
.collect {
.collect { loggedIn ->
isLoggedIn = loggedIn
invalidateCaches()
}
}
}
private var isLoggedIn: Boolean = false
private val authPlugin get() = if (isLoggedIn) plugin else null
fun invalidateCaches() {
albumInfoCache.invalidateAll()
albumTracksCache.invalidateAll()
@ -68,17 +73,15 @@ class AlbumRepository(
pluginManager.withScope {
plugin.use {
val album = metadataAlbumAPI.getAlbum(albumId)
val isSaved = metadataAlbumAPI.isSavedAlbums(listOf(albumId)).firstOrNull() ?: false
val isSaved = libraryRepository.isSavedAlbums(listOf(albumId)).firstOrNull() ?: false
album to isSaved
}
}
}?.also {
libraryRepository.isSavedAlbums(listOf(albumId))
}
}
suspend fun getAlbumTracks(albumId: String, paginationStrategy: PaginationStrategy? = null) =
plugin?.let { plugin ->
authPlugin?.let { plugin ->
albumTracksCache.get(albumId to (paginationStrategy ?: PaginationStrategy.Offset(0, 20))) {
pluginManager.withScope {
plugin.use {

View File

@ -18,9 +18,11 @@
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
@ -37,6 +39,7 @@ 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
@ -75,6 +78,14 @@ 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
}
@ -110,7 +121,7 @@ fun AlbumScreen(albumId: String) {
}
Scaffold(
topBar = { ApplicationMainBar() }
topBar = { ApplicationMainBar(backButton = !isCollapsedHeaderShown) }
) { innerPadding ->
when (state) {
is AlbumScreenState.Loading -> {
@ -165,14 +176,28 @@ fun AlbumScreen(albumId: String) {
album?.artists?.joinToString { it.name }.orEmpty().ifBlank { "Unknown artist" }
val ownerImageURL = album?.artists?.firstOrNull()?.thumbnails?.firstOrNull()?.url
val firstArtistId = album?.artists?.firstOrNull()?.id
val artworkUrl = album?.thumbnails?.firstOrNull()?.url.orEmpty()
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(
title = album?.title ?: "Loading album...",
description = album?.description ?: "${album?.albumType?.name.orEmpty()}${album?.releaseDate.orEmpty()}",
imageURL = album?.thumbnails?.firstOrNull()?.url.orEmpty(),
imageURL = artworkUrl,
ownerName = ownerName,
ownerImageURL = ownerImageURL,
onOwnerClick = {
@ -185,9 +210,7 @@ fun AlbumScreen(albumId: String) {
onPlay = viewModel::playAlbum,
onShufflePlay = {},
onAddToQueue = viewModel::addAlbumToQueue,
isPlaying =
currentCollectionEntry?.id == albumId &&
playerState == PlayerState.PLAYING,
isPlaying = isAlbumPlaying,
isFollowing = savedAlbumIds.contains(albumId),
onFollowClick = viewModel::toggleSavedAlbum,
)

View File

@ -68,12 +68,17 @@ class LibraryRepository(
.filterNotNull()
.flatMapLatest { it.loggedInFlow }
.distinctUntilChanged()
.collect {
.collect { loggedIn ->
isLoggedIn = loggedIn
invalidateCaches()
}
}
}
private var isLoggedIn: Boolean = false
private val authPlugin get() = if (isLoggedIn) plugin else null
fun invalidateCaches() {
playlistCache.invalidateAll()
albumCache.invalidateAll()
@ -84,7 +89,7 @@ class LibraryRepository(
}
suspend fun savedPlaylists(paginationStrategy: PaginationStrategy? = null) =
plugin?.let { plugin ->
authPlugin?.let { plugin ->
playlistCache.get(
key = paginationStrategy ?: PaginationStrategy.Offset(0, 20)
) {
@ -99,7 +104,7 @@ class LibraryRepository(
}
suspend fun savedAlbums(paginationStrategy: PaginationStrategy? = null) =
plugin?.let { plugin ->
authPlugin?.let { plugin ->
albumCache.get(
key = paginationStrategy ?: PaginationStrategy.Offset(0, 20)
) {
@ -114,7 +119,7 @@ class LibraryRepository(
}
suspend fun savedArtists(paginationStrategy: PaginationStrategy? = null) =
plugin?.let { plugin ->
authPlugin?.let { plugin ->
artistCache.get(
key = paginationStrategy ?: PaginationStrategy.Offset(0, 20)
) {
@ -131,11 +136,11 @@ class LibraryRepository(
suspend fun isSavedPlaylists(ids: List<String>): List<Boolean> {
val unknownIds = ids.filterNot { savedPlaylistIds.value.contains(it) }
if(unknownIds.isEmpty()) {
if (unknownIds.isEmpty()) {
return ids.map { true }
}
val unknownStates = plugin?.let { plugin ->
val unknownStates = authPlugin?.let { plugin ->
val savedStates = pluginManager.withScope {
plugin.use {
metadataPlaylistAPI.isSavedPlaylists(unknownIds)
@ -155,7 +160,7 @@ class LibraryRepository(
}
suspend fun savePlaylists(ids: List<String>) {
plugin?.let { plugin ->
authPlugin?.let { plugin ->
pluginManager.withScope {
plugin.use {
metadataPlaylistAPI.savePlaylists(ids)
@ -167,7 +172,7 @@ class LibraryRepository(
}
suspend fun removeSavedPlaylists(ids: List<String>) {
plugin?.let { plugin ->
authPlugin?.let { plugin ->
pluginManager.withScope {
plugin.use {
metadataPlaylistAPI.removeSavedPlaylists(ids)
@ -181,11 +186,11 @@ class LibraryRepository(
suspend fun isSavedAlbums(ids: List<String>): List<Boolean> {
val unknownIds = ids.filterNot { savedAlbumIds.value.contains(it) }
if(unknownIds.isEmpty()) {
if (unknownIds.isEmpty()) {
return ids.map { true }
}
val unknownStates = plugin?.let { plugin ->
val unknownStates = authPlugin?.let { plugin ->
val savedStates = pluginManager.withScope {
plugin.use {
metadataAlbumAPI.isSavedAlbums(unknownIds)
@ -205,7 +210,7 @@ class LibraryRepository(
}
suspend fun saveAlbums(ids: List<String>) {
plugin?.let { plugin ->
authPlugin?.let { plugin ->
pluginManager.withScope {
plugin.use {
metadataAlbumAPI.saveAlbums(ids)
@ -217,7 +222,7 @@ class LibraryRepository(
}
suspend fun removeSavedAlbums(ids: List<String>) {
plugin?.let { plugin ->
authPlugin?.let { plugin ->
pluginManager.withScope {
plugin.use {
metadataAlbumAPI.removeSavedAlbums(ids)
@ -231,11 +236,11 @@ class LibraryRepository(
suspend fun isSavedArtists(ids: List<String>): List<Boolean> {
val unknownIds = ids.filterNot { savedArtistIds.value.contains(it) }
if(unknownIds.isEmpty()) {
if (unknownIds.isEmpty()) {
return ids.map { true }
}
val unknownStates = plugin?.let { plugin ->
val unknownStates = authPlugin?.let { plugin ->
val savedStates = pluginManager.withScope {
plugin.use {
metadataArtistAPI.isSavedArtists(unknownIds)
@ -255,7 +260,7 @@ class LibraryRepository(
}
suspend fun saveArtists(ids: List<String>) {
plugin?.let { plugin ->
authPlugin?.let { plugin ->
pluginManager.withScope {
plugin.use {
metadataArtistAPI.saveArtists(ids)
@ -267,7 +272,7 @@ class LibraryRepository(
}
suspend fun removeSavedArtists(ids: List<String>) {
plugin?.let { plugin ->
authPlugin?.let { plugin ->
pluginManager.withScope {
plugin.use {
metadataArtistAPI.removeSavedArtists(ids)
@ -279,7 +284,7 @@ class LibraryRepository(
}
suspend fun currentUser(): MetadataUser? {
return plugin?.let { plugin ->
return authPlugin?.let { plugin ->
pluginManager.withScope {
plugin.use {
metadataUserAPI.getUser("")
@ -296,7 +301,7 @@ class LibraryRepository(
imageBase64: String,
trackIds: List<String>,
): MetadataPlaylist? {
return plugin?.let { plugin ->
return authPlugin?.let { plugin ->
pluginManager.withScope {
plugin.use {
val result = metadataPlaylistAPI.createPlaylist(
@ -324,7 +329,7 @@ class LibraryRepository(
imageBase64: String?,
trackIds: List<String>?,
): MetadataPlaylist? {
return plugin?.let { plugin ->
return authPlugin?.let { plugin ->
pluginManager.withScope {
plugin.use {
val result = metadataPlaylistAPI.updatePlaylist(
@ -344,7 +349,7 @@ class LibraryRepository(
}
suspend fun addTracksToPlaylist(playlistId: String, trackIds: List<String>) {
plugin?.let { plugin ->
authPlugin?.let { plugin ->
pluginManager.withScope {
plugin.use {
metadataPlaylistAPI.addTracksToPlaylist(playlistId, trackIds)

View File

@ -53,12 +53,15 @@ class PlaylistRepository(
.filterNotNull()
.flatMapLatest { it.loggedInFlow }
.distinctUntilChanged()
.collect {
.collect { loggedIn ->
isLoggedIn = loggedIn
invalidateCaches()
}
}
}
private var isLoggedIn: Boolean = false
fun invalidateCaches() {
playlistInfoCache.invalidateAll()
playlistTracksCache.invalidateAll()
@ -69,12 +72,10 @@ class PlaylistRepository(
pluginManager.withScope {
plugin.use {
val playlist = metadataPlaylistAPI.getPlaylist(playlistId)
val isSaved = metadataPlaylistAPI.isSavedPlaylists(listOf(playlistId)).firstOrNull() ?: false
val isSaved = libraryRepository.isSavedPlaylists(listOf(playlistId)).firstOrNull() ?: false
playlist to isSaved
}
}
}.also {
libraryRepository.isSavedPlaylists(listOf(playlistId))
}
}

View File

@ -21,10 +21,12 @@ 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
@ -43,6 +45,7 @@ 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
@ -87,6 +90,14 @@ 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) {
@ -105,7 +116,7 @@ fun PlaylistScreen(playlistId: String) {
}
Scaffold(
topBar = { ApplicationMainBar() }
topBar = { ApplicationMainBar(backButton = !isCollapsedHeaderShown) }
) { innerPadding ->
when (state) {
is PlaylistScreenState.Loading -> {
@ -164,6 +175,9 @@ fun PlaylistScreen(playlistId: String) {
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
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
@ -180,20 +194,29 @@ 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 = playlist?.thumbnails?.firstOrNull()?.url.orEmpty(),
imageURL = artworkUrl,
ownerName = ownerName,
ownerImageURL = ownerImageURL,
onOwnerClick = {},
onPlay = viewModel::playPlaylist,
onShufflePlay = {},
onAddToQueue = viewModel::addPlaylistToQueue,
isPlaying =
currentCollectionEntry?.id == playlistId &&
playerState == PlayerState.PLAYING,
isPlaying = isPlaying,
isFollowing = savedPlaylistIds.contains(playlistId),
onFollowClick = viewModel::toggleSavedPlaylist,
showFollowButton = playlistId != "saved_tracks",

View File

@ -111,7 +111,11 @@ class PlaylistViewModel(
}
private suspend fun loadCurrentUser() {
_currentUserId.value = libraryRepository.currentUser()?.id
runCatching {
_currentUserId.value = libraryRepository.currentUser()?.id
}.onFailure { e ->
logger.e(e) { "Failed to load current user" }
}
}
private suspend fun loadInitialData() = runCatching {
@ -159,11 +163,15 @@ class PlaylistViewModel(
fun toggleSavedPlaylist() {
viewModelScope.launch {
val isLiked = libraryRepository.isSavedPlaylists(listOf(playlistId)).firstOrNull() ?: false
if (isLiked) {
libraryRepository.removeSavedPlaylists(listOf(playlistId))
} else {
libraryRepository.savePlaylists(listOf(playlistId))
runCatching {
val isLiked = libraryRepository.isSavedPlaylists(listOf(playlistId)).firstOrNull() ?: false
if (isLiked) {
libraryRepository.removeSavedPlaylists(listOf(playlistId))
} else {
libraryRepository.savePlaylists(listOf(playlistId))
}
}.onFailure { e ->
logger.e(e) { "Failed to toggle saved playlist" }
}
}
}

View File

@ -56,12 +56,17 @@ class SavedTracksRepository(
.filterNotNull()
.flatMapLatest { it.loggedInFlow }
.distinctUntilChanged()
.collect {
.collect { loggedIn ->
isLoggedIn = loggedIn
invalidateCaches()
}
}
}
private var isLoggedIn: Boolean = false
private val authPlugin get() = if (isLoggedIn) plugin else null
fun invalidateCaches() {
savedTracksCache.invalidateAll()
totalCountCache.invalidateAll()
@ -69,7 +74,7 @@ class SavedTracksRepository(
}
suspend fun getSavedTracks(paginationStrategy: PaginationStrategy? = null) =
plugin?.let { plugin ->
authPlugin?.let { plugin ->
val strategy = paginationStrategy ?: PaginationStrategy.Offset(0, 50)
savedTracksCache.get(strategy) {
val tracks = pluginManager.withScope {
@ -95,7 +100,7 @@ class SavedTracksRepository(
}
suspend fun saveTracks(ids: List<String>) {
plugin?.let { plugin ->
authPlugin?.let { plugin ->
pluginManager.withScope {
plugin.use {
metadataTrackAPI.saveTracks(ids)
@ -108,7 +113,7 @@ class SavedTracksRepository(
}
suspend fun removeSavedTracks(ids: List<String>) {
plugin?.let { plugin ->
authPlugin?.let { plugin ->
pluginManager.withScope {
plugin.use {
metadataTrackAPI.removeSavedTracks(ids)

View File

@ -18,9 +18,11 @@
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
@ -38,6 +40,7 @@ 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
@ -77,6 +80,14 @@ 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
}
@ -112,7 +123,7 @@ fun SavedTracksScreen() {
}
Scaffold(
topBar = { ApplicationMainBar() }
topBar = { ApplicationMainBar(backButton = !isCollapsedHeaderShown) }
) { innerPadding ->
when (state) {
is SavedTracksScreenState.Loading -> {
@ -165,8 +176,23 @@ fun SavedTracksScreen() {
is 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(
title = "Saved Tracks",
imageURL = "",
imageResource = Res.drawable.liked_tracks,
isPlaying = isPlaying,
onPlay = viewModel::playSavedTracks,
onBack = { navigationCommands.pop() },
)
},
isCollapsedHeaderShown = isCollapsedHeaderShown,
headerContent = {
CollectionDetails(
title = "Saved Tracks",
@ -179,8 +205,7 @@ fun SavedTracksScreen() {
onPlay = viewModel::playSavedTracks,
onShufflePlay = {},
onAddToQueue = viewModel::addSavedTracksToQueue,
isPlaying = currentCollectionEntry is QueueCollectionEntry.SavedTracks &&
playerState == PlayerState.PLAYING,
isPlaying = isPlaying,
isFollowing = false,
onFollowClick = {},
showFollowButton = false,

View File

@ -23,8 +23,4 @@ class RealMetadataUserAPI : MetadataUserAPI {
override suspend fun getUser(id: String): MetadataUser? {
return FakeMetadataStore.getUser(id)
}
override suspend fun me(): MetadataUser? {
return FakeMetadataStore.getCurrentUser()
}
}