From 79d47c1d874c761f8d32a98a46ecefb88ea34a25 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Tue, 20 Oct 2026 20:32:47 +0600 Subject: [PATCH] feat: enhance loading states for various UI components and update dependencies --- .../org/spotube/spotube/core/di/Modules.kt | 2 + .../core/remote/RemotePlaybackController.kt | 202 +++++++++++------- .../spotube/core/ui/component/AlbumCard.kt | 7 +- .../core/ui/component/CollectionDetails.kt | 11 +- .../core/ui/component/CollectionView.kt | 13 ++ .../spotube/core/ui/component/ErrorDisplay.kt | 1 + .../core/ui/component/FeaturedCarousel.kt | 32 ++- .../core/ui/component/LikedTracksCard.kt | 38 +++- .../core/ui/component/LoginRequiredDisplay.kt | 4 +- .../spotube/core/ui/component/PlaylistCard.kt | 7 +- .../core/ui/component/cards/PlayableCard.kt | 13 +- .../ui/component/cards/PlayableListTile.kt | 4 + .../spotube/modules/album/AlbumScreen.kt | 5 + .../spotube/modules/album/AlbumViewModel.kt | 36 +++- .../spotube/modules/artist/ArtistScreen.kt | 19 +- .../spotube/modules/artist/ArtistViewModel.kt | 33 ++- .../spotube/modules/devices/DevicesScreen.kt | 25 +-- .../modules/devices/DevicesViewModel.kt | 19 +- .../spotube/spotube/modules/jam/JamScreen.kt | 3 +- .../spotube/modules/jam/JamViewModel.kt | 20 +- .../spotube/modules/library/LibraryScreen.kt | 2 +- .../spotube/modules/library/LibraryState.kt | 13 +- .../LibraryLocalTracksViewModel.kt | 27 ++- .../playlist/LibraryPlaylistsScreen.kt | 8 +- .../playlist/LibraryPlaylistsViewModel.kt | 57 +++-- .../playlist/PlaylistFormController.kt | 96 +++++++++ .../library/playlist/PlaylistFormSheet.kt | 51 ++--- .../playlist/AddTracksToPlaylistDialog.kt | 105 +++------ .../playlist/AddTracksToPlaylistViewModel.kt | 131 ++++++++++++ .../modules/playlist/PlaylistScreen.kt | 24 +-- .../modules/playlist/PlaylistViewModel.kt | 67 ++++-- .../modules/plugin/MetadataAuthViewModel.kt | 25 ++- .../spotube/modules/plugin/PluginManager.kt | 32 +-- .../spotube/modules/plugin/PluginScreen.kt | 47 ++-- .../spotube/modules/plugin/PluginViewModel.kt | 85 ++++++-- .../modules/plugin/components/PluginCard.kt | 6 +- .../plugin/components/PluginDialogParts.kt | 4 +- .../components/PluginPermissionsDialog.kt | 4 +- .../plugin/guard/MetadataPluginGuard.kt | 1 + .../modules/saved_tracks/SavedTracksScreen.kt | 4 + .../saved_tracks/SavedTracksViewModel.kt | 28 +++ .../modules/settings/JamSettingsScreen.kt | 10 +- .../modules/settings/JamSettingsViewModel.kt | 8 +- .../settings/LocalMediaFoldersViewModel.kt | 2 +- .../components/LocalMediaFoldersEditor.kt | 4 +- .../modules/shell/PlayerHeartButton.kt | 19 +- gradle/libs.versions.toml | 2 +- 47 files changed, 938 insertions(+), 418 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/PlaylistFormController.kt create mode 100644 composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/AddTracksToPlaylistViewModel.kt diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Modules.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Modules.kt index 5022fcd5..05ec4253 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Modules.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Modules.kt @@ -67,6 +67,7 @@ import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaFoldersC import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaLibraryCoordinator import org.spotube.spotube.modules.library.playlist.LibraryPlaylistsViewModel import org.spotube.spotube.modules.lyrics.LyricsViewModel +import org.spotube.spotube.modules.playlist.AddTracksToPlaylistViewModel import org.spotube.spotube.modules.playlist.PlaylistRepository import org.spotube.spotube.modules.playlist.PlaylistViewModel import org.spotube.spotube.modules.plugin.AudioPluginSource @@ -185,6 +186,7 @@ val sharedModules = module { downloadManager = get(), remotePlaybackController = get()) } + viewModel { (playlistId: String) -> AddTracksToPlaylistViewModel(playlistId, get(), get()) } // Saved Tracks singleOf(::SavedTracksRepository) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemotePlaybackController.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemotePlaybackController.kt index d4442f15..3b37ff81 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemotePlaybackController.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemotePlaybackController.kt @@ -28,12 +28,16 @@ import org.spotube.spotube.modules.blacklist.BlacklistRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.jetbrains.compose.resources.getPluralString @@ -82,6 +86,22 @@ sealed interface PlaybackDestinationRequest { val tracks: List) : PlaybackDestinationRequest } +/** + * Identifies a whole-collection action (e.g. "shuffle album X") while its tracks are being + * fetched and queued locally, so the button that started it can show a loading state. + */ +data class CollectionPlaybackKey( + val type: RemoteCollectionType, + val id: String, + val action: PlaybackDestinationAction, + val shuffle: Boolean = false) + +/** Loading flags for a collection screen's play / shuffle / add-to-queue buttons. */ +data class CollectionPlaybackUiState( + val isPlayLoading: Boolean = false, + val isShuffleLoading: Boolean = false, + val isAddToQueueLoading: Boolean = false) + /** * Routes playback actions (play / add to queue / play next) to either the local * device or a connected remote device. When a remote device is connected the @@ -103,6 +123,18 @@ class RemotePlaybackController( private val _events = MutableSharedFlow(extraBufferCapacity = 8) val events: SharedFlow = _events.asSharedFlow() + /** Collection actions currently running locally; see [CollectionPlaybackKey]. */ + private val _busyCollections = MutableStateFlow>(emptySet()) + val busyCollections: StateFlow> = _busyCollections.asStateFlow() + + fun collectionPlaybackUiState(type: RemoteCollectionType, id: String): Flow = + busyCollections.map { busy -> + CollectionPlaybackUiState( + isPlayLoading = CollectionPlaybackKey(type, id, PlaybackDestinationAction.Play) in busy, + isShuffleLoading = CollectionPlaybackKey(type, id, PlaybackDestinationAction.Play, shuffle = true) in busy, + isAddToQueueLoading = CollectionPlaybackKey(type, id, PlaybackDestinationAction.AddToQueue) in busy) + }.distinctUntilChanged() + fun isRemoteConnected(): Boolean { return remoteControlClient.connectionState.value is ConnectionState.Connected } @@ -230,103 +262,119 @@ class RemotePlaybackController( } private fun executeLocally(request: PlaybackDestinationRequest) { + // Starting from a specific track (a tap in a track list) isn't tied to a collection button. + val busyKey = (request as? PlaybackDestinationRequest.Collection) + ?.takeIf { it.startTrack == null } + ?.let { CollectionPlaybackKey(it.type, it.id, it.action, it.shuffle) } + if (busyKey != null && busyKey in _busyCollections.value) return + busyKey?.let { key -> _busyCollections.update { it + key } } scope.launch { - when (request) { - is PlaybackDestinationRequest.Collection -> { - val startTrack = request.startTrack - when (request.type) { - RemoteCollectionType.Playlist -> when (request.action) { - PlaybackDestinationAction.Play -> { - if (startTrack != null) { - collectionPlaybackHelper.playPlaylistFromTrack(request.id, startTrack) - } else { - collectionPlaybackHelper.playPlaylist(request.id, request.shuffle) - } + try { + executeLocallyNow(request) + } catch (e: Exception) { + logger.e(e) { "Failed to play ${request.title} locally" } + } finally { + busyKey?.let { key -> _busyCollections.update { it - key } } + } + } + } + + private suspend fun executeLocallyNow(request: PlaybackDestinationRequest) { + when (request) { + is PlaybackDestinationRequest.Collection -> { + val startTrack = request.startTrack + when (request.type) { + RemoteCollectionType.Playlist -> when (request.action) { + PlaybackDestinationAction.Play -> { + if (startTrack != null) { + collectionPlaybackHelper.playPlaylistFromTrack(request.id, startTrack) + } else { + collectionPlaybackHelper.playPlaylist(request.id, request.shuffle) } - - PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addPlaylistToQueue(request.id) - PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.playPlaylistNext(request.id) } - RemoteCollectionType.Album -> when (request.action) { - PlaybackDestinationAction.Play -> { - if (startTrack != null) { - collectionPlaybackHelper.playAlbumFromTrack(request.id, startTrack) - } else { - collectionPlaybackHelper.playAlbum(request.id, request.shuffle) - } + PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addPlaylistToQueue(request.id) + PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.playPlaylistNext(request.id) + } + + RemoteCollectionType.Album -> when (request.action) { + PlaybackDestinationAction.Play -> { + if (startTrack != null) { + collectionPlaybackHelper.playAlbumFromTrack(request.id, startTrack) + } else { + collectionPlaybackHelper.playAlbum(request.id, request.shuffle) } - - PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addAlbumToQueue(request.id) - PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.playAlbumNext(request.id) } - RemoteCollectionType.ArtistTopTracks -> when (request.action) { - PlaybackDestinationAction.Play -> collectionPlaybackHelper.playArtistTopTracks(request.id) - PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addArtistTopTracksToQueue(request.id) - PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.playArtistTopTracksNext(request.id) - } + PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addAlbumToQueue(request.id) + PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.playAlbumNext(request.id) + } - RemoteCollectionType.SavedTracks -> when (request.action) { - PlaybackDestinationAction.Play -> { - if (startTrack != null) { - collectionPlaybackHelper.playSavedTracksFromTrack(startTrack) - } else { - collectionPlaybackHelper.playSavedTracks(request.shuffle) - } + RemoteCollectionType.ArtistTopTracks -> when (request.action) { + PlaybackDestinationAction.Play -> collectionPlaybackHelper.playArtistTopTracks(request.id) + PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addArtistTopTracksToQueue(request.id) + PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.playArtistTopTracksNext(request.id) + } + + RemoteCollectionType.SavedTracks -> when (request.action) { + PlaybackDestinationAction.Play -> { + if (startTrack != null) { + collectionPlaybackHelper.playSavedTracksFromTrack(startTrack) + } else { + collectionPlaybackHelper.playSavedTracks(request.shuffle) } - - PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addSavedTracksToQueue() - PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.addSavedTracksToQueue() } + + PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addSavedTracksToQueue() + PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.addSavedTracksToQueue() } } + } - is PlaybackDestinationRequest.Track -> { - val entry = QueueEntry.StreamingTrack(track = request.track, url = "") - when (request.action) { - PlaybackDestinationAction.Play -> { - audioPlayerQueue.load( - entries = listOf(entry), - autoPlay = true, - startPosition = 0, - collectionEntry = null) - } + is PlaybackDestinationRequest.Track -> { + val entry = QueueEntry.StreamingTrack(track = request.track, url = "") + when (request.action) { + PlaybackDestinationAction.Play -> { + audioPlayerQueue.load( + entries = listOf(entry), + autoPlay = true, + startPosition = 0, + collectionEntry = null) + } - PlaybackDestinationAction.AddToQueue -> { - audioPlayerQueue.addToQueue(entry) - } + PlaybackDestinationAction.AddToQueue -> { + audioPlayerQueue.addToQueue(entry) + } - PlaybackDestinationAction.PlayNext -> { - val queue = audioPlayerQueue.getQueue() - queue.find { candidate -> - (candidate as? QueueEntry.StreamingTrack)?.track?.matchesTrack(request.track) == true - }?.let { audioPlayerQueue.removeFromQueue(it) } - audioPlayerQueue.addAllAfterCurrent(listOf(entry)) - } + PlaybackDestinationAction.PlayNext -> { + val queue = audioPlayerQueue.getQueue() + queue.find { candidate -> + (candidate as? QueueEntry.StreamingTrack)?.track?.matchesTrack(request.track) == true + }?.let { audioPlayerQueue.removeFromQueue(it) } + audioPlayerQueue.addAllAfterCurrent(listOf(entry)) } } + } - is PlaybackDestinationRequest.Tracks -> { - val entries = request.tracks - .filter { track -> !isTrackBlacklisted(track) } - .map { track -> QueueEntry.StreamingTrack(track = track, url = "") } - when (request.action) { - PlaybackDestinationAction.Play -> { - audioPlayerQueue.load( - entries = entries, - autoPlay = true, - startPosition = 0, - collectionEntry = null) - } + is PlaybackDestinationRequest.Tracks -> { + val entries = request.tracks + .filter { track -> !isTrackBlacklisted(track) } + .map { track -> QueueEntry.StreamingTrack(track = track, url = "") } + when (request.action) { + PlaybackDestinationAction.Play -> { + audioPlayerQueue.load( + entries = entries, + autoPlay = true, + startPosition = 0, + collectionEntry = null) + } - PlaybackDestinationAction.AddToQueue -> { - audioPlayerQueue.addAllToQueue(entries) - } + PlaybackDestinationAction.AddToQueue -> { + audioPlayerQueue.addAllToQueue(entries) + } - PlaybackDestinationAction.PlayNext -> { - audioPlayerQueue.addAllAfterCurrent(entries) - } + PlaybackDestinationAction.PlayNext -> { + audioPlayerQueue.addAllAfterCurrent(entries) } } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/AlbumCard.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/AlbumCard.kt index 40694847..b651edbf 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/AlbumCard.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/AlbumCard.kt @@ -28,6 +28,8 @@ import org.spotube.spotube.core.audioplayer.AudioPlayerQueue import org.spotube.spotube.core.navigation.NavigationCommands import org.spotube.spotube.core.navigation.Routes import org.spotube.spotube.core.playback.CollectionPlaybackHelper +import org.spotube.spotube.core.remote.CollectionPlaybackKey +import org.spotube.spotube.core.remote.PlaybackDestinationAction import org.spotube.spotube.core.remote.RemoteCollectionType import org.spotube.spotube.core.remote.RemotePlaybackController import org.spotube.spotube.core.ui.component.cards.PlayableCard @@ -44,6 +46,7 @@ fun AlbumCard( listMode: Boolean = false ) { val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() + val busyCollections by remotePlaybackController.busyCollections.collectAsStateWithLifecycle() PlayableCard( title = album.title, @@ -71,6 +74,8 @@ fun AlbumCard( }, modifier = if (listMode) modifier else modifier.width(160.dp), sharedElementKey = "album_art_${album.id}", - listMode = listMode + listMode = listMode, + playLoading = CollectionPlaybackKey(RemoteCollectionType.Album, album.id, PlaybackDestinationAction.Play) in busyCollections, + addToQueueLoading = CollectionPlaybackKey(RemoteCollectionType.Album, album.id, PlaybackDestinationAction.AddToQueue) in busyCollections ) } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/CollectionDetails.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/CollectionDetails.kt index 1ac2597e..84901d48 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/CollectionDetails.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/CollectionDetails.kt @@ -88,6 +88,10 @@ fun CollectionDetails( isJamGuest: Boolean = false, onAddToJam: (() -> Unit)? = null, isFollowing: Boolean = false, + isFollowLoading: Boolean = false, + isPlayLoading: Boolean = false, + isShuffleLoading: Boolean = false, + isAddToQueueLoading: Boolean = false, onFollowClick: () -> Unit = { }, showFollowButton: Boolean = true, onEdit: (() -> Unit)? = null, @@ -126,7 +130,8 @@ fun CollectionDetails( MillenniumButton( modifier = modifier, - onClick = onPlay) { + onClick = onPlay, + loading = isPlayLoading) { MillenniumIcon( imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2, contentDescription = if (isPlaying) stringResource(Res.string.player_pause_content_description) else stringResource(Res.string.player_play_content_description)) @@ -135,6 +140,7 @@ fun CollectionDetails( MillenniumButton( modifier = modifier, onClick = onShufflePlay, + loading = isShuffleLoading, style = MillenniumButtonStyle.Secondary ) { MillenniumIcon(imageVector = Iconsax.IconsaxShuffle, contentDescription = stringResource(Res.string.collection_details_shuffle_play)) @@ -148,6 +154,7 @@ fun CollectionDetails( Row(verticalAlignment = Alignment.CenterVertically) { MillenniumIconButton( onClick = onAddToQueue, + loading = isAddToQueueLoading, style = MillenniumIconButtonStyle.Quiet) { MillenniumIcon( imageVector = Iconsax.IconsaxAddSquare, @@ -159,6 +166,7 @@ fun CollectionDetails( if (isFollowing) { MillenniumIconButton( onClick = onFollowClick, + loading = isFollowLoading, style = MillenniumIconButtonStyle.Quiet) { MillenniumIcon( imageVector = Iconsax.IconsaxHeart2, @@ -167,6 +175,7 @@ fun CollectionDetails( } else { MillenniumIconButton( onClick = onFollowClick, + loading = isFollowLoading, style = MillenniumIconButtonStyle.Quiet) { MillenniumIcon( imageVector = Iconsax.IconsaxHeart, diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/CollectionView.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/CollectionView.kt index 678dfc5b..e29aa918 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/CollectionView.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/CollectionView.kt @@ -69,6 +69,10 @@ fun CollectionView( onAddToQueue: () -> Unit, isPlaying: Boolean = false, isFollowing: Boolean = false, + isFollowLoading: Boolean = false, + isPlayLoading: Boolean = false, + isShuffleLoading: Boolean = false, + isAddToQueueLoading: Boolean = false, onFollowClick: () -> Unit = {}, showFollowButton: Boolean = true, onEdit: (() -> Unit)? = null, @@ -123,6 +127,7 @@ fun CollectionView( { MillenniumIconButton( onClick = onPlay, + loading = isPlayLoading, style = MillenniumIconButtonStyle.Quiet) { MillenniumIcon( imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2, @@ -159,6 +164,10 @@ fun CollectionView( onAddToQueue = onAddToQueue, isPlaying = isPlaying, isFollowing = isFollowing, + isFollowLoading = isFollowLoading, + isPlayLoading = isPlayLoading, + isShuffleLoading = isShuffleLoading, + isAddToQueueLoading = isAddToQueueLoading, onFollowClick = onFollowClick, showFollowButton = showFollowButton, onEdit = onEdit, @@ -180,6 +189,10 @@ fun CollectionView( onAddToQueue = onAddToQueue, isPlaying = isPlaying, isFollowing = isFollowing, + isFollowLoading = isFollowLoading, + isPlayLoading = isPlayLoading, + isShuffleLoading = isShuffleLoading, + isAddToQueueLoading = isAddToQueueLoading, onFollowClick = onFollowClick, showFollowButton = showFollowButton, onEdit = onEdit, diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/ErrorDisplay.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/ErrorDisplay.kt index 753947e4..a5484b68 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/ErrorDisplay.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/ErrorDisplay.kt @@ -87,6 +87,7 @@ fun ErrorDisplay( LoginRequiredDisplay( pluginName = authState.pluginName, onLogin = authViewModel::login, + isLoggingIn = authState.isLoggingIn, modifier = modifier) return } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/FeaturedCarousel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/FeaturedCarousel.kt index 33ff15e6..6c768959 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/FeaturedCarousel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/FeaturedCarousel.kt @@ -72,7 +72,10 @@ import org.spotube.spotube.core.audioplayer.QueueCollectionEntry import org.spotube.spotube.core.audioplayer.QueueEntry import org.spotube.spotube.core.navigation.NavigationCommands import org.spotube.spotube.core.navigation.Routes -import org.spotube.spotube.core.playback.CollectionPlaybackHelper +import org.spotube.spotube.core.remote.CollectionPlaybackKey +import org.spotube.spotube.core.remote.PlaybackDestinationAction +import org.spotube.spotube.core.remote.RemoteCollectionType +import org.spotube.spotube.core.remote.RemotePlaybackController import org.spotube.spotube.core.ui.coverflow.Coverflow import org.spotube.spotube.core.ui.coverflow.CoverflowParams import org.spotube.spotube.core.ui.coverflow.rememberCoverflowState @@ -288,11 +291,12 @@ private fun FeaturedPlayButton( item: MetadataBrowseItem, audioPlayerQueue: AudioPlayerQueue, navigationCommands: NavigationCommands, - playbackHelper: CollectionPlaybackHelper = koinInject(), + remotePlaybackController: RemotePlaybackController = koinInject(), enabled: Boolean, modifier: Modifier = Modifier) { val scope = rememberCoroutineScope() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() + val busyCollections by remotePlaybackController.busyCollections.collectAsStateWithLifecycle() when (item) { is MetadataBrowseItem.Album -> { @@ -309,10 +313,18 @@ private fun FeaturedPlayButton( if (isPlaying) { navigationCommands.navigateTo(Routes.Album(item.data.id)) } else { - scope.launch { playbackHelper.playAlbum(item.data.id) } + remotePlaybackController.requestCollectionPlay( + RemoteCollectionType.Album, + item.data.id, + item.data.title + ) } }, - enabled = enabled + enabled = enabled, + loading = CollectionPlaybackKey( + RemoteCollectionType.Album, item.data.id, PlaybackDestinationAction.Play + ) in busyCollections, + foreground = Color.White ) { MillenniumIcon( imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, @@ -337,10 +349,18 @@ private fun FeaturedPlayButton( if (isPlaying) { navigationCommands.navigateTo(Routes.Playlist(item.data.id)) } else { - scope.launch { playbackHelper.playPlaylist(item.data.id) } + remotePlaybackController.requestCollectionPlay( + RemoteCollectionType.Playlist, + item.data.id, + item.data.title + ) } }, - enabled = enabled + enabled = enabled, + loading = CollectionPlaybackKey( + RemoteCollectionType.Playlist, item.data.id, PlaybackDestinationAction.Play + ) in busyCollections, + foreground = Color.White ) { MillenniumIcon( imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/LikedTracksCard.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/LikedTracksCard.kt index 33ec72c8..98439207 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/LikedTracksCard.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/LikedTracksCard.kt @@ -38,7 +38,6 @@ import com.composeunstyled.Text import com.composeunstyled.LocalTextStyle import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -50,14 +49,17 @@ import org.spotube.spotube.core.audioplayer.AudioPlayerQueue import org.spotube.spotube.core.audioplayer.QueueCollectionEntry import org.spotube.spotube.core.navigation.NavigationCommands import org.spotube.spotube.core.navigation.Routes -import org.spotube.spotube.core.playback.CollectionPlaybackHelper import org.spotube.spotube.resources.iconsax.Iconsax import org.spotube.spotube.resources.iconsax.IconsaxAddSquare import org.spotube.spotube.resources.iconsax.IconsaxPauseCircle import org.spotube.spotube.resources.iconsax.Play -import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource import org.koin.compose.koinInject +import org.spotube.spotube.core.remote.CollectionPlaybackKey +import org.spotube.spotube.core.remote.PlaybackDestinationAction +import org.spotube.spotube.core.remote.RemoteCollectionType +import org.spotube.spotube.core.remote.RemotePlaybackController +import org.spotube.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID import spotube.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource import org.spotube.spotube.core.ui.component.cards.PlayableListTile @@ -72,14 +74,26 @@ import androidx.compose.ui.platform.LocalInputModeManager fun LikedTracksCard( modifier: Modifier = Modifier, audioPlayerQueue: AudioPlayerQueue = koinInject(), - playbackHelper: CollectionPlaybackHelper = koinInject(), + remotePlaybackController: RemotePlaybackController = koinInject(), navigationCommands: NavigationCommands = koinInject(), listMode: Boolean = false ) { - val scope = rememberCoroutineScope() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() + val busyCollections by remotePlaybackController.busyCollections.collectAsStateWithLifecycle() val isPlaying = currentCollectionEntry is QueueCollectionEntry.SavedTracks val title = stringResource(Res.string.media_liked_tracks) + val playLoading = CollectionPlaybackKey( + RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, PlaybackDestinationAction.Play + ) in busyCollections + val addToQueueLoading = CollectionPlaybackKey( + RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, PlaybackDestinationAction.AddToQueue + ) in busyCollections + val onPlay = { + remotePlaybackController.requestCollectionPlay(RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, title) + } + val onAddToQueue = { + remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, title) + } val subtitle = stringResource(Res.string.media_saved_songs) if (listMode) { PlayableListTile( @@ -87,10 +101,12 @@ fun LikedTracksCard( subtitle = subtitle, isPlaying = isPlaying, onClick = { navigationCommands.navigateTo(Routes.SavedTracks) }, - onPlay = { scope.launch { playbackHelper.playSavedTracks() } }, - onAddToQueue = { scope.launch { playbackHelper.addSavedTracksToQueue() } }, + onPlay = onPlay, + onAddToQueue = onAddToQueue, modifier = modifier, sharedElementKey = "saved_tracks_art", + playLoading = playLoading, + addToQueueLoading = addToQueueLoading, artwork = { Image( painterResource(Res.drawable.liked_tracks), @@ -132,13 +148,14 @@ fun LikedTracksCard( modifier = Modifier.fillMaxWidth().aspectRatio(1f).padding(8.dp), contentAlignment = Alignment.BottomEnd ) { - if (isHovered || touchInput || isPlaying) { + if (isHovered || touchInput || isPlaying || playLoading || addToQueueLoading) { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(4.dp) ) { MillenniumIconButton( - onClick = { scope.launch { playbackHelper.addSavedTracksToQueue() } }, + onClick = onAddToQueue, + loading = addToQueueLoading, modifier = Modifier.size(30.dp), style = MillenniumIconButtonStyle.Secondary ) { @@ -150,7 +167,8 @@ fun LikedTracksCard( } Spacer(modifier = Modifier.height(8.dp)) MillenniumIconButton( - onClick = { scope.launch { playbackHelper.playSavedTracks() } }, + onClick = onPlay, + loading = playLoading, modifier = Modifier.size(30.dp), style = MillenniumIconButtonStyle.Primary ) { diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/LoginRequiredDisplay.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/LoginRequiredDisplay.kt index 77a84021..f65f5b9a 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/LoginRequiredDisplay.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/LoginRequiredDisplay.kt @@ -49,7 +49,8 @@ import spotube.composeapp.generated.resources.* fun LoginRequiredDisplay( pluginName: String?, onLogin: () -> Unit, - modifier: Modifier = Modifier) { + modifier: Modifier = Modifier, + isLoggingIn: Boolean = false) { Column( modifier = modifier .fillMaxWidth() @@ -88,6 +89,7 @@ fun LoginRequiredDisplay( MillenniumButton( onClick = onLogin, + loading = isLoggingIn, modifier = Modifier.height(44.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { MillenniumIcon( diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/PlaylistCard.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/PlaylistCard.kt index d29fa1c4..ecb12b79 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/PlaylistCard.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/PlaylistCard.kt @@ -27,6 +27,8 @@ import org.spotube.spotube.core.audioplayer.AudioPlayerQueue import org.spotube.spotube.core.navigation.NavigationCommands import org.spotube.spotube.core.navigation.Routes import org.spotube.spotube.core.playback.CollectionPlaybackHelper +import org.spotube.spotube.core.remote.CollectionPlaybackKey +import org.spotube.spotube.core.remote.PlaybackDestinationAction import org.spotube.spotube.core.remote.RemoteCollectionType import org.spotube.spotube.core.remote.RemotePlaybackController import org.spotube.spotube.core.ui.component.cards.PlayableCard @@ -45,6 +47,7 @@ fun PlaylistCard( ) { val scope = rememberCoroutineScope() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() + val busyCollections by remotePlaybackController.busyCollections.collectAsStateWithLifecycle() PlayableCard( title = playlist.title, @@ -71,6 +74,8 @@ fun PlaylistCard( }, modifier = modifier, sharedElementKey = "playlist_art_${playlist.id}", - listMode = listMode + listMode = listMode, + playLoading = CollectionPlaybackKey(RemoteCollectionType.Playlist, playlist.id, PlaybackDestinationAction.Play) in busyCollections, + addToQueueLoading = CollectionPlaybackKey(RemoteCollectionType.Playlist, playlist.id, PlaybackDestinationAction.AddToQueue) in busyCollections ) } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/PlayableCard.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/PlayableCard.kt index c8b98a14..8f9e81d8 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/PlayableCard.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/PlayableCard.kt @@ -76,7 +76,9 @@ fun PlayableCard( onAddToQueue: (() -> Unit)? = null, modifier: Modifier = Modifier, sharedElementKey: String? = null, - listMode: Boolean = false + listMode: Boolean = false, + playLoading: Boolean = false, + addToQueueLoading: Boolean = false ) { if (listMode) { PlayableListTile( @@ -88,7 +90,9 @@ fun PlayableCard( onPlay, onAddToQueue, modifier, - sharedElementKey + sharedElementKey, + playLoading = playLoading, + addToQueueLoading = addToQueueLoading ) return } @@ -124,7 +128,8 @@ fun PlayableCard( contentAlignment = Alignment.BottomEnd ) { this@Column.AnimatedVisibility( - visible = highlighted || touchInput || (isPlaying && onPlay != null), + visible = highlighted || touchInput || (isPlaying && onPlay != null) || + playLoading || addToQueueLoading, enter = fadeIn(), exit = fadeOut() ) { @@ -137,6 +142,7 @@ fun PlayableCard( onAddToQueue?.takeIf { !isPlaying }?.let { MillenniumIconButton( onClick = onAddToQueue, + loading = addToQueueLoading, modifier = Modifier.size(34.dp), style = MillenniumIconButtonStyle.Secondary ) { @@ -152,6 +158,7 @@ fun PlayableCard( onPlay?.let { MillenniumIconButton( onClick = onPlay, + loading = playLoading, modifier = Modifier.size(34.dp), style = MillenniumIconButtonStyle.Primary diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/PlayableListTile.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/PlayableListTile.kt index f01bcb0f..abc69cda 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/PlayableListTile.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/PlayableListTile.kt @@ -56,6 +56,8 @@ fun PlayableListTile( onAddToQueue: (() -> Unit)? = null, modifier: Modifier = Modifier, sharedElementKey: String? = null, + playLoading: Boolean = false, + addToQueueLoading: Boolean = false, imageModel: Any? = imageURL, imageShape: Shape = RoundedCornerShape(6.dp), placeholderIcon: ImageVector = Iconsax.IconsaxMusic, @@ -115,6 +117,7 @@ fun PlayableListTile( if (onAddToQueue != null && !isPlaying) { MillenniumIconButton( onClick = onAddToQueue, + loading = addToQueueLoading, modifier = Modifier.size(if (compact) 32.dp else 40.dp), style = MillenniumIconButtonStyle.Secondary ) { @@ -127,6 +130,7 @@ fun PlayableListTile( if (onPlay != null) { MillenniumIconButton( onClick = onPlay, + loading = playLoading, modifier = Modifier.size(if (compact) 32.dp else 40.dp), style = MillenniumIconButtonStyle.Primary ) { diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/album/AlbumScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/album/AlbumScreen.kt index 50d690f2..bd134ba8 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/album/AlbumScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/album/AlbumScreen.kt @@ -45,6 +45,7 @@ fun AlbumScreen( navigationCommands: NavigationCommands ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + val playbackUiState by viewModel.playbackUiState.collectAsStateWithLifecycle() val jamRoomService: JamRoomService = koinInject() val jamActive by jamRoomService.role.map { it != null } .collectAsStateWithLifecycle(initialValue = false) @@ -71,6 +72,9 @@ fun AlbumScreen( val isLoading = state is AlbumScreenState.Loading && (dataState == null || dataState.tracks.isEmpty()) CollectionView( + isPlayLoading = playbackUiState.isPlayLoading, + isShuffleLoading = playbackUiState.isShuffleLoading, + isAddToQueueLoading = playbackUiState.isAddToQueueLoading, title = album?.title ?: stringResource(Res.string.album_loading_title), description = album?.description ?: "${album?.albumType?.name.orEmpty()} • ${album?.releaseDate.orEmpty()}", imageURL = artworkUrl, @@ -86,6 +90,7 @@ fun AlbumScreen( onAddToQueue = viewModel::addAlbumToQueue, isPlaying = isAlbumPlaying, isFollowing = savedAlbumIds.contains(albumId), + isFollowLoading = dataState?.isSaving == true, onFollowClick = viewModel::toggleSavedAlbum, isLoading = isLoading, error = errorMessage, diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/album/AlbumViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/album/AlbumViewModel.kt index 0ba58bd4..eced34d5 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/album/AlbumViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/album/AlbumViewModel.kt @@ -26,6 +26,7 @@ import org.spotube.spotube.core.audioplayer.AudioPlayerQueue import org.spotube.spotube.core.audioplayer.QueueEntry import org.spotube.spotube.core.di.injectLogger import org.spotube.spotube.core.playback.CollectionPlaybackHelper +import org.spotube.spotube.core.remote.CollectionPlaybackUiState import org.spotube.spotube.core.remote.RemoteCollectionType import org.spotube.spotube.core.remote.RemotePlaybackController import org.spotube.spotube.core.share.ShareService @@ -47,6 +48,7 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.jetbrains.compose.resources.getString @@ -104,6 +106,11 @@ class AlbumViewModel( private val _state = MutableStateFlow(AlbumScreenState.Loading) val uiState: StateFlow = _state.asStateFlow() + + /** Loading flags for this screen's play / shuffle / add-to-queue buttons. */ + val playbackUiState: StateFlow = remotePlaybackController + .collectionPlaybackUiState(RemoteCollectionType.Album, albumId) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), CollectionPlaybackUiState()) val savedAlbumIds get() = libraryRepository.savedAlbumIdsFlow @@ -203,13 +210,30 @@ class AlbumViewModel( } fun toggleSavedAlbum() { + if ((_state.value as? AlbumScreenState.Data)?.isSaving == true) return + setSaving(true) viewModelScope.launch { - val isLiked = - libraryRepository.isSavedAlbums(listOf(albumId)).firstOrNull() ?: false - if (isLiked) { - libraryRepository.removeSavedAlbums(listOf(albumId)) - } else { - libraryRepository.saveAlbums(listOf(albumId)) + runCatching { + val isLiked = + libraryRepository.isSavedAlbums(listOf(albumId)).firstOrNull() ?: false + if (isLiked) { + libraryRepository.removeSavedAlbums(listOf(albumId)) + } else { + libraryRepository.saveAlbums(listOf(albumId)) + } + }.onFailure { e -> + logger.e(e) { "Failed to toggle saved album" } + } + setSaving(false) + } + } + + private fun setSaving(isSaving: Boolean) { + _state.update { state -> + when (state) { + is AlbumScreenState.Data.Loaded -> state.copy(isSaving = isSaving) + is AlbumScreenState.Data.LoadingMore -> state.copy(isSaving = isSaving) + else -> state } } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/artist/ArtistScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/artist/ArtistScreen.kt index 30bda22f..68d5560b 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/artist/ArtistScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/artist/ArtistScreen.kt @@ -104,6 +104,7 @@ fun ArtistScreen( navigationCommands: NavigationCommands ) { val state by viewModel.state.collectAsStateWithLifecycle() + val playbackUiState by viewModel.playbackUiState.collectAsStateWithLifecycle() val jamRoomService: JamRoomService = koinInject() val jamActive by jamRoomService.role.map { it != null } .collectAsStateWithLifecycle(initialValue = false) @@ -149,6 +150,7 @@ fun ArtistScreen( ArtistHeaderCard( artist = currentState.artist, isSaved = savedArtistIds.contains(currentState.artist.id), + isSaving = currentState.isSaving, onFollowClick = viewModel::toggleSavedArtist, isBlacklisted = currentState.artist.id in blacklistedArtistIds, onBlacklistClick = { viewModel.toggleArtistBlacklist(currentState.artist) }) @@ -157,7 +159,9 @@ fun ArtistScreen( item { TopTracksHeader( onPlay = viewModel::playTopTracks, - onAddToQueue = viewModel::addTopTracksToQueue + onAddToQueue = viewModel::addTopTracksToQueue, + isPlayLoading = playbackUiState.isPlayLoading, + isAddToQueueLoading = playbackUiState.isAddToQueueLoading ) } @@ -345,6 +349,7 @@ private fun ArtistErrorContent( private fun ArtistHeaderCard( artist: MetadataArtist.Detailed, isSaved: Boolean, + isSaving: Boolean, onFollowClick: () -> Unit, isBlacklisted: Boolean, onBlacklistClick: () -> Unit @@ -377,6 +382,7 @@ private fun ArtistHeaderCard( ArtistHeaderActions( isSaved = isSaved, + isSaving = isSaving, onFollowClick = onFollowClick, isBlacklisted = isBlacklisted, onBlacklistClick = onBlacklistClick @@ -406,6 +412,7 @@ private fun ArtistHeaderCard( ArtistHeaderActions( isSaved = isSaved, + isSaving = isSaving, onFollowClick = onFollowClick, isBlacklisted = isBlacklisted, onBlacklistClick = onBlacklistClick @@ -496,6 +503,7 @@ private fun ArtistMeta( @Composable private fun ArtistHeaderActions( isSaved: Boolean, + isSaving: Boolean, onFollowClick: () -> Unit, isBlacklisted: Boolean, onBlacklistClick: () -> Unit @@ -507,6 +515,7 @@ private fun ArtistHeaderActions( if (isSaved) { MillenniumButton( onClick = onFollowClick, + loading = isSaving, style = MillenniumButtonStyle.Secondary ) { Text( @@ -517,7 +526,7 @@ private fun ArtistHeaderActions( ) } } else { - MillenniumButton(onClick = onFollowClick) { + MillenniumButton(onClick = onFollowClick, loading = isSaving) { Text(stringResource(Res.string.collection_details_follow), modifier = Modifier.width(65.dp), textAlign = TextAlign.Center) } } @@ -554,7 +563,9 @@ private fun ArtistHeaderActions( @Composable private fun TopTracksHeader( onPlay: () -> Unit, - onAddToQueue: () -> Unit + onAddToQueue: () -> Unit, + isPlayLoading: Boolean = false, + isAddToQueueLoading: Boolean = false ) { Row( modifier = Modifier @@ -572,6 +583,7 @@ private fun TopTracksHeader( Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { MillenniumIconButton( onClick = onPlay, + loading = isPlayLoading, style = MillenniumIconButtonStyle.Primary ) { MillenniumIcon( @@ -582,6 +594,7 @@ private fun TopTracksHeader( } MillenniumIconButton( onClick = onAddToQueue, + loading = isAddToQueueLoading, style = MillenniumIconButtonStyle.Secondary ) { MillenniumIcon( diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/artist/ArtistViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/artist/ArtistViewModel.kt index 9f915280..81764bab 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/artist/ArtistViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/artist/ArtistViewModel.kt @@ -27,6 +27,7 @@ import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import org.spotube.spotube.core.audioplayer.AudioPlayerQueue import org.spotube.spotube.core.audioplayer.QueueEntry import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.core.remote.CollectionPlaybackUiState import org.spotube.spotube.core.remote.RemoteCollectionType import org.spotube.spotube.core.remote.RemotePlaybackController import org.spotube.spotube.core.share.ShareService @@ -44,6 +45,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.jetbrains.compose.resources.getString @@ -61,7 +63,8 @@ sealed interface ArtistScreenState { val relatedArtists: List, val relatedArtistsNextPagination: PaginationStrategy?, val featuredPlaylists: List, - val featuredPlaylistsNextPagination: PaginationStrategy?) : ArtistScreenState + val featuredPlaylistsNextPagination: PaginationStrategy?, + val isSaving: Boolean = false) : ArtistScreenState data class Error(val message: String) : ArtistScreenState } @@ -82,6 +85,11 @@ class ArtistViewModel( private val _state = MutableStateFlow(ArtistScreenState.Loading) val state: StateFlow = _state.asStateFlow() + /** Loading flags for the top-tracks play / add-to-queue buttons. */ + val playbackUiState: StateFlow = remotePlaybackController + .collectionPlaybackUiState(RemoteCollectionType.ArtistTopTracks, artistId) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), CollectionPlaybackUiState()) + val savedArtistIds get() = libraryRepository.savedArtistIdsFlow @@ -152,13 +160,26 @@ class ArtistViewModel( } fun toggleSavedArtist() { + if ((_state.value as? ArtistScreenState.Loaded)?.isSaving == true) return + setSaving(true) viewModelScope.launch { - val isLiked = libraryRepository.isSavedArtists(listOf(artistId)).firstOrNull() ?: false - if (isLiked) { - libraryRepository.removeSavedArtists(listOf(artistId)) - } else { - libraryRepository.saveArtists(listOf(artistId)) + runCatching { + val isLiked = libraryRepository.isSavedArtists(listOf(artistId)).firstOrNull() ?: false + if (isLiked) { + libraryRepository.removeSavedArtists(listOf(artistId)) + } else { + libraryRepository.saveArtists(listOf(artistId)) + } + }.onFailure { e -> + logger.e(e) { "Failed to toggle saved artist" } } + setSaving(false) + } + } + + private fun setSaving(isSaving: Boolean) { + _state.update { state -> + if (state is ArtistScreenState.Loaded) state.copy(isSaving = isSaving) else state } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesScreen.kt index 58067ff9..e9d946d1 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesScreen.kt @@ -122,22 +122,16 @@ fun DevicesScreen( backButton = true, title = { Text(stringResource(Res.string.devices_title)) }, actions = { - if (state.isDiscovering) { - MillenniumSpokeSpinner( - size = 20.dp, - modifier = Modifier.padding(horizontal = 10.dp) + MillenniumIconButton( + onClick = viewModel::startDiscovery, + enabled = !state.isConnecting, + loading = state.isDiscovering, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxRefreshRight, + contentDescription = stringResource(Res.string.devices_refresh) ) - } else { - MillenniumIconButton( - onClick = viewModel::startDiscovery, - enabled = !state.isConnecting, - style = MillenniumIconButtonStyle.Quiet - ) { - MillenniumIcon( - imageVector = Iconsax.IconsaxRefreshRight, - contentDescription = stringResource(Res.string.devices_refresh) - ) - } } } ) @@ -302,6 +296,7 @@ private fun LazyItemScope.ConnectedDeviceCard( ) { MillenniumButton( onClick = onDisconnect, + loading = device.isDisconnecting, style = MillenniumButtonStyle.Secondary ) { Text(stringResource(Res.string.devices_disconnect)) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesViewModel.kt index e9c24fa4..19f365ee 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesViewModel.kt @@ -66,7 +66,8 @@ data class DeviceItemUi( data class ConnectedDeviceUi( val name: String?, - val address: String) + val address: String, + val isDisconnecting: Boolean = false) data class DevicesUiState( val devices: List = emptyList(), @@ -89,14 +90,15 @@ class DevicesViewModel( private val _isDiscovering = MutableStateFlow(false) private val _connectingToDevice = MutableStateFlow(null) private val _error = MutableStateFlow(null) + private val _isDisconnecting = MutableStateFlow(false) val uiState: StateFlow = combine( _devices, _isDiscovering, _connectingToDevice, remoteControlClient.connectionState, - _error - ) { devices, isDiscovering, connectingTo, connection, error -> + combine(_error, _isDisconnecting, ::Pair) + ) { devices, isDiscovering, connectingTo, connection, (error, isDisconnecting) -> val connected = connection as? ConnectionState.Connected DevicesUiState( devices = devices.values.map { device -> @@ -112,7 +114,8 @@ class DevicesViewModel( connectedDevice = connected?.let { ConnectedDeviceUi( name = it.deviceName.ifBlank { null }, - address = "${it.host}:${it.port}") + address = "${it.host}:${it.port}", + isDisconnecting = isDisconnecting) }, error = error) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DevicesUiState()) @@ -288,8 +291,14 @@ class DevicesViewModel( } fun disconnect() { + if (_isDisconnecting.value) return + _isDisconnecting.value = true viewModelScope.launch { - remoteControlClient.disconnect() + try { + remoteControlClient.disconnect() + } finally { + _isDisconnecting.value = false + } } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamScreen.kt index 3ae9a7a2..568a018b 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamScreen.kt @@ -203,12 +203,12 @@ internal fun SetupPanel( MillenniumButton( onClick = if (state.setupMode == JamSetupMode.Create) onCreate else onJoin, enabled = if (state.setupMode == JamSetupMode.Create) state.canCreate else state.canJoin, + loading = state.isBusy, modifier = Modifier.fillMaxWidth() ) { Text( stringResource( when { - state.isBusy -> Res.string.jam_setup_working state.setupMode == JamSetupMode.Create -> Res.string.jam_setup_create_button else -> Res.string.jam_setup_join_button } @@ -451,6 +451,7 @@ internal fun SessionPanel( } MillenniumButton( onClick = onLeave, + loading = state.isLeaving, style = MillenniumButtonStyle.Secondary, modifier = Modifier.align(Alignment.End) ) { diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamViewModel.kt index 2d89c67d..5041e371 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamViewModel.kt @@ -65,6 +65,7 @@ data class JamUiState( val canCreate: Boolean = false, val canJoin: Boolean = false, val isBusy: Boolean = false, + val isLeaving: Boolean = false, val isHost: Boolean = false, val supportsNativeShare: Boolean = false, val codeCopied: Boolean = false @@ -79,6 +80,7 @@ class JamViewModel( private val _localError = MutableStateFlow(null) private val setupMode = MutableStateFlow(JamSetupMode.Create) private val isBusy = MutableStateFlow(false) + private val isLeaving = MutableStateFlow(false) private val codeCopied = MutableStateFlow(false) private val codeInput = TextFieldState() private var copyFeedbackJob: Job? = null @@ -99,7 +101,8 @@ class JamViewModel( isBusy, codeCopied, snapshotFlow { codeInput.text.toString() }, - participantMenu + participantMenu, + isLeaving ) { values -> @Suppress("UNCHECKED_CAST") val role = values[0] as JamRole? @@ -134,10 +137,11 @@ class JamViewModel( error = localError ?: connectionError, setupMode = values[7] as JamSetupMode, isBusy = busy, + isLeaving = values[12] as Boolean, codeCopied = values[9] as Boolean, codeInput = codeInput, - canCreate = brokerConfigured && !busy, - canJoin = brokerConfigured && !busy && + canCreate = brokerConfigured, + canJoin = brokerConfigured && JamRoomCode.isValid(JamRoomCode.normalize(values[10] as String)), isHost = role == JamRole.Host, supportsNativeShare = supportsNativeShare @@ -216,9 +220,15 @@ class JamViewModel( } fun leave() { + if (isLeaving.value) return + isLeaving.value = true viewModelScope.launch { - jamRoomService.leaveRoom() - _localError.value = null + try { + jamRoomService.leaveRoom() + _localError.value = null + } finally { + isLeaving.value = false + } } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryScreen.kt index f7b7bfc0..144286a0 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryScreen.kt @@ -120,7 +120,6 @@ fun LibraryScreen(libraryState: LibraryState = koinInject()) { LibraryTab.Playlists -> MetadataPluginGuard(showTopBar = false, requireLogin = true) { LibraryPlaylistsScreen( showCreatePlaylist = state.showCreatePlaylist, - onDismissCreatePlaylist = libraryState::dismissCreatePlaylist, viewMode = viewMode ) } @@ -228,6 +227,7 @@ internal fun RowScope.LibraryToolbarActions( icon = Iconsax.IconsaxRefreshRight, contentDescription = stringResource(Res.string.library_action_refresh_local_tracks), onClick = onRefresh, + loading = state.isRefreshing, modifier = actionModifier, style = MillenniumIconButtonStyle.Quiet ) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryState.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryState.kt index aa703732..cb85e965 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryState.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryState.kt @@ -42,6 +42,7 @@ data class LibraryToolbarUiState( val canFilter: Boolean = true, val canCreatePlaylist: Boolean = true, val canRefresh: Boolean = false, + val isRefreshing: Boolean = false, val showCreatePlaylist: Boolean = false, val canChangeView: Boolean = true, val viewModeOverride: LibraryViewMode? = null @@ -57,13 +58,15 @@ class LibraryState : ViewModel() { private val viewModes = MutableStateFlow>(emptyMap()) private val refreshRequests = MutableSharedFlow(extraBufferCapacity = 1) val localRefreshRequests = refreshRequests.asSharedFlow() + private val localRefreshing = MutableStateFlow(false) val uiState = combine( currentTab.flatMapLatest { tab -> queryFlow(tab).map { query -> tab to query } }, searchMode, createPlaylistVisible, - viewModes - ) { (tab, query), searching, creating, modes -> + viewModes, + localRefreshing + ) { (tab, query), searching, creating, modes, refreshing -> LibraryToolbarUiState( currentTab = tab, searchMode = tab != LibraryTab.Downloads && (searching || query.isNotEmpty()), @@ -71,6 +74,7 @@ class LibraryState : ViewModel() { canFilter = tab != LibraryTab.Downloads, canCreatePlaylist = tab == LibraryTab.Playlists, canRefresh = tab == LibraryTab.LocalTracks, + isRefreshing = tab == LibraryTab.LocalTracks && refreshing, showCreatePlaylist = tab == LibraryTab.Playlists && creating, canChangeView = tab == LibraryTab.Playlists || tab == LibraryTab.Albums || tab == LibraryTab.Artists, viewModeOverride = modes[tab] @@ -113,6 +117,11 @@ class LibraryState : ViewModel() { refreshRequests.tryEmit(Unit) } + /** Reported by the local tracks ViewModel so the toolbar's refresh action can show progress. */ + fun setLocalRefreshing(refreshing: Boolean) { + localRefreshing.value = refreshing + } + fun toggleViewMode(isCompact: Boolean) { val tab = currentTab.value if (tab != LibraryTab.Playlists && tab != LibraryTab.Albums && tab != LibraryTab.Artists) return diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LibraryLocalTracksViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LibraryLocalTracksViewModel.kt index b28db0c6..3fd0c16c 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LibraryLocalTracksViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LibraryLocalTracksViewModel.kt @@ -31,6 +31,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlin.coroutines.cancellation.CancellationException import org.spotube.spotube.PlatformType import org.spotube.spotube.getPlatform import org.jetbrains.compose.resources.getString @@ -160,19 +161,25 @@ class LibraryLocalTracksViewModel( } fun refresh() { - if (!_state.value.canAccessMedia) return + if (!_state.value.canAccessMedia || _state.value.isRefreshing) return + _state.update { it.copy(isRefreshing = true, error = null) } + libraryState.setLocalRefreshing(true) viewModelScope.launch { - _state.update { it.copy(isRefreshing = true, error = null) } - runCatching { - coordinator.refreshNow("manual") - }.onFailure { throwable -> - val message = - throwable.message ?: getString(Res.string.library_local_refresh_failed) - _state.update { - it.copy(error = message) + try { + runCatching { + coordinator.refreshNow("manual") + }.onFailure { throwable -> + if (throwable is CancellationException) throw throwable + val message = + throwable.message ?: getString(Res.string.library_local_refresh_failed) + _state.update { + it.copy(error = message) + } } + } finally { + _state.update { it.copy(isRefreshing = false) } + libraryState.setLocalRefreshing(false) } - _state.update { it.copy(isRefreshing = false) } } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt index cb652364..85a7f52a 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt @@ -28,11 +28,11 @@ import spotube.composeapp.generated.resources.* fun LibraryPlaylistsScreen( modifier: Modifier = Modifier, showCreatePlaylist: Boolean = false, - onDismissCreatePlaylist: () -> Unit = {}, viewMode: LibraryViewMode = LibraryViewMode.Grid, viewModel: LibraryPlaylistsViewModel = koinViewModel() ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + val playlistFormState by viewModel.playlistFormState.collectAsStateWithLifecycle() Column(modifier) { when (val current = state) { is LibraryPlaylistsState.Error -> Box( @@ -87,8 +87,10 @@ fun LibraryPlaylistsScreen( } PlaylistFormSheet( visible = showCreatePlaylist, - onDismiss = onDismissCreatePlaylist, - onSubmit = viewModel::createPlaylist + formState = playlistFormState, + onDismiss = viewModel::dismissCreatePlaylist, + onSubmit = viewModel::createPlaylist, + onImagePicked = viewModel::onPlaylistFormImagePicked ) } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LibraryPlaylistsViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LibraryPlaylistsViewModel.kt index 159fbd04..2503abf9 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LibraryPlaylistsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LibraryPlaylistsViewModel.kt @@ -35,6 +35,7 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import io.github.vinceglb.filekit.PlatformFile import org.koin.core.component.KoinComponent import org.jetbrains.compose.resources.getString import spotube.composeapp.generated.resources.Res @@ -159,10 +160,31 @@ class LibraryPlaylistsViewModel( } } - fun createPlaylist(data: PlaylistFormData) = createPlaylist( - data.name, data.description.ifBlank { null }, data.isPublic, - data.isCollaborating, data.imageBase64, emptyList() - ) + private val playlistForm = PlaylistFormController(viewModelScope, logger) + val playlistFormState: StateFlow = playlistForm.uiState + + fun onPlaylistFormImagePicked(file: PlatformFile) = playlistForm.onImagePicked(file) + + fun dismissCreatePlaylist() { + if (playlistFormState.value.isSubmitting) return + playlistForm.dismiss() + libraryState.dismissCreatePlaylist() + } + + fun createPlaylist(data: PlaylistFormData) { + playlistForm.submit(onSuccess = libraryState::dismissCreatePlaylist) { + repository.createPlaylist( + name = data.name, + description = data.description.ifBlank { null }, + isPublic = data.isPublic, + isCollaborating = data.isCollaborating, + imageBase64 = data.imageBase64, + trackIds = emptyList() + ) + repository.invalidateCaches() + loadInitialData() + } + } fun refresh() { viewModelScope.launch { @@ -170,31 +192,4 @@ class LibraryPlaylistsViewModel( loadInitialData() } } - - fun createPlaylist( - name: String, - description: String?, - isPublic: Boolean, - isCollaborating: Boolean, - imageBase64: String, - trackIds: List - ) { - viewModelScope.launch { - runCatching { - repository.createPlaylist( - name = name, - description = description, - isPublic = isPublic, - isCollaborating = isCollaborating, - imageBase64 = imageBase64, - trackIds = trackIds - ) - }.onFailure { e -> - logger.e(e) { "Failed to create playlist" } - }.onSuccess { - repository.invalidateCaches() - loadInitialData() - } - } - } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/PlaylistFormController.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/PlaylistFormController.kt new file mode 100644 index 00000000..ed60c201 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/PlaylistFormController.kt @@ -0,0 +1,96 @@ +/* + * 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 org.spotube.spotube.modules.library.playlist + +import co.touchlab.kermit.Logger +import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.readBytes +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okio.ByteString.Companion.toByteString + +data class PlaylistFormUiState( + val isVisible: Boolean = false, + val isSubmitting: Boolean = false, + val isProcessingImage: Boolean = false, + /** Base64 of an image picked in this session; `null` keeps the playlist's current cover. */ + val pickedImageBase64: String? = null +) + +/** + * Async state of [PlaylistFormSheet] (visibility, image encoding, submission), owned by the + * ViewModel that opens the sheet and driven on its [scope]. + */ +class PlaylistFormController( + private val scope: CoroutineScope, + private val logger: Logger +) { + private val _uiState = MutableStateFlow(PlaylistFormUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun show() { + _uiState.value = PlaylistFormUiState(isVisible = true) + } + + fun dismiss() { + if (_uiState.value.isSubmitting) return + _uiState.value = PlaylistFormUiState() + } + + fun onImagePicked(file: PlatformFile) { + if (_uiState.value.isProcessingImage) return + _uiState.update { it.copy(isProcessingImage = true) } + scope.launch { + runCatching { + withContext(Dispatchers.Default) { file.readBytes().toByteString().base64() } + }.onSuccess { base64 -> + _uiState.update { it.copy(pickedImageBase64 = base64) } + }.onFailure { e -> + logger.e(e) { "Failed to read playlist cover image" } + } + _uiState.update { it.copy(isProcessingImage = false) } + } + } + + /** + * Runs [action]; the sheet closes (and [onSuccess] runs) only if it succeeds, and stays open + * (re-enabled) on failure. + */ + fun submit(onSuccess: () -> Unit = {}, action: suspend () -> Unit) { + val current = _uiState.value + if (current.isSubmitting || current.isProcessingImage) return + _uiState.update { it.copy(isSubmitting = true) } + scope.launch { + runCatching { action() } + .onSuccess { + _uiState.value = PlaylistFormUiState() + onSuccess() + } + .onFailure { e -> + logger.e(e) { "Failed to submit playlist form" } + _uiState.update { it.copy(isSubmitting = false) } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/PlaylistFormSheet.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/PlaylistFormSheet.kt index 47e82f01..7ef9e130 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/PlaylistFormSheet.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/PlaylistFormSheet.kt @@ -34,9 +34,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll import org.spotube.spotube.core.ui.theming.AppTheme import com.composeunstyled.SheetDetent @@ -51,7 +49,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment @@ -62,12 +59,8 @@ import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import io.github.vinceglb.filekit.dialogs.FileKitType import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher -import io.github.vinceglb.filekit.readBytes -import kotlinx.coroutines.Dispatchers +import io.github.vinceglb.filekit.PlatformFile import kotlinx.coroutines.flow.drop -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import okio.ByteString.Companion.toByteString import org.jetbrains.compose.resources.stringResource import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.library_playlist_form_change_image @@ -101,8 +94,10 @@ data class PlaylistFormData( @Composable fun PlaylistFormSheet( visible: Boolean, + formState: PlaylistFormUiState, onDismiss: () -> Unit, onSubmit: (PlaylistFormData) -> Unit, + onImagePicked: (PlatformFile) -> Unit, modifier: Modifier = Modifier, initialData: PlaylistFormData? = null, isEditing: Boolean = false @@ -119,8 +114,8 @@ fun PlaylistFormSheet( initialData?.isCollaborating ?: false ) } - var imageBase64 by remember(initialData) { mutableStateOf(initialData?.imageBase64 ?: "") } - var imagePreviewUrl by remember(initialData) { mutableStateOf(initialData?.imagePreviewUrl) } + val imageBase64 = formState.pickedImageBase64 ?: initialData?.imageBase64.orEmpty() + val imagePreviewUrl = if (formState.pickedImageBase64 != null) null else initialData?.imagePreviewUrl val nameState = rememberTextFieldState(initialData?.name ?: "") val descriptionState = rememberTextFieldState(initialData?.description ?: "") @@ -136,20 +131,10 @@ fun PlaylistFormSheet( .collect { description = it } } - val scope = rememberCoroutineScope() val imagePicker = rememberFilePickerLauncher( type = FileKitType.Image ) { file -> - if (file != null) { - scope.launch { - val bytes = file.readBytes() - val base64 = withContext(Dispatchers.Default) { - bytes.toByteString().base64() - } - imageBase64 = base64 - imagePreviewUrl = null - } - } + if (file != null) onImagePicked(file) } val currentData = PlaylistFormData( @@ -176,15 +161,14 @@ fun PlaylistFormSheet( horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)) { MillenniumButton( onClick = onDismiss, + enabled = !formState.isSubmitting, style = MillenniumButtonStyle.Secondary) { Text(stringResource(Res.string.common_cancel)) } MillenniumButton( - onClick = { - onSubmit(currentData) - onDismiss() - }, - enabled = name.isNotBlank()) { + onClick = { onSubmit(currentData) }, + enabled = name.isNotBlank() && !formState.isProcessingImage, + loading = formState.isSubmitting) { Text(stringResource(if (isEditing) Res.string.common_save else Res.string.library_playlist_form_create)) } } @@ -198,6 +182,8 @@ fun PlaylistFormSheet( onIsCollaboratingChange = { isCollaborating = it }, imageBase64 = imageBase64, imagePreviewUrl = imagePreviewUrl, + isProcessingImage = formState.isProcessingImage, + isImagePickEnabled = !formState.isSubmitting, onPickImage = handleImagePick) } } @@ -212,12 +198,14 @@ private fun PlaylistFormFields( onIsCollaboratingChange: (Boolean) -> Unit, imageBase64: String, imagePreviewUrl: String?, + isProcessingImage: Boolean, + isImagePickEnabled: Boolean, onPickImage: () -> Unit ) { + // AdaptiveDialogBottomSheet already scrolls its content; a second scroll here nests + // scrollables and crashes with infinite height constraints. Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()), + modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp) ) { MillenniumTextField( @@ -312,7 +300,10 @@ private fun PlaylistFormFields( } } } - MillenniumButton(onClick = onPickImage) { + MillenniumButton( + onClick = onPickImage, + enabled = isImagePickEnabled, + loading = isProcessingImage) { Text(stringResource(if (imageBase64.isNotBlank() || imagePreviewUrl != null) Res.string.library_playlist_form_change_image else Res.string.library_playlist_form_pick_image)) } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/AddTracksToPlaylistDialog.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/AddTracksToPlaylistDialog.kt index c5f10295..2f785cd8 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/AddTracksToPlaylistDialog.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/AddTracksToPlaylistDialog.kt @@ -20,7 +20,6 @@ package org.spotube.spotube.modules.playlist import org.spotube.spotube.core.ui.component.AdaptiveDialogBottomSheet import org.spotube.millennium.composables.MillenniumCircularProgress -import org.spotube.millennium.composables.MillenniumSpokeSpinner import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumButton import org.spotube.millennium.composables.MillenniumButtonStyle @@ -48,15 +47,8 @@ import com.composeunstyled.Text import com.composeunstyled.rememberModalBottomSheetState import com.composeunstyled.currentWindowWidthBreakpoint import org.spotube.millennium.adaptive.WindowBreakpoints -import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -65,16 +57,14 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import org.spotube.spotube.modules.library.LibraryRepository -import org.spotube.spotube.modules.search.SearchRepository import org.spotube.spotube.resources.iconsax.Iconsax import org.spotube.spotube.resources.iconsax.IconsaxCheckCircle import org.spotube.spotube.resources.iconsax.IconsaxFilterSearch import org.spotube.spotube.resources.iconsax.IconsaxSearch -import kotlinx.coroutines.flow.drop -import kotlinx.coroutines.launch -import org.koin.compose.koinInject import org.jetbrains.compose.resources.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.playlist_add_tracks_add import spotube.composeapp.generated.resources.playlist_add_tracks_added_content_description @@ -90,59 +80,17 @@ fun AddTracksToPlaylistDialog( visible: Boolean, playlistId: String, onDismiss: () -> Unit, - modifier: Modifier = Modifier, - searchRepository: SearchRepository = koinInject(), - libraryRepository: LibraryRepository = koinInject()) { + modifier: Modifier = Modifier) { if (!visible) return - val isLargeScreen = currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Medium - - var query by remember { mutableStateOf("") } - var searchResults by remember { mutableStateOf>(emptyList()) } - var isSearching by remember { mutableStateOf(false) } - var hasSearched by remember { mutableStateOf(false) } - var addingTrackIds by remember { mutableStateOf>(emptySet()) } - var addedTrackIds by remember { mutableStateOf>(emptySet()) } - val scope = rememberCoroutineScope() - val queryState = rememberTextFieldState(query) - - LaunchedEffect(queryState) { - snapshotFlow { queryState.text.toString() } - .drop(1) - .collect { query = it } - } - - LaunchedEffect(visible) { - if (!visible) { - query = "" - searchResults = emptyList() - hasSearched = false - addingTrackIds = emptySet() - addedTrackIds = emptySet() - } - } - - val handleSearch = { - if (query.isNotBlank() && !isSearching) { - isSearching = true - hasSearched = true - scope.launch { - val result = searchRepository.searchTracks(query) - searchResults = result.items.map { it.data } - isSearching = false - } - } - } - - val handleAddTrack = { track: MetadataTrack -> - if (!addingTrackIds.contains(track.id) && !addedTrackIds.contains(track.id)) { - addingTrackIds = addingTrackIds + track.id - scope.launch { - libraryRepository.addTracksToPlaylist(playlistId, listOf(track.id)) - addingTrackIds = addingTrackIds - track.id - addedTrackIds = addedTrackIds + track.id - } - } + val viewModel: AddTracksToPlaylistViewModel = koinViewModel( + key = "add-tracks-$playlistId", + parameters = { parametersOf(playlistId) } + ) + val state by viewModel.uiState.collectAsStateWithLifecycle() + val dismiss = { + viewModel.reset() + onDismiss() } val content: @Composable () -> Unit = { @@ -154,18 +102,19 @@ fun AddTracksToPlaylistDialog( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { MillenniumTextField( - state = queryState, + state = state.queryState, modifier = Modifier.weight(1f), placeholder = stringResource(Res.string.playlist_add_tracks_search_placeholder)) MillenniumIconButton( icon = Iconsax.IconsaxSearch, contentDescription = stringResource(Res.string.playlist_add_tracks_search_content_description), - onClick = handleSearch, + onClick = viewModel::search, + loading = state.isSearching, style = MillenniumIconButtonStyle.Secondary) } when { - isSearching -> { + state.isSearching -> { Box( modifier = Modifier .fillMaxWidth() @@ -174,7 +123,7 @@ fun AddTracksToPlaylistDialog( MillenniumCircularProgress(size = 40.dp) } } - hasSearched && searchResults.isEmpty() -> { + state.hasSearched && state.results.isEmpty() -> { Box( modifier = Modifier .fillMaxWidth() @@ -183,18 +132,18 @@ fun AddTracksToPlaylistDialog( Text(stringResource(Res.string.playlist_add_tracks_no_results)) } } - searchResults.isNotEmpty() -> { + state.results.isNotEmpty() -> { LazyColumn( modifier = Modifier .fillMaxWidth() .weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { - items(searchResults, key = { it.id }) { track -> + items(state.results, key = { it.track.id }) { result -> TrackSearchResultRow( - track = track, - isAdding = addingTrackIds.contains(track.id), - isAdded = addedTrackIds.contains(track.id), - onAdd = { handleAddTrack(track) }) + track = result.track, + isAdding = result.isAdding, + isAdded = result.isAdded, + onAdd = { viewModel.addTrack(result.track.id) }) } } } @@ -213,7 +162,7 @@ fun AddTracksToPlaylistDialog( val dialogTitle = stringResource(Res.string.playlist_add_tracks_title) AdaptiveDialogBottomSheet( - onDismiss = onDismiss, + onDismiss = dismiss, modifier = modifier, dialogTitle = dialogTitle, title = { Text(dialogTitle, style = AppTheme.typography.headlineSmall) }, @@ -221,7 +170,7 @@ fun AddTracksToPlaylistDialog( Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { - MillenniumButton(onClick = onDismiss) { + MillenniumButton(onClick = dismiss) { Text(stringResource(Res.string.playlist_add_tracks_done)) } } @@ -288,12 +237,10 @@ private fun TrackSearchResultRow( tint = AppTheme.colors.primary, modifier = Modifier.size(24.dp)) } - isAdding -> { - MillenniumSpokeSpinner(size = 24.dp) - } else -> { MillenniumButton( onClick = onAdd, + loading = isAdding, style = MillenniumButtonStyle.Secondary) { Text(stringResource(Res.string.playlist_add_tracks_add)) } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/AddTracksToPlaylistViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/AddTracksToPlaylistViewModel.kt new file mode 100644 index 00000000..c120b64b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/AddTracksToPlaylistViewModel.kt @@ -0,0 +1,131 @@ +/* + * 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 org.spotube.spotube.modules.playlist + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import co.touchlab.kermit.Logger +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.modules.library.LibraryRepository +import org.spotube.spotube.modules.search.SearchRepository +import kotlin.coroutines.cancellation.CancellationException + +data class AddTrackResultUi( + val track: MetadataTrack, + val isAdding: Boolean, + val isAdded: Boolean +) + +data class AddTracksToPlaylistUiState( + val queryState: TextFieldState = TextFieldState(), + val results: List = emptyList(), + val isSearching: Boolean = false, + val hasSearched: Boolean = false +) + +private data class AddTracksState( + val results: List = emptyList(), + val isSearching: Boolean = false, + val hasSearched: Boolean = false, + val addingTrackIds: Set = emptySet(), + val addedTrackIds: Set = emptySet() +) + +class AddTracksToPlaylistViewModel( + private val playlistId: String, + private val searchRepository: SearchRepository, + private val libraryRepository: LibraryRepository +) : ViewModel() { + private val logger = Logger.withTag("AddTracksToPlaylistViewModel") + private val queryState = TextFieldState() + private val state = MutableStateFlow(AddTracksState()) + private var searchJob: Job? = null + + val uiState: StateFlow = state.map { current -> + AddTracksToPlaylistUiState( + queryState = queryState, + results = current.results.map { track -> + AddTrackResultUi( + track = track, + isAdding = track.id in current.addingTrackIds, + isAdded = track.id in current.addedTrackIds + ) + }, + isSearching = current.isSearching, + hasSearched = current.hasSearched + ) + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + AddTracksToPlaylistUiState(queryState = queryState) + ) + + fun search() { + val query = queryState.text.toString() + if (query.isBlank() || state.value.isSearching) return + state.update { it.copy(isSearching = true, hasSearched = true) } + searchJob = viewModelScope.launch { + try { + val results = searchRepository.searchTracks(query).items.map { it.data } + state.update { it.copy(results = results) } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (e: Exception) { + logger.e(e) { "Failed to search tracks for \"$query\"" } + state.update { it.copy(results = emptyList()) } + } finally { + state.update { it.copy(isSearching = false) } + } + } + } + + fun addTrack(trackId: String) { + val current = state.value + if (trackId in current.addingTrackIds || trackId in current.addedTrackIds) return + state.update { it.copy(addingTrackIds = it.addingTrackIds + trackId) } + viewModelScope.launch { + try { + libraryRepository.addTracksToPlaylist(playlistId, listOf(trackId)) + state.update { it.copy(addedTrackIds = it.addedTrackIds + trackId) } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (e: Exception) { + logger.e(e) { "Failed to add track $trackId to playlist $playlistId" } + } finally { + state.update { it.copy(addingTrackIds = it.addingTrackIds - trackId) } + } + } + } + + /** Clears the search so the dialog opens fresh next time; in-flight adds still complete. */ + fun reset() { + searchJob?.cancel() + queryState.clearText() + state.update { AddTracksState() } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/PlaylistScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/PlaylistScreen.kt index ececcd31..370c8ca6 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/PlaylistScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/PlaylistScreen.kt @@ -66,6 +66,7 @@ fun PlaylistScreen( navigationCommands: NavigationCommands ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + val playbackUiState by viewModel.playbackUiState.collectAsStateWithLifecycle() val jamRoomService: JamRoomService = koinInject() val jamActive by jamRoomService.role.map { it != null } .collectAsStateWithLifecycle(initialValue = false) @@ -77,7 +78,7 @@ fun PlaylistScreen( val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle() val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle() val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.collectAsStateWithLifecycle() - var showEditPlaylist by remember { mutableStateOf(false) } + val editPlaylistFormState by viewModel.editPlaylistFormState.collectAsStateWithLifecycle() var showAddTracksDialog by remember { mutableStateOf(false) } val dataState = state as? PlaylistScreenState.Data @@ -117,6 +118,9 @@ fun PlaylistScreen( } else null CollectionView( + isPlayLoading = playbackUiState.isPlayLoading, + isShuffleLoading = playbackUiState.isShuffleLoading, + isAddToQueueLoading = playbackUiState.isAddToQueueLoading, title = playlist?.title ?: stringResource(Res.string.playlist_loading_title), description = playlist?.description.orEmpty(), imageURL = artworkUrl, @@ -128,9 +132,10 @@ fun PlaylistScreen( onAddToQueue = viewModel::addPlaylistToQueue, isPlaying = isPlaying, isFollowing = savedPlaylistIds.contains(playlistId), + isFollowLoading = dataState?.isSaving == true, onFollowClick = viewModel::toggleSavedPlaylist, showFollowButton = playlistId != "saved_tracks", - onEdit = if (isOwner) { { showEditPlaylist = true } } else null, + onEdit = if (isOwner) viewModel::showEditPlaylist else null, isLoading = isLoading, error = errorMessage, onRetry = { viewModel.refresh() }, @@ -157,8 +162,10 @@ fun PlaylistScreen( trailingContent = { val loadedPlaylist = (dataState as? PlaylistScreenState.Data.Loaded)?.playlist PlaylistFormSheet( - visible = showEditPlaylist, - onDismiss = { showEditPlaylist = false }, + visible = editPlaylistFormState.isVisible, + formState = editPlaylistFormState, + onDismiss = viewModel::dismissEditPlaylist, + onImagePicked = viewModel::onEditPlaylistImagePicked, isEditing = true, initialData = loadedPlaylist?.let { PlaylistFormData( @@ -168,14 +175,7 @@ fun PlaylistScreen( isCollaborating = false, imagePreviewUrl = it.thumbnails.firstOrNull()?.url) }, - onSubmit = { data -> - viewModel.updatePlaylist( - name = data.name, - description = data.description.ifBlank { null }, - isPublic = data.isPublic, - isCollaborating = data.isCollaborating, - imageBase64 = data.imageBase64.ifBlank { null }) - }) + onSubmit = viewModel::updatePlaylist) AddToPlaylistPicker( visible = showAddToPlaylistPicker, diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/PlaylistViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/PlaylistViewModel.kt index af4d5686..241605c6 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/PlaylistViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/PlaylistViewModel.kt @@ -26,6 +26,7 @@ import org.spotube.spotube.core.audioplayer.AudioPlayerQueue import org.spotube.spotube.core.audioplayer.QueueEntry import org.spotube.spotube.core.di.injectLogger import org.spotube.spotube.core.playback.CollectionPlaybackHelper +import org.spotube.spotube.core.remote.CollectionPlaybackUiState import org.spotube.spotube.core.remote.RemoteCollectionType import org.spotube.spotube.core.remote.RemotePlaybackController import org.spotube.spotube.core.share.ShareService @@ -48,6 +49,11 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn +import io.github.vinceglb.filekit.PlatformFile +import org.spotube.spotube.modules.library.playlist.PlaylistFormController +import org.spotube.spotube.modules.library.playlist.PlaylistFormData +import org.spotube.spotube.modules.library.playlist.PlaylistFormUiState +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.jetbrains.compose.resources.getString @@ -105,6 +111,11 @@ class PlaylistViewModel( private val _state = MutableStateFlow(PlaylistScreenState.Loading) val uiState: StateFlow = _state.asStateFlow() + + /** Loading flags for this screen's play / shuffle / add-to-queue buttons. */ + val playbackUiState: StateFlow = remotePlaybackController + .collectionPlaybackUiState(RemoteCollectionType.Playlist, playlistId) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), CollectionPlaybackUiState()) val savedPlaylistIds get() = libraryRepository.savedPlaylistIdsFlow @@ -206,6 +217,8 @@ class PlaylistViewModel( } fun toggleSavedPlaylist() { + if ((_state.value as? PlaylistScreenState.Data)?.isSaving == true) return + setSaving(true) viewModelScope.launch { runCatching { val isLiked = libraryRepository.isSavedPlaylists(listOf(playlistId)).firstOrNull() ?: false @@ -217,6 +230,17 @@ class PlaylistViewModel( }.onFailure { e -> logger.e(e) { "Failed to toggle saved playlist" } } + setSaving(false) + } + } + + private fun setSaving(isSaving: Boolean) { + _state.update { state -> + when (state) { + is PlaylistScreenState.Data.Loaded -> state.copy(isSaving = isSaving) + is PlaylistScreenState.Data.LoadingMore -> state.copy(isSaving = isSaving) + else -> state + } } } @@ -256,28 +280,27 @@ class PlaylistViewModel( } } - fun updatePlaylist( - name: String?, - description: String?, - isPublic: Boolean?, - isCollaborating: Boolean?, - imageBase64: String?) { - viewModelScope.launch { - runCatching { - libraryRepository.updatePlaylist( - id = playlistId, - name = name, - description = description, - isPublic = isPublic, - isCollaborating = isCollaborating, - imageBase64 = imageBase64, - trackIds = null) - }.onFailure { e -> - logger.e(e) { "Failed to update playlist" } - }.onSuccess { - repository.invalidateCaches() - loadInitialData() - } + private val editPlaylistForm = PlaylistFormController(viewModelScope, logger) + val editPlaylistFormState: StateFlow = editPlaylistForm.uiState + + fun showEditPlaylist() = editPlaylistForm.show() + + fun dismissEditPlaylist() = editPlaylistForm.dismiss() + + fun onEditPlaylistImagePicked(file: PlatformFile) = editPlaylistForm.onImagePicked(file) + + fun updatePlaylist(data: PlaylistFormData) { + editPlaylistForm.submit { + libraryRepository.updatePlaylist( + id = playlistId, + name = data.name, + description = data.description.ifBlank { null }, + isPublic = data.isPublic, + isCollaborating = data.isCollaborating, + imageBase64 = data.imageBase64.ifBlank { null }, + trackIds = null) + repository.invalidateCaches() + loadInitialData() } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/MetadataAuthViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/MetadataAuthViewModel.kt index 0350ba14..051ec896 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/MetadataAuthViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/MetadataAuthViewModel.kt @@ -23,6 +23,7 @@ import org.spotube.spotube.core.di.injectLogger import kotlinx.coroutines.CancellationException import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine @@ -30,12 +31,16 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent data class MetadataAuthUiState( /** True when the selected metadata plugin needs an account and the user isn't signed in. */ val loginRequired: Boolean = false, - val pluginName: String? = null) + val pluginName: String? = null, + /** True while a login started from the prompt is still running. */ + val isLoggingIn: Boolean = false) /** * Tells screens whether the selected metadata plugin is waiting for a login, so they can show a @@ -45,15 +50,17 @@ data class MetadataAuthUiState( class MetadataAuthViewModel( private val pluginManager: PluginManager) : ViewModel(), KoinComponent { private val logger by injectLogger() + private val isLoggingIn = MutableStateFlow(false) val uiState: StateFlow = combine( pluginManager.selectedMetadataPlugin.flatMapLatest { service -> if (service == null) flowOf(false) else loginRequiredFlow(service) }, - pluginManager.state) { loginRequired, managerState -> + pluginManager.state, + isLoggingIn) { loginRequired, managerState, loggingIn -> val name = (managerState as? PluginManagerStates.Data) ?.selectedPlugins?.get(PluginAbility.METADATA)?.name - MetadataAuthUiState(loginRequired = loginRequired, pluginName = name) + MetadataAuthUiState(loginRequired = loginRequired, pluginName = name, isLoggingIn = loggingIn) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), MetadataAuthUiState()) private fun loginRequiredFlow(service: org.spotube.spotube.core.zipline.PluginService): Flow = flow { @@ -71,10 +78,20 @@ class MetadataAuthViewModel( } fun login() { + if (isLoggingIn.value) return val service = pluginManager.selectedMetadataPlugin.value ?: return - pluginManager.launchTask { + // The login runs in the plugin manager's scope so it survives this screen; we only wait on it. + val task = pluginManager.launchTask { runCatching { service.use { coreAPI.login() } } .onFailure { logger.e(it) { "Metadata plugin login failed" } } } + isLoggingIn.value = true + viewModelScope.launch { + try { + task.join() + } finally { + isLoggingIn.update { false } + } + } } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt index 81f1ae84..71871b23 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt @@ -390,22 +390,30 @@ class PluginManager( } } - /** Called when the user confirms installation/update/replacement in the dialog. */ - fun confirmInstall() { - val pending = pendingPlugin.value ?: return - val bytes = pending.bytes ?: return dismissInstall() + /** + * Called when the user confirms installation/update/replacement in the dialog. The prompt stays + * up until the install finishes, so the dialog can show progress. + * + * @return The install job, or `null` when there was nothing to install. + */ + fun confirmInstall(): Job? { + val pending = pendingPlugin.value ?: return null + val bytes = pending.bytes ?: run { dismissInstall(); return null } val allowReplacingInstalled = when (pending.kind) { InstallPromptKind.INSTALL -> false InstallPromptKind.UPDATE, InstallPromptKind.REPLACE -> true - InstallPromptKind.INFO -> return dismissInstall() - InstallPromptKind.REPLACE -> true + InstallPromptKind.INFO -> run { dismissInstall(); return null } } - pendingPlugin.value = null - scope.launch { - addPluginFromByteArray( - bytes = bytes, - allowReplacingInstalled = allowReplacingInstalled - ) + return scope.launch { + try { + addPluginFromByteArray( + bytes = bytes, + allowReplacingInstalled = allowReplacingInstalled + ) + } finally { + // Only close our own prompt; another plugin may have been queued meanwhile. + pendingPlugin.compareAndSet(pending, null) + } } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginScreen.kt index d5542e1f..0d52c279 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginScreen.kt @@ -235,6 +235,7 @@ internal fun PluginScreenContent( } else { null }, + isConfirming = state.isConfirmingInstall, onDismiss = { viewModel.dismissInstall() } ) } @@ -274,6 +275,7 @@ internal fun PluginScreenContent( urlInput = state.urlInput, urlError = state.urlError, isLoadingUrl = state.isLoadingUrl, + isPreparingFile = state.isPreparingFile, onUrlChange = { viewModel.onUrlInputChange(it) }, onSubmitUrl = { viewModel.submitUrl() }, onPickFile = { launcher.launch() }, @@ -459,6 +461,8 @@ internal fun PluginScreenContent( onRemove = { viewModel.removePlugin(item.plugin) }, isLoggedIn = item.authState.isLoggedIn, logoPath = item.logoPath, + isAuthInProgress = item.isAuthInProgress, + isRemoving = item.isRemoving, onInfo = { viewModel.showPluginInfo(item.plugin) }, onSupport = if (item.isSelected) { { viewModel.loadSupport(item.plugin) } @@ -620,6 +624,7 @@ private fun InstallSheet( urlInput: String, urlError: UrlError?, isLoadingUrl: Boolean, + isPreparingFile: Boolean, onUrlChange: (String) -> Unit, onSubmitUrl: () -> Unit, onPickFile: () -> Unit, @@ -671,20 +676,16 @@ private fun InstallSheet( error = urlError?.text()) MillenniumButton( onClick = onSubmitUrl, - enabled = !isLoadingUrl, + loading = isLoadingUrl, modifier = Modifier.fillMaxWidth(), style = MillenniumButtonStyle.Primary) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - if (isLoadingUrl) { - MillenniumSpokeSpinner(size = 16.dp, color = MillenniumTheme.colors.onAccent) - } else { - MillenniumIcon( - Iconsax.IconsaxImportArrow2Bulk, - contentDescription = null, - modifier = Modifier.size(18.dp)) - } + MillenniumIcon( + Iconsax.IconsaxImportArrow2Bulk, + contentDescription = null, + modifier = Modifier.size(18.dp)) Text(stringResource(Res.string.plugin_action_download)) } } @@ -703,7 +704,7 @@ private fun InstallSheet( MillenniumSeparator(modifier = Modifier.weight(1f)) } - PluginPanel(modifier = Modifier.clickable(onClick = onPickFile)) { + PluginPanel(modifier = Modifier.clickable(enabled = !isPreparingFile, onClick = onPickFile)) { Row( modifier = Modifier.fillMaxWidth().padding(14.dp), verticalAlignment = Alignment.CenterVertically, @@ -721,11 +722,15 @@ private fun InstallSheet( style = AppTheme.typography.labelSmall, color = AppTheme.colors.onSurfaceVariant) } - MillenniumIcon( - FeatherIcons.ChevronRight, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = AppTheme.colors.onSurfaceVariant) + if (isPreparingFile) { + MillenniumSpokeSpinner(size = 18.dp) + } else { + MillenniumIcon( + FeatherIcons.ChevronRight, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = AppTheme.colors.onSurfaceVariant) + } } } } @@ -1013,20 +1018,16 @@ private fun DiscoverRepoCard( } MillenniumButton( onClick = onInstall, - enabled = !isInstalling, + loading = isInstalling, style = MillenniumButtonStyle.Secondary ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - if (isInstalling) { - MillenniumSpokeSpinner(size = 16.dp) - } else { - MillenniumIcon( - Iconsax.IconsaxAdd, - contentDescription = null) - } + MillenniumIcon( + Iconsax.IconsaxAdd, + contentDescription = null) Text(stringResource(Res.string.plugin_section_install)) } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginViewModel.kt index 510d04e8..e3d5ff5c 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginViewModel.kt @@ -26,6 +26,7 @@ import io.github.vinceglb.filekit.PlatformFile import io.github.vinceglb.filekit.readBytes import kotlinx.coroutines.CancellationException import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -55,7 +56,9 @@ data class PluginListItem( val plugin: PluginEntry, val isSelected: Boolean, val authState: PluginAuthState, - val logoPath: Path?) + val logoPath: Path?, + val isAuthInProgress: Boolean = false, + val isRemoving: Boolean = false) data class AbilitySelection( val ability: PluginAbility, @@ -92,6 +95,8 @@ sealed interface PluginUiState { val urlInput: String = "", val urlError: UrlError? = null, val isLoadingUrl: Boolean = false, + val isPreparingFile: Boolean = false, + val isConfirmingInstall: Boolean = false, val showInstallSheet: Boolean = false, val showPluginInfo: PluginEntry? = null, val pluginInfoLogoPath: Path? = null, @@ -118,6 +123,10 @@ private data class PluginScreenState( val urlInput: String = "", val urlError: UrlError? = null, val isLoadingUrl: Boolean = false, + val isPreparingFile: Boolean = false, + val isConfirmingInstall: Boolean = false, + val authInProgressIds: Set = emptySet(), + val removingIds: Set = emptySet(), val showInstallSheet: Boolean = false, val showPluginInfo: PluginEntry? = null, val pluginInfoLogoPath: Path? = null, @@ -235,7 +244,9 @@ class PluginViewModel( plugin = plugin, isSelected = state.selectedPlugins.containsValue(plugin), authState = authStates[plugin.id] ?: PluginAuthState(), - logoPath = logoPaths[plugin.id]) + logoPath = logoPaths[plugin.id], + isAuthInProgress = plugin.id in screen.authInProgressIds, + isRemoving = plugin.id in screen.removingIds) }, abilitySelections = listOf( AbilitySelection( @@ -263,6 +274,8 @@ class PluginViewModel( urlInput = screen.urlInput, urlError = screen.urlError, isLoadingUrl = screen.isLoadingUrl, + isPreparingFile = screen.isPreparingFile, + isConfirmingInstall = screen.isConfirmingInstall, showInstallSheet = screen.showInstallSheet, showPluginInfo = screen.showPluginInfo, pluginInfoLogoPath = screen.pluginInfoLogoPath, @@ -360,12 +373,18 @@ class PluginViewModel( } fun onFileSelected(file: PlatformFile?) { - if (file == null) return + if (file == null || _screenState.value.isPreparingFile) return + _screenState.update { it.copy(isPreparingFile = true) } viewModelScope.launch { - runCatching { - pluginManager.preparePlugin(file.readBytes()) - }.onFailure { e -> - logger.e(e) { "Failed to prepare plugin from file" } + try { + runCatching { + pluginManager.preparePlugin(file.readBytes()) + }.onFailure { e -> + if (e is CancellationException) throw e + logger.e(e) { "Failed to prepare plugin from file" } + } + } finally { + _screenState.update { it.copy(isPreparingFile = false) } } } } @@ -456,34 +475,68 @@ class PluginViewModel( } fun removePlugin(plugin: PluginEntry) { + if (plugin.id in _screenState.value.removingIds) return + _screenState.update { it.copy(removingIds = it.removingIds + plugin.id) } viewModelScope.launch { - runCatching { - pluginManager.removePlugin(plugin) - }.onFailure { e -> - logger.e(e) { "Failed to remove plugin" } + try { + runCatching { + pluginManager.removePlugin(plugin) + }.onFailure { e -> + if (e is CancellationException) throw e + logger.e(e) { "Failed to remove plugin" } + } + } finally { + _screenState.update { it.copy(removingIds = it.removingIds - plugin.id) } } } } fun login(plugin: PluginEntry) { + if (plugin.id in _screenState.value.authInProgressIds) return val service = findServiceForPlugin(plugin.id) ?: return - pluginManager.launchTask { + trackAuthTask(plugin.id, pluginManager.launchTask { service.use { coreAPI.login() } - } + }) } fun logout(plugin: PluginEntry) { + if (plugin.id in _screenState.value.authInProgressIds) return val service = findServiceForPlugin(plugin.id) ?: return - pluginManager.launchTask { + val task = pluginManager.launchTask { service.use { coreAPI.logout() } } - viewModelScope.launch { + trackAuthTask(plugin.id, task) { webViewController.clearData(plugin.id) } } + /** + * Marks [pluginId] busy until [task] (running in the plugin manager's scope, so it outlives this + * screen) and the optional [alongside] work finish, whatever their outcome. + */ + private fun trackAuthTask(pluginId: String, task: Job, alongside: suspend () -> Unit = {}) { + _screenState.update { it.copy(authInProgressIds = it.authInProgressIds + pluginId) } + viewModelScope.launch { + try { + alongside() + task.join() + } finally { + _screenState.update { it.copy(authInProgressIds = it.authInProgressIds - pluginId) } + } + } + } + fun confirmInstall() { - pluginManager.confirmInstall() + if (_screenState.value.isConfirmingInstall) return + val task = pluginManager.confirmInstall() ?: return + _screenState.update { it.copy(isConfirmingInstall = true) } + viewModelScope.launch { + try { + task.join() + } finally { + _screenState.update { it.copy(isConfirmingInstall = false) } + } + } } fun dismissInstall() { diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginCard.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginCard.kt index 0828cced..4937e3bb 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginCard.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginCard.kt @@ -77,7 +77,9 @@ internal fun PluginCard( onLogout: (() -> Unit)? = null, onInfo: (() -> Unit)? = null, onSupport: (() -> Unit)? = null, - logoPath: Path? = null) { + logoPath: Path? = null, + isAuthInProgress: Boolean = false, + isRemoving: Boolean = false) { Row( modifier = Modifier .fillMaxWidth() @@ -272,6 +274,7 @@ internal fun PluginCard( } else { MillenniumIconButton( onClick = onRemove, + loading = isRemoving, style = MillenniumIconButtonStyle.Quiet) { MillenniumIcon( Iconsax.IconsaxTrash, @@ -289,6 +292,7 @@ internal fun PluginCard( authAction?.let { (action, label) -> MillenniumButton( onClick = action, + loading = isAuthInProgress, style = MillenniumButtonStyle.Secondary) { Text( text = stringResource(label), diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginDialogParts.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginDialogParts.kt index c86c4e9d..26e390ef 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginDialogParts.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginDialogParts.kt @@ -226,7 +226,8 @@ internal fun PluginDialogActions( onSecondary: () -> Unit, primaryLabel: String? = null, onPrimary: (() -> Unit)? = null, - primaryEnabled: Boolean = true) { + primaryEnabled: Boolean = true, + primaryLoading: Boolean = false) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { @@ -240,6 +241,7 @@ internal fun PluginDialogActions( MillenniumButton( onClick = onPrimary, enabled = primaryEnabled, + loading = primaryLoading, modifier = Modifier.weight(1f), style = MillenniumButtonStyle.Primary) { Text(primaryLabel) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginPermissionsDialog.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginPermissionsDialog.kt index 750448e7..c919aaa4 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginPermissionsDialog.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginPermissionsDialog.kt @@ -72,6 +72,7 @@ fun PluginPermissionDialog( existingPlugin: PluginEntry? = null, logoPath: Path? = null, onConfirm: (() -> Unit)? = null, + isConfirming: Boolean = false, onDismiss: () -> Unit) { AdaptiveDialogBottomSheet( onDismiss = onDismiss, @@ -83,7 +84,8 @@ fun PluginPermissionDialog( if (confirmLabel == null) Res.string.common_close else Res.string.common_cancel), onSecondary = onDismiss, primaryLabel = confirmLabel, - onPrimary = onConfirm) + onPrimary = onConfirm, + primaryLoading = isConfirming) }) { PluginDialogBody { PluginPanel { diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/guard/MetadataPluginGuard.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/guard/MetadataPluginGuard.kt index 5f37592d..a2dfde04 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/guard/MetadataPluginGuard.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/guard/MetadataPluginGuard.kt @@ -108,6 +108,7 @@ fun MetadataPluginGuard( LoginRequiredDisplay( pluginName = authState.pluginName, onLogin = authViewModel::login, + isLoggingIn = authState.isLoggingIn, modifier = Modifier.widthIn(max = 420.dp)) } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/saved_tracks/SavedTracksScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/saved_tracks/SavedTracksScreen.kt index b6bf6425..b3da0317 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/saved_tracks/SavedTracksScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/saved_tracks/SavedTracksScreen.kt @@ -46,6 +46,7 @@ fun SavedTracksScreen( navigationCommands: NavigationCommands ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + val playbackUiState by viewModel.playbackUiState.collectAsStateWithLifecycle() val jamRoomService: JamRoomService = koinInject() val jamActive by jamRoomService.role.map { it != null } .collectAsStateWithLifecycle(initialValue = false) @@ -65,6 +66,9 @@ fun SavedTracksScreen( val isLoading = state is SavedTracksScreenState.Loading && (dataState == null || dataState.tracks.isEmpty()) CollectionView( + isPlayLoading = playbackUiState.isPlayLoading, + isShuffleLoading = playbackUiState.isShuffleLoading, + isAddToQueueLoading = playbackUiState.isAddToQueueLoading, title = stringResource(Res.string.saved_tracks_title), description = dataState?.let { pluralStringResource(Res.plurals.library_local_tracks_count, it.totalCount, it.totalCount) } ?: "", imageURL = "", diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/saved_tracks/SavedTracksViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/saved_tracks/SavedTracksViewModel.kt index 2a947179..6ee5e398 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/saved_tracks/SavedTracksViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/saved_tracks/SavedTracksViewModel.kt @@ -34,6 +34,7 @@ import org.spotube.spotube.core.audioplayer.AudioPlayerQueue import org.spotube.spotube.core.audioplayer.QueueEntry import org.spotube.spotube.core.di.injectLogger import org.spotube.spotube.core.playback.CollectionPlaybackHelper +import org.spotube.spotube.core.remote.CollectionPlaybackUiState import org.spotube.spotube.core.remote.RemoteCollectionType import org.spotube.spotube.core.remote.RemotePlaybackController import org.spotube.spotube.core.share.ShareService @@ -54,6 +55,7 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.koin.compose.koinInject import org.koin.core.component.KoinComponent @@ -103,9 +105,18 @@ class SavedTracksViewModel( private val _state = MutableStateFlow(SavedTracksScreenState.Loading) val uiState: StateFlow = _state.asStateFlow() + + /** Loading flags for this screen's play / shuffle / add-to-queue buttons. */ + val playbackUiState: StateFlow = remotePlaybackController + .collectionPlaybackUiState(RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), CollectionPlaybackUiState()) val savedTrackIdsFlow: StateFlow> get() = repository.savedTracksIdsFlow + // Tracks whose save/remove request is still in flight. + private val _pendingSavedTrackIds = MutableStateFlow>(emptySet()) + val pendingSavedTrackIds: StateFlow> = _pendingSavedTrackIds.asStateFlow() + private val _currentUserId = MutableStateFlow(null) val currentUserId: StateFlow = _currentUserId.asStateFlow() @@ -340,6 +351,23 @@ class SavedTracksViewModel( repository.removeSavedTracks(trackIds) } + fun toggleSavedTrack(trackId: String, isSaved: Boolean) { + if (trackId in _pendingSavedTrackIds.value) return + _pendingSavedTrackIds.update { it + trackId } + viewModelScope.launch { + runCatching { + if (isSaved) { + repository.removeSavedTracks(listOf(trackId)) + } else { + repository.saveTracks(listOf(trackId)) + } + }.onFailure { e -> + logger.e(e) { "Failed to toggle saved track" } + } + _pendingSavedTrackIds.update { it - trackId } + } + } + private fun MetadataTrack.matchesTrack(other: MetadataTrack): Boolean { if (id.isNotBlank() && other.id.isNotBlank()) return id == other.id return title == other.title && diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsScreen.kt index d7810185..0643cb4b 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsScreen.kt @@ -244,13 +244,9 @@ internal fun JamSettingsContent( trailingContent = { MillenniumButton( onClick = viewModel::testConnection, - enabled = canTest) { - Text( - if (uiState.isTesting) { - stringResource(Res.string.settings_jam_broker_testing) - } else { - stringResource(Res.string.settings_jam_broker_test) - }) + enabled = broker.host.isNotBlank(), + loading = uiState.isTesting) { + Text(stringResource(Res.string.settings_jam_broker_test)) } }, onClick = viewModel::testConnection) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsViewModel.kt index 64a96ce2..61e58722 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsViewModel.kt @@ -38,8 +38,12 @@ class JamSettingsViewModel( if (_uiState.value.isTesting) return _uiState.update { it.copy(isTesting = true, testResult = null) } viewModelScope.launch { - val result = jamRoomClient.testConnection(uiState.value.settings.jamBroker) - _uiState.update { it.copy(isTesting = false, testResult = result) } + try { + val result = jamRoomClient.testConnection(uiState.value.settings.jamBroker) + _uiState.update { it.copy(testResult = result) } + } finally { + _uiState.update { it.copy(isTesting = false) } + } } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/LocalMediaFoldersViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/LocalMediaFoldersViewModel.kt index 5368d6d0..3a0b58b8 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/LocalMediaFoldersViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/LocalMediaFoldersViewModel.kt @@ -103,7 +103,7 @@ class LocalMediaFoldersViewModel( path.substringAfterLast('/').substringAfterLast('\\').ifBlank { path }) }, isSaving = draft.isSaving, - canSave = draft.isOpen && !draft.isSaving && draft.paths != draft.original, + canSave = draft.isOpen && draft.paths != draft.original, saveFailed = draft.saveFailed, automaticNote = when { hasDownloadFolder && settings.enableMusicCaching -> Res.string.settings_local_media_folders_automatic_both diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/components/LocalMediaFoldersEditor.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/components/LocalMediaFoldersEditor.kt index c6b61799..01795adf 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/components/LocalMediaFoldersEditor.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/components/LocalMediaFoldersEditor.kt @@ -188,8 +188,8 @@ internal fun LocalMediaFoldersEditorContent( ) { Text(stringResource(Res.string.common_cancel)) } - MillenniumButton(onClick = onSave, enabled = state.canSave) { - Text(stringResource(if (state.isSaving) Res.string.settings_local_media_folders_saving else Res.string.common_save)) + MillenniumButton(onClick = onSave, enabled = state.canSave, loading = state.isSaving) { + Text(stringResource(Res.string.common_save)) } } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerHeartButton.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerHeartButton.kt index e20ce752..732a4d0a 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerHeartButton.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerHeartButton.kt @@ -26,7 +26,6 @@ import androidx.compose.foundation.shape.CircleShape import org.spotube.spotube.core.ui.theming.AppTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.spotube.spotube.core.audioplayer.AudioPlayerQueue import org.spotube.spotube.core.audioplayer.QueueEntry @@ -36,7 +35,6 @@ import org.spotube.spotube.modules.saved_tracks.rememberIsSavedTracks import org.spotube.spotube.resources.iconsax.Iconsax import org.spotube.spotube.resources.iconsax.IconsaxHeart import org.spotube.spotube.resources.iconsax.IconsaxHeart2 -import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.player_like_content_description @@ -46,12 +44,12 @@ import spotube.composeapp.generated.resources.player_unlike_content_description fun PlayerHeartButton( audioPlayerQueue: AudioPlayerQueue, savedTracksViewModel: SavedTracksViewModel) { - val scope = rememberCoroutineScope() val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle() val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id val isSavedTrackState = rememberIsSavedTracks(trackIds = currentTrackId?.let { listOf(it) } ?: emptyList()) val savedTrackIds by savedTracksViewModel.savedTrackIdsFlow.collectAsStateWithLifecycle() + val pendingSavedTrackIds by savedTracksViewModel.pendingSavedTrackIds.collectAsStateWithLifecycle() val isInSavedIds = if (currentTrackId != null) currentTrackId in savedTrackIds else false val isLiked = if (isSavedTrackState is SavedState.Success) { @@ -60,21 +58,10 @@ fun PlayerHeartButton( isInSavedIds } - fun onLike() { - val entry = currentQueueEntry as? QueueEntry.StreamingTrack ?: return - if (isLiked) { - scope.launch { - savedTracksViewModel.removeSavedTracks(listOf(entry.track.id)) - } - } else { - scope.launch { - savedTracksViewModel.saveTracks(listOf(entry.track.id)) - } - } - } MillenniumIconButton( - onClick = ::onLike, + onClick = { currentTrackId?.let { savedTracksViewModel.toggleSavedTrack(it, isLiked) } }, enabled = isSavedTrackState is SavedState.Success, + loading = currentTrackId != null && currentTrackId in pendingSavedTrackIds, style = MillenniumIconButtonStyle.Secondary) { MillenniumIcon( imageVector = if (isLiked) Iconsax.IconsaxHeart2 else Iconsax.IconsaxHeart, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d3087d75..30e39bf7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -41,7 +41,7 @@ kotlin = "2.3.21" kotlinx-coroutines = "1.11.0" kotlinx-io = "0.9.1" kotlinx-serialization-json = "1.11.0" -millennium = "0.1.0" +millennium = "0.1.1" mqttClient = "2.1.1" mqttBuffer = "6.30.8" multiplatformMarkdownRenderer = "0.45.0"