diff --git a/.gitignore b/.gitignore
index 7a3faf1f..cfc7da21 100644
--- a/.gitignore
+++ b/.gitignore
@@ -108,3 +108,8 @@ tm.json
android/build
android/app/.cxx
+
+/node_modules
+/package.json
+/package-lock.json
+/bun.lock
\ No newline at end of file
diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/media/PlaybackService.kt b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/media/PlaybackService.kt
index f16a1ffe..a19c40fc 100644
--- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/media/PlaybackService.kt
+++ b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/media/PlaybackService.kt
@@ -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
}
}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt
index f82904cf..cc578a25 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt
@@ -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()
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AlbumCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AlbumCard.kt
index 3dfcd375..2e021980 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AlbumCard.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AlbumCard.kt
@@ -60,5 +60,6 @@ fun AlbumCard(
scope.launch { playbackHelper.addAlbumToQueue(album.id) }
},
modifier = modifier.width(160.dp),
+ sharedElementKey = "album_art_${album.id}",
)
}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionDetails.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionDetails.kt
index 0cbb24e4..7c0c73d8 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionDetails.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionDetails.kt
@@ -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 = {},
)
-}
\ No newline at end of file
+}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/LikedTracksCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/LikedTracksCard.kt
index 635a10a5..eca9b2e2 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/LikedTracksCard.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/LikedTracksCard.kt
@@ -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,
)
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/PlaylistCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/PlaylistCard.kt
index d604f23e..1bca2308 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/PlaylistCard.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/PlaylistCard.kt
@@ -57,6 +57,7 @@ fun PlaylistCard(
onAddToQueue = {
scope.launch { playbackHelper.addPlaylistToQueue(playlist.id) }
},
- modifier = modifier
+ modifier = modifier,
+ sharedElementKey = "playlist_art_${playlist.id}",
)
}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/SharedTransitionLocals.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/SharedTransitionLocals.kt
new file mode 100644
index 00000000..64c05db0
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/SharedTransitionLocals.kt
@@ -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 .
+ */
+
+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 { null }
+
+@OptIn(ExperimentalSharedTransitionApi::class)
+val LocalAnimatedVisibilityScope = compositionLocalOf { 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,
+ )
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt
index a0ff05dc..ebfc537c 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt
@@ -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(
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableCard.kt
index dd1f0f36..14aee52b 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableCard.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableCard.kt
@@ -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 = {}
)
}
-}
\ No newline at end of file
+}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumRepository.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumRepository.kt
index 23e8be24..a8d2e8ec 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumRepository.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumRepository.kt
@@ -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 {
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt
index a16c7b4f..c4074c61 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt
@@ -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>(emptyList()) }
var currentUserId by remember { mutableStateOf(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,
)
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryRepository.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryRepository.kt
index dcf8a566..bc1129cf 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryRepository.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryRepository.kt
@@ -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): List {
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) {
- plugin?.let { plugin ->
+ authPlugin?.let { plugin ->
pluginManager.withScope {
plugin.use {
metadataPlaylistAPI.savePlaylists(ids)
@@ -167,7 +172,7 @@ class LibraryRepository(
}
suspend fun removeSavedPlaylists(ids: List) {
- plugin?.let { plugin ->
+ authPlugin?.let { plugin ->
pluginManager.withScope {
plugin.use {
metadataPlaylistAPI.removeSavedPlaylists(ids)
@@ -181,11 +186,11 @@ class LibraryRepository(
suspend fun isSavedAlbums(ids: List): List {
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) {
- plugin?.let { plugin ->
+ authPlugin?.let { plugin ->
pluginManager.withScope {
plugin.use {
metadataAlbumAPI.saveAlbums(ids)
@@ -217,7 +222,7 @@ class LibraryRepository(
}
suspend fun removeSavedAlbums(ids: List) {
- plugin?.let { plugin ->
+ authPlugin?.let { plugin ->
pluginManager.withScope {
plugin.use {
metadataAlbumAPI.removeSavedAlbums(ids)
@@ -231,11 +236,11 @@ class LibraryRepository(
suspend fun isSavedArtists(ids: List): List {
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) {
- plugin?.let { plugin ->
+ authPlugin?.let { plugin ->
pluginManager.withScope {
plugin.use {
metadataArtistAPI.saveArtists(ids)
@@ -267,7 +272,7 @@ class LibraryRepository(
}
suspend fun removeSavedArtists(ids: List) {
- 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,
): 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?,
): 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) {
- plugin?.let { plugin ->
+ authPlugin?.let { plugin ->
pluginManager.withScope {
plugin.use {
metadataPlaylistAPI.addTracksToPlaylist(playlistId, trackIds)
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistRepository.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistRepository.kt
index bdaf3860..c7d444f6 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistRepository.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistRepository.kt
@@ -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))
}
}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt
index 8cf52b9f..b0a6032d 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt
@@ -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>(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",
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt
index dcedfdff..6fc31b94 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt
@@ -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" }
}
}
}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksRepository.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksRepository.kt
index c538d6df..e5a7a2e9 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksRepository.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksRepository.kt
@@ -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) {
- plugin?.let { plugin ->
+ authPlugin?.let { plugin ->
pluginManager.withScope {
plugin.use {
metadataTrackAPI.saveTracks(ids)
@@ -108,7 +113,7 @@ class SavedTracksRepository(
}
suspend fun removeSavedTracks(ids: List) {
- plugin?.let { plugin ->
+ authPlugin?.let { plugin ->
pluginManager.withScope {
plugin.use {
metadataTrackAPI.removeSavedTracks(ids)
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt
index 168d5a61..9acd85bb 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt
@@ -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>(emptyList()) }
var currentUserId by remember { mutableStateOf(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,
diff --git a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataUserAPI.kt b/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataUserAPI.kt
index 9fa844cf..cc86648d 100644
--- a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataUserAPI.kt
+++ b/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataUserAPI.kt
@@ -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()
- }
}
\ No newline at end of file