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