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