feat: enhance loading states for various UI components and update dependencies

This commit is contained in:
Kingkor Roy Tirtho 2026-10-20 20:32:47 +06:00
parent 08892c2aa0
commit 79d47c1d87
47 changed files with 938 additions and 418 deletions

View File

@ -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)

View File

@ -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<MetadataTrack>) : 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<String>(extraBufferCapacity = 8)
val events: SharedFlow<String> = _events.asSharedFlow()
/** Collection actions currently running locally; see [CollectionPlaybackKey]. */
private val _busyCollections = MutableStateFlow<Set<CollectionPlaybackKey>>(emptySet())
val busyCollections: StateFlow<Set<CollectionPlaybackKey>> = _busyCollections.asStateFlow()
fun collectionPlaybackUiState(type: RemoteCollectionType, id: String): Flow<CollectionPlaybackUiState> =
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,7 +262,24 @@ 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 {
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
@ -331,7 +380,6 @@ class RemotePlaybackController(
}
}
}
}
private fun executeOnRemote(request: PlaybackDestinationRequest) {
scope.launch {

View File

@ -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
)
}

View File

@ -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,

View File

@ -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,

View File

@ -87,6 +87,7 @@ fun ErrorDisplay(
LoginRequiredDisplay(
pluginName = authState.pluginName,
onLogin = authViewModel::login,
isLoggingIn = authState.isLoggingIn,
modifier = modifier)
return
}

View File

@ -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,

View File

@ -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
) {

View File

@ -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(

View File

@ -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
)
}

View File

@ -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

View File

@ -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
) {

View File

@ -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,

View File

@ -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>(AlbumScreenState.Loading)
val uiState: StateFlow<AlbumScreenState> = _state.asStateFlow()
/** Loading flags for this screen's play / shuffle / add-to-queue buttons. */
val playbackUiState: StateFlow<CollectionPlaybackUiState> = remotePlaybackController
.collectionPlaybackUiState(RemoteCollectionType.Album, albumId)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), CollectionPlaybackUiState())
val savedAlbumIds
get() = libraryRepository.savedAlbumIdsFlow
@ -203,7 +210,10 @@ class AlbumViewModel(
}
fun toggleSavedAlbum() {
if ((_state.value as? AlbumScreenState.Data)?.isSaving == true) return
setSaving(true)
viewModelScope.launch {
runCatching {
val isLiked =
libraryRepository.isSavedAlbums(listOf(albumId)).firstOrNull() ?: false
if (isLiked) {
@ -211,6 +221,20 @@ class AlbumViewModel(
} 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
}
}
}

View File

@ -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(

View File

@ -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<MetadataArtist.Basic>,
val relatedArtistsNextPagination: PaginationStrategy?,
val featuredPlaylists: List<MetadataPlaylist>,
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>(ArtistScreenState.Loading)
val state: StateFlow<ArtistScreenState> = _state.asStateFlow()
/** Loading flags for the top-tracks play / add-to-queue buttons. */
val playbackUiState: StateFlow<CollectionPlaybackUiState> = 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 {
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
}
}

View File

@ -122,15 +122,10 @@ 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)
)
} else {
MillenniumIconButton(
onClick = viewModel::startDiscovery,
enabled = !state.isConnecting,
loading = state.isDiscovering,
style = MillenniumIconButtonStyle.Quiet
) {
MillenniumIcon(
@ -139,7 +134,6 @@ fun DevicesScreen(
)
}
}
}
)
Box(
@ -302,6 +296,7 @@ private fun LazyItemScope.ConnectedDeviceCard(
) {
MillenniumButton(
onClick = onDisconnect,
loading = device.isDisconnecting,
style = MillenniumButtonStyle.Secondary
) {
Text(stringResource(Res.string.devices_disconnect))

View File

@ -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<DeviceItemUi> = emptyList(),
@ -89,14 +90,15 @@ class DevicesViewModel(
private val _isDiscovering = MutableStateFlow(false)
private val _connectingToDevice = MutableStateFlow<DiscoveredDevice?>(null)
private val _error = MutableStateFlow<DevicesError?>(null)
private val _isDisconnecting = MutableStateFlow(false)
val uiState: StateFlow<DevicesUiState> = 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 {
try {
remoteControlClient.disconnect()
} finally {
_isDisconnecting.value = false
}
}
}

View File

@ -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)
) {

View File

@ -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<String?>(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 {
try {
jamRoomService.leaveRoom()
_localError.value = null
} finally {
isLeaving.value = false
}
}
}

View File

@ -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
)

View File

@ -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<Map<LibraryTab, LibraryViewMode>>(emptyMap())
private val refreshRequests = MutableSharedFlow<Unit>(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

View File

@ -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
viewModelScope.launch {
if (!_state.value.canAccessMedia || _state.value.isRefreshing) return
_state.update { it.copy(isRefreshing = true, error = null) }
libraryState.setLocalRefreshing(true)
viewModelScope.launch {
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)
}
}
}

View File

@ -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
)
}
}

View File

@ -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<PlaylistFormUiState> = 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<String>
) {
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()
}
}
}
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<PlaylistFormUiState> = _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) }
}
}
}
}

View File

@ -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))
}
}

View File

@ -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<List<MetadataTrack>>(emptyList()) }
var isSearching by remember { mutableStateOf(false) }
var hasSearched by remember { mutableStateOf(false) }
var addingTrackIds by remember { mutableStateOf<Set<String>>(emptySet()) }
var addedTrackIds by remember { mutableStateOf<Set<String>>(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))
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<AddTrackResultUi> = emptyList(),
val isSearching: Boolean = false,
val hasSearched: Boolean = false
)
private data class AddTracksState(
val results: List<MetadataTrack> = emptyList(),
val isSearching: Boolean = false,
val hasSearched: Boolean = false,
val addingTrackIds: Set<String> = emptySet(),
val addedTrackIds: Set<String> = 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<AddTracksToPlaylistUiState> = 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() }
}
}

View File

@ -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,

View File

@ -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>(PlaylistScreenState.Loading)
val uiState: StateFlow<PlaylistScreenState> = _state.asStateFlow()
/** Loading flags for this screen's play / shuffle / add-to-queue buttons. */
val playbackUiState: StateFlow<CollectionPlaybackUiState> = 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,30 +280,29 @@ class PlaylistViewModel(
}
}
fun updatePlaylist(
name: String?,
description: String?,
isPublic: Boolean?,
isCollaborating: Boolean?,
imageBase64: String?) {
viewModelScope.launch {
runCatching {
private val editPlaylistForm = PlaylistFormController(viewModelScope, logger)
val editPlaylistFormState: StateFlow<PlaylistFormUiState> = 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 = name,
description = description,
isPublic = isPublic,
isCollaborating = isCollaborating,
imageBase64 = imageBase64,
name = data.name,
description = data.description.ifBlank { null },
isPublic = data.isPublic,
isCollaborating = data.isCollaborating,
imageBase64 = data.imageBase64.ifBlank { null },
trackIds = null)
}.onFailure { e ->
logger.e(e) { "Failed to update playlist" }
}.onSuccess {
repository.invalidateCaches()
loadInitialData()
}
}
}
fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) {
viewModelScope.launch {

View File

@ -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<MetadataAuthViewModel>()
private val isLoggingIn = MutableStateFlow(false)
val uiState: StateFlow<MetadataAuthUiState> = 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<Boolean> = 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 }
}
}
}
}

View File

@ -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 {
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)
}
}
}

View File

@ -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))
}
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,6 +722,9 @@ private fun InstallSheet(
style = AppTheme.typography.labelSmall,
color = AppTheme.colors.onSurfaceVariant)
}
if (isPreparingFile) {
MillenniumSpokeSpinner(size = 18.dp)
} else {
MillenniumIcon(
FeatherIcons.ChevronRight,
contentDescription = null,
@ -731,6 +735,7 @@ private fun InstallSheet(
}
}
}
}
@Composable
private fun PluginInfoDialog(
@ -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)
}
Text(stringResource(Res.string.plugin_section_install))
}
}

View File

@ -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<String> = emptySet(),
val removingIds: Set<String> = 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,13 +373,19 @@ 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 {
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 {
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() {

View File

@ -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),

View File

@ -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)

View File

@ -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 {

View File

@ -108,6 +108,7 @@ fun MetadataPluginGuard(
LoginRequiredDisplay(
pluginName = authState.pluginName,
onLogin = authViewModel::login,
isLoggingIn = authState.isLoggingIn,
modifier = Modifier.widthIn(max = 420.dp))
}
}

View File

@ -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 = "",

View File

@ -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>(SavedTracksScreenState.Loading)
val uiState: StateFlow<SavedTracksScreenState> = _state.asStateFlow()
/** Loading flags for this screen's play / shuffle / add-to-queue buttons. */
val playbackUiState: StateFlow<CollectionPlaybackUiState> = remotePlaybackController
.collectionPlaybackUiState(RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), CollectionPlaybackUiState())
val savedTrackIdsFlow: StateFlow<Set<String>>
get() = repository.savedTracksIdsFlow
// Tracks whose save/remove request is still in flight.
private val _pendingSavedTrackIds = MutableStateFlow<Set<String>>(emptySet())
val pendingSavedTrackIds: StateFlow<Set<String>> = _pendingSavedTrackIds.asStateFlow()
private val _currentUserId = MutableStateFlow<String?>(null)
val currentUserId: StateFlow<String?> = _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 &&

View File

@ -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)

View File

@ -38,8 +38,12 @@ class JamSettingsViewModel(
if (_uiState.value.isTesting) return
_uiState.update { it.copy(isTesting = true, testResult = null) }
viewModelScope.launch {
try {
val result = jamRoomClient.testConnection(uiState.value.settings.jamBroker)
_uiState.update { it.copy(isTesting = false, testResult = result) }
_uiState.update { it.copy(testResult = result) }
} finally {
_uiState.update { it.copy(isTesting = false) }
}
}
}

View File

@ -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

View File

@ -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))
}
}
}

View File

@ -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,

View File

@ -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"