From 7bf17afc427fdadc83bc8edbed0f6672002b47f0 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 4 Sep 2026 12:02:32 +0600 Subject: [PATCH] feat(remote-control): enhance remote playback functionality with new commands and UI integration --- .../dev/krtirtho/spotube/core/di/Modules.kt | 8 +- .../core/playback/CollectionPlaybackHelper.kt | 51 ++ .../core/remote/RemoteControlHandler.kt | 95 ++- .../core/remote/RemoteControlProtocol.kt | 25 + .../core/remote/RemotePlaybackController.kt | 343 ++++++++-- .../spotube/core/ui/component/AlbumCard.kt | 20 +- .../spotube/core/ui/component/PlaylistCard.kt | 17 +- .../spotube/modules/album/AlbumViewModel.kt | 64 +- .../spotube/modules/artist/ArtistViewModel.kt | 103 ++- .../modules/devices/PlayDestinationPicker.kt | 98 ++- .../modules/devices/RemoteControlScreen.kt | 597 ++++++++++-------- .../modules/playlist/PlaylistScreen.kt | 9 - .../modules/playlist/PlaylistViewModel.kt | 84 +-- .../saved_tracks/SavedTracksViewModel.kt | 54 +- .../spotube/modules/search/SearchScreen.kt | 42 +- .../spotube/modules/shell/AppShell.kt | 2 + 16 files changed, 1012 insertions(+), 600 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt index a2b6fe98..1b3d4f3c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt @@ -158,6 +158,7 @@ val sharedModules = module { libraryRepository = get(), shareService = get(), downloadManager = get(), + remotePlaybackController = get(), ) } @@ -173,6 +174,7 @@ val sharedModules = module { blacklistRepository = get(), shareService = get(), downloadManager = get(), + remotePlaybackController = get(), ) } @@ -196,6 +198,7 @@ val sharedModules = module { blacklistRepository = get(), shareService = get(), downloadManager = get(), + remotePlaybackController = get(), ) } @@ -208,6 +211,7 @@ val sharedModules = module { albumRepository = get(), playlistRepository = get(), savedTracksRepository = get(), + artistRepository = get(), audioPlayerQueue = get(), blacklistRepository = get(), ) @@ -219,13 +223,13 @@ val sharedModules = module { singleOf(::LocalServer) withOptions { createdAtStart() } - single { RemoteControlHandler(get(), get(), get()) } + single { RemoteControlHandler(get(), get(), get(), get()) } single { RemoteControlClient() } singleOf(::DeviceDiscoveryService) single { RemoteControlService(get(), get(), get()) } withOptions { createdAtStart() } - single { RemotePlaybackController() } + single { RemotePlaybackController(get(), get(), get(), get()) } single { JamSessionService(get(), get()) } singleOf(::JamDeepLinkService) singleOf(::AudioPlayerQueueRepository) { bind() } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/playback/CollectionPlaybackHelper.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/playback/CollectionPlaybackHelper.kt index 81594eda..7441309c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/playback/CollectionPlaybackHelper.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/playback/CollectionPlaybackHelper.kt @@ -22,6 +22,7 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.modules.album.AlbumRepository +import dev.krtirtho.spotube.modules.artist.ArtistRepository import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository import dev.krtirtho.spotube.modules.playlist.PlaylistRepository import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository @@ -30,6 +31,7 @@ class CollectionPlaybackHelper( private val albumRepository: AlbumRepository, private val playlistRepository: PlaylistRepository, private val savedTracksRepository: SavedTracksRepository, + private val artistRepository: ArtistRepository, private val audioPlayerQueue: AudioPlayerQueue, private val blacklistRepository: BlacklistRepository, ) { @@ -56,6 +58,13 @@ class CollectionPlaybackHelper( } } + suspend fun playAlbumNext(albumId: String) { + val entries = fetchAllAlbumTracks(albumId) + if (entries.isNotEmpty()) { + audioPlayerQueue.addAllAfterCurrent(entries) + } + } + suspend fun playAlbumFromTrack(albumId: String, track: MetadataTrack) { val entries = fetchAllAlbumTracks(albumId) if (entries.isEmpty()) return @@ -104,6 +113,13 @@ class CollectionPlaybackHelper( } } + suspend fun playPlaylistNext(playlistId: String) { + val entries = fetchAllPlaylistTracks(playlistId) + if (entries.isNotEmpty()) { + audioPlayerQueue.addAllAfterCurrent(entries) + } + } + suspend fun playPlaylistFromTrack(playlistId: String, track: MetadataTrack) { val entries = fetchAllPlaylistTracks(playlistId) if (entries.isEmpty()) return @@ -142,6 +158,32 @@ class CollectionPlaybackHelper( } } + suspend fun playArtistTopTracks(artistId: String) { + val entries = fetchArtistTopTracks(artistId) + if (entries.isNotEmpty()) { + audioPlayerQueue.load( + entries = entries, + autoPlay = true, + startPosition = 0, + collectionEntry = null, + ) + } + } + + suspend fun addArtistTopTracksToQueue(artistId: String) { + val entries = fetchArtistTopTracks(artistId) + if (entries.isNotEmpty()) { + audioPlayerQueue.addAllToQueue(entries) + } + } + + suspend fun playArtistTopTracksNext(artistId: String) { + val entries = fetchArtistTopTracks(artistId) + if (entries.isNotEmpty()) { + audioPlayerQueue.addAllAfterCurrent(entries) + } + } + suspend fun addSavedTracksToQueue() { val entries = fetchAllSavedTracks() if (entries.isNotEmpty()) { @@ -243,6 +285,15 @@ class CollectionPlaybackHelper( } } + private suspend fun fetchArtistTopTracks(artistId: String): List { + val tracks = artistRepository.topTracks(artistId).orEmpty() + val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() + val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() + return tracks + .filter { track -> !isTrackBlacklisted(track, blacklistedTrackIds, blacklistedArtistIds) } + .map { track -> QueueEntry.StreamingTrack(track = track, url = "") } + } + private fun isTrackBlacklisted( track: MetadataTrack, blacklistedTrackIds: Set, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt index 89be1235..602fc9f1 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt @@ -23,6 +23,7 @@ import dev.krtirtho.spotube.core.audioplayer.LoopState import dev.krtirtho.spotube.core.audioplayer.PlayerState as AudioPlayerState import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.di.injectLogger +import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper import dev.krtirtho.spotube.modules.settings.SettingsRepository import io.ktor.server.websocket.WebSocketServerSession import io.ktor.websocket.CloseReason @@ -47,6 +48,7 @@ class RemoteControlHandler( private val settingsRepository: SettingsRepository, private val audioPlayer: AudioPlayerInterface, private val audioPlayerQueue: AudioPlayerQueue, + private val collectionPlaybackHelper: CollectionPlaybackHelper, ) : KoinComponent { val logger by injectLogger() @@ -170,7 +172,7 @@ class RemoteControlHandler( private suspend fun handleCommand(session: WebSocketServerSession, envelope: CommandEnvelope) { when (val command = envelope.command) { is RemoteControlCommand.Play -> { - logger.d { "Remote play request: ${command.source} (playback source not yet implemented)" } + handleCollectionSource(command.source, RemoteCollectionAction.Play) } is RemoteControlCommand.Pause -> { audioPlayer.pause() @@ -213,7 +215,36 @@ class RemoteControlHandler( audioPlayer.loop(loopState) } is RemoteControlCommand.AddToQueue -> { - logger.d { "Remote add to queue: ${command.source} (source parsing not yet implemented)" } + handleCollectionSource(command.source, RemoteCollectionAction.AddToQueue) + } + is RemoteControlCommand.PlayNext -> { + handleCollectionSource(command.source, RemoteCollectionAction.PlayNext) + } + is RemoteControlCommand.PlayTrack -> { + audioPlayerQueue.load( + entries = listOf(QueueEntry.StreamingTrack(track = command.track, url = "")), + autoPlay = true, + startPosition = 0, + collectionEntry = null, + ) + } + is RemoteControlCommand.AddTrackToQueue -> { + audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = command.track, url = "")) + } + is RemoteControlCommand.PlayTrackNext -> { + audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = command.track, url = ""))) + } + is RemoteControlCommand.AddTracksToQueue -> { + val entries = command.tracks.map { track -> + QueueEntry.StreamingTrack(track = track, url = "") + } + audioPlayerQueue.addAllToQueue(entries) + } + is RemoteControlCommand.PlayTracksNext -> { + val entries = command.tracks.map { track -> + QueueEntry.StreamingTrack(track = track, url = "") + } + audioPlayerQueue.addAllAfterCurrent(entries) } is RemoteControlCommand.PlayIndex -> { audioPlayerQueue.jumpTo(command.index) @@ -262,6 +293,55 @@ class RemoteControlHandler( } } + /** + * Resolves a `spotube://` collection source URI (playlist/album/artist top + * tracks/saved tracks) and applies the requested action on the remote queue. + */ + private suspend fun handleCollectionSource(source: String, action: RemoteCollectionAction) { + logger.d { "Remote collection $action for source: $source" } + when { + source.startsWith(COLLECTION_PLAYLIST_PREFIX) -> { + val id = source.removePrefix(COLLECTION_PLAYLIST_PREFIX) + when (action) { + RemoteCollectionAction.Play -> collectionPlaybackHelper.playPlaylist(id) + RemoteCollectionAction.AddToQueue -> collectionPlaybackHelper.addPlaylistToQueue(id) + RemoteCollectionAction.PlayNext -> collectionPlaybackHelper.playPlaylistNext(id) + } + } + + source.startsWith(COLLECTION_ALBUM_PREFIX) -> { + val id = source.removePrefix(COLLECTION_ALBUM_PREFIX) + when (action) { + RemoteCollectionAction.Play -> collectionPlaybackHelper.playAlbum(id) + RemoteCollectionAction.AddToQueue -> collectionPlaybackHelper.addAlbumToQueue(id) + RemoteCollectionAction.PlayNext -> collectionPlaybackHelper.playAlbumNext(id) + } + } + + source.startsWith(COLLECTION_ARTIST_TOP_PREFIX) -> { + val id = source.removePrefix(COLLECTION_ARTIST_TOP_PREFIX) + when (action) { + RemoteCollectionAction.Play -> collectionPlaybackHelper.playArtistTopTracks(id) + RemoteCollectionAction.AddToQueue -> collectionPlaybackHelper.addArtistTopTracksToQueue(id) + RemoteCollectionAction.PlayNext -> collectionPlaybackHelper.playArtistTopTracksNext(id) + } + } + + source == COLLECTION_SAVED_TRACKS -> { + when (action) { + RemoteCollectionAction.Play -> collectionPlaybackHelper.playSavedTracks() + RemoteCollectionAction.AddToQueue -> collectionPlaybackHelper.addSavedTracksToQueue() + RemoteCollectionAction.PlayNext -> { + // Saved tracks "play next" is not supported; add to queue instead + collectionPlaybackHelper.addSavedTracksToQueue() + } + } + } + + else -> logger.w { "Unknown remote collection source: $source" } + } + } + private suspend fun broadcastState(session: WebSocketServerSession) { val current = audioPlayerQueue.currentQueueEntryFlow.value val state = RemoteControlEvent.PlayerState( @@ -366,6 +446,17 @@ data class CommandEnvelope( val command: RemoteControlCommand, ) +private const val COLLECTION_PLAYLIST_PREFIX = "spotube://playlist/" +private const val COLLECTION_ALBUM_PREFIX = "spotube://album/" +private const val COLLECTION_ARTIST_TOP_PREFIX = "spotube://artist/" +private const val COLLECTION_SAVED_TRACKS = "spotube://saved_tracks" + +enum class RemoteCollectionAction { + Play, + AddToQueue, + PlayNext, +} + data class ConnectionRequest( val deviceId: String, val deviceName: String, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt index f2739792..fc51834e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt @@ -17,6 +17,7 @@ package dev.krtirtho.spotube.core.remote +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -62,6 +63,30 @@ sealed class RemoteControlCommand { @SerialName("addToQueue") data class AddToQueue(val source: String) : RemoteControlCommand() + @Serializable + @SerialName("playNext") + data class PlayNext(val source: String) : RemoteControlCommand() + + @Serializable + @SerialName("playTrack") + data class PlayTrack(val track: MetadataTrack) : RemoteControlCommand() + + @Serializable + @SerialName("addTrackToQueue") + data class AddTrackToQueue(val track: MetadataTrack) : RemoteControlCommand() + + @Serializable + @SerialName("playTrackNext") + data class PlayTrackNext(val track: MetadataTrack) : RemoteControlCommand() + + @Serializable + @SerialName("addTracksToQueue") + data class AddTracksToQueue(val tracks: List) : RemoteControlCommand() + + @Serializable + @SerialName("playTracksNext") + data class PlayTracksNext(val tracks: List) : RemoteControlCommand() + @Serializable @SerialName("playIndex") data class PlayIndex(val index: Int) : RemoteControlCommand() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt index 39b4fdae..a0ba5f5b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt @@ -18,100 +18,307 @@ package dev.krtirtho.spotube.core.remote import co.touchlab.kermit.Logger +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue +import dev.krtirtho.spotube.core.audioplayer.QueueEntry +import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper +import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.IO +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent -import org.koin.core.component.inject + +enum class PlaybackDestinationAction { + Play, + AddToQueue, + PlayNext, +} + +enum class RemoteCollectionType { + Playlist, + Album, + ArtistTopTracks, + SavedTracks, +} /** - * Manages the play destination picker state and remote playback commands. - * Injected into ViewModels to handle playback actions when a remote device is connected. + * A playback request awaiting a destination choice (local device vs a connected + * remote device). [title] is the content label shown in the picker dialog. */ -class RemotePlaybackController : KoinComponent { +sealed interface PlaybackDestinationRequest { + val title: String + val action: PlaybackDestinationAction + + data class Collection( + override val title: String, + override val action: PlaybackDestinationAction, + val type: RemoteCollectionType, + val id: String, + val startTrack: MetadataTrack? = null, + ) : PlaybackDestinationRequest + + data class Track( + override val title: String, + override val action: PlaybackDestinationAction, + val track: MetadataTrack, + ) : PlaybackDestinationRequest + + data class Tracks( + override val title: String, + override val action: PlaybackDestinationAction, + val tracks: List, + ) : PlaybackDestinationRequest +} + +/** + * 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 + * user is shown a destination picker; otherwise the action runs locally. + */ +class RemotePlaybackController( + private val remoteControlClient: RemoteControlClient, + private val collectionPlaybackHelper: CollectionPlaybackHelper, + private val audioPlayerQueue: AudioPlayerQueue, + private val blacklistRepository: BlacklistRepository, +) : KoinComponent { private val logger = Logger.withTag("RemotePlaybackController") - private val remoteControlClient: RemoteControlClient by inject() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - private val _showPicker = MutableStateFlow(false) - val showPicker: StateFlow = _showPicker.asStateFlow() + private val _pendingRequest = MutableStateFlow(null) + val pendingRequest: StateFlow = _pendingRequest.asStateFlow() - private var pendingAction: (() -> Unit)? = null - - /** - * Checks if a remote device is connected. - */ fun isRemoteConnected(): Boolean { return remoteControlClient.connectionState.value is ConnectionState.Connected } - /** - * Wraps a playback action. If a remote device is connected, shows the picker. - * Otherwise, executes the action immediately. - * - * @param action The action to execute if playing locally - */ - fun wrapPlaybackAction(action: () -> Unit) { - if (isRemoteConnected()) { - pendingAction = action - _showPicker.value = true - } else { - action() - } + // ---------- Collection actions ---------- + + fun requestCollectionPlay( + type: RemoteCollectionType, + id: String, + title: String, + startTrack: MetadataTrack? = null, + ) { + request(PlaybackDestinationRequest.Collection(title, PlaybackDestinationAction.Play, type, id, startTrack)) } - /** - * Called when the user chooses to play locally. - */ + fun requestCollectionAddToQueue(type: RemoteCollectionType, id: String, title: String) { + request(PlaybackDestinationRequest.Collection(title, PlaybackDestinationAction.AddToQueue, type, id)) + } + + fun requestCollectionPlayNext(type: RemoteCollectionType, id: String, title: String) { + request(PlaybackDestinationRequest.Collection(title, PlaybackDestinationAction.PlayNext, type, id)) + } + + // ---------- Single track actions ---------- + + fun requestTrackAddToQueue(track: MetadataTrack) { + request(PlaybackDestinationRequest.Track(track.title, PlaybackDestinationAction.AddToQueue, track)) + } + + fun requestTrackPlayNext(track: MetadataTrack) { + request(PlaybackDestinationRequest.Track(track.title, PlaybackDestinationAction.PlayNext, track)) + } + + // ---------- Bulk track actions ---------- + + fun requestTracksAddToQueue(tracks: List, title: String) { + if (tracks.isEmpty()) return + request(PlaybackDestinationRequest.Tracks(title, PlaybackDestinationAction.AddToQueue, tracks)) + } + + fun requestTracksPlayNext(tracks: List, title: String) { + if (tracks.isEmpty()) return + request(PlaybackDestinationRequest.Tracks(title, PlaybackDestinationAction.PlayNext, tracks)) + } + + // ---------- Picker resolution ---------- + fun playLocally() { - _showPicker.value = false - pendingAction?.invoke() - pendingAction = null + val request = _pendingRequest.value ?: return + _pendingRequest.value = null + executeLocally(request) } - /** - * Called when the user chooses to play on the remote device. - * Sends a play command to the remote device. - * - * @param source The source identifier (e.g., playlist ID, album ID, track ID) - */ - fun playOnRemote(source: String) { - _showPicker.value = false - pendingAction = null - - CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch { - try { - remoteControlClient.sendCommand(RemoteControlCommand.Play(source)) - logger.i { "Sent play command for source: $source" } - } catch (e: Exception) { - logger.e(e) { "Failed to send play command" } - } - } + fun playOnRemote() { + val request = _pendingRequest.value ?: return + _pendingRequest.value = null + executeOnRemote(request) } - /** - * Called when the user dismisses the picker. - */ fun dismissPicker() { - _showPicker.value = false - pendingAction = null + _pendingRequest.value = null } - /** - * Sends an add-to-queue command to the remote device. - * - * @param source The source identifier (e.g., playlist ID, album ID, track ID) - */ - fun addToQueueOnRemote(source: String) { - CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch { - try { - remoteControlClient.sendCommand(RemoteControlCommand.AddToQueue(source)) - logger.i { "Sent add-to-queue command for source: $source" } - } catch (e: Exception) { - logger.e(e) { "Failed to send add-to-queue command" } + // ---------- Internals ---------- + + private fun request(request: PlaybackDestinationRequest) { + if (isRemoteConnected()) { + _pendingRequest.value = request + } else { + executeLocally(request) + } + } + + private fun executeLocally(request: PlaybackDestinationRequest) { + scope.launch { + when (request) { + is PlaybackDestinationRequest.Collection -> { + val startTrack = request.startTrack + when (request.type) { + RemoteCollectionType.Playlist -> when (request.action) { + PlaybackDestinationAction.Play -> { + if (startTrack != null) { + collectionPlaybackHelper.playPlaylistFromTrack(request.id, startTrack) + } else { + collectionPlaybackHelper.playPlaylist(request.id) + } + } + + PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addPlaylistToQueue(request.id) + PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.playPlaylistNext(request.id) + } + + RemoteCollectionType.Album -> when (request.action) { + PlaybackDestinationAction.Play -> { + if (startTrack != null) { + collectionPlaybackHelper.playAlbumFromTrack(request.id, startTrack) + } else { + collectionPlaybackHelper.playAlbum(request.id) + } + } + + PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addAlbumToQueue(request.id) + PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.playAlbumNext(request.id) + } + + RemoteCollectionType.ArtistTopTracks -> when (request.action) { + PlaybackDestinationAction.Play -> collectionPlaybackHelper.playArtistTopTracks(request.id) + PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addArtistTopTracksToQueue(request.id) + PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.playArtistTopTracksNext(request.id) + } + + RemoteCollectionType.SavedTracks -> when (request.action) { + PlaybackDestinationAction.Play -> { + if (startTrack != null) { + collectionPlaybackHelper.playSavedTracksFromTrack(startTrack) + } else { + collectionPlaybackHelper.playSavedTracks() + } + } + + PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addSavedTracksToQueue() + PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.addSavedTracksToQueue() + } + } + } + + is PlaybackDestinationRequest.Track -> { + val entry = QueueEntry.StreamingTrack(track = request.track, url = "") + when (request.action) { + PlaybackDestinationAction.Play -> { + audioPlayerQueue.load( + entries = listOf(entry), + autoPlay = true, + startPosition = 0, + collectionEntry = null, + ) + } + + PlaybackDestinationAction.AddToQueue -> { + audioPlayerQueue.addToQueue(entry) + } + + PlaybackDestinationAction.PlayNext -> { + val queue = audioPlayerQueue.getQueue() + queue.find { candidate -> + (candidate as? QueueEntry.StreamingTrack)?.track?.matchesTrack(request.track) == true + }?.let { audioPlayerQueue.removeFromQueue(it) } + audioPlayerQueue.addAllAfterCurrent(listOf(entry)) + } + } + } + + is PlaybackDestinationRequest.Tracks -> { + val entries = request.tracks + .filter { track -> !isTrackBlacklisted(track) } + .map { track -> QueueEntry.StreamingTrack(track = track, url = "") } + when (request.action) { + PlaybackDestinationAction.Play -> { + audioPlayerQueue.load( + entries = entries, + autoPlay = true, + startPosition = 0, + collectionEntry = null, + ) + } + + PlaybackDestinationAction.AddToQueue -> { + audioPlayerQueue.addAllToQueue(entries) + } + + PlaybackDestinationAction.PlayNext -> { + audioPlayerQueue.addAllAfterCurrent(entries) + } + } + } } } } -} + + private fun executeOnRemote(request: PlaybackDestinationRequest) { + scope.launch { + try { + val command = when (request) { + is PlaybackDestinationRequest.Collection -> { + val source = when (request.type) { + RemoteCollectionType.Playlist -> "spotube://playlist/${request.id}" + RemoteCollectionType.Album -> "spotube://album/${request.id}" + RemoteCollectionType.ArtistTopTracks -> "spotube://artist/${request.id}" + RemoteCollectionType.SavedTracks -> "spotube://saved_tracks" + } + when (request.action) { + PlaybackDestinationAction.Play -> RemoteControlCommand.Play(source) + PlaybackDestinationAction.AddToQueue -> RemoteControlCommand.AddToQueue(source) + PlaybackDestinationAction.PlayNext -> RemoteControlCommand.PlayNext(source) + } + } + + is PlaybackDestinationRequest.Track -> when (request.action) { + PlaybackDestinationAction.Play -> RemoteControlCommand.PlayTrack(request.track) + PlaybackDestinationAction.AddToQueue -> RemoteControlCommand.AddTrackToQueue(request.track) + PlaybackDestinationAction.PlayNext -> RemoteControlCommand.PlayTrackNext(request.track) + } + + is PlaybackDestinationRequest.Tracks -> when (request.action) { + PlaybackDestinationAction.Play -> RemoteControlCommand.PlayTracksNext(request.tracks) + PlaybackDestinationAction.AddToQueue -> RemoteControlCommand.AddTracksToQueue(request.tracks) + PlaybackDestinationAction.PlayNext -> RemoteControlCommand.PlayTracksNext(request.tracks) + } + } + remoteControlClient.sendCommand(command) + logger.i { "Sent remote ${request.action} for ${request.title}" } + } catch (e: Exception) { + logger.e(e) { "Failed to send remote playback command" } + } + } + } + + private suspend fun isTrackBlacklisted(track: MetadataTrack): Boolean { + val trackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() + val artistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() + return track.id in trackIds || track.artists.any { it.id in artistIds } + } + + private fun MetadataTrack.matchesTrack(other: MetadataTrack): Boolean { + if (id.isNotBlank() && other.id.isNotBlank()) return id == other.id + return title == other.title && + durationMs == other.durationMs && + album?.id == other.album?.id && + artists.map { it.id.ifBlank { it.name } } == other.artists.map { it.id.ifBlank { it.name } } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AlbumCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AlbumCard.kt index 2e021980..769b8c43 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AlbumCard.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AlbumCard.kt @@ -20,7 +20,6 @@ package dev.krtirtho.spotube.core.ui.component import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -29,8 +28,9 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper +import dev.krtirtho.spotube.core.remote.RemoteCollectionType +import dev.krtirtho.spotube.core.remote.RemotePlaybackController import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard -import kotlinx.coroutines.launch import org.koin.compose.koinInject @Composable @@ -39,9 +39,9 @@ fun AlbumCard( modifier: Modifier = Modifier, audioPlayerQueue: AudioPlayerQueue = koinInject(), playbackHelper: CollectionPlaybackHelper = koinInject(), - navigationCommands: NavigationCommands = koinInject() + navigationCommands: NavigationCommands = koinInject(), + remotePlaybackController: RemotePlaybackController = koinInject(), ) { - val scope = rememberCoroutineScope() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() PlayableCard( @@ -54,10 +54,18 @@ fun AlbumCard( }, onPlay = { if (currentCollectionEntry?.id == album.id) return@PlayableCard - scope.launch { playbackHelper.playAlbum(album.id) } + remotePlaybackController.requestCollectionPlay( + RemoteCollectionType.Album, + album.id, + album.title, + ) }, onAddToQueue = { - scope.launch { playbackHelper.addAlbumToQueue(album.id) } + remotePlaybackController.requestCollectionAddToQueue( + RemoteCollectionType.Album, + album.id, + album.title, + ) }, modifier = modifier.width(160.dp), sharedElementKey = "album_art_${album.id}", diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/PlaylistCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/PlaylistCard.kt index 1bca2308..cf83ec60 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/PlaylistCard.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/PlaylistCard.kt @@ -27,6 +27,8 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper +import dev.krtirtho.spotube.core.remote.RemoteCollectionType +import dev.krtirtho.spotube.core.remote.RemotePlaybackController import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard import kotlinx.coroutines.launch import org.koin.compose.koinInject @@ -37,7 +39,8 @@ fun PlaylistCard( modifier: Modifier = Modifier, audioPlayerQueue: AudioPlayerQueue = koinInject(), playbackHelper: CollectionPlaybackHelper = koinInject(), - navigationCommands: NavigationCommands = koinInject() + navigationCommands: NavigationCommands = koinInject(), + remotePlaybackController: RemotePlaybackController = koinInject(), ) { val scope = rememberCoroutineScope() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() @@ -52,10 +55,18 @@ fun PlaylistCard( }, onPlay = { if (audioPlayerQueue.isPlaylistPlaying(playlist.id)) return@PlayableCard - scope.launch { playbackHelper.playPlaylist(playlist.id) } + remotePlaybackController.requestCollectionPlay( + RemoteCollectionType.Playlist, + playlist.id, + playlist.title, + ) }, onAddToQueue = { - scope.launch { playbackHelper.addPlaylistToQueue(playlist.id) } + remotePlaybackController.requestCollectionAddToQueue( + RemoteCollectionType.Playlist, + playlist.id, + playlist.title, + ) }, modifier = modifier, sharedElementKey = "playlist_art_${playlist.id}", diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt index f705968c..eb2b2ee0 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt @@ -26,6 +26,8 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper +import dev.krtirtho.spotube.core.remote.RemoteCollectionType +import dev.krtirtho.spotube.core.remote.RemotePlaybackController import dev.krtirtho.spotube.core.share.ShareService import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext @@ -97,6 +99,7 @@ class AlbumViewModel( private val blacklistRepository: BlacklistRepository, private val shareService: ShareService, private val downloadManager: DownloadManager, + private val remotePlaybackController: RemotePlaybackController, ) : ViewModel(), KoinComponent { private val logger by injectLogger() @@ -217,15 +220,28 @@ class AlbumViewModel( } fun playAlbum() { - viewModelScope.launch { playbackHelper.playAlbum(albumId) } + val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album" + remotePlaybackController.requestCollectionPlay(RemoteCollectionType.Album, albumId, title) } fun addAlbumToQueue() { - viewModelScope.launch { playbackHelper.addAlbumToQueue(albumId) } + val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album" + remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.Album, albumId, title) + } + + fun playAlbumNext() { + val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album" + remotePlaybackController.requestCollectionPlayNext(RemoteCollectionType.Album, albumId, title) } fun playAlbumFromTrack(track: MetadataTrack) { - viewModelScope.launch { playbackHelper.playAlbumFromTrack(albumId, track) } + val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album" + remotePlaybackController.requestCollectionPlay( + type = RemoteCollectionType.Album, + id = albumId, + title = title, + startTrack = track, + ) } fun refresh() { @@ -241,14 +257,20 @@ class AlbumViewModel( is TrackOptionsAction.StartRadio -> {} is TrackOptionsAction.PlayNext -> { val queue = audioPlayerQueue.getQueue() - queue.find { entry -> + val existing = queue.find { entry -> (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true - }?.let { audioPlayerQueue.removeFromQueue(it) } - audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) + } + if (existing != null) { + // Already in the local queue: move it to the next position + audioPlayerQueue.removeFromQueue(existing) + audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) + } else { + remotePlaybackController.requestTrackPlayNext(track) + } } is TrackOptionsAction.AddToQueue -> { - audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = "")) + remotePlaybackController.requestTrackAddToQueue(track) } is TrackOptionsAction.RemoveFromQueue -> { @@ -312,33 +334,13 @@ class AlbumViewModel( } fun addTracksToQueue(tracks: List) { - viewModelScope.launch { - val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() - val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() - - val filteredTracks = tracks.filter { track -> - track.id !in blacklistedTrackIds && - track.artists.none { it.id in blacklistedArtistIds } - } - - val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } - audioPlayerQueue.addAllToQueue(entries) - } + val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album" + remotePlaybackController.requestTracksAddToQueue(tracks, title) } fun playTracksNext(tracks: List) { - viewModelScope.launch { - val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() - val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() - - val filteredTracks = tracks.filter { track -> - track.id !in blacklistedTrackIds && - track.artists.none { it.id in blacklistedArtistIds } - } - - val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } - audioPlayerQueue.addAllAfterCurrent(entries) - } + val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album" + remotePlaybackController.requestTracksPlayNext(tracks, title) } fun isTrackBlacklisted(track: MetadataTrack): Boolean { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt index e6af958c..2baa9e9d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt @@ -27,6 +27,8 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.di.injectLogger +import dev.krtirtho.spotube.core.remote.RemoteCollectionType +import dev.krtirtho.spotube.core.remote.RemotePlaybackController import dev.krtirtho.spotube.core.share.ShareService import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext @@ -73,6 +75,7 @@ class ArtistViewModel( private val blacklistRepository: BlacklistRepository, private val shareService: ShareService, private val downloadManager: DownloadManager, + private val remotePlaybackController: RemotePlaybackController, ) : ViewModel(), KoinComponent { private val logger by injectLogger() @@ -247,80 +250,40 @@ class ArtistViewModel( } fun addTopTracksToQueue() { - viewModelScope.launch { - val entries = resolveTopTrackEntries() - if (entries.isEmpty()) return@launch - audioPlayerQueue.addAllToQueue(entries) - } + val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist" + remotePlaybackController.requestCollectionAddToQueue( + RemoteCollectionType.ArtistTopTracks, + artistId, + artistName, + ) } fun playTopTracks() { - viewModelScope.launch { - val entries = resolveTopTrackEntries() - if (entries.isEmpty()) return@launch - audioPlayerQueue.load( - entries = entries, - autoPlay = true, - startPosition = 0, - collectionEntry = null, - ) - } + val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist" + remotePlaybackController.requestCollectionPlay( + RemoteCollectionType.ArtistTopTracks, + artistId, + artistName, + ) } fun playTopTracksFromTrack(track: MetadataTrack) { - viewModelScope.launch { - val queue = audioPlayerQueue.getQueue() - val queueIndex = queue.indexOfFirst { entry -> - (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true - } - if (queueIndex >= 0) { - audioPlayerQueue.jumpTo(queueIndex) - return@launch - } - - val entries = resolveTopTrackEntries() - if (entries.isEmpty()) return@launch - - val startPosition = entries.indexOfFirst { entry -> - (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true - }.coerceAtLeast(0) - - audioPlayerQueue.load( - entries = entries, - autoPlay = true, - startPosition = startPosition, - collectionEntry = null, - ) - } + val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist" + remotePlaybackController.requestCollectionPlay( + type = RemoteCollectionType.ArtistTopTracks, + id = artistId, + title = artistName, + startTrack = track, + ) } fun addTracksToQueue(tracks: List) { - viewModelScope.launch { - val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() - val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() - - val filteredTracks = tracks.filter { track -> - track.id !in blacklistedTrackIds && - track.artists.none { it.id in blacklistedArtistIds } - } - - val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } - audioPlayerQueue.addAllToQueue(entries) - } + val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist" + remotePlaybackController.requestTracksAddToQueue(tracks, artistName) } fun playTracksNext(tracks: List) { - viewModelScope.launch { - val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() - val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() - - val filteredTracks = tracks.filter { track -> - track.id !in blacklistedTrackIds && - track.artists.none { it.id in blacklistedArtistIds } - } - - val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } - audioPlayerQueue.addAllAfterCurrent(entries) - } + val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist" + remotePlaybackController.requestTracksPlayNext(tracks, artistName) } fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) { @@ -329,13 +292,19 @@ class ArtistViewModel( is TrackOptionsAction.StartRadio -> {} is TrackOptionsAction.PlayNext -> { val queue = audioPlayerQueue.getQueue() - queue.find { entry -> + val existing = queue.find { entry -> (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true - }?.let { audioPlayerQueue.removeFromQueue(it) } - audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) + } + if (existing != null) { + // Already in the local queue: move it to the next position + audioPlayerQueue.removeFromQueue(existing) + audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) + } else { + remotePlaybackController.requestTrackPlayNext(track) + } } is TrackOptionsAction.AddToQueue -> { - audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = "")) + remotePlaybackController.requestTrackAddToQueue(track) } is TrackOptionsAction.RemoveFromQueue -> { val queue = audioPlayerQueue.getQueue() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt index 3c412260..a5b9f4b9 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt @@ -17,8 +17,10 @@ package dev.krtirtho.spotube.modules.devices +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -26,38 +28,49 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.krtirtho.spotube.core.remote.ConnectionState +import dev.krtirtho.spotube.core.remote.PlaybackDestinationAction import dev.krtirtho.spotube.core.remote.RemoteControlClient +import dev.krtirtho.spotube.core.remote.RemotePlaybackController +import dev.krtirtho.spotube.core.ui.base.ListRowTile import dev.krtirtho.spotube.core.ui.base.ThemedDialog +import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.IconsaxCd +import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen import org.koin.compose.koinInject /** - * Dialog shown when a remote device is connected and the user tries to play/add to queue. - * Allows the user to choose between playing on the local device or the remote device. + * Globally hosted dialog shown when a remote device is connected and the user + * tries to play / add to queue / play next. Lets the user choose between the + * local device and the connected remote device(s). */ @Composable -fun PlayDestinationPicker( - visible: Boolean, - onDismiss: () -> Unit, - onPlayLocally: () -> Unit, - onPlayOnRemote: () -> Unit, -) { +fun PlayDestinationPickerHost() { + val controller = koinInject() val remoteControlClient = koinInject() + val request by controller.pendingRequest.collectAsStateWithLifecycle() val connectionState by remoteControlClient.connectionState.collectAsStateWithLifecycle() - if (!visible) return + val pendingRequest = request ?: return val remoteDeviceName = when (val state = connectionState) { is ConnectionState.Connected -> "Remote Device (${state.host})" else -> "Remote Device" } + val actionLabel = when (pendingRequest.action) { + PlaybackDestinationAction.Play -> "Play" + PlaybackDestinationAction.AddToQueue -> "Add to queue" + PlaybackDestinationAction.PlayNext -> "Play next" + } + ThemedDialog( - onDismissRequest = onDismiss, + onDismissRequest = controller::dismissPicker, title = { Text( - text = "Play Where?", + text = "Where to $actionLabel?", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, ) @@ -65,24 +78,69 @@ fun PlayDestinationPicker( content = { Column( modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { Text( - text = "Choose where to play this content:", + text = "$actionLabel \"${pendingRequest.title}\" on:", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) + + ListRowTile( + onClick = controller::playLocally, + modifier = Modifier.fillMaxWidth(), + leading = { + Icon( + imageVector = Iconsax.IconsaxCd, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + title = { + Text( + text = "This Device", + style = MaterialTheme.typography.bodyLarge, + ) + }, + subtitle = { + Text( + text = "$actionLabel here", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + ) + + ListRowTile( + onClick = controller::playOnRemote, + modifier = Modifier.fillMaxWidth(), + leading = { + Icon( + imageVector = Iconsax.IconsaxMirroringScreen, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + title = { + Text( + text = remoteDeviceName, + style = MaterialTheme.typography.bodyLarge, + ) + }, + subtitle = { + Text( + text = "$actionLabel on the connected device", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + ) } }, actions = { - TextButton(onClick = onDismiss) { + TextButton(onClick = controller::dismissPicker) { Text("Cancel") } - TextButton(onClick = onPlayLocally) { - Text("This Device") - } - TextButton(onClick = onPlayOnRemote) { - Text(remoteDeviceName) - } }, ) -} +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt index ed03f6f3..31949017 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt @@ -18,6 +18,8 @@ package dev.krtirtho.spotube.modules.devices import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -25,11 +27,13 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState @@ -97,101 +101,131 @@ fun RemoteControlScreen( val isQueueVisible by viewModel.isQueueVisible.collectAsStateWithLifecycle() val shellBottomInset = LocalAppShellBottomInset.current - Scaffold( - topBar = { - ApplicationMainBar( - title = { Text("Remote Control") }, - backButton = true, - actions = { - GhostIconButton( - onClick = viewModel::toggleQueueVisibility, - ) { - Icon( - imageVector = Iconsax.IconsaxMusicFilter, - contentDescription = "Queue", - tint = if (isQueueVisible) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) - } - GhostIconButton( - onClick = { - viewModel.disconnect() - onDisconnect() + // Center the mobile-inspired layout and limit its width so it doesn't + // stretch awkwardly on large screens. + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.TopCenter, + ) { + Scaffold( + modifier = Modifier + .fillMaxHeight(), + topBar = { + ApplicationMainBar( + title = { Text("Remote Control") }, + backButton = true, + actions = { + GhostIconButton( + onClick = viewModel::toggleQueueVisibility, + ) { + Icon( + imageVector = Iconsax.IconsaxMusicFilter, + contentDescription = "Queue", + tint = if (isQueueVisible) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + GhostIconButton( + onClick = { + viewModel.disconnect() + onDisconnect() + } + ) { + Icon( + imageVector = Iconsax.IconsaxCloseSquare, + contentDescription = "Disconnect", + ) } - ) { - Icon( - imageVector = Iconsax.IconsaxCloseSquare, - contentDescription = "Disconnect", - ) } - } - ) - } - ) { padding -> - when (connectionState) { - is ConnectionState.Connected -> { - RemoteControlContent( - playerState = playerState, - onTogglePlayPause = viewModel::togglePlayPause, - onSkipNext = viewModel::skipNext, - onSkipPrevious = viewModel::skipPrevious, - onSeek = viewModel::seek, - onSetVolume = viewModel::setVolume, - onToggleShuffle = viewModel::toggleShuffle, - onCycleLoopMode = viewModel::cycleLoopMode, - modifier = Modifier.padding(padding).padding(bottom = shellBottomInset) ) } - is ConnectionState.Connecting -> { - Box( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .padding(bottom = shellBottomInset), - contentAlignment = Alignment.Center - ) { - Text("Connecting...") + ) { padding -> + when (connectionState) { + is ConnectionState.Connected -> { + RemoteControlContent( + playerState = playerState, + onTogglePlayPause = viewModel::togglePlayPause, + onSkipNext = viewModel::skipNext, + onSkipPrevious = viewModel::skipPrevious, + onSeek = viewModel::seek, + onSetVolume = viewModel::setVolume, + onToggleShuffle = viewModel::toggleShuffle, + onCycleLoopMode = viewModel::cycleLoopMode, + modifier = Modifier.padding(padding).padding(bottom = shellBottomInset) + ) } - } - is ConnectionState.Disconnected -> { - Box( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .padding(bottom = shellBottomInset), - contentAlignment = Alignment.Center - ) { - Text("Disconnected") + is ConnectionState.Connecting -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(bottom = shellBottomInset), + contentAlignment = Alignment.Center + ) { + Text("Connecting...") + } } - } - is ConnectionState.Error -> { - Box( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .padding(bottom = shellBottomInset), - contentAlignment = Alignment.Center - ) { - Text("Connection error: ${(connectionState as ConnectionState.Error).message}") + is ConnectionState.Disconnected -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(bottom = shellBottomInset), + contentAlignment = Alignment.Center + ) { + Text("Disconnected") + } + } + is ConnectionState.Error -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(bottom = shellBottomInset), + contentAlignment = Alignment.Center + ) { + Text("Connection error: ${(connectionState as ConnectionState.Error).message}") + } } } } } - QueueSheet( - isVisible = isQueueVisible, - onDismiss = { viewModel.toggleQueueVisibility() }, - modifier = Modifier.fillMaxSize(), - ) { - RemoteQueueSection( - queueState = queueState, - onPlayQueueItem = viewModel::playQueueItem, - onRemoveQueueItem = viewModel::removeQueueItem, + // Click-outside scrim for the sliding queue sheet on large screens. + // (The ModalBottomSheet variant has its own built-in scrim.) + if (isQueueVisible) { + Box( + modifier = Modifier + .fillMaxSize() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { viewModel.toggleQueueVisibility() }, + ) ) } + + // Keep the sliding sheet above the AppLargePlayer on large screens. + Box( + modifier = Modifier + .fillMaxSize() + .padding(bottom = shellBottomInset), + ) { + QueueSheet( + isVisible = isQueueVisible, + onDismiss = { viewModel.toggleQueueVisibility() }, + modifier = Modifier.fillMaxSize(), + ) { + RemoteQueueSection( + queueState = queueState, + onPlayQueueItem = viewModel::playQueueItem, + onRemoveQueueItem = viewModel::removeQueueItem, + ) + } + } } @Composable @@ -206,227 +240,230 @@ private fun RemoteControlContent( onCycleLoopMode: () -> Unit, modifier: Modifier = Modifier, ) { - Column( - modifier = modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Spacer(modifier = Modifier.height(32.dp)) - - // Album art - Box( - modifier = Modifier - .fillMaxWidth() - .aspectRatio(1f) - .clip(RoundedCornerShape(16.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant), - ) { - if (playerState.currentTrackCoverUrl != null) { - AsyncImage( - model = playerState.currentTrackCoverUrl, - contentDescription = "Album cover", - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) - } else { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Iconsax.IconsaxMusicFilter, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(48.dp), - ) - } - } - } - - Spacer(modifier = Modifier.height(32.dp)) - - // Track info + Box (modifier = Modifier.fillMaxSize()) { Column( - modifier = Modifier.fillMaxWidth(), + modifier = modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp) + .widthIn(max = 480.dp) + .align(Alignment.TopCenter), horizontalAlignment = Alignment.CenterHorizontally, ) { - Text( - text = playerState.currentTrackTitle ?: "Unknown Track", - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - textAlign = TextAlign.Center, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) + Spacer(modifier = Modifier.height(32.dp)) - Spacer(modifier = Modifier.height(8.dp)) + // Album art + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + ) { + if (playerState.currentTrackCoverUrl != null) { + AsyncImage( + model = playerState.currentTrackCoverUrl, + contentDescription = "Album cover", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + } else { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Iconsax.IconsaxMusicFilter, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(48.dp), + ) + } + } + } - Text( - text = playerState.currentTrackArtists ?: "Unknown Artist", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Spacer(modifier = Modifier.height(32.dp)) - if (playerState.currentTrackAlbum != null) { - Spacer(modifier = Modifier.height(4.dp)) + // Track info + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { Text( - text = playerState.currentTrackAlbum!!, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + text = playerState.currentTrackTitle ?: "Unknown Track", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = playerState.currentTrackArtists ?: "Unknown Artist", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, maxLines = 1, overflow = TextOverflow.Ellipsis, ) + + if (playerState.currentTrackAlbum != null) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = playerState.currentTrackAlbum!!, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } - } - Spacer(modifier = Modifier.height(32.dp)) + Spacer(modifier = Modifier.height(32.dp)) - // Seek bar - Column( - modifier = Modifier.fillMaxWidth(), - ) { - Slider( - value = playerState.positionMs.toFloat(), - onValueChange = { onSeek(it.toLong()) }, - valueRange = 0f..playerState.durationMs.toFloat().coerceAtLeast(1f), + // Seek bar + Column( modifier = Modifier.fillMaxWidth(), - ) + ) { + Slider( + value = playerState.positionMs.toFloat(), + onValueChange = { onSeek(it.toLong()) }, + valueRange = 0f..playerState.durationMs.toFloat().coerceAtLeast(1f), + modifier = Modifier.fillMaxWidth(), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = formatDuration(playerState.positionMs), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = formatDuration(playerState.durationMs), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Playback controls Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, ) { - Text( - text = formatDuration(playerState.positionMs), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + // Shuffle + IconButton( + onClick = onToggleShuffle, + modifier = Modifier.size(48.dp), + ) { + Icon( + imageVector = Iconsax.IconsaxShuffle, + contentDescription = "Shuffle", + tint = if (playerState.shuffleEnabled) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + + // Skip previous + IconButton( + onClick = onSkipPrevious, + modifier = Modifier.size(56.dp), + ) { + Icon( + imageVector = Iconsax.IconsaxPrevious, + contentDescription = "Previous", + modifier = Modifier.size(32.dp), + ) + } + + // Play/Pause + val baseTheme = LocalBaseUITheme.current + val circlePrimaryIconTheme = remember(baseTheme) { + baseTheme.iconButtons.primary.copy( + shape = BaseUITheme.InteractionState.fromSingleValue(CircleShape) + ) + } + PrimaryIconButton( + onClick = onTogglePlayPause, + modifier = Modifier.size(72.dp), + theme = circlePrimaryIconTheme, + ) { + Icon( + imageVector = if (playerState.isPlaying) { + Iconsax.IconsaxPause + } else { + Iconsax.IconsaxPlay + }, + contentDescription = if (playerState.isPlaying) "Pause" else "Play", + modifier = Modifier.size(40.dp), + ) + } + + // Skip next + IconButton( + onClick = onSkipNext, + modifier = Modifier.size(56.dp), + ) { + Icon( + imageVector = Iconsax.IconsaxNext, + contentDescription = "Next", + modifier = Modifier.size(32.dp), + ) + } + + // Loop mode + IconButton( + onClick = onCycleLoopMode, + modifier = Modifier.size(48.dp), + ) { + Icon( + imageVector = Iconsax.IconsaxRepeateMusic, + contentDescription = "Loop mode", + tint = if (playerState.loopMode != "none") { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + + Spacer(modifier = Modifier.height(32.dp)) + + // Volume control + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = Iconsax.IconsaxVolumeHigh, + contentDescription = "Volume", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp), ) - Text( - text = formatDuration(playerState.durationMs), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + + Slider( + value = playerState.volume, + onValueChange = onSetVolume, + valueRange = 0f..1f, + modifier = Modifier.weight(1f), ) } + + Spacer(modifier = Modifier.height(32.dp)) } - - Spacer(modifier = Modifier.height(24.dp)) - - // Playback controls - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically, - ) { - // Shuffle - IconButton( - onClick = onToggleShuffle, - modifier = Modifier.size(48.dp), - ) { - Icon( - imageVector = Iconsax.IconsaxShuffle, - contentDescription = "Shuffle", - tint = if (playerState.shuffleEnabled) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) - } - - // Skip previous - IconButton( - onClick = onSkipPrevious, - modifier = Modifier.size(56.dp), - ) { - Icon( - imageVector = Iconsax.IconsaxPrevious, - contentDescription = "Previous", - modifier = Modifier.size(32.dp), - ) - } - - // Play/Pause - val baseTheme = LocalBaseUITheme.current - val circlePrimaryIconTheme = remember(baseTheme) { - baseTheme.iconButtons.primary.copy( - shape = BaseUITheme.InteractionState.fromSingleValue(CircleShape) - ) - } - PrimaryIconButton( - onClick = onTogglePlayPause, - modifier = Modifier.size(72.dp), - theme = circlePrimaryIconTheme, - ) { - Icon( - imageVector = if (playerState.isPlaying) { - Iconsax.IconsaxPause - } else { - Iconsax.IconsaxPlay - }, - contentDescription = if (playerState.isPlaying) "Pause" else "Play", - modifier = Modifier.size(40.dp), - ) - } - - // Skip next - IconButton( - onClick = onSkipNext, - modifier = Modifier.size(56.dp), - ) { - Icon( - imageVector = Iconsax.IconsaxNext, - contentDescription = "Next", - modifier = Modifier.size(32.dp), - ) - } - - // Loop mode - IconButton( - onClick = onCycleLoopMode, - modifier = Modifier.size(48.dp), - ) { - Icon( - imageVector = Iconsax.IconsaxRepeateMusic, - contentDescription = "Loop mode", - tint = if (playerState.loopMode != "none") { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) - } - } - - Spacer(modifier = Modifier.height(32.dp)) - - // Volume control - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Icon( - imageVector = Iconsax.IconsaxVolumeHigh, - contentDescription = "Volume", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(24.dp), - ) - - Slider( - value = playerState.volume, - onValueChange = onSetVolume, - valueRange = 0f..1f, - modifier = Modifier.weight(1f), - ) - } - - Spacer(modifier = Modifier.height(32.dp)) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt index dfbc15e1..7a5c0d58 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt @@ -38,7 +38,6 @@ import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.ui.base.OutlineButton import dev.krtirtho.spotube.core.ui.component.CollectionView -import dev.krtirtho.spotube.modules.devices.PlayDestinationPicker import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormData import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormSheet @@ -60,7 +59,6 @@ fun PlaylistScreen( val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle() val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle() val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.collectAsStateWithLifecycle() - val showPlayDestinationPicker by viewModel.showPlayDestinationPicker.collectAsStateWithLifecycle() var showEditPlaylist by remember { mutableStateOf(false) } var showAddTracksDialog by remember { mutableStateOf(false) } @@ -171,13 +169,6 @@ fun PlaylistScreen( viewModel.refresh() }, ) - - PlayDestinationPicker( - visible = showPlayDestinationPicker, - onDismiss = viewModel::dismissPlayPicker, - onPlayLocally = viewModel::playLocally, - onPlayOnRemote = viewModel::playOnRemote, - ) }, ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt index e0e3a054..8380e8af 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt @@ -26,6 +26,7 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper +import dev.krtirtho.spotube.core.remote.RemoteCollectionType import dev.krtirtho.spotube.core.remote.RemotePlaybackController import dev.krtirtho.spotube.core.share.ShareService import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction @@ -117,8 +118,6 @@ class PlaylistViewModel( private val _blacklistedArtistIds = MutableStateFlow>(emptySet()) val blacklistedArtistIds: StateFlow> = _blacklistedArtistIds.asStateFlow() - val showPlayDestinationPicker = remotePlaybackController.showPicker - private val _tracksToAddToPlaylist = MutableStateFlow>(emptyList()) private val _showAddToPlaylistPicker = MutableStateFlow(false) val showAddToPlaylistPicker: StateFlow = _showAddToPlaylistPicker.asStateFlow() @@ -227,35 +226,28 @@ class PlaylistViewModel( } fun playPlaylist() { - remotePlaybackController.wrapPlaybackAction { - viewModelScope.launch { playbackHelper.playPlaylist(playlistId) } - } + val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist" + remotePlaybackController.requestCollectionPlay(RemoteCollectionType.Playlist, playlistId, title) } fun addPlaylistToQueue() { - if (remotePlaybackController.isRemoteConnected()) { - remotePlaybackController.addToQueueOnRemote(playlistId) - } else { - viewModelScope.launch { playbackHelper.addPlaylistToQueue(playlistId) } - } + val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist" + remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.Playlist, playlistId, title) + } + + fun playPlaylistNext() { + val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist" + remotePlaybackController.requestCollectionPlayNext(RemoteCollectionType.Playlist, playlistId, title) } fun playPlaylistFromTrack(track: MetadataTrack) { - remotePlaybackController.wrapPlaybackAction { - viewModelScope.launch { playbackHelper.playPlaylistFromTrack(playlistId, track) } - } - } - - fun playLocally() { - remotePlaybackController.playLocally() - } - - fun playOnRemote() { - remotePlaybackController.playOnRemote(playlistId) - } - - fun dismissPlayPicker() { - remotePlaybackController.dismissPicker() + val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist" + remotePlaybackController.requestCollectionPlay( + type = RemoteCollectionType.Playlist, + id = playlistId, + title = title, + startTrack = track, + ) } fun refresh() { @@ -298,14 +290,20 @@ class PlaylistViewModel( is TrackOptionsAction.StartRadio -> {} is TrackOptionsAction.PlayNext -> { val queue = audioPlayerQueue.getQueue() - queue.find { entry -> + val existing = queue.find { entry -> (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true - }?.let { audioPlayerQueue.removeFromQueue(it) } - audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) + } + if (existing != null) { + // Already in the local queue: move it to the next position + audioPlayerQueue.removeFromQueue(existing) + audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) + } else { + remotePlaybackController.requestTrackPlayNext(track) + } } is TrackOptionsAction.AddToQueue -> { - audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = "")) + remotePlaybackController.requestTrackAddToQueue(track) } is TrackOptionsAction.RemoveFromQueue -> { @@ -381,33 +379,13 @@ class PlaylistViewModel( } fun addTracksToQueue(tracks: List) { - viewModelScope.launch { - val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() - val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() - - val filteredTracks = tracks.filter { track -> - track.id !in blacklistedTrackIds && - track.artists.none { it.id in blacklistedArtistIds } - } - - val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } - audioPlayerQueue.addAllToQueue(entries) - } + val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist" + remotePlaybackController.requestTracksAddToQueue(tracks, title) } fun playTracksNext(tracks: List) { - viewModelScope.launch { - val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() - val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() - - val filteredTracks = tracks.filter { track -> - track.id !in blacklistedTrackIds && - track.artists.none { it.id in blacklistedArtistIds } - } - - val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } - audioPlayerQueue.addAllAfterCurrent(entries) - } + val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist" + remotePlaybackController.requestTracksPlayNext(tracks, title) } val savedTrackIds diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt index 41fa1bc7..72d52113 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt @@ -34,6 +34,8 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper +import dev.krtirtho.spotube.core.remote.RemoteCollectionType +import dev.krtirtho.spotube.core.remote.RemotePlaybackController import dev.krtirtho.spotube.core.share.ShareService import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext @@ -97,6 +99,7 @@ class SavedTracksViewModel( private val libraryRepository: LibraryRepository, private val shareService: ShareService, private val downloadManager: DownloadManager, + private val remotePlaybackController: RemotePlaybackController, ) : ViewModel(), KoinComponent { private val logger by injectLogger() @@ -200,15 +203,20 @@ class SavedTracksViewModel( } fun playSavedTracks() { - viewModelScope.launch { playbackHelper.playSavedTracks() } + remotePlaybackController.requestCollectionPlay(RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, "Saved Tracks") } fun addSavedTracksToQueue() { - viewModelScope.launch { playbackHelper.addSavedTracksToQueue() } + remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, "Saved Tracks") } fun playSavedTracksFromTrack(track: MetadataTrack) { - viewModelScope.launch { playbackHelper.playSavedTracksFromTrack(track) } + remotePlaybackController.requestCollectionPlay( + type = RemoteCollectionType.SavedTracks, + id = SAVED_TRACKS_COLLECTION_ID, + title = "Saved Tracks", + startTrack = track, + ) } fun refresh() { @@ -224,14 +232,20 @@ class SavedTracksViewModel( is TrackOptionsAction.StartRadio -> {} is TrackOptionsAction.PlayNext -> { val queue = audioPlayerQueue.getQueue() - queue.find { entry -> + val existing = queue.find { entry -> (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true - }?.let { audioPlayerQueue.removeFromQueue(it) } - audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) + } + if (existing != null) { + // Already in the local queue: move it to the next position + audioPlayerQueue.removeFromQueue(existing) + audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) + } else { + remotePlaybackController.requestTrackPlayNext(track) + } } is TrackOptionsAction.AddToQueue -> { - audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = "")) + remotePlaybackController.requestTrackAddToQueue(track) } is TrackOptionsAction.RemoveFromQueue -> { @@ -299,33 +313,11 @@ class SavedTracksViewModel( } fun addTracksToQueue(tracks: List) { - viewModelScope.launch { - val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() - val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() - - val filteredTracks = tracks.filter { track -> - track.id !in blacklistedTrackIds && - track.artists.none { it.id in blacklistedArtistIds } - } - - val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } - audioPlayerQueue.addAllToQueue(entries) - } + remotePlaybackController.requestTracksAddToQueue(tracks, "Saved Tracks") } fun playTracksNext(tracks: List) { - viewModelScope.launch { - val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() - val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() - - val filteredTracks = tracks.filter { track -> - track.id !in blacklistedTrackIds && - track.artists.none { it.id in blacklistedArtistIds } - } - - val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } - audioPlayerQueue.addAllAfterCurrent(entries) - } + remotePlaybackController.requestTracksPlayNext(tracks, "Saved Tracks") } suspend fun isSavedTracks(trackIds: List): List { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt index 0428244f..b8f699d8 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt @@ -86,6 +86,7 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.Routes +import dev.krtirtho.spotube.core.remote.RemotePlaybackController import dev.krtirtho.spotube.core.share.ShareService import dev.krtirtho.spotube.core.ui.base.AutocompleteTextField import dev.krtirtho.spotube.core.ui.base.ChipTab @@ -121,6 +122,7 @@ private val GridMinCellSize = 180.dp fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { val audioPlayerQueue: AudioPlayerQueue = koinInject() val shareService: ShareService = koinInject() + val remotePlaybackController: RemotePlaybackController = koinInject() val downloadsViewModel: DownloadsViewModel = koinViewModel() val libraryRepository: LibraryRepository = koinInject() val blacklistRepository: BlacklistRepository = koinInject() @@ -171,14 +173,20 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { is TrackOptionsAction.StartRadio -> {} is TrackOptionsAction.PlayNext -> { val queue = audioPlayerQueue.getQueue() - queue.find { entry -> + val existing = queue.find { entry -> (entry as? QueueEntry.StreamingTrack)?.track?.id == track.id - }?.let { audioPlayerQueue.removeFromQueue(it) } - audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) + } + if (existing != null) { + // Already in the local queue: move it to the next position + audioPlayerQueue.removeFromQueue(existing) + audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) + } else { + remotePlaybackController.requestTrackPlayNext(track) + } } is TrackOptionsAction.AddToQueue -> { - audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = "")) + remotePlaybackController.requestTrackAddToQueue(track) } is TrackOptionsAction.RemoveFromQueue -> { @@ -237,33 +245,11 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { } fun bulkAddToQueue(tracks: List) { - scope.launch { - val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() - val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() - - val filteredTracks = tracks.filter { track -> - track.id !in blacklistedTrackIds && - track.artists.none { it.id in blacklistedArtistIds } - } - - val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } - audioPlayerQueue.addAllToQueue(entries) - } + remotePlaybackController.requestTracksAddToQueue(tracks, "Search results") } fun bulkPlayNext(tracks: List) { - scope.launch { - val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() - val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() - - val filteredTracks = tracks.filter { track -> - track.id !in blacklistedTrackIds && - track.artists.none { it.id in blacklistedArtistIds } - } - - val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } - audioPlayerQueue.addAllAfterCurrent(entries) - } + remotePlaybackController.requestTracksPlayNext(tracks, "Search results") } Scaffold( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt index e41b2ef5..14fe94f9 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt @@ -70,6 +70,7 @@ import dev.krtirtho.spotube.core.navigation.NavigationState import dev.krtirtho.spotube.core.navigation.Navigator import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.remote.ConnectionRequestDialogHost +import dev.krtirtho.spotube.modules.devices.PlayDestinationPickerHost import dev.krtirtho.spotube.modules.lyrics.LyricsScreen import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContent import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContentViewModel @@ -118,6 +119,7 @@ fun AppShell( } ConnectionRequestDialogHost() + PlayDestinationPickerHost() Box(modifier = Modifier.fillMaxSize()) { val useSidebar = viewModel.useSidebar()