Compare commits

..

3 Commits

19 changed files with 1388 additions and 622 deletions

View File

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

View File

@ -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<QueueEntry> {
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<String>,

View File

@ -33,11 +33,8 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
@ -69,8 +66,11 @@ class RemoteControlClient {
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
private val _stateUpdates = MutableSharedFlow<RemoteControlEvent>(extraBufferCapacity = 32)
val stateUpdates: SharedFlow<RemoteControlEvent> = _stateUpdates.asSharedFlow()
private val _latestPlayerState = MutableStateFlow<RemoteControlEvent.PlayerState?>(null)
val latestPlayerState: StateFlow<RemoteControlEvent.PlayerState?> = _latestPlayerState.asStateFlow()
private val _latestQueue = MutableStateFlow<RemoteControlEvent.QueueUpdated?>(null)
val latestQueue: StateFlow<RemoteControlEvent.QueueUpdated?> = _latestQueue.asStateFlow()
suspend fun connect(host: String, port: Int, deviceId: String, deviceName: String) {
if (_connectionState.value is ConnectionState.Connected) {
@ -101,13 +101,16 @@ class RemoteControlClient {
private suspend fun receiveLoop(host: String, port: Int) {
val currentSession = session ?: return
logger.d { "Starting receive loop for $host:$port" }
try {
for (frame in currentSession.incoming) {
when (frame) {
is Frame.Text -> {
val text = frame.readText()
logger.d { "Received frame: $text" }
try {
val event = json.decodeFromString(RemoteControlEvent.serializer(), text)
logger.d { "Parsed event: $event" }
when (event) {
is RemoteControlEvent.Connected -> {
logger.i { "Connection authorized by server" }
@ -118,9 +121,17 @@ class RemoteControlClient {
// Keep showing connecting state
}
else -> {
// Only emit state updates after connection is established
// Only store state updates after connection is established
if (_connectionState.value is ConnectionState.Connected) {
_stateUpdates.emit(event)
when (event) {
is RemoteControlEvent.PlayerState -> {
_latestPlayerState.value = event
}
is RemoteControlEvent.QueueUpdated -> {
_latestQueue.value = event
}
else -> {}
}
}
}
}
@ -136,6 +147,7 @@ class RemoteControlClient {
else -> {}
}
}
logger.d { "Receive loop exited normally" }
} catch (e: Exception) {
logger.e(e) { "Error in receive loop" }
_connectionState.value = ConnectionState.Error(e.message ?: "Connection lost")

View File

@ -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
@ -30,8 +31,14 @@ import io.ktor.websocket.Frame
import io.ktor.websocket.close
import io.ktor.websocket.readText
import kotlin.coroutines.resume
import kotlin.time.TimeSource
import kotlin.time.Duration.Companion.milliseconds
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@ -41,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<RemoteControlHandler>()
@ -74,7 +82,7 @@ class RemoteControlHandler(
val waitingMessage = RemoteControlEvent.WaitingForPermission(
"Waiting for permission from $deviceName..."
)
session.send(Frame.Text(json.encodeToString(RemoteControlEvent.WaitingForPermission.serializer(), waitingMessage)))
session.send(Frame.Text(json.encodeToString(RemoteControlEvent.serializer(), waitingMessage)))
val request = ConnectionRequest(
deviceId = deviceId ?: "unknown",
@ -99,14 +107,29 @@ class RemoteControlHandler(
}
// Send connected message
session.send(Frame.Text(json.encodeToString(RemoteControlEvent.Connected.serializer(), RemoteControlEvent.Connected)))
session.send(Frame.Text(json.encodeToString(RemoteControlEvent.serializer(), RemoteControlEvent.Connected)))
logger.i { "Remote control connection established from $deviceName ($deviceId)" }
// Broadcast initial player state so the controller shows current track info
broadcastState(session)
broadcastQueue(session)
try {
// Periodically push player state so the controller's progress bar
// stays in sync even when no commands are being sent.
coroutineScope {
launch {
while (isActive) {
delay(1_000)
broadcastState(session)
}
}
handleControlLoop(session)
}
} catch (e: Exception) {
logger.w(e) { "Error in remote control session" }
} finally {
logger.d { "Closing session in finally block" }
session.close()
}
}
@ -129,9 +152,11 @@ class RemoteControlHandler(
}
private suspend fun handleControlLoop(session: WebSocketServerSession) {
logger.d { "Starting control loop for session" }
for (frame in session.incoming) {
if (frame is Frame.Text) {
val text = frame.readText()
logger.d { "Received command: $text" }
try {
val envelope = json.decodeFromString(CommandEnvelope.serializer(), text)
handleCommand(session, envelope)
@ -141,21 +166,25 @@ class RemoteControlHandler(
}
}
}
logger.d { "Control loop exited normally" }
}
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()
}
is RemoteControlCommand.TogglePlayPause -> {
if (audioPlayer.playerStateFlow.value == AudioPlayerState.PLAYING) {
val isPlaying = audioPlayer.playerStateFlow.value == AudioPlayerState.PLAYING
if (isPlaying) {
audioPlayer.pause()
waitForPlaybackState(expectPlaying = false)
} else {
audioPlayer.play()
waitForPlaybackState(expectPlaying = true)
}
}
is RemoteControlCommand.Seek -> {
@ -186,26 +215,133 @@ 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)
}
is RemoteControlCommand.RemoveFromQueue -> {
audioPlayerQueue.removeFromQueueByMediaUrl(command.mediaUrl)
val queue = audioPlayerQueue.queueFlow.value
val entry = queue.firstOrNull { candidate ->
when (candidate) {
is QueueEntry.StreamingTrack -> candidate.track.id == command.mediaUrl
is QueueEntry.LocalTrack -> candidate.url == command.mediaUrl
}
}
if (entry != null) {
audioPlayerQueue.removeFromQueue(entry)
} else {
logger.w { "Remote remove: no matching queue entry for ${command.mediaUrl}" }
}
}
}
sendAck(session, envelope.commandId)
broadcastState(session)
broadcastQueue(session)
}
private suspend fun sendAck(session: WebSocketServerSession, commandId: String) {
val text = json.encodeToString(RemoteControlEvent.Ack.serializer(), RemoteControlEvent.Ack(commandId))
val text = json.encodeToString(RemoteControlEvent.serializer(), RemoteControlEvent.Ack(commandId))
session.send(Frame.Text(text))
}
private suspend fun sendError(session: WebSocketServerSession, message: String) {
val text = json.encodeToString(RemoteControlEvent.Error.serializer(), RemoteControlEvent.Error(message))
val text = json.encodeToString(RemoteControlEvent.serializer(), RemoteControlEvent.Error(message))
session.send(Frame.Text(text))
}
/**
* Player state changes are applied asynchronously (e.g. ExoPlayer listener
* callbacks posted to the main looper), so after play/pause we poll until
* [playerStateFlow] reflects the expected state before broadcasting it back
* to the controller. Otherwise the client would see a stale (inverted) icon.
*/
private suspend fun waitForPlaybackState(expectPlaying: Boolean, timeoutMs: Long = 1_000) {
val timeoutAt = TimeSource.Monotonic.markNow() + timeoutMs.milliseconds
while (timeoutAt.hasNotPassedNow()) {
if ((audioPlayer.playerStateFlow.value == AudioPlayerState.PLAYING) == expectPlaying) return
delay(25)
}
}
/**
* 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(
@ -221,10 +357,62 @@ class RemoteControlHandler(
currentTrackAlbum = current?.albumOrNull(),
currentTrackCoverUrl = current?.coverUrlOrNull(),
)
val text = json.encodeToString(RemoteControlEvent.PlayerState.serializer(), state)
val text = json.encodeToString(RemoteControlEvent.serializer(), state)
session.send(Frame.Text(text))
}
private suspend fun broadcastQueue(session: WebSocketServerSession) {
val queue = audioPlayerQueue.queueFlow.value
val current = audioPlayerQueue.currentQueueEntryFlow.value
val currentIndex = if (current != null) {
queue.indexOfFirst { it.matchesCurrent(current) }
} else {
-1
}
val event = RemoteControlEvent.QueueUpdated(
entries = queue.map { it.toRemoteQueueEntry() },
currentIndex = currentIndex,
)
val text = json.encodeToString(RemoteControlEvent.serializer(), event)
session.send(Frame.Text(text))
}
private fun QueueEntry.matchesCurrent(current: QueueEntry): Boolean {
return when {
this is QueueEntry.StreamingTrack && current is QueueEntry.StreamingTrack -> {
this.track.id == current.track.id
}
this is QueueEntry.LocalTrack && current is QueueEntry.LocalTrack -> {
this.url == current.url && this.name == current.name
}
else -> false
}
}
private fun QueueEntry.toRemoteQueueEntry(): RemoteQueueEntry = when (this) {
is QueueEntry.StreamingTrack -> RemoteQueueEntry(
mediaUrl = track.id,
trackId = track.id,
title = track.title,
artists = track.artists.joinToString(", ") { artist -> artist.name },
album = track.album?.title,
coverUrl = coverUrlOrNull(),
durationMs = track.durationMs,
)
is QueueEntry.LocalTrack -> RemoteQueueEntry(
mediaUrl = url,
trackId = url,
title = name,
artists = artists.joinToString(", "),
album = album,
coverUrl = null,
durationMs = duration,
)
}
private fun QueueEntry.mediaKey(): String = when (this) {
is QueueEntry.StreamingTrack -> track.id
is QueueEntry.LocalTrack -> url
@ -246,7 +434,8 @@ class RemoteControlHandler(
}
private fun QueueEntry.coverUrlOrNull(): String? = when (this) {
is QueueEntry.StreamingTrack -> track.thumbnails?.firstOrNull()?.url
is QueueEntry.StreamingTrack -> track.thumbnails?.maxByOrNull { it.width * it.height }?.url
?: track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url
is QueueEntry.LocalTrack -> null
}
}
@ -257,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,

View File

@ -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,34 @@ 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<MetadataTrack>) : RemoteControlCommand()
@Serializable
@SerialName("playTracksNext")
data class PlayTracksNext(val tracks: List<MetadataTrack>) : RemoteControlCommand()
@Serializable
@SerialName("playIndex")
data class PlayIndex(val index: Int) : RemoteControlCommand()
@Serializable
@SerialName("removeFromQueue")
data class RemoveFromQueue(val mediaUrl: String) : RemoteControlCommand()

View File

@ -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<MetadataTrack>,
) : 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<Boolean> = _showPicker.asStateFlow()
private val _pendingRequest = MutableStateFlow<PlaybackDestinationRequest?>(null)
val pendingRequest: StateFlow<PlaybackDestinationRequest?> = _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<MetadataTrack>, title: String) {
if (tracks.isEmpty()) return
request(PlaybackDestinationRequest.Tracks(title, PlaybackDestinationAction.AddToQueue, tracks))
}
fun requestTracksPlayNext(tracks: List<MetadataTrack>, 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 {
// ---------- 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 {
remoteControlClient.sendCommand(RemoteControlCommand.AddToQueue(source))
logger.i { "Sent add-to-queue command for source: $source" }
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 add-to-queue command" }
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 } }
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -72,7 +72,8 @@ fun DevicesScreen(
viewModel.startDiscovery()
onDispose {
viewModel.stopDiscovery()
viewModel.disconnect()
// Don't disconnect here - the connection should persist when navigating to RemoteControlScreen
// The RemoteControlViewModel will manage the connection lifecycle
}
}

View File

@ -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<RemotePlaybackController>()
val remoteControlClient = koinInject<RemoteControlClient>()
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)
}
},
)
}

View File

@ -18,21 +18,30 @@
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
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
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@ -40,11 +49,12 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@ -53,12 +63,22 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import dev.krtirtho.spotube.core.remote.ConnectionState
import dev.krtirtho.spotube.core.remote.RemoteQueueEntry
import dev.krtirtho.spotube.core.ui.base.BaseUITheme
import dev.krtirtho.spotube.core.ui.base.GhostIconButton
import dev.krtirtho.spotube.core.ui.base.IconButton
import dev.krtirtho.spotube.core.ui.base.ListRowTile
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
import dev.krtirtho.spotube.core.ui.base.PrimaryIconButton
import dev.krtirtho.spotube.core.ui.base.Slider
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
import dev.krtirtho.spotube.modules.shell.player_queue.QueueSheet
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore
import dev.krtirtho.spotube.resources.iconsax.IconsaxCloseSquare
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicFilter
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemove
import dev.krtirtho.spotube.resources.iconsax.IconsaxNext
import dev.krtirtho.spotube.resources.iconsax.IconsaxPause
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay
@ -77,13 +97,37 @@ fun RemoteControlScreen(
val viewModel = koinViewModel<RemoteControlViewModel>()
val playerState by viewModel.playerState.collectAsStateWithLifecycle()
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
val queueState by viewModel.queueState.collectAsStateWithLifecycle()
val isQueueVisible by viewModel.isQueueVisible.collectAsStateWithLifecycle()
val shellBottomInset = LocalAppShellBottomInset.current
// 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()
@ -110,14 +154,15 @@ fun RemoteControlScreen(
onSetVolume = viewModel::setVolume,
onToggleShuffle = viewModel::toggleShuffle,
onCycleLoopMode = viewModel::cycleLoopMode,
modifier = Modifier.padding(padding)
modifier = Modifier.padding(padding).padding(bottom = shellBottomInset)
)
}
is ConnectionState.Connecting -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
.padding(padding)
.padding(bottom = shellBottomInset),
contentAlignment = Alignment.Center
) {
Text("Connecting...")
@ -127,7 +172,8 @@ fun RemoteControlScreen(
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
.padding(padding)
.padding(bottom = shellBottomInset),
contentAlignment = Alignment.Center
) {
Text("Disconnected")
@ -137,7 +183,8 @@ fun RemoteControlScreen(
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
.padding(padding)
.padding(bottom = shellBottomInset),
contentAlignment = Alignment.Center
) {
Text("Connection error: ${(connectionState as ConnectionState.Error).message}")
@ -145,6 +192,40 @@ fun RemoteControlScreen(
}
}
}
}
// 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
@ -159,11 +240,13 @@ private fun RemoteControlContent(
onCycleLoopMode: () -> Unit,
modifier: Modifier = Modifier,
) {
Box (modifier = Modifier.fillMaxSize()) {
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp),
.padding(horizontal = 24.dp)
.widthIn(max = 480.dp)
.align(Alignment.TopCenter),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.height(32.dp))
@ -174,13 +257,28 @@ private fun RemoteControlContent(
.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))
@ -290,14 +388,16 @@ private fun RemoteControlContent(
}
// Play/Pause
IconButton(
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)
.background(
color = MaterialTheme.colorScheme.primary,
shape = CircleShape,
),
modifier = Modifier.size(72.dp),
theme = circlePrimaryIconTheme,
) {
Icon(
imageVector = if (playerState.isPlaying) {
@ -306,7 +406,6 @@ private fun RemoteControlContent(
Iconsax.IconsaxPlay
},
contentDescription = if (playerState.isPlaying) "Pause" else "Play",
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(40.dp),
)
}
@ -365,6 +464,151 @@ private fun RemoteControlContent(
Spacer(modifier = Modifier.height(32.dp))
}
}
}
@Composable
private fun RemoteQueueSection(
queueState: RemoteQueueState,
onPlayQueueItem: (Int) -> Unit,
onRemoveQueueItem: (String) -> Unit,
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
text = "Queue",
style = MaterialTheme.typography.titleLarge,
)
if (queueState.entries.isEmpty()) {
Text(
text = "No queue entries",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
itemsIndexed(
items = queueState.entries,
key = { index, entry -> "${entry.mediaUrl}@$index" },
) { index, entry ->
RemoteQueueItemRow(
entry = entry,
index = index,
isCurrent = index == queueState.currentIndex,
onPlayClick = { onPlayQueueItem(index) },
onRemoveClick = { onRemoveQueueItem(entry.mediaUrl) },
)
}
}
}
}
}
@Composable
private fun RemoteQueueItemRow(
entry: RemoteQueueEntry,
index: Int,
isCurrent: Boolean,
onPlayClick: () -> Unit,
onRemoveClick: () -> Unit,
) {
var showMenu by remember { mutableStateOf(false) }
ListRowTile(
onClick = onPlayClick,
selected = isCurrent,
modifier = Modifier,
leading = {
Box(
modifier = Modifier
.size(48.dp)
.clip(MaterialTheme.shapes.small)
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
if (entry.coverUrl != null) {
AsyncImage(
model = entry.coverUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
} else {
Text(
text = "${index + 1}",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
title = {
Text(
text = entry.title,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = if (isCurrent) {
MaterialTheme.colorScheme.onSecondaryContainer
} else {
MaterialTheme.colorScheme.onSurface
},
)
},
subtitle = {
Text(
text = entry.artists,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
trailing = {
Text(
text = formatDuration(entry.durationMs),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Box {
GhostIconButton(
onClick = { showMenu = true },
modifier = Modifier.size(36.dp),
) {
Icon(
Iconsax.Iconsax3DotsMore,
contentDescription = "More options",
modifier = Modifier.size(18.dp),
)
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
) {
DropdownMenuItem(
text = { Text("Remove from queue") },
onClick = {
onRemoveClick()
showMenu = false
},
leadingIcon = {
Icon(Iconsax.IconsaxMusicSquareRemove, contentDescription = null)
},
)
}
}
}
)
}
private fun formatDuration(ms: Long): String {

View File

@ -24,7 +24,7 @@ import dev.krtirtho.spotube.core.remote.ConnectionState
import dev.krtirtho.spotube.core.remote.RemoteControlClient
import dev.krtirtho.spotube.core.remote.RemoteControlCommand
import dev.krtirtho.spotube.core.remote.RemoteControlEvent
import kotlinx.coroutines.Job
import dev.krtirtho.spotube.core.remote.RemoteQueueEntry
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@ -47,6 +47,11 @@ data class RemotePlayerState(
val currentTrackCoverUrl: String? = null,
)
data class RemoteQueueState(
val entries: List<RemoteQueueEntry> = emptyList(),
val currentIndex: Int = -1,
)
class RemoteControlViewModel : ViewModel(), KoinComponent {
private val logger = Logger.withTag("RemoteControlViewModel")
private val remoteControlClient: RemoteControlClient by inject()
@ -57,7 +62,11 @@ class RemoteControlViewModel : ViewModel(), KoinComponent {
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
private var stateUpdateJob: Job? = null
private val _queueState = MutableStateFlow(RemoteQueueState())
val queueState: StateFlow<RemoteQueueState> = _queueState.asStateFlow()
private val _isQueueVisible = MutableStateFlow(false)
val isQueueVisible: StateFlow<Boolean> = _isQueueVisible.asStateFlow()
init {
viewModelScope.launch {
@ -67,23 +76,23 @@ class RemoteControlViewModel : ViewModel(), KoinComponent {
}
viewModelScope.launch {
remoteControlClient.stateUpdates.collect { event ->
handleStateUpdate(event)
remoteControlClient.latestPlayerState.collect { event ->
if (event != null) {
handlePlayerState(event)
}
}
}
private fun handleStateUpdate(event: RemoteControlEvent) {
when (event) {
is RemoteControlEvent.Connected -> {
// Connection already handled in RemoteControlClient
logger.d { "Connection confirmed" }
viewModelScope.launch {
remoteControlClient.latestQueue.collect { event ->
if (event != null) {
handleQueueUpdated(event)
}
is RemoteControlEvent.WaitingForPermission -> {
// Waiting for permission - no action needed
logger.d { "Waiting for permission: ${event.message}" }
}
is RemoteControlEvent.PlayerState -> {
}
}
private fun handlePlayerState(event: RemoteControlEvent.PlayerState) {
_playerState.update {
it.copy(
isPlaying = event.isPlaying,
@ -100,17 +109,12 @@ class RemoteControlViewModel : ViewModel(), KoinComponent {
)
}
}
is RemoteControlEvent.QueueUpdated -> {
// TODO: Handle queue updates if needed
logger.d { "Queue updated: ${event.entries.size} entries" }
}
is RemoteControlEvent.Ack -> {
logger.d { "Command acknowledged: ${event.commandId}" }
}
is RemoteControlEvent.Error -> {
logger.e { "Remote error: ${event.message}" }
}
}
private fun handleQueueUpdated(event: RemoteControlEvent.QueueUpdated) {
_queueState.value = RemoteQueueState(
entries = event.entries,
currentIndex = event.currentIndex,
)
}
fun togglePlayPause() {
@ -162,6 +166,23 @@ class RemoteControlViewModel : ViewModel(), KoinComponent {
}
}
fun playQueueItem(index: Int) {
if (index < 0) return
viewModelScope.launch {
remoteControlClient.sendCommand(RemoteControlCommand.PlayIndex(index))
}
}
fun removeQueueItem(mediaUrl: String) {
viewModelScope.launch {
remoteControlClient.sendCommand(RemoteControlCommand.RemoveFromQueue(mediaUrl))
}
}
fun toggleQueueVisibility() {
_isQueueVisible.update { !it }
}
fun disconnect() {
viewModelScope.launch {
remoteControlClient.disconnect()

View File

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

View File

@ -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<Set<String>>(emptySet())
val blacklistedArtistIds: StateFlow<Set<String>> = _blacklistedArtistIds.asStateFlow()
val showPlayDestinationPicker = remotePlaybackController.showPicker
private val _tracksToAddToPlaylist = MutableStateFlow<List<MetadataTrack>>(emptyList())
private val _showAddToPlaylistPicker = MutableStateFlow(false)
val showAddToPlaylistPicker: StateFlow<Boolean> = _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) }
}
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<MetadataTrack>) {
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<MetadataTrack>) {
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

View File

@ -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<SavedTracksViewModel>()
@ -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) }
}
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<MetadataTrack>) {
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<MetadataTrack>) {
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<String>): List<Boolean> {

View File

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

View File

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