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.local_tracks.media.LocalMediaLibraryCoordinator
import org.spotube.spotube.modules.library.playlist.LibraryPlaylistsViewModel import org.spotube.spotube.modules.library.playlist.LibraryPlaylistsViewModel
import org.spotube.spotube.modules.lyrics.LyricsViewModel 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.PlaylistRepository
import org.spotube.spotube.modules.playlist.PlaylistViewModel import org.spotube.spotube.modules.playlist.PlaylistViewModel
import org.spotube.spotube.modules.plugin.AudioPluginSource import org.spotube.spotube.modules.plugin.AudioPluginSource
@ -185,6 +186,7 @@ val sharedModules = module {
downloadManager = get(), downloadManager = get(),
remotePlaybackController = get()) remotePlaybackController = get())
} }
viewModel { (playlistId: String) -> AddTracksToPlaylistViewModel(playlistId, get(), get()) }
// Saved Tracks // Saved Tracks
singleOf(::SavedTracksRepository) singleOf(::SavedTracksRepository)

View File

@ -28,12 +28,16 @@ import org.spotube.spotube.modules.blacklist.BlacklistRepository
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow 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 kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.jetbrains.compose.resources.getPluralString import org.jetbrains.compose.resources.getPluralString
@ -82,6 +86,22 @@ sealed interface PlaybackDestinationRequest {
val tracks: List<MetadataTrack>) : 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 * 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 * 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) private val _events = MutableSharedFlow<String>(extraBufferCapacity = 8)
val events: SharedFlow<String> = _events.asSharedFlow() 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 { fun isRemoteConnected(): Boolean {
return remoteControlClient.connectionState.value is ConnectionState.Connected return remoteControlClient.connectionState.value is ConnectionState.Connected
} }
@ -230,103 +262,119 @@ class RemotePlaybackController(
} }
private fun executeLocally(request: PlaybackDestinationRequest) { 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 { scope.launch {
when (request) { try {
is PlaybackDestinationRequest.Collection -> { executeLocallyNow(request)
val startTrack = request.startTrack } catch (e: Exception) {
when (request.type) { logger.e(e) { "Failed to play ${request.title} locally" }
RemoteCollectionType.Playlist -> when (request.action) { } finally {
PlaybackDestinationAction.Play -> { busyKey?.let { key -> _busyCollections.update { it - key } }
if (startTrack != null) { }
collectionPlaybackHelper.playPlaylistFromTrack(request.id, startTrack) }
} else { }
collectionPlaybackHelper.playPlaylist(request.id, request.shuffle)
} 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.AddToQueue -> collectionPlaybackHelper.addPlaylistToQueue(request.id)
PlaybackDestinationAction.Play -> { PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.playPlaylistNext(request.id)
if (startTrack != null) { }
collectionPlaybackHelper.playAlbumFromTrack(request.id, startTrack)
} else { RemoteCollectionType.Album -> when (request.action) {
collectionPlaybackHelper.playAlbum(request.id, request.shuffle) 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.AddToQueue -> collectionPlaybackHelper.addAlbumToQueue(request.id)
PlaybackDestinationAction.Play -> collectionPlaybackHelper.playArtistTopTracks(request.id) PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.playAlbumNext(request.id)
PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addArtistTopTracksToQueue(request.id) }
PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.playArtistTopTracksNext(request.id)
}
RemoteCollectionType.SavedTracks -> when (request.action) { RemoteCollectionType.ArtistTopTracks -> when (request.action) {
PlaybackDestinationAction.Play -> { PlaybackDestinationAction.Play -> collectionPlaybackHelper.playArtistTopTracks(request.id)
if (startTrack != null) { PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addArtistTopTracksToQueue(request.id)
collectionPlaybackHelper.playSavedTracksFromTrack(startTrack) PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.playArtistTopTracksNext(request.id)
} else { }
collectionPlaybackHelper.playSavedTracks(request.shuffle)
} 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 -> { is PlaybackDestinationRequest.Track -> {
val entry = QueueEntry.StreamingTrack(track = request.track, url = "") val entry = QueueEntry.StreamingTrack(track = request.track, url = "")
when (request.action) { when (request.action) {
PlaybackDestinationAction.Play -> { PlaybackDestinationAction.Play -> {
audioPlayerQueue.load( audioPlayerQueue.load(
entries = listOf(entry), entries = listOf(entry),
autoPlay = true, autoPlay = true,
startPosition = 0, startPosition = 0,
collectionEntry = null) collectionEntry = null)
} }
PlaybackDestinationAction.AddToQueue -> { PlaybackDestinationAction.AddToQueue -> {
audioPlayerQueue.addToQueue(entry) audioPlayerQueue.addToQueue(entry)
} }
PlaybackDestinationAction.PlayNext -> { PlaybackDestinationAction.PlayNext -> {
val queue = audioPlayerQueue.getQueue() val queue = audioPlayerQueue.getQueue()
queue.find { candidate -> queue.find { candidate ->
(candidate as? QueueEntry.StreamingTrack)?.track?.matchesTrack(request.track) == true (candidate as? QueueEntry.StreamingTrack)?.track?.matchesTrack(request.track) == true
}?.let { audioPlayerQueue.removeFromQueue(it) } }?.let { audioPlayerQueue.removeFromQueue(it) }
audioPlayerQueue.addAllAfterCurrent(listOf(entry)) audioPlayerQueue.addAllAfterCurrent(listOf(entry))
}
} }
} }
}
is PlaybackDestinationRequest.Tracks -> { is PlaybackDestinationRequest.Tracks -> {
val entries = request.tracks val entries = request.tracks
.filter { track -> !isTrackBlacklisted(track) } .filter { track -> !isTrackBlacklisted(track) }
.map { track -> QueueEntry.StreamingTrack(track = track, url = "") } .map { track -> QueueEntry.StreamingTrack(track = track, url = "") }
when (request.action) { when (request.action) {
PlaybackDestinationAction.Play -> { PlaybackDestinationAction.Play -> {
audioPlayerQueue.load( audioPlayerQueue.load(
entries = entries, entries = entries,
autoPlay = true, autoPlay = true,
startPosition = 0, startPosition = 0,
collectionEntry = null) collectionEntry = null)
} }
PlaybackDestinationAction.AddToQueue -> { PlaybackDestinationAction.AddToQueue -> {
audioPlayerQueue.addAllToQueue(entries) audioPlayerQueue.addAllToQueue(entries)
} }
PlaybackDestinationAction.PlayNext -> { PlaybackDestinationAction.PlayNext -> {
audioPlayerQueue.addAllAfterCurrent(entries) audioPlayerQueue.addAllAfterCurrent(entries)
}
} }
} }
} }

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.NavigationCommands
import org.spotube.spotube.core.navigation.Routes import org.spotube.spotube.core.navigation.Routes
import org.spotube.spotube.core.playback.CollectionPlaybackHelper 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.RemoteCollectionType
import org.spotube.spotube.core.remote.RemotePlaybackController import org.spotube.spotube.core.remote.RemotePlaybackController
import org.spotube.spotube.core.ui.component.cards.PlayableCard import org.spotube.spotube.core.ui.component.cards.PlayableCard
@ -44,6 +46,7 @@ fun AlbumCard(
listMode: Boolean = false listMode: Boolean = false
) { ) {
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
val busyCollections by remotePlaybackController.busyCollections.collectAsStateWithLifecycle()
PlayableCard( PlayableCard(
title = album.title, title = album.title,
@ -71,6 +74,8 @@ fun AlbumCard(
}, },
modifier = if (listMode) modifier else modifier.width(160.dp), modifier = if (listMode) modifier else modifier.width(160.dp),
sharedElementKey = "album_art_${album.id}", 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, isJamGuest: Boolean = false,
onAddToJam: (() -> Unit)? = null, onAddToJam: (() -> Unit)? = null,
isFollowing: Boolean = false, isFollowing: Boolean = false,
isFollowLoading: Boolean = false,
isPlayLoading: Boolean = false,
isShuffleLoading: Boolean = false,
isAddToQueueLoading: Boolean = false,
onFollowClick: () -> Unit = { }, onFollowClick: () -> Unit = { },
showFollowButton: Boolean = true, showFollowButton: Boolean = true,
onEdit: (() -> Unit)? = null, onEdit: (() -> Unit)? = null,
@ -126,7 +130,8 @@ fun CollectionDetails(
MillenniumButton( MillenniumButton(
modifier = modifier, modifier = modifier,
onClick = onPlay) { onClick = onPlay,
loading = isPlayLoading) {
MillenniumIcon( MillenniumIcon(
imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2, 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)) 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( MillenniumButton(
modifier = modifier, modifier = modifier,
onClick = onShufflePlay, onClick = onShufflePlay,
loading = isShuffleLoading,
style = MillenniumButtonStyle.Secondary style = MillenniumButtonStyle.Secondary
) { ) {
MillenniumIcon(imageVector = Iconsax.IconsaxShuffle, contentDescription = stringResource(Res.string.collection_details_shuffle_play)) MillenniumIcon(imageVector = Iconsax.IconsaxShuffle, contentDescription = stringResource(Res.string.collection_details_shuffle_play))
@ -148,6 +154,7 @@ fun CollectionDetails(
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
MillenniumIconButton( MillenniumIconButton(
onClick = onAddToQueue, onClick = onAddToQueue,
loading = isAddToQueueLoading,
style = MillenniumIconButtonStyle.Quiet) { style = MillenniumIconButtonStyle.Quiet) {
MillenniumIcon( MillenniumIcon(
imageVector = Iconsax.IconsaxAddSquare, imageVector = Iconsax.IconsaxAddSquare,
@ -159,6 +166,7 @@ fun CollectionDetails(
if (isFollowing) { if (isFollowing) {
MillenniumIconButton( MillenniumIconButton(
onClick = onFollowClick, onClick = onFollowClick,
loading = isFollowLoading,
style = MillenniumIconButtonStyle.Quiet) { style = MillenniumIconButtonStyle.Quiet) {
MillenniumIcon( MillenniumIcon(
imageVector = Iconsax.IconsaxHeart2, imageVector = Iconsax.IconsaxHeart2,
@ -167,6 +175,7 @@ fun CollectionDetails(
} else { } else {
MillenniumIconButton( MillenniumIconButton(
onClick = onFollowClick, onClick = onFollowClick,
loading = isFollowLoading,
style = MillenniumIconButtonStyle.Quiet) { style = MillenniumIconButtonStyle.Quiet) {
MillenniumIcon( MillenniumIcon(
imageVector = Iconsax.IconsaxHeart, imageVector = Iconsax.IconsaxHeart,

View File

@ -69,6 +69,10 @@ fun CollectionView(
onAddToQueue: () -> Unit, onAddToQueue: () -> Unit,
isPlaying: Boolean = false, isPlaying: Boolean = false,
isFollowing: Boolean = false, isFollowing: Boolean = false,
isFollowLoading: Boolean = false,
isPlayLoading: Boolean = false,
isShuffleLoading: Boolean = false,
isAddToQueueLoading: Boolean = false,
onFollowClick: () -> Unit = {}, onFollowClick: () -> Unit = {},
showFollowButton: Boolean = true, showFollowButton: Boolean = true,
onEdit: (() -> Unit)? = null, onEdit: (() -> Unit)? = null,
@ -123,6 +127,7 @@ fun CollectionView(
{ {
MillenniumIconButton( MillenniumIconButton(
onClick = onPlay, onClick = onPlay,
loading = isPlayLoading,
style = MillenniumIconButtonStyle.Quiet) { style = MillenniumIconButtonStyle.Quiet) {
MillenniumIcon( MillenniumIcon(
imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2, imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2,
@ -159,6 +164,10 @@ fun CollectionView(
onAddToQueue = onAddToQueue, onAddToQueue = onAddToQueue,
isPlaying = isPlaying, isPlaying = isPlaying,
isFollowing = isFollowing, isFollowing = isFollowing,
isFollowLoading = isFollowLoading,
isPlayLoading = isPlayLoading,
isShuffleLoading = isShuffleLoading,
isAddToQueueLoading = isAddToQueueLoading,
onFollowClick = onFollowClick, onFollowClick = onFollowClick,
showFollowButton = showFollowButton, showFollowButton = showFollowButton,
onEdit = onEdit, onEdit = onEdit,
@ -180,6 +189,10 @@ fun CollectionView(
onAddToQueue = onAddToQueue, onAddToQueue = onAddToQueue,
isPlaying = isPlaying, isPlaying = isPlaying,
isFollowing = isFollowing, isFollowing = isFollowing,
isFollowLoading = isFollowLoading,
isPlayLoading = isPlayLoading,
isShuffleLoading = isShuffleLoading,
isAddToQueueLoading = isAddToQueueLoading,
onFollowClick = onFollowClick, onFollowClick = onFollowClick,
showFollowButton = showFollowButton, showFollowButton = showFollowButton,
onEdit = onEdit, onEdit = onEdit,

View File

@ -87,6 +87,7 @@ fun ErrorDisplay(
LoginRequiredDisplay( LoginRequiredDisplay(
pluginName = authState.pluginName, pluginName = authState.pluginName,
onLogin = authViewModel::login, onLogin = authViewModel::login,
isLoggingIn = authState.isLoggingIn,
modifier = modifier) modifier = modifier)
return 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.audioplayer.QueueEntry
import org.spotube.spotube.core.navigation.NavigationCommands import org.spotube.spotube.core.navigation.NavigationCommands
import org.spotube.spotube.core.navigation.Routes 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.Coverflow
import org.spotube.spotube.core.ui.coverflow.CoverflowParams import org.spotube.spotube.core.ui.coverflow.CoverflowParams
import org.spotube.spotube.core.ui.coverflow.rememberCoverflowState import org.spotube.spotube.core.ui.coverflow.rememberCoverflowState
@ -288,11 +291,12 @@ private fun FeaturedPlayButton(
item: MetadataBrowseItem, item: MetadataBrowseItem,
audioPlayerQueue: AudioPlayerQueue, audioPlayerQueue: AudioPlayerQueue,
navigationCommands: NavigationCommands, navigationCommands: NavigationCommands,
playbackHelper: CollectionPlaybackHelper = koinInject(), remotePlaybackController: RemotePlaybackController = koinInject(),
enabled: Boolean, enabled: Boolean,
modifier: Modifier = Modifier) { modifier: Modifier = Modifier) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
val busyCollections by remotePlaybackController.busyCollections.collectAsStateWithLifecycle()
when (item) { when (item) {
is MetadataBrowseItem.Album -> { is MetadataBrowseItem.Album -> {
@ -309,10 +313,18 @@ private fun FeaturedPlayButton(
if (isPlaying) { if (isPlaying) {
navigationCommands.navigateTo(Routes.Album(item.data.id)) navigationCommands.navigateTo(Routes.Album(item.data.id))
} else { } 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( MillenniumIcon(
imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
@ -337,10 +349,18 @@ private fun FeaturedPlayButton(
if (isPlaying) { if (isPlaying) {
navigationCommands.navigateTo(Routes.Playlist(item.data.id)) navigationCommands.navigateTo(Routes.Playlist(item.data.id))
} else { } 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( MillenniumIcon(
imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,

View File

@ -38,7 +38,6 @@ import com.composeunstyled.Text
import com.composeunstyled.LocalTextStyle import com.composeunstyled.LocalTextStyle
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.audioplayer.QueueCollectionEntry
import org.spotube.spotube.core.navigation.NavigationCommands import org.spotube.spotube.core.navigation.NavigationCommands
import org.spotube.spotube.core.navigation.Routes 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.Iconsax
import org.spotube.spotube.resources.iconsax.IconsaxAddSquare import org.spotube.spotube.resources.iconsax.IconsaxAddSquare
import org.spotube.spotube.resources.iconsax.IconsaxPauseCircle import org.spotube.spotube.resources.iconsax.IconsaxPauseCircle
import org.spotube.spotube.resources.iconsax.Play import org.spotube.spotube.resources.iconsax.Play
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import org.koin.compose.koinInject 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 spotube.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import org.spotube.spotube.core.ui.component.cards.PlayableListTile import org.spotube.spotube.core.ui.component.cards.PlayableListTile
@ -72,14 +74,26 @@ import androidx.compose.ui.platform.LocalInputModeManager
fun LikedTracksCard( fun LikedTracksCard(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
audioPlayerQueue: AudioPlayerQueue = koinInject(), audioPlayerQueue: AudioPlayerQueue = koinInject(),
playbackHelper: CollectionPlaybackHelper = koinInject(), remotePlaybackController: RemotePlaybackController = koinInject(),
navigationCommands: NavigationCommands = koinInject(), navigationCommands: NavigationCommands = koinInject(),
listMode: Boolean = false listMode: Boolean = false
) { ) {
val scope = rememberCoroutineScope()
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
val busyCollections by remotePlaybackController.busyCollections.collectAsStateWithLifecycle()
val isPlaying = currentCollectionEntry is QueueCollectionEntry.SavedTracks val isPlaying = currentCollectionEntry is QueueCollectionEntry.SavedTracks
val title = stringResource(Res.string.media_liked_tracks) 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) val subtitle = stringResource(Res.string.media_saved_songs)
if (listMode) { if (listMode) {
PlayableListTile( PlayableListTile(
@ -87,10 +101,12 @@ fun LikedTracksCard(
subtitle = subtitle, subtitle = subtitle,
isPlaying = isPlaying, isPlaying = isPlaying,
onClick = { navigationCommands.navigateTo(Routes.SavedTracks) }, onClick = { navigationCommands.navigateTo(Routes.SavedTracks) },
onPlay = { scope.launch { playbackHelper.playSavedTracks() } }, onPlay = onPlay,
onAddToQueue = { scope.launch { playbackHelper.addSavedTracksToQueue() } }, onAddToQueue = onAddToQueue,
modifier = modifier, modifier = modifier,
sharedElementKey = "saved_tracks_art", sharedElementKey = "saved_tracks_art",
playLoading = playLoading,
addToQueueLoading = addToQueueLoading,
artwork = { artwork = {
Image( Image(
painterResource(Res.drawable.liked_tracks), painterResource(Res.drawable.liked_tracks),
@ -132,13 +148,14 @@ fun LikedTracksCard(
modifier = Modifier.fillMaxWidth().aspectRatio(1f).padding(8.dp), modifier = Modifier.fillMaxWidth().aspectRatio(1f).padding(8.dp),
contentAlignment = Alignment.BottomEnd contentAlignment = Alignment.BottomEnd
) { ) {
if (isHovered || touchInput || isPlaying) { if (isHovered || touchInput || isPlaying || playLoading || addToQueueLoading) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(4.dp) modifier = Modifier.padding(4.dp)
) { ) {
MillenniumIconButton( MillenniumIconButton(
onClick = { scope.launch { playbackHelper.addSavedTracksToQueue() } }, onClick = onAddToQueue,
loading = addToQueueLoading,
modifier = Modifier.size(30.dp), modifier = Modifier.size(30.dp),
style = MillenniumIconButtonStyle.Secondary style = MillenniumIconButtonStyle.Secondary
) { ) {
@ -150,7 +167,8 @@ fun LikedTracksCard(
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
MillenniumIconButton( MillenniumIconButton(
onClick = { scope.launch { playbackHelper.playSavedTracks() } }, onClick = onPlay,
loading = playLoading,
modifier = Modifier.size(30.dp), modifier = Modifier.size(30.dp),
style = MillenniumIconButtonStyle.Primary style = MillenniumIconButtonStyle.Primary
) { ) {

View File

@ -49,7 +49,8 @@ import spotube.composeapp.generated.resources.*
fun LoginRequiredDisplay( fun LoginRequiredDisplay(
pluginName: String?, pluginName: String?,
onLogin: () -> Unit, onLogin: () -> Unit,
modifier: Modifier = Modifier) { modifier: Modifier = Modifier,
isLoggingIn: Boolean = false) {
Column( Column(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
@ -88,6 +89,7 @@ fun LoginRequiredDisplay(
MillenniumButton( MillenniumButton(
onClick = onLogin, onClick = onLogin,
loading = isLoggingIn,
modifier = Modifier.height(44.dp)) { modifier = Modifier.height(44.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
MillenniumIcon( 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.NavigationCommands
import org.spotube.spotube.core.navigation.Routes import org.spotube.spotube.core.navigation.Routes
import org.spotube.spotube.core.playback.CollectionPlaybackHelper 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.RemoteCollectionType
import org.spotube.spotube.core.remote.RemotePlaybackController import org.spotube.spotube.core.remote.RemotePlaybackController
import org.spotube.spotube.core.ui.component.cards.PlayableCard import org.spotube.spotube.core.ui.component.cards.PlayableCard
@ -45,6 +47,7 @@ fun PlaylistCard(
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
val busyCollections by remotePlaybackController.busyCollections.collectAsStateWithLifecycle()
PlayableCard( PlayableCard(
title = playlist.title, title = playlist.title,
@ -71,6 +74,8 @@ fun PlaylistCard(
}, },
modifier = modifier, modifier = modifier,
sharedElementKey = "playlist_art_${playlist.id}", 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, onAddToQueue: (() -> Unit)? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
sharedElementKey: String? = null, sharedElementKey: String? = null,
listMode: Boolean = false listMode: Boolean = false,
playLoading: Boolean = false,
addToQueueLoading: Boolean = false
) { ) {
if (listMode) { if (listMode) {
PlayableListTile( PlayableListTile(
@ -88,7 +90,9 @@ fun PlayableCard(
onPlay, onPlay,
onAddToQueue, onAddToQueue,
modifier, modifier,
sharedElementKey sharedElementKey,
playLoading = playLoading,
addToQueueLoading = addToQueueLoading
) )
return return
} }
@ -124,7 +128,8 @@ fun PlayableCard(
contentAlignment = Alignment.BottomEnd contentAlignment = Alignment.BottomEnd
) { ) {
this@Column.AnimatedVisibility( this@Column.AnimatedVisibility(
visible = highlighted || touchInput || (isPlaying && onPlay != null), visible = highlighted || touchInput || (isPlaying && onPlay != null) ||
playLoading || addToQueueLoading,
enter = fadeIn(), enter = fadeIn(),
exit = fadeOut() exit = fadeOut()
) { ) {
@ -137,6 +142,7 @@ fun PlayableCard(
onAddToQueue?.takeIf { !isPlaying }?.let { onAddToQueue?.takeIf { !isPlaying }?.let {
MillenniumIconButton( MillenniumIconButton(
onClick = onAddToQueue, onClick = onAddToQueue,
loading = addToQueueLoading,
modifier = Modifier.size(34.dp), modifier = Modifier.size(34.dp),
style = MillenniumIconButtonStyle.Secondary style = MillenniumIconButtonStyle.Secondary
) { ) {
@ -152,6 +158,7 @@ fun PlayableCard(
onPlay?.let { onPlay?.let {
MillenniumIconButton( MillenniumIconButton(
onClick = onPlay, onClick = onPlay,
loading = playLoading,
modifier = Modifier.size(34.dp), modifier = Modifier.size(34.dp),
style = MillenniumIconButtonStyle.Primary style = MillenniumIconButtonStyle.Primary

View File

@ -56,6 +56,8 @@ fun PlayableListTile(
onAddToQueue: (() -> Unit)? = null, onAddToQueue: (() -> Unit)? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
sharedElementKey: String? = null, sharedElementKey: String? = null,
playLoading: Boolean = false,
addToQueueLoading: Boolean = false,
imageModel: Any? = imageURL, imageModel: Any? = imageURL,
imageShape: Shape = RoundedCornerShape(6.dp), imageShape: Shape = RoundedCornerShape(6.dp),
placeholderIcon: ImageVector = Iconsax.IconsaxMusic, placeholderIcon: ImageVector = Iconsax.IconsaxMusic,
@ -115,6 +117,7 @@ fun PlayableListTile(
if (onAddToQueue != null && !isPlaying) { if (onAddToQueue != null && !isPlaying) {
MillenniumIconButton( MillenniumIconButton(
onClick = onAddToQueue, onClick = onAddToQueue,
loading = addToQueueLoading,
modifier = Modifier.size(if (compact) 32.dp else 40.dp), modifier = Modifier.size(if (compact) 32.dp else 40.dp),
style = MillenniumIconButtonStyle.Secondary style = MillenniumIconButtonStyle.Secondary
) { ) {
@ -127,6 +130,7 @@ fun PlayableListTile(
if (onPlay != null) { if (onPlay != null) {
MillenniumIconButton( MillenniumIconButton(
onClick = onPlay, onClick = onPlay,
loading = playLoading,
modifier = Modifier.size(if (compact) 32.dp else 40.dp), modifier = Modifier.size(if (compact) 32.dp else 40.dp),
style = MillenniumIconButtonStyle.Primary style = MillenniumIconButtonStyle.Primary
) { ) {

View File

@ -45,6 +45,7 @@ fun AlbumScreen(
navigationCommands: NavigationCommands navigationCommands: NavigationCommands
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val playbackUiState by viewModel.playbackUiState.collectAsStateWithLifecycle()
val jamRoomService: JamRoomService = koinInject() val jamRoomService: JamRoomService = koinInject()
val jamActive by jamRoomService.role.map { it != null } val jamActive by jamRoomService.role.map { it != null }
.collectAsStateWithLifecycle(initialValue = false) .collectAsStateWithLifecycle(initialValue = false)
@ -71,6 +72,9 @@ fun AlbumScreen(
val isLoading = state is AlbumScreenState.Loading && (dataState == null || dataState.tracks.isEmpty()) val isLoading = state is AlbumScreenState.Loading && (dataState == null || dataState.tracks.isEmpty())
CollectionView( CollectionView(
isPlayLoading = playbackUiState.isPlayLoading,
isShuffleLoading = playbackUiState.isShuffleLoading,
isAddToQueueLoading = playbackUiState.isAddToQueueLoading,
title = album?.title ?: stringResource(Res.string.album_loading_title), title = album?.title ?: stringResource(Res.string.album_loading_title),
description = album?.description ?: "${album?.albumType?.name.orEmpty()} • ${album?.releaseDate.orEmpty()}", description = album?.description ?: "${album?.albumType?.name.orEmpty()} • ${album?.releaseDate.orEmpty()}",
imageURL = artworkUrl, imageURL = artworkUrl,
@ -86,6 +90,7 @@ fun AlbumScreen(
onAddToQueue = viewModel::addAlbumToQueue, onAddToQueue = viewModel::addAlbumToQueue,
isPlaying = isAlbumPlaying, isPlaying = isAlbumPlaying,
isFollowing = savedAlbumIds.contains(albumId), isFollowing = savedAlbumIds.contains(albumId),
isFollowLoading = dataState?.isSaving == true,
onFollowClick = viewModel::toggleSavedAlbum, onFollowClick = viewModel::toggleSavedAlbum,
isLoading = isLoading, isLoading = isLoading,
error = errorMessage, 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.audioplayer.QueueEntry
import org.spotube.spotube.core.di.injectLogger import org.spotube.spotube.core.di.injectLogger
import org.spotube.spotube.core.playback.CollectionPlaybackHelper 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.RemoteCollectionType
import org.spotube.spotube.core.remote.RemotePlaybackController import org.spotube.spotube.core.remote.RemotePlaybackController
import org.spotube.spotube.core.share.ShareService 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.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.getString
@ -104,6 +106,11 @@ class AlbumViewModel(
private val _state = MutableStateFlow<AlbumScreenState>(AlbumScreenState.Loading) private val _state = MutableStateFlow<AlbumScreenState>(AlbumScreenState.Loading)
val uiState: StateFlow<AlbumScreenState> = _state.asStateFlow() 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 val savedAlbumIds
get() = libraryRepository.savedAlbumIdsFlow get() = libraryRepository.savedAlbumIdsFlow
@ -203,13 +210,30 @@ class AlbumViewModel(
} }
fun toggleSavedAlbum() { fun toggleSavedAlbum() {
if ((_state.value as? AlbumScreenState.Data)?.isSaving == true) return
setSaving(true)
viewModelScope.launch { viewModelScope.launch {
val isLiked = runCatching {
libraryRepository.isSavedAlbums(listOf(albumId)).firstOrNull() ?: false val isLiked =
if (isLiked) { libraryRepository.isSavedAlbums(listOf(albumId)).firstOrNull() ?: false
libraryRepository.removeSavedAlbums(listOf(albumId)) if (isLiked) {
} else { libraryRepository.removeSavedAlbums(listOf(albumId))
libraryRepository.saveAlbums(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
} }
} }
} }

View File

@ -104,6 +104,7 @@ fun ArtistScreen(
navigationCommands: NavigationCommands navigationCommands: NavigationCommands
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val playbackUiState by viewModel.playbackUiState.collectAsStateWithLifecycle()
val jamRoomService: JamRoomService = koinInject() val jamRoomService: JamRoomService = koinInject()
val jamActive by jamRoomService.role.map { it != null } val jamActive by jamRoomService.role.map { it != null }
.collectAsStateWithLifecycle(initialValue = false) .collectAsStateWithLifecycle(initialValue = false)
@ -149,6 +150,7 @@ fun ArtistScreen(
ArtistHeaderCard( ArtistHeaderCard(
artist = currentState.artist, artist = currentState.artist,
isSaved = savedArtistIds.contains(currentState.artist.id), isSaved = savedArtistIds.contains(currentState.artist.id),
isSaving = currentState.isSaving,
onFollowClick = viewModel::toggleSavedArtist, onFollowClick = viewModel::toggleSavedArtist,
isBlacklisted = currentState.artist.id in blacklistedArtistIds, isBlacklisted = currentState.artist.id in blacklistedArtistIds,
onBlacklistClick = { viewModel.toggleArtistBlacklist(currentState.artist) }) onBlacklistClick = { viewModel.toggleArtistBlacklist(currentState.artist) })
@ -157,7 +159,9 @@ fun ArtistScreen(
item { item {
TopTracksHeader( TopTracksHeader(
onPlay = viewModel::playTopTracks, 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( private fun ArtistHeaderCard(
artist: MetadataArtist.Detailed, artist: MetadataArtist.Detailed,
isSaved: Boolean, isSaved: Boolean,
isSaving: Boolean,
onFollowClick: () -> Unit, onFollowClick: () -> Unit,
isBlacklisted: Boolean, isBlacklisted: Boolean,
onBlacklistClick: () -> Unit onBlacklistClick: () -> Unit
@ -377,6 +382,7 @@ private fun ArtistHeaderCard(
ArtistHeaderActions( ArtistHeaderActions(
isSaved = isSaved, isSaved = isSaved,
isSaving = isSaving,
onFollowClick = onFollowClick, onFollowClick = onFollowClick,
isBlacklisted = isBlacklisted, isBlacklisted = isBlacklisted,
onBlacklistClick = onBlacklistClick onBlacklistClick = onBlacklistClick
@ -406,6 +412,7 @@ private fun ArtistHeaderCard(
ArtistHeaderActions( ArtistHeaderActions(
isSaved = isSaved, isSaved = isSaved,
isSaving = isSaving,
onFollowClick = onFollowClick, onFollowClick = onFollowClick,
isBlacklisted = isBlacklisted, isBlacklisted = isBlacklisted,
onBlacklistClick = onBlacklistClick onBlacklistClick = onBlacklistClick
@ -496,6 +503,7 @@ private fun ArtistMeta(
@Composable @Composable
private fun ArtistHeaderActions( private fun ArtistHeaderActions(
isSaved: Boolean, isSaved: Boolean,
isSaving: Boolean,
onFollowClick: () -> Unit, onFollowClick: () -> Unit,
isBlacklisted: Boolean, isBlacklisted: Boolean,
onBlacklistClick: () -> Unit onBlacklistClick: () -> Unit
@ -507,6 +515,7 @@ private fun ArtistHeaderActions(
if (isSaved) { if (isSaved) {
MillenniumButton( MillenniumButton(
onClick = onFollowClick, onClick = onFollowClick,
loading = isSaving,
style = MillenniumButtonStyle.Secondary style = MillenniumButtonStyle.Secondary
) { ) {
Text( Text(
@ -517,7 +526,7 @@ private fun ArtistHeaderActions(
) )
} }
} else { } else {
MillenniumButton(onClick = onFollowClick) { MillenniumButton(onClick = onFollowClick, loading = isSaving) {
Text(stringResource(Res.string.collection_details_follow), modifier = Modifier.width(65.dp), textAlign = TextAlign.Center) Text(stringResource(Res.string.collection_details_follow), modifier = Modifier.width(65.dp), textAlign = TextAlign.Center)
} }
} }
@ -554,7 +563,9 @@ private fun ArtistHeaderActions(
@Composable @Composable
private fun TopTracksHeader( private fun TopTracksHeader(
onPlay: () -> Unit, onPlay: () -> Unit,
onAddToQueue: () -> Unit onAddToQueue: () -> Unit,
isPlayLoading: Boolean = false,
isAddToQueueLoading: Boolean = false
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
@ -572,6 +583,7 @@ private fun TopTracksHeader(
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
MillenniumIconButton( MillenniumIconButton(
onClick = onPlay, onClick = onPlay,
loading = isPlayLoading,
style = MillenniumIconButtonStyle.Primary style = MillenniumIconButtonStyle.Primary
) { ) {
MillenniumIcon( MillenniumIcon(
@ -582,6 +594,7 @@ private fun TopTracksHeader(
} }
MillenniumIconButton( MillenniumIconButton(
onClick = onAddToQueue, onClick = onAddToQueue,
loading = isAddToQueueLoading,
style = MillenniumIconButtonStyle.Secondary style = MillenniumIconButtonStyle.Secondary
) { ) {
MillenniumIcon( 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.AudioPlayerQueue
import org.spotube.spotube.core.audioplayer.QueueEntry import org.spotube.spotube.core.audioplayer.QueueEntry
import org.spotube.spotube.core.di.injectLogger 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.RemoteCollectionType
import org.spotube.spotube.core.remote.RemotePlaybackController import org.spotube.spotube.core.remote.RemotePlaybackController
import org.spotube.spotube.core.share.ShareService 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.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.getString
@ -61,7 +63,8 @@ sealed interface ArtistScreenState {
val relatedArtists: List<MetadataArtist.Basic>, val relatedArtists: List<MetadataArtist.Basic>,
val relatedArtistsNextPagination: PaginationStrategy?, val relatedArtistsNextPagination: PaginationStrategy?,
val featuredPlaylists: List<MetadataPlaylist>, val featuredPlaylists: List<MetadataPlaylist>,
val featuredPlaylistsNextPagination: PaginationStrategy?) : ArtistScreenState val featuredPlaylistsNextPagination: PaginationStrategy?,
val isSaving: Boolean = false) : ArtistScreenState
data class Error(val message: String) : ArtistScreenState data class Error(val message: String) : ArtistScreenState
} }
@ -82,6 +85,11 @@ class ArtistViewModel(
private val _state = MutableStateFlow<ArtistScreenState>(ArtistScreenState.Loading) private val _state = MutableStateFlow<ArtistScreenState>(ArtistScreenState.Loading)
val state: StateFlow<ArtistScreenState> = _state.asStateFlow() 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 val savedArtistIds
get() = libraryRepository.savedArtistIdsFlow get() = libraryRepository.savedArtistIdsFlow
@ -152,13 +160,26 @@ class ArtistViewModel(
} }
fun toggleSavedArtist() { fun toggleSavedArtist() {
if ((_state.value as? ArtistScreenState.Loaded)?.isSaving == true) return
setSaving(true)
viewModelScope.launch { viewModelScope.launch {
val isLiked = libraryRepository.isSavedArtists(listOf(artistId)).firstOrNull() ?: false runCatching {
if (isLiked) { val isLiked = libraryRepository.isSavedArtists(listOf(artistId)).firstOrNull() ?: false
libraryRepository.removeSavedArtists(listOf(artistId)) if (isLiked) {
} else { libraryRepository.removeSavedArtists(listOf(artistId))
libraryRepository.saveArtists(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,22 +122,16 @@ fun DevicesScreen(
backButton = true, backButton = true,
title = { Text(stringResource(Res.string.devices_title)) }, title = { Text(stringResource(Res.string.devices_title)) },
actions = { actions = {
if (state.isDiscovering) { MillenniumIconButton(
MillenniumSpokeSpinner( onClick = viewModel::startDiscovery,
size = 20.dp, enabled = !state.isConnecting,
modifier = Modifier.padding(horizontal = 10.dp) 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( MillenniumButton(
onClick = onDisconnect, onClick = onDisconnect,
loading = device.isDisconnecting,
style = MillenniumButtonStyle.Secondary style = MillenniumButtonStyle.Secondary
) { ) {
Text(stringResource(Res.string.devices_disconnect)) Text(stringResource(Res.string.devices_disconnect))

View File

@ -66,7 +66,8 @@ data class DeviceItemUi(
data class ConnectedDeviceUi( data class ConnectedDeviceUi(
val name: String?, val name: String?,
val address: String) val address: String,
val isDisconnecting: Boolean = false)
data class DevicesUiState( data class DevicesUiState(
val devices: List<DeviceItemUi> = emptyList(), val devices: List<DeviceItemUi> = emptyList(),
@ -89,14 +90,15 @@ class DevicesViewModel(
private val _isDiscovering = MutableStateFlow(false) private val _isDiscovering = MutableStateFlow(false)
private val _connectingToDevice = MutableStateFlow<DiscoveredDevice?>(null) private val _connectingToDevice = MutableStateFlow<DiscoveredDevice?>(null)
private val _error = MutableStateFlow<DevicesError?>(null) private val _error = MutableStateFlow<DevicesError?>(null)
private val _isDisconnecting = MutableStateFlow(false)
val uiState: StateFlow<DevicesUiState> = combine( val uiState: StateFlow<DevicesUiState> = combine(
_devices, _devices,
_isDiscovering, _isDiscovering,
_connectingToDevice, _connectingToDevice,
remoteControlClient.connectionState, remoteControlClient.connectionState,
_error combine(_error, _isDisconnecting, ::Pair)
) { devices, isDiscovering, connectingTo, connection, error -> ) { devices, isDiscovering, connectingTo, connection, (error, isDisconnecting) ->
val connected = connection as? ConnectionState.Connected val connected = connection as? ConnectionState.Connected
DevicesUiState( DevicesUiState(
devices = devices.values.map { device -> devices = devices.values.map { device ->
@ -112,7 +114,8 @@ class DevicesViewModel(
connectedDevice = connected?.let { connectedDevice = connected?.let {
ConnectedDeviceUi( ConnectedDeviceUi(
name = it.deviceName.ifBlank { null }, name = it.deviceName.ifBlank { null },
address = "${it.host}:${it.port}") address = "${it.host}:${it.port}",
isDisconnecting = isDisconnecting)
}, },
error = error) error = error)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DevicesUiState()) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DevicesUiState())
@ -288,8 +291,14 @@ class DevicesViewModel(
} }
fun disconnect() { fun disconnect() {
if (_isDisconnecting.value) return
_isDisconnecting.value = true
viewModelScope.launch { viewModelScope.launch {
remoteControlClient.disconnect() try {
remoteControlClient.disconnect()
} finally {
_isDisconnecting.value = false
}
} }
} }

View File

@ -203,12 +203,12 @@ internal fun SetupPanel(
MillenniumButton( MillenniumButton(
onClick = if (state.setupMode == JamSetupMode.Create) onCreate else onJoin, onClick = if (state.setupMode == JamSetupMode.Create) onCreate else onJoin,
enabled = if (state.setupMode == JamSetupMode.Create) state.canCreate else state.canJoin, enabled = if (state.setupMode == JamSetupMode.Create) state.canCreate else state.canJoin,
loading = state.isBusy,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Text( Text(
stringResource( stringResource(
when { when {
state.isBusy -> Res.string.jam_setup_working
state.setupMode == JamSetupMode.Create -> Res.string.jam_setup_create_button state.setupMode == JamSetupMode.Create -> Res.string.jam_setup_create_button
else -> Res.string.jam_setup_join_button else -> Res.string.jam_setup_join_button
} }
@ -451,6 +451,7 @@ internal fun SessionPanel(
} }
MillenniumButton( MillenniumButton(
onClick = onLeave, onClick = onLeave,
loading = state.isLeaving,
style = MillenniumButtonStyle.Secondary, style = MillenniumButtonStyle.Secondary,
modifier = Modifier.align(Alignment.End) modifier = Modifier.align(Alignment.End)
) { ) {

View File

@ -65,6 +65,7 @@ data class JamUiState(
val canCreate: Boolean = false, val canCreate: Boolean = false,
val canJoin: Boolean = false, val canJoin: Boolean = false,
val isBusy: Boolean = false, val isBusy: Boolean = false,
val isLeaving: Boolean = false,
val isHost: Boolean = false, val isHost: Boolean = false,
val supportsNativeShare: Boolean = false, val supportsNativeShare: Boolean = false,
val codeCopied: Boolean = false val codeCopied: Boolean = false
@ -79,6 +80,7 @@ class JamViewModel(
private val _localError = MutableStateFlow<String?>(null) private val _localError = MutableStateFlow<String?>(null)
private val setupMode = MutableStateFlow(JamSetupMode.Create) private val setupMode = MutableStateFlow(JamSetupMode.Create)
private val isBusy = MutableStateFlow(false) private val isBusy = MutableStateFlow(false)
private val isLeaving = MutableStateFlow(false)
private val codeCopied = MutableStateFlow(false) private val codeCopied = MutableStateFlow(false)
private val codeInput = TextFieldState() private val codeInput = TextFieldState()
private var copyFeedbackJob: Job? = null private var copyFeedbackJob: Job? = null
@ -99,7 +101,8 @@ class JamViewModel(
isBusy, isBusy,
codeCopied, codeCopied,
snapshotFlow { codeInput.text.toString() }, snapshotFlow { codeInput.text.toString() },
participantMenu participantMenu,
isLeaving
) { values -> ) { values ->
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
val role = values[0] as JamRole? val role = values[0] as JamRole?
@ -134,10 +137,11 @@ class JamViewModel(
error = localError ?: connectionError, error = localError ?: connectionError,
setupMode = values[7] as JamSetupMode, setupMode = values[7] as JamSetupMode,
isBusy = busy, isBusy = busy,
isLeaving = values[12] as Boolean,
codeCopied = values[9] as Boolean, codeCopied = values[9] as Boolean,
codeInput = codeInput, codeInput = codeInput,
canCreate = brokerConfigured && !busy, canCreate = brokerConfigured,
canJoin = brokerConfigured && !busy && canJoin = brokerConfigured &&
JamRoomCode.isValid(JamRoomCode.normalize(values[10] as String)), JamRoomCode.isValid(JamRoomCode.normalize(values[10] as String)),
isHost = role == JamRole.Host, isHost = role == JamRole.Host,
supportsNativeShare = supportsNativeShare supportsNativeShare = supportsNativeShare
@ -216,9 +220,15 @@ class JamViewModel(
} }
fun leave() { fun leave() {
if (isLeaving.value) return
isLeaving.value = true
viewModelScope.launch { viewModelScope.launch {
jamRoomService.leaveRoom() try {
_localError.value = null 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) { LibraryTab.Playlists -> MetadataPluginGuard(showTopBar = false, requireLogin = true) {
LibraryPlaylistsScreen( LibraryPlaylistsScreen(
showCreatePlaylist = state.showCreatePlaylist, showCreatePlaylist = state.showCreatePlaylist,
onDismissCreatePlaylist = libraryState::dismissCreatePlaylist,
viewMode = viewMode viewMode = viewMode
) )
} }
@ -228,6 +227,7 @@ internal fun RowScope.LibraryToolbarActions(
icon = Iconsax.IconsaxRefreshRight, icon = Iconsax.IconsaxRefreshRight,
contentDescription = stringResource(Res.string.library_action_refresh_local_tracks), contentDescription = stringResource(Res.string.library_action_refresh_local_tracks),
onClick = onRefresh, onClick = onRefresh,
loading = state.isRefreshing,
modifier = actionModifier, modifier = actionModifier,
style = MillenniumIconButtonStyle.Quiet style = MillenniumIconButtonStyle.Quiet
) )

View File

@ -42,6 +42,7 @@ data class LibraryToolbarUiState(
val canFilter: Boolean = true, val canFilter: Boolean = true,
val canCreatePlaylist: Boolean = true, val canCreatePlaylist: Boolean = true,
val canRefresh: Boolean = false, val canRefresh: Boolean = false,
val isRefreshing: Boolean = false,
val showCreatePlaylist: Boolean = false, val showCreatePlaylist: Boolean = false,
val canChangeView: Boolean = true, val canChangeView: Boolean = true,
val viewModeOverride: LibraryViewMode? = null val viewModeOverride: LibraryViewMode? = null
@ -57,13 +58,15 @@ class LibraryState : ViewModel() {
private val viewModes = MutableStateFlow<Map<LibraryTab, LibraryViewMode>>(emptyMap()) private val viewModes = MutableStateFlow<Map<LibraryTab, LibraryViewMode>>(emptyMap())
private val refreshRequests = MutableSharedFlow<Unit>(extraBufferCapacity = 1) private val refreshRequests = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val localRefreshRequests = refreshRequests.asSharedFlow() val localRefreshRequests = refreshRequests.asSharedFlow()
private val localRefreshing = MutableStateFlow(false)
val uiState = combine( val uiState = combine(
currentTab.flatMapLatest { tab -> queryFlow(tab).map { query -> tab to query } }, currentTab.flatMapLatest { tab -> queryFlow(tab).map { query -> tab to query } },
searchMode, searchMode,
createPlaylistVisible, createPlaylistVisible,
viewModes viewModes,
) { (tab, query), searching, creating, modes -> localRefreshing
) { (tab, query), searching, creating, modes, refreshing ->
LibraryToolbarUiState( LibraryToolbarUiState(
currentTab = tab, currentTab = tab,
searchMode = tab != LibraryTab.Downloads && (searching || query.isNotEmpty()), searchMode = tab != LibraryTab.Downloads && (searching || query.isNotEmpty()),
@ -71,6 +74,7 @@ class LibraryState : ViewModel() {
canFilter = tab != LibraryTab.Downloads, canFilter = tab != LibraryTab.Downloads,
canCreatePlaylist = tab == LibraryTab.Playlists, canCreatePlaylist = tab == LibraryTab.Playlists,
canRefresh = tab == LibraryTab.LocalTracks, canRefresh = tab == LibraryTab.LocalTracks,
isRefreshing = tab == LibraryTab.LocalTracks && refreshing,
showCreatePlaylist = tab == LibraryTab.Playlists && creating, showCreatePlaylist = tab == LibraryTab.Playlists && creating,
canChangeView = tab == LibraryTab.Playlists || tab == LibraryTab.Albums || tab == LibraryTab.Artists, canChangeView = tab == LibraryTab.Playlists || tab == LibraryTab.Albums || tab == LibraryTab.Artists,
viewModeOverride = modes[tab] viewModeOverride = modes[tab]
@ -113,6 +117,11 @@ class LibraryState : ViewModel() {
refreshRequests.tryEmit(Unit) 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) { fun toggleViewMode(isCompact: Boolean) {
val tab = currentTab.value val tab = currentTab.value
if (tab != LibraryTab.Playlists && tab != LibraryTab.Albums && tab != LibraryTab.Artists) return 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.SharingStarted
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
import org.spotube.spotube.PlatformType import org.spotube.spotube.PlatformType
import org.spotube.spotube.getPlatform import org.spotube.spotube.getPlatform
import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.getString
@ -160,19 +161,25 @@ class LibraryLocalTracksViewModel(
} }
fun refresh() { 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 { viewModelScope.launch {
_state.update { it.copy(isRefreshing = true, error = null) } try {
runCatching { runCatching {
coordinator.refreshNow("manual") coordinator.refreshNow("manual")
}.onFailure { throwable -> }.onFailure { throwable ->
val message = if (throwable is CancellationException) throw throwable
throwable.message ?: getString(Res.string.library_local_refresh_failed) val message =
_state.update { throwable.message ?: getString(Res.string.library_local_refresh_failed)
it.copy(error = message) _state.update {
it.copy(error = message)
}
} }
} finally {
_state.update { it.copy(isRefreshing = false) }
libraryState.setLocalRefreshing(false)
} }
_state.update { it.copy(isRefreshing = false) }
} }
} }

View File

@ -28,11 +28,11 @@ import spotube.composeapp.generated.resources.*
fun LibraryPlaylistsScreen( fun LibraryPlaylistsScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
showCreatePlaylist: Boolean = false, showCreatePlaylist: Boolean = false,
onDismissCreatePlaylist: () -> Unit = {},
viewMode: LibraryViewMode = LibraryViewMode.Grid, viewMode: LibraryViewMode = LibraryViewMode.Grid,
viewModel: LibraryPlaylistsViewModel = koinViewModel() viewModel: LibraryPlaylistsViewModel = koinViewModel()
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val playlistFormState by viewModel.playlistFormState.collectAsStateWithLifecycle()
Column(modifier) { Column(modifier) {
when (val current = state) { when (val current = state) {
is LibraryPlaylistsState.Error -> Box( is LibraryPlaylistsState.Error -> Box(
@ -87,8 +87,10 @@ fun LibraryPlaylistsScreen(
} }
PlaylistFormSheet( PlaylistFormSheet(
visible = showCreatePlaylist, visible = showCreatePlaylist,
onDismiss = onDismissCreatePlaylist, formState = playlistFormState,
onSubmit = viewModel::createPlaylist 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.SharingStarted
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import io.github.vinceglb.filekit.PlatformFile
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.getString
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
@ -159,10 +160,31 @@ class LibraryPlaylistsViewModel(
} }
} }
fun createPlaylist(data: PlaylistFormData) = createPlaylist( private val playlistForm = PlaylistFormController(viewModelScope, logger)
data.name, data.description.ifBlank { null }, data.isPublic, val playlistFormState: StateFlow<PlaylistFormUiState> = playlistForm.uiState
data.isCollaborating, data.imageBase64, emptyList()
) 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() { fun refresh() {
viewModelScope.launch { viewModelScope.launch {
@ -170,31 +192,4 @@ class LibraryPlaylistsViewModel(
loadInitialData() 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.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import org.spotube.spotube.core.ui.theming.AppTheme import org.spotube.spotube.core.ui.theming.AppTheme
import com.composeunstyled.SheetDetent import com.composeunstyled.SheetDetent
@ -51,7 +49,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -62,12 +59,8 @@ import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import io.github.vinceglb.filekit.dialogs.FileKitType import io.github.vinceglb.filekit.dialogs.FileKitType
import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher
import io.github.vinceglb.filekit.readBytes import io.github.vinceglb.filekit.PlatformFile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.drop 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 org.jetbrains.compose.resources.stringResource
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.library_playlist_form_change_image import spotube.composeapp.generated.resources.library_playlist_form_change_image
@ -101,8 +94,10 @@ data class PlaylistFormData(
@Composable @Composable
fun PlaylistFormSheet( fun PlaylistFormSheet(
visible: Boolean, visible: Boolean,
formState: PlaylistFormUiState,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onSubmit: (PlaylistFormData) -> Unit, onSubmit: (PlaylistFormData) -> Unit,
onImagePicked: (PlatformFile) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
initialData: PlaylistFormData? = null, initialData: PlaylistFormData? = null,
isEditing: Boolean = false isEditing: Boolean = false
@ -119,8 +114,8 @@ fun PlaylistFormSheet(
initialData?.isCollaborating ?: false initialData?.isCollaborating ?: false
) )
} }
var imageBase64 by remember(initialData) { mutableStateOf(initialData?.imageBase64 ?: "") } val imageBase64 = formState.pickedImageBase64 ?: initialData?.imageBase64.orEmpty()
var imagePreviewUrl by remember(initialData) { mutableStateOf(initialData?.imagePreviewUrl) } val imagePreviewUrl = if (formState.pickedImageBase64 != null) null else initialData?.imagePreviewUrl
val nameState = rememberTextFieldState(initialData?.name ?: "") val nameState = rememberTextFieldState(initialData?.name ?: "")
val descriptionState = rememberTextFieldState(initialData?.description ?: "") val descriptionState = rememberTextFieldState(initialData?.description ?: "")
@ -136,20 +131,10 @@ fun PlaylistFormSheet(
.collect { description = it } .collect { description = it }
} }
val scope = rememberCoroutineScope()
val imagePicker = rememberFilePickerLauncher( val imagePicker = rememberFilePickerLauncher(
type = FileKitType.Image type = FileKitType.Image
) { file -> ) { file ->
if (file != null) { if (file != null) onImagePicked(file)
scope.launch {
val bytes = file.readBytes()
val base64 = withContext(Dispatchers.Default) {
bytes.toByteString().base64()
}
imageBase64 = base64
imagePreviewUrl = null
}
}
} }
val currentData = PlaylistFormData( val currentData = PlaylistFormData(
@ -176,15 +161,14 @@ fun PlaylistFormSheet(
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)) { horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)) {
MillenniumButton( MillenniumButton(
onClick = onDismiss, onClick = onDismiss,
enabled = !formState.isSubmitting,
style = MillenniumButtonStyle.Secondary) { style = MillenniumButtonStyle.Secondary) {
Text(stringResource(Res.string.common_cancel)) Text(stringResource(Res.string.common_cancel))
} }
MillenniumButton( MillenniumButton(
onClick = { onClick = { onSubmit(currentData) },
onSubmit(currentData) enabled = name.isNotBlank() && !formState.isProcessingImage,
onDismiss() loading = formState.isSubmitting) {
},
enabled = name.isNotBlank()) {
Text(stringResource(if (isEditing) Res.string.common_save else Res.string.library_playlist_form_create)) Text(stringResource(if (isEditing) Res.string.common_save else Res.string.library_playlist_form_create))
} }
} }
@ -198,6 +182,8 @@ fun PlaylistFormSheet(
onIsCollaboratingChange = { isCollaborating = it }, onIsCollaboratingChange = { isCollaborating = it },
imageBase64 = imageBase64, imageBase64 = imageBase64,
imagePreviewUrl = imagePreviewUrl, imagePreviewUrl = imagePreviewUrl,
isProcessingImage = formState.isProcessingImage,
isImagePickEnabled = !formState.isSubmitting,
onPickImage = handleImagePick) onPickImage = handleImagePick)
} }
} }
@ -212,12 +198,14 @@ private fun PlaylistFormFields(
onIsCollaboratingChange: (Boolean) -> Unit, onIsCollaboratingChange: (Boolean) -> Unit,
imageBase64: String, imageBase64: String,
imagePreviewUrl: String?, imagePreviewUrl: String?,
isProcessingImage: Boolean,
isImagePickEnabled: Boolean,
onPickImage: () -> Unit onPickImage: () -> Unit
) { ) {
// AdaptiveDialogBottomSheet already scrolls its content; a second scroll here nests
// scrollables and crashes with infinite height constraints.
Column( Column(
modifier = Modifier modifier = Modifier.fillMaxWidth(),
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
MillenniumTextField( 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)) 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.spotube.core.ui.component.AdaptiveDialogBottomSheet
import org.spotube.millennium.composables.MillenniumCircularProgress import org.spotube.millennium.composables.MillenniumCircularProgress
import org.spotube.millennium.composables.MillenniumSpokeSpinner
import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumButton import org.spotube.millennium.composables.MillenniumButton
import org.spotube.millennium.composables.MillenniumButtonStyle import org.spotube.millennium.composables.MillenniumButtonStyle
@ -48,15 +47,8 @@ import com.composeunstyled.Text
import com.composeunstyled.rememberModalBottomSheetState import com.composeunstyled.rememberModalBottomSheetState
import com.composeunstyled.currentWindowWidthBreakpoint import com.composeunstyled.currentWindowWidthBreakpoint
import org.spotube.millennium.adaptive.WindowBreakpoints import org.spotube.millennium.adaptive.WindowBreakpoints
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@ -65,16 +57,14 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack 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.Iconsax
import org.spotube.spotube.resources.iconsax.IconsaxCheckCircle import org.spotube.spotube.resources.iconsax.IconsaxCheckCircle
import org.spotube.spotube.resources.iconsax.IconsaxFilterSearch import org.spotube.spotube.resources.iconsax.IconsaxFilterSearch
import org.spotube.spotube.resources.iconsax.IconsaxSearch 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 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.Res
import spotube.composeapp.generated.resources.playlist_add_tracks_add import spotube.composeapp.generated.resources.playlist_add_tracks_add
import spotube.composeapp.generated.resources.playlist_add_tracks_added_content_description import spotube.composeapp.generated.resources.playlist_add_tracks_added_content_description
@ -90,59 +80,17 @@ fun AddTracksToPlaylistDialog(
visible: Boolean, visible: Boolean,
playlistId: String, playlistId: String,
onDismiss: () -> Unit, onDismiss: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier) {
searchRepository: SearchRepository = koinInject(),
libraryRepository: LibraryRepository = koinInject()) {
if (!visible) return if (!visible) return
val isLargeScreen = currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Medium val viewModel: AddTracksToPlaylistViewModel = koinViewModel(
key = "add-tracks-$playlistId",
var query by remember { mutableStateOf("") } parameters = { parametersOf(playlistId) }
var searchResults by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) } )
var isSearching by remember { mutableStateOf(false) } val state by viewModel.uiState.collectAsStateWithLifecycle()
var hasSearched by remember { mutableStateOf(false) } val dismiss = {
var addingTrackIds by remember { mutableStateOf<Set<String>>(emptySet()) } viewModel.reset()
var addedTrackIds by remember { mutableStateOf<Set<String>>(emptySet()) } onDismiss()
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 content: @Composable () -> Unit = { val content: @Composable () -> Unit = {
@ -154,18 +102,19 @@ fun AddTracksToPlaylistDialog(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically) { verticalAlignment = Alignment.CenterVertically) {
MillenniumTextField( MillenniumTextField(
state = queryState, state = state.queryState,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
placeholder = stringResource(Res.string.playlist_add_tracks_search_placeholder)) placeholder = stringResource(Res.string.playlist_add_tracks_search_placeholder))
MillenniumIconButton( MillenniumIconButton(
icon = Iconsax.IconsaxSearch, icon = Iconsax.IconsaxSearch,
contentDescription = stringResource(Res.string.playlist_add_tracks_search_content_description), contentDescription = stringResource(Res.string.playlist_add_tracks_search_content_description),
onClick = handleSearch, onClick = viewModel::search,
loading = state.isSearching,
style = MillenniumIconButtonStyle.Secondary) style = MillenniumIconButtonStyle.Secondary)
} }
when { when {
isSearching -> { state.isSearching -> {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -174,7 +123,7 @@ fun AddTracksToPlaylistDialog(
MillenniumCircularProgress(size = 40.dp) MillenniumCircularProgress(size = 40.dp)
} }
} }
hasSearched && searchResults.isEmpty() -> { state.hasSearched && state.results.isEmpty() -> {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -183,18 +132,18 @@ fun AddTracksToPlaylistDialog(
Text(stringResource(Res.string.playlist_add_tracks_no_results)) Text(stringResource(Res.string.playlist_add_tracks_no_results))
} }
} }
searchResults.isNotEmpty() -> { state.results.isNotEmpty() -> {
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.weight(1f), .weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp)) { verticalArrangement = Arrangement.spacedBy(4.dp)) {
items(searchResults, key = { it.id }) { track -> items(state.results, key = { it.track.id }) { result ->
TrackSearchResultRow( TrackSearchResultRow(
track = track, track = result.track,
isAdding = addingTrackIds.contains(track.id), isAdding = result.isAdding,
isAdded = addedTrackIds.contains(track.id), isAdded = result.isAdded,
onAdd = { handleAddTrack(track) }) onAdd = { viewModel.addTrack(result.track.id) })
} }
} }
} }
@ -213,7 +162,7 @@ fun AddTracksToPlaylistDialog(
val dialogTitle = stringResource(Res.string.playlist_add_tracks_title) val dialogTitle = stringResource(Res.string.playlist_add_tracks_title)
AdaptiveDialogBottomSheet( AdaptiveDialogBottomSheet(
onDismiss = onDismiss, onDismiss = dismiss,
modifier = modifier, modifier = modifier,
dialogTitle = dialogTitle, dialogTitle = dialogTitle,
title = { Text(dialogTitle, style = AppTheme.typography.headlineSmall) }, title = { Text(dialogTitle, style = AppTheme.typography.headlineSmall) },
@ -221,7 +170,7 @@ fun AddTracksToPlaylistDialog(
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End) { horizontalArrangement = Arrangement.End) {
MillenniumButton(onClick = onDismiss) { MillenniumButton(onClick = dismiss) {
Text(stringResource(Res.string.playlist_add_tracks_done)) Text(stringResource(Res.string.playlist_add_tracks_done))
} }
} }
@ -288,12 +237,10 @@ private fun TrackSearchResultRow(
tint = AppTheme.colors.primary, tint = AppTheme.colors.primary,
modifier = Modifier.size(24.dp)) modifier = Modifier.size(24.dp))
} }
isAdding -> {
MillenniumSpokeSpinner(size = 24.dp)
}
else -> { else -> {
MillenniumButton( MillenniumButton(
onClick = onAdd, onClick = onAdd,
loading = isAdding,
style = MillenniumButtonStyle.Secondary) { style = MillenniumButtonStyle.Secondary) {
Text(stringResource(Res.string.playlist_add_tracks_add)) 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 navigationCommands: NavigationCommands
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val playbackUiState by viewModel.playbackUiState.collectAsStateWithLifecycle()
val jamRoomService: JamRoomService = koinInject() val jamRoomService: JamRoomService = koinInject()
val jamActive by jamRoomService.role.map { it != null } val jamActive by jamRoomService.role.map { it != null }
.collectAsStateWithLifecycle(initialValue = false) .collectAsStateWithLifecycle(initialValue = false)
@ -77,7 +78,7 @@ fun PlaylistScreen(
val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle() val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle()
val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle() val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle()
val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.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) } var showAddTracksDialog by remember { mutableStateOf(false) }
val dataState = state as? PlaylistScreenState.Data val dataState = state as? PlaylistScreenState.Data
@ -117,6 +118,9 @@ fun PlaylistScreen(
} else null } else null
CollectionView( CollectionView(
isPlayLoading = playbackUiState.isPlayLoading,
isShuffleLoading = playbackUiState.isShuffleLoading,
isAddToQueueLoading = playbackUiState.isAddToQueueLoading,
title = playlist?.title ?: stringResource(Res.string.playlist_loading_title), title = playlist?.title ?: stringResource(Res.string.playlist_loading_title),
description = playlist?.description.orEmpty(), description = playlist?.description.orEmpty(),
imageURL = artworkUrl, imageURL = artworkUrl,
@ -128,9 +132,10 @@ fun PlaylistScreen(
onAddToQueue = viewModel::addPlaylistToQueue, onAddToQueue = viewModel::addPlaylistToQueue,
isPlaying = isPlaying, isPlaying = isPlaying,
isFollowing = savedPlaylistIds.contains(playlistId), isFollowing = savedPlaylistIds.contains(playlistId),
isFollowLoading = dataState?.isSaving == true,
onFollowClick = viewModel::toggleSavedPlaylist, onFollowClick = viewModel::toggleSavedPlaylist,
showFollowButton = playlistId != "saved_tracks", showFollowButton = playlistId != "saved_tracks",
onEdit = if (isOwner) { { showEditPlaylist = true } } else null, onEdit = if (isOwner) viewModel::showEditPlaylist else null,
isLoading = isLoading, isLoading = isLoading,
error = errorMessage, error = errorMessage,
onRetry = { viewModel.refresh() }, onRetry = { viewModel.refresh() },
@ -157,8 +162,10 @@ fun PlaylistScreen(
trailingContent = { trailingContent = {
val loadedPlaylist = (dataState as? PlaylistScreenState.Data.Loaded)?.playlist val loadedPlaylist = (dataState as? PlaylistScreenState.Data.Loaded)?.playlist
PlaylistFormSheet( PlaylistFormSheet(
visible = showEditPlaylist, visible = editPlaylistFormState.isVisible,
onDismiss = { showEditPlaylist = false }, formState = editPlaylistFormState,
onDismiss = viewModel::dismissEditPlaylist,
onImagePicked = viewModel::onEditPlaylistImagePicked,
isEditing = true, isEditing = true,
initialData = loadedPlaylist?.let { initialData = loadedPlaylist?.let {
PlaylistFormData( PlaylistFormData(
@ -168,14 +175,7 @@ fun PlaylistScreen(
isCollaborating = false, isCollaborating = false,
imagePreviewUrl = it.thumbnails.firstOrNull()?.url) imagePreviewUrl = it.thumbnails.firstOrNull()?.url)
}, },
onSubmit = { data -> onSubmit = viewModel::updatePlaylist)
viewModel.updatePlaylist(
name = data.name,
description = data.description.ifBlank { null },
isPublic = data.isPublic,
isCollaborating = data.isCollaborating,
imageBase64 = data.imageBase64.ifBlank { null })
})
AddToPlaylistPicker( AddToPlaylistPicker(
visible = showAddToPlaylistPicker, 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.audioplayer.QueueEntry
import org.spotube.spotube.core.di.injectLogger import org.spotube.spotube.core.di.injectLogger
import org.spotube.spotube.core.playback.CollectionPlaybackHelper 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.RemoteCollectionType
import org.spotube.spotube.core.remote.RemotePlaybackController import org.spotube.spotube.core.remote.RemotePlaybackController
import org.spotube.spotube.core.share.ShareService 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.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn 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 kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.getString
@ -105,6 +111,11 @@ class PlaylistViewModel(
private val _state = MutableStateFlow<PlaylistScreenState>(PlaylistScreenState.Loading) private val _state = MutableStateFlow<PlaylistScreenState>(PlaylistScreenState.Loading)
val uiState: StateFlow<PlaylistScreenState> = _state.asStateFlow() 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 val savedPlaylistIds
get() = libraryRepository.savedPlaylistIdsFlow get() = libraryRepository.savedPlaylistIdsFlow
@ -206,6 +217,8 @@ class PlaylistViewModel(
} }
fun toggleSavedPlaylist() { fun toggleSavedPlaylist() {
if ((_state.value as? PlaylistScreenState.Data)?.isSaving == true) return
setSaving(true)
viewModelScope.launch { viewModelScope.launch {
runCatching { runCatching {
val isLiked = libraryRepository.isSavedPlaylists(listOf(playlistId)).firstOrNull() ?: false val isLiked = libraryRepository.isSavedPlaylists(listOf(playlistId)).firstOrNull() ?: false
@ -217,6 +230,17 @@ class PlaylistViewModel(
}.onFailure { e -> }.onFailure { e ->
logger.e(e) { "Failed to toggle saved playlist" } 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( private val editPlaylistForm = PlaylistFormController(viewModelScope, logger)
name: String?, val editPlaylistFormState: StateFlow<PlaylistFormUiState> = editPlaylistForm.uiState
description: String?,
isPublic: Boolean?, fun showEditPlaylist() = editPlaylistForm.show()
isCollaborating: Boolean?,
imageBase64: String?) { fun dismissEditPlaylist() = editPlaylistForm.dismiss()
viewModelScope.launch {
runCatching { fun onEditPlaylistImagePicked(file: PlatformFile) = editPlaylistForm.onImagePicked(file)
libraryRepository.updatePlaylist(
id = playlistId, fun updatePlaylist(data: PlaylistFormData) {
name = name, editPlaylistForm.submit {
description = description, libraryRepository.updatePlaylist(
isPublic = isPublic, id = playlistId,
isCollaborating = isCollaborating, name = data.name,
imageBase64 = imageBase64, description = data.description.ifBlank { null },
trackIds = null) isPublic = data.isPublic,
}.onFailure { e -> isCollaborating = data.isCollaborating,
logger.e(e) { "Failed to update playlist" } imageBase64 = data.imageBase64.ifBlank { null },
}.onSuccess { trackIds = null)
repository.invalidateCaches() repository.invalidateCaches()
loadInitialData() loadInitialData()
}
} }
} }

View File

@ -23,6 +23,7 @@ import org.spotube.spotube.core.di.injectLogger
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@ -30,12 +31,16 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
data class MetadataAuthUiState( data class MetadataAuthUiState(
/** True when the selected metadata plugin needs an account and the user isn't signed in. */ /** True when the selected metadata plugin needs an account and the user isn't signed in. */
val loginRequired: Boolean = false, 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 * 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( class MetadataAuthViewModel(
private val pluginManager: PluginManager) : ViewModel(), KoinComponent { private val pluginManager: PluginManager) : ViewModel(), KoinComponent {
private val logger by injectLogger<MetadataAuthViewModel>() private val logger by injectLogger<MetadataAuthViewModel>()
private val isLoggingIn = MutableStateFlow(false)
val uiState: StateFlow<MetadataAuthUiState> = combine( val uiState: StateFlow<MetadataAuthUiState> = combine(
pluginManager.selectedMetadataPlugin.flatMapLatest { service -> pluginManager.selectedMetadataPlugin.flatMapLatest { service ->
if (service == null) flowOf(false) else loginRequiredFlow(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) val name = (managerState as? PluginManagerStates.Data)
?.selectedPlugins?.get(PluginAbility.METADATA)?.name ?.selectedPlugins?.get(PluginAbility.METADATA)?.name
MetadataAuthUiState(loginRequired = loginRequired, pluginName = name) MetadataAuthUiState(loginRequired = loginRequired, pluginName = name, isLoggingIn = loggingIn)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), MetadataAuthUiState()) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), MetadataAuthUiState())
private fun loginRequiredFlow(service: org.spotube.spotube.core.zipline.PluginService): Flow<Boolean> = flow { private fun loginRequiredFlow(service: org.spotube.spotube.core.zipline.PluginService): Flow<Boolean> = flow {
@ -71,10 +78,20 @@ class MetadataAuthViewModel(
} }
fun login() { fun login() {
if (isLoggingIn.value) return
val service = pluginManager.selectedMetadataPlugin.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() } } runCatching { service.use { coreAPI.login() } }
.onFailure { logger.e(it) { "Metadata plugin login failed" } } .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() { * Called when the user confirms installation/update/replacement in the dialog. The prompt stays
val pending = pendingPlugin.value ?: return * up until the install finishes, so the dialog can show progress.
val bytes = pending.bytes ?: return dismissInstall() *
* @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) { val allowReplacingInstalled = when (pending.kind) {
InstallPromptKind.INSTALL -> false InstallPromptKind.INSTALL -> false
InstallPromptKind.UPDATE, InstallPromptKind.REPLACE -> true InstallPromptKind.UPDATE, InstallPromptKind.REPLACE -> true
InstallPromptKind.INFO -> return dismissInstall() InstallPromptKind.INFO -> run { dismissInstall(); return null }
InstallPromptKind.REPLACE -> true
} }
pendingPlugin.value = null return scope.launch {
scope.launch { try {
addPluginFromByteArray( addPluginFromByteArray(
bytes = bytes, bytes = bytes,
allowReplacingInstalled = allowReplacingInstalled 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 { } else {
null null
}, },
isConfirming = state.isConfirmingInstall,
onDismiss = { viewModel.dismissInstall() } onDismiss = { viewModel.dismissInstall() }
) )
} }
@ -274,6 +275,7 @@ internal fun PluginScreenContent(
urlInput = state.urlInput, urlInput = state.urlInput,
urlError = state.urlError, urlError = state.urlError,
isLoadingUrl = state.isLoadingUrl, isLoadingUrl = state.isLoadingUrl,
isPreparingFile = state.isPreparingFile,
onUrlChange = { viewModel.onUrlInputChange(it) }, onUrlChange = { viewModel.onUrlInputChange(it) },
onSubmitUrl = { viewModel.submitUrl() }, onSubmitUrl = { viewModel.submitUrl() },
onPickFile = { launcher.launch() }, onPickFile = { launcher.launch() },
@ -459,6 +461,8 @@ internal fun PluginScreenContent(
onRemove = { viewModel.removePlugin(item.plugin) }, onRemove = { viewModel.removePlugin(item.plugin) },
isLoggedIn = item.authState.isLoggedIn, isLoggedIn = item.authState.isLoggedIn,
logoPath = item.logoPath, logoPath = item.logoPath,
isAuthInProgress = item.isAuthInProgress,
isRemoving = item.isRemoving,
onInfo = { viewModel.showPluginInfo(item.plugin) }, onInfo = { viewModel.showPluginInfo(item.plugin) },
onSupport = if (item.isSelected) { onSupport = if (item.isSelected) {
{ viewModel.loadSupport(item.plugin) } { viewModel.loadSupport(item.plugin) }
@ -620,6 +624,7 @@ private fun InstallSheet(
urlInput: String, urlInput: String,
urlError: UrlError?, urlError: UrlError?,
isLoadingUrl: Boolean, isLoadingUrl: Boolean,
isPreparingFile: Boolean,
onUrlChange: (String) -> Unit, onUrlChange: (String) -> Unit,
onSubmitUrl: () -> Unit, onSubmitUrl: () -> Unit,
onPickFile: () -> Unit, onPickFile: () -> Unit,
@ -671,20 +676,16 @@ private fun InstallSheet(
error = urlError?.text()) error = urlError?.text())
MillenniumButton( MillenniumButton(
onClick = onSubmitUrl, onClick = onSubmitUrl,
enabled = !isLoadingUrl, loading = isLoadingUrl,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
style = MillenniumButtonStyle.Primary) { style = MillenniumButtonStyle.Primary) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)) { horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (isLoadingUrl) { MillenniumIcon(
MillenniumSpokeSpinner(size = 16.dp, color = MillenniumTheme.colors.onAccent) Iconsax.IconsaxImportArrow2Bulk,
} else { contentDescription = null,
MillenniumIcon( modifier = Modifier.size(18.dp))
Iconsax.IconsaxImportArrow2Bulk,
contentDescription = null,
modifier = Modifier.size(18.dp))
}
Text(stringResource(Res.string.plugin_action_download)) Text(stringResource(Res.string.plugin_action_download))
} }
} }
@ -703,7 +704,7 @@ private fun InstallSheet(
MillenniumSeparator(modifier = Modifier.weight(1f)) MillenniumSeparator(modifier = Modifier.weight(1f))
} }
PluginPanel(modifier = Modifier.clickable(onClick = onPickFile)) { PluginPanel(modifier = Modifier.clickable(enabled = !isPreparingFile, onClick = onPickFile)) {
Row( Row(
modifier = Modifier.fillMaxWidth().padding(14.dp), modifier = Modifier.fillMaxWidth().padding(14.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@ -721,11 +722,15 @@ private fun InstallSheet(
style = AppTheme.typography.labelSmall, style = AppTheme.typography.labelSmall,
color = AppTheme.colors.onSurfaceVariant) color = AppTheme.colors.onSurfaceVariant)
} }
MillenniumIcon( if (isPreparingFile) {
FeatherIcons.ChevronRight, MillenniumSpokeSpinner(size = 18.dp)
contentDescription = null, } else {
modifier = Modifier.size(18.dp), MillenniumIcon(
tint = AppTheme.colors.onSurfaceVariant) FeatherIcons.ChevronRight,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = AppTheme.colors.onSurfaceVariant)
}
} }
} }
} }
@ -1013,20 +1018,16 @@ private fun DiscoverRepoCard(
} }
MillenniumButton( MillenniumButton(
onClick = onInstall, onClick = onInstall,
enabled = !isInstalling, loading = isInstalling,
style = MillenniumButtonStyle.Secondary style = MillenniumButtonStyle.Secondary
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
if (isInstalling) { MillenniumIcon(
MillenniumSpokeSpinner(size = 16.dp) Iconsax.IconsaxAdd,
} else { contentDescription = null)
MillenniumIcon(
Iconsax.IconsaxAdd,
contentDescription = null)
}
Text(stringResource(Res.string.plugin_section_install)) 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 io.github.vinceglb.filekit.readBytes
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@ -55,7 +56,9 @@ data class PluginListItem(
val plugin: PluginEntry, val plugin: PluginEntry,
val isSelected: Boolean, val isSelected: Boolean,
val authState: PluginAuthState, val authState: PluginAuthState,
val logoPath: Path?) val logoPath: Path?,
val isAuthInProgress: Boolean = false,
val isRemoving: Boolean = false)
data class AbilitySelection( data class AbilitySelection(
val ability: PluginAbility, val ability: PluginAbility,
@ -92,6 +95,8 @@ sealed interface PluginUiState {
val urlInput: String = "", val urlInput: String = "",
val urlError: UrlError? = null, val urlError: UrlError? = null,
val isLoadingUrl: Boolean = false, val isLoadingUrl: Boolean = false,
val isPreparingFile: Boolean = false,
val isConfirmingInstall: Boolean = false,
val showInstallSheet: Boolean = false, val showInstallSheet: Boolean = false,
val showPluginInfo: PluginEntry? = null, val showPluginInfo: PluginEntry? = null,
val pluginInfoLogoPath: Path? = null, val pluginInfoLogoPath: Path? = null,
@ -118,6 +123,10 @@ private data class PluginScreenState(
val urlInput: String = "", val urlInput: String = "",
val urlError: UrlError? = null, val urlError: UrlError? = null,
val isLoadingUrl: Boolean = false, 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 showInstallSheet: Boolean = false,
val showPluginInfo: PluginEntry? = null, val showPluginInfo: PluginEntry? = null,
val pluginInfoLogoPath: Path? = null, val pluginInfoLogoPath: Path? = null,
@ -235,7 +244,9 @@ class PluginViewModel(
plugin = plugin, plugin = plugin,
isSelected = state.selectedPlugins.containsValue(plugin), isSelected = state.selectedPlugins.containsValue(plugin),
authState = authStates[plugin.id] ?: PluginAuthState(), 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( abilitySelections = listOf(
AbilitySelection( AbilitySelection(
@ -263,6 +274,8 @@ class PluginViewModel(
urlInput = screen.urlInput, urlInput = screen.urlInput,
urlError = screen.urlError, urlError = screen.urlError,
isLoadingUrl = screen.isLoadingUrl, isLoadingUrl = screen.isLoadingUrl,
isPreparingFile = screen.isPreparingFile,
isConfirmingInstall = screen.isConfirmingInstall,
showInstallSheet = screen.showInstallSheet, showInstallSheet = screen.showInstallSheet,
showPluginInfo = screen.showPluginInfo, showPluginInfo = screen.showPluginInfo,
pluginInfoLogoPath = screen.pluginInfoLogoPath, pluginInfoLogoPath = screen.pluginInfoLogoPath,
@ -360,12 +373,18 @@ class PluginViewModel(
} }
fun onFileSelected(file: PlatformFile?) { fun onFileSelected(file: PlatformFile?) {
if (file == null) return if (file == null || _screenState.value.isPreparingFile) return
_screenState.update { it.copy(isPreparingFile = true) }
viewModelScope.launch { viewModelScope.launch {
runCatching { try {
pluginManager.preparePlugin(file.readBytes()) runCatching {
}.onFailure { e -> pluginManager.preparePlugin(file.readBytes())
logger.e(e) { "Failed to prepare plugin from file" } }.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) { fun removePlugin(plugin: PluginEntry) {
if (plugin.id in _screenState.value.removingIds) return
_screenState.update { it.copy(removingIds = it.removingIds + plugin.id) }
viewModelScope.launch { viewModelScope.launch {
runCatching { try {
pluginManager.removePlugin(plugin) runCatching {
}.onFailure { e -> pluginManager.removePlugin(plugin)
logger.e(e) { "Failed to remove 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) { fun login(plugin: PluginEntry) {
if (plugin.id in _screenState.value.authInProgressIds) return
val service = findServiceForPlugin(plugin.id) ?: return val service = findServiceForPlugin(plugin.id) ?: return
pluginManager.launchTask { trackAuthTask(plugin.id, pluginManager.launchTask {
service.use { coreAPI.login() } service.use { coreAPI.login() }
} })
} }
fun logout(plugin: PluginEntry) { fun logout(plugin: PluginEntry) {
if (plugin.id in _screenState.value.authInProgressIds) return
val service = findServiceForPlugin(plugin.id) ?: return val service = findServiceForPlugin(plugin.id) ?: return
pluginManager.launchTask { val task = pluginManager.launchTask {
service.use { coreAPI.logout() } service.use { coreAPI.logout() }
} }
viewModelScope.launch { trackAuthTask(plugin.id, task) {
webViewController.clearData(plugin.id) 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() { 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() { fun dismissInstall() {

View File

@ -77,7 +77,9 @@ internal fun PluginCard(
onLogout: (() -> Unit)? = null, onLogout: (() -> Unit)? = null,
onInfo: (() -> Unit)? = null, onInfo: (() -> Unit)? = null,
onSupport: (() -> Unit)? = null, onSupport: (() -> Unit)? = null,
logoPath: Path? = null) { logoPath: Path? = null,
isAuthInProgress: Boolean = false,
isRemoving: Boolean = false) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -272,6 +274,7 @@ internal fun PluginCard(
} else { } else {
MillenniumIconButton( MillenniumIconButton(
onClick = onRemove, onClick = onRemove,
loading = isRemoving,
style = MillenniumIconButtonStyle.Quiet) { style = MillenniumIconButtonStyle.Quiet) {
MillenniumIcon( MillenniumIcon(
Iconsax.IconsaxTrash, Iconsax.IconsaxTrash,
@ -289,6 +292,7 @@ internal fun PluginCard(
authAction?.let { (action, label) -> authAction?.let { (action, label) ->
MillenniumButton( MillenniumButton(
onClick = action, onClick = action,
loading = isAuthInProgress,
style = MillenniumButtonStyle.Secondary) { style = MillenniumButtonStyle.Secondary) {
Text( Text(
text = stringResource(label), text = stringResource(label),

View File

@ -226,7 +226,8 @@ internal fun PluginDialogActions(
onSecondary: () -> Unit, onSecondary: () -> Unit,
primaryLabel: String? = null, primaryLabel: String? = null,
onPrimary: (() -> Unit)? = null, onPrimary: (() -> Unit)? = null,
primaryEnabled: Boolean = true) { primaryEnabled: Boolean = true,
primaryLoading: Boolean = false) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp)) { horizontalArrangement = Arrangement.spacedBy(10.dp)) {
@ -240,6 +241,7 @@ internal fun PluginDialogActions(
MillenniumButton( MillenniumButton(
onClick = onPrimary, onClick = onPrimary,
enabled = primaryEnabled, enabled = primaryEnabled,
loading = primaryLoading,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
style = MillenniumButtonStyle.Primary) { style = MillenniumButtonStyle.Primary) {
Text(primaryLabel) Text(primaryLabel)

View File

@ -72,6 +72,7 @@ fun PluginPermissionDialog(
existingPlugin: PluginEntry? = null, existingPlugin: PluginEntry? = null,
logoPath: Path? = null, logoPath: Path? = null,
onConfirm: (() -> Unit)? = null, onConfirm: (() -> Unit)? = null,
isConfirming: Boolean = false,
onDismiss: () -> Unit) { onDismiss: () -> Unit) {
AdaptiveDialogBottomSheet( AdaptiveDialogBottomSheet(
onDismiss = onDismiss, onDismiss = onDismiss,
@ -83,7 +84,8 @@ fun PluginPermissionDialog(
if (confirmLabel == null) Res.string.common_close else Res.string.common_cancel), if (confirmLabel == null) Res.string.common_close else Res.string.common_cancel),
onSecondary = onDismiss, onSecondary = onDismiss,
primaryLabel = confirmLabel, primaryLabel = confirmLabel,
onPrimary = onConfirm) onPrimary = onConfirm,
primaryLoading = isConfirming)
}) { }) {
PluginDialogBody { PluginDialogBody {
PluginPanel { PluginPanel {

View File

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

View File

@ -46,6 +46,7 @@ fun SavedTracksScreen(
navigationCommands: NavigationCommands navigationCommands: NavigationCommands
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val playbackUiState by viewModel.playbackUiState.collectAsStateWithLifecycle()
val jamRoomService: JamRoomService = koinInject() val jamRoomService: JamRoomService = koinInject()
val jamActive by jamRoomService.role.map { it != null } val jamActive by jamRoomService.role.map { it != null }
.collectAsStateWithLifecycle(initialValue = false) .collectAsStateWithLifecycle(initialValue = false)
@ -65,6 +66,9 @@ fun SavedTracksScreen(
val isLoading = state is SavedTracksScreenState.Loading && (dataState == null || dataState.tracks.isEmpty()) val isLoading = state is SavedTracksScreenState.Loading && (dataState == null || dataState.tracks.isEmpty())
CollectionView( CollectionView(
isPlayLoading = playbackUiState.isPlayLoading,
isShuffleLoading = playbackUiState.isShuffleLoading,
isAddToQueueLoading = playbackUiState.isAddToQueueLoading,
title = stringResource(Res.string.saved_tracks_title), title = stringResource(Res.string.saved_tracks_title),
description = dataState?.let { pluralStringResource(Res.plurals.library_local_tracks_count, it.totalCount, it.totalCount) } ?: "", description = dataState?.let { pluralStringResource(Res.plurals.library_local_tracks_count, it.totalCount, it.totalCount) } ?: "",
imageURL = "", 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.audioplayer.QueueEntry
import org.spotube.spotube.core.di.injectLogger import org.spotube.spotube.core.di.injectLogger
import org.spotube.spotube.core.playback.CollectionPlaybackHelper 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.RemoteCollectionType
import org.spotube.spotube.core.remote.RemotePlaybackController import org.spotube.spotube.core.remote.RemotePlaybackController
import org.spotube.spotube.core.share.ShareService 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.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.compose.koinInject import org.koin.compose.koinInject
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
@ -103,9 +105,18 @@ class SavedTracksViewModel(
private val _state = MutableStateFlow<SavedTracksScreenState>(SavedTracksScreenState.Loading) private val _state = MutableStateFlow<SavedTracksScreenState>(SavedTracksScreenState.Loading)
val uiState: StateFlow<SavedTracksScreenState> = _state.asStateFlow() 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>> val savedTrackIdsFlow: StateFlow<Set<String>>
get() = repository.savedTracksIdsFlow 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) private val _currentUserId = MutableStateFlow<String?>(null)
val currentUserId: StateFlow<String?> = _currentUserId.asStateFlow() val currentUserId: StateFlow<String?> = _currentUserId.asStateFlow()
@ -340,6 +351,23 @@ class SavedTracksViewModel(
repository.removeSavedTracks(trackIds) 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 { private fun MetadataTrack.matchesTrack(other: MetadataTrack): Boolean {
if (id.isNotBlank() && other.id.isNotBlank()) return id == other.id if (id.isNotBlank() && other.id.isNotBlank()) return id == other.id
return title == other.title && return title == other.title &&

View File

@ -244,13 +244,9 @@ internal fun JamSettingsContent(
trailingContent = { trailingContent = {
MillenniumButton( MillenniumButton(
onClick = viewModel::testConnection, onClick = viewModel::testConnection,
enabled = canTest) { enabled = broker.host.isNotBlank(),
Text( loading = uiState.isTesting) {
if (uiState.isTesting) { Text(stringResource(Res.string.settings_jam_broker_test))
stringResource(Res.string.settings_jam_broker_testing)
} else {
stringResource(Res.string.settings_jam_broker_test)
})
} }
}, },
onClick = viewModel::testConnection) onClick = viewModel::testConnection)

View File

@ -38,8 +38,12 @@ class JamSettingsViewModel(
if (_uiState.value.isTesting) return if (_uiState.value.isTesting) return
_uiState.update { it.copy(isTesting = true, testResult = null) } _uiState.update { it.copy(isTesting = true, testResult = null) }
viewModelScope.launch { viewModelScope.launch {
val result = jamRoomClient.testConnection(uiState.value.settings.jamBroker) try {
_uiState.update { it.copy(isTesting = false, testResult = result) } val result = jamRoomClient.testConnection(uiState.value.settings.jamBroker)
_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 }) path.substringAfterLast('/').substringAfterLast('\\').ifBlank { path })
}, },
isSaving = draft.isSaving, isSaving = draft.isSaving,
canSave = draft.isOpen && !draft.isSaving && draft.paths != draft.original, canSave = draft.isOpen && draft.paths != draft.original,
saveFailed = draft.saveFailed, saveFailed = draft.saveFailed,
automaticNote = when { automaticNote = when {
hasDownloadFolder && settings.enableMusicCaching -> Res.string.settings_local_media_folders_automatic_both 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)) Text(stringResource(Res.string.common_cancel))
} }
MillenniumButton(onClick = onSave, enabled = state.canSave) { MillenniumButton(onClick = onSave, enabled = state.canSave, loading = state.isSaving) {
Text(stringResource(if (state.isSaving) Res.string.settings_local_media_folders_saving else Res.string.common_save)) 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 org.spotube.spotube.core.ui.theming.AppTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.spotube.spotube.core.audioplayer.AudioPlayerQueue import org.spotube.spotube.core.audioplayer.AudioPlayerQueue
import org.spotube.spotube.core.audioplayer.QueueEntry 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.Iconsax
import org.spotube.spotube.resources.iconsax.IconsaxHeart import org.spotube.spotube.resources.iconsax.IconsaxHeart
import org.spotube.spotube.resources.iconsax.IconsaxHeart2 import org.spotube.spotube.resources.iconsax.IconsaxHeart2
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.player_like_content_description import spotube.composeapp.generated.resources.player_like_content_description
@ -46,12 +44,12 @@ import spotube.composeapp.generated.resources.player_unlike_content_description
fun PlayerHeartButton( fun PlayerHeartButton(
audioPlayerQueue: AudioPlayerQueue, audioPlayerQueue: AudioPlayerQueue,
savedTracksViewModel: SavedTracksViewModel) { savedTracksViewModel: SavedTracksViewModel) {
val scope = rememberCoroutineScope()
val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle() val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id
val isSavedTrackState = val isSavedTrackState =
rememberIsSavedTracks(trackIds = currentTrackId?.let { listOf(it) } ?: emptyList()) rememberIsSavedTracks(trackIds = currentTrackId?.let { listOf(it) } ?: emptyList())
val savedTrackIds by savedTracksViewModel.savedTrackIdsFlow.collectAsStateWithLifecycle() val savedTrackIds by savedTracksViewModel.savedTrackIdsFlow.collectAsStateWithLifecycle()
val pendingSavedTrackIds by savedTracksViewModel.pendingSavedTrackIds.collectAsStateWithLifecycle()
val isInSavedIds = if (currentTrackId != null) currentTrackId in savedTrackIds else false val isInSavedIds = if (currentTrackId != null) currentTrackId in savedTrackIds else false
val isLiked = if (isSavedTrackState is SavedState.Success) { val isLiked = if (isSavedTrackState is SavedState.Success) {
@ -60,21 +58,10 @@ fun PlayerHeartButton(
isInSavedIds 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( MillenniumIconButton(
onClick = ::onLike, onClick = { currentTrackId?.let { savedTracksViewModel.toggleSavedTrack(it, isLiked) } },
enabled = isSavedTrackState is SavedState.Success, enabled = isSavedTrackState is SavedState.Success,
loading = currentTrackId != null && currentTrackId in pendingSavedTrackIds,
style = MillenniumIconButtonStyle.Secondary) { style = MillenniumIconButtonStyle.Secondary) {
MillenniumIcon( MillenniumIcon(
imageVector = if (isLiked) Iconsax.IconsaxHeart2 else Iconsax.IconsaxHeart, imageVector = if (isLiked) Iconsax.IconsaxHeart2 else Iconsax.IconsaxHeart,

View File

@ -41,7 +41,7 @@ kotlin = "2.3.21"
kotlinx-coroutines = "1.11.0" kotlinx-coroutines = "1.11.0"
kotlinx-io = "0.9.1" kotlinx-io = "0.9.1"
kotlinx-serialization-json = "1.11.0" kotlinx-serialization-json = "1.11.0"
millennium = "0.1.0" millennium = "0.1.1"
mqttClient = "2.1.1" mqttClient = "2.1.1"
mqttBuffer = "6.30.8" mqttBuffer = "6.30.8"
multiplatformMarkdownRenderer = "0.45.0" multiplatformMarkdownRenderer = "0.45.0"