Compare commits

..

No commits in common. "7bf17afc427fdadc83bc8edbed0f6672002b47f0" and "df3a6dcbd275299888ba1e591f7f5d92fe5ea461" have entirely different histories.

19 changed files with 610 additions and 1376 deletions

View File

@ -158,7 +158,6 @@ val sharedModules = module {
libraryRepository = get(), libraryRepository = get(),
shareService = get(), shareService = get(),
downloadManager = get(), downloadManager = get(),
remotePlaybackController = get(),
) )
} }
@ -174,7 +173,6 @@ val sharedModules = module {
blacklistRepository = get(), blacklistRepository = get(),
shareService = get(), shareService = get(),
downloadManager = get(), downloadManager = get(),
remotePlaybackController = get(),
) )
} }
@ -198,7 +196,6 @@ val sharedModules = module {
blacklistRepository = get(), blacklistRepository = get(),
shareService = get(), shareService = get(),
downloadManager = get(), downloadManager = get(),
remotePlaybackController = get(),
) )
} }
@ -211,7 +208,6 @@ val sharedModules = module {
albumRepository = get(), albumRepository = get(),
playlistRepository = get(), playlistRepository = get(),
savedTracksRepository = get(), savedTracksRepository = get(),
artistRepository = get(),
audioPlayerQueue = get(), audioPlayerQueue = get(),
blacklistRepository = get(), blacklistRepository = get(),
) )
@ -223,13 +219,13 @@ val sharedModules = module {
singleOf(::LocalServer) withOptions { singleOf(::LocalServer) withOptions {
createdAtStart() createdAtStart()
} }
single { RemoteControlHandler(get(), get(), get(), get()) } single { RemoteControlHandler(get(), get(), get()) }
single { RemoteControlClient() } single { RemoteControlClient() }
singleOf(::DeviceDiscoveryService) singleOf(::DeviceDiscoveryService)
single { RemoteControlService(get(), get(), get()) } withOptions { single { RemoteControlService(get(), get(), get()) } withOptions {
createdAtStart() createdAtStart()
} }
single { RemotePlaybackController(get(), get(), get(), get()) } single { RemotePlaybackController() }
single { JamSessionService(get(), get()) } single { JamSessionService(get(), get()) }
singleOf(::JamDeepLinkService) singleOf(::JamDeepLinkService)
singleOf(::AudioPlayerQueueRepository) { bind<QueueStateRepository>() } singleOf(::AudioPlayerQueueRepository) { bind<QueueStateRepository>() }

View File

@ -22,7 +22,6 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry
import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.modules.album.AlbumRepository 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.blacklist.BlacklistRepository
import dev.krtirtho.spotube.modules.playlist.PlaylistRepository import dev.krtirtho.spotube.modules.playlist.PlaylistRepository
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
@ -31,7 +30,6 @@ class CollectionPlaybackHelper(
private val albumRepository: AlbumRepository, private val albumRepository: AlbumRepository,
private val playlistRepository: PlaylistRepository, private val playlistRepository: PlaylistRepository,
private val savedTracksRepository: SavedTracksRepository, private val savedTracksRepository: SavedTracksRepository,
private val artistRepository: ArtistRepository,
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val blacklistRepository: BlacklistRepository, private val blacklistRepository: BlacklistRepository,
) { ) {
@ -58,13 +56,6 @@ class CollectionPlaybackHelper(
} }
} }
suspend fun playAlbumNext(albumId: String) {
val entries = fetchAllAlbumTracks(albumId)
if (entries.isNotEmpty()) {
audioPlayerQueue.addAllAfterCurrent(entries)
}
}
suspend fun playAlbumFromTrack(albumId: String, track: MetadataTrack) { suspend fun playAlbumFromTrack(albumId: String, track: MetadataTrack) {
val entries = fetchAllAlbumTracks(albumId) val entries = fetchAllAlbumTracks(albumId)
if (entries.isEmpty()) return if (entries.isEmpty()) return
@ -113,13 +104,6 @@ class CollectionPlaybackHelper(
} }
} }
suspend fun playPlaylistNext(playlistId: String) {
val entries = fetchAllPlaylistTracks(playlistId)
if (entries.isNotEmpty()) {
audioPlayerQueue.addAllAfterCurrent(entries)
}
}
suspend fun playPlaylistFromTrack(playlistId: String, track: MetadataTrack) { suspend fun playPlaylistFromTrack(playlistId: String, track: MetadataTrack) {
val entries = fetchAllPlaylistTracks(playlistId) val entries = fetchAllPlaylistTracks(playlistId)
if (entries.isEmpty()) return if (entries.isEmpty()) return
@ -158,32 +142,6 @@ 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() { suspend fun addSavedTracksToQueue() {
val entries = fetchAllSavedTracks() val entries = fetchAllSavedTracks()
if (entries.isNotEmpty()) { if (entries.isNotEmpty()) {
@ -285,15 +243,6 @@ 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( private fun isTrackBlacklisted(
track: MetadataTrack, track: MetadataTrack,
blacklistedTrackIds: Set<String>, blacklistedTrackIds: Set<String>,

View File

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

View File

@ -23,7 +23,6 @@ import dev.krtirtho.spotube.core.audioplayer.LoopState
import dev.krtirtho.spotube.core.audioplayer.PlayerState as AudioPlayerState import dev.krtirtho.spotube.core.audioplayer.PlayerState as AudioPlayerState
import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
import dev.krtirtho.spotube.modules.settings.SettingsRepository import dev.krtirtho.spotube.modules.settings.SettingsRepository
import io.ktor.server.websocket.WebSocketServerSession import io.ktor.server.websocket.WebSocketServerSession
import io.ktor.websocket.CloseReason import io.ktor.websocket.CloseReason
@ -31,14 +30,8 @@ import io.ktor.websocket.Frame
import io.ktor.websocket.close import io.ktor.websocket.close
import io.ktor.websocket.readText import io.ktor.websocket.readText
import kotlin.coroutines.resume 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.MutableSharedFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@ -48,7 +41,6 @@ class RemoteControlHandler(
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
private val audioPlayer: AudioPlayerInterface, private val audioPlayer: AudioPlayerInterface,
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val collectionPlaybackHelper: CollectionPlaybackHelper,
) : KoinComponent { ) : KoinComponent {
val logger by injectLogger<RemoteControlHandler>() val logger by injectLogger<RemoteControlHandler>()
@ -82,7 +74,7 @@ class RemoteControlHandler(
val waitingMessage = RemoteControlEvent.WaitingForPermission( val waitingMessage = RemoteControlEvent.WaitingForPermission(
"Waiting for permission from $deviceName..." "Waiting for permission from $deviceName..."
) )
session.send(Frame.Text(json.encodeToString(RemoteControlEvent.serializer(), waitingMessage))) session.send(Frame.Text(json.encodeToString(RemoteControlEvent.WaitingForPermission.serializer(), waitingMessage)))
val request = ConnectionRequest( val request = ConnectionRequest(
deviceId = deviceId ?: "unknown", deviceId = deviceId ?: "unknown",
@ -107,29 +99,14 @@ class RemoteControlHandler(
} }
// Send connected message // Send connected message
session.send(Frame.Text(json.encodeToString(RemoteControlEvent.serializer(), RemoteControlEvent.Connected))) session.send(Frame.Text(json.encodeToString(RemoteControlEvent.Connected.serializer(), RemoteControlEvent.Connected)))
logger.i { "Remote control connection established from $deviceName ($deviceId)" } 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 { try {
// Periodically push player state so the controller's progress bar handleControlLoop(session)
// stays in sync even when no commands are being sent.
coroutineScope {
launch {
while (isActive) {
delay(1_000)
broadcastState(session)
}
}
handleControlLoop(session)
}
} catch (e: Exception) { } catch (e: Exception) {
logger.w(e) { "Error in remote control session" } logger.w(e) { "Error in remote control session" }
} finally { } finally {
logger.d { "Closing session in finally block" }
session.close() session.close()
} }
} }
@ -152,11 +129,9 @@ class RemoteControlHandler(
} }
private suspend fun handleControlLoop(session: WebSocketServerSession) { private suspend fun handleControlLoop(session: WebSocketServerSession) {
logger.d { "Starting control loop for session" }
for (frame in session.incoming) { for (frame in session.incoming) {
if (frame is Frame.Text) { if (frame is Frame.Text) {
val text = frame.readText() val text = frame.readText()
logger.d { "Received command: $text" }
try { try {
val envelope = json.decodeFromString(CommandEnvelope.serializer(), text) val envelope = json.decodeFromString(CommandEnvelope.serializer(), text)
handleCommand(session, envelope) handleCommand(session, envelope)
@ -166,25 +141,21 @@ class RemoteControlHandler(
} }
} }
} }
logger.d { "Control loop exited normally" }
} }
private suspend fun handleCommand(session: WebSocketServerSession, envelope: CommandEnvelope) { private suspend fun handleCommand(session: WebSocketServerSession, envelope: CommandEnvelope) {
when (val command = envelope.command) { when (val command = envelope.command) {
is RemoteControlCommand.Play -> { is RemoteControlCommand.Play -> {
handleCollectionSource(command.source, RemoteCollectionAction.Play) logger.d { "Remote play request: ${command.source} (playback source not yet implemented)" }
} }
is RemoteControlCommand.Pause -> { is RemoteControlCommand.Pause -> {
audioPlayer.pause() audioPlayer.pause()
} }
is RemoteControlCommand.TogglePlayPause -> { is RemoteControlCommand.TogglePlayPause -> {
val isPlaying = audioPlayer.playerStateFlow.value == AudioPlayerState.PLAYING if (audioPlayer.playerStateFlow.value == AudioPlayerState.PLAYING) {
if (isPlaying) {
audioPlayer.pause() audioPlayer.pause()
waitForPlaybackState(expectPlaying = false)
} else { } else {
audioPlayer.play() audioPlayer.play()
waitForPlaybackState(expectPlaying = true)
} }
} }
is RemoteControlCommand.Seek -> { is RemoteControlCommand.Seek -> {
@ -215,133 +186,26 @@ class RemoteControlHandler(
audioPlayer.loop(loopState) audioPlayer.loop(loopState)
} }
is RemoteControlCommand.AddToQueue -> { is RemoteControlCommand.AddToQueue -> {
handleCollectionSource(command.source, RemoteCollectionAction.AddToQueue) logger.d { "Remote add to queue: ${command.source} (source parsing not yet implemented)" }
}
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 -> { is RemoteControlCommand.RemoveFromQueue -> {
val queue = audioPlayerQueue.queueFlow.value audioPlayerQueue.removeFromQueueByMediaUrl(command.mediaUrl)
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) sendAck(session, envelope.commandId)
broadcastState(session) broadcastState(session)
broadcastQueue(session)
} }
private suspend fun sendAck(session: WebSocketServerSession, commandId: String) { private suspend fun sendAck(session: WebSocketServerSession, commandId: String) {
val text = json.encodeToString(RemoteControlEvent.serializer(), RemoteControlEvent.Ack(commandId)) val text = json.encodeToString(RemoteControlEvent.Ack.serializer(), RemoteControlEvent.Ack(commandId))
session.send(Frame.Text(text)) session.send(Frame.Text(text))
} }
private suspend fun sendError(session: WebSocketServerSession, message: String) { private suspend fun sendError(session: WebSocketServerSession, message: String) {
val text = json.encodeToString(RemoteControlEvent.serializer(), RemoteControlEvent.Error(message)) val text = json.encodeToString(RemoteControlEvent.Error.serializer(), RemoteControlEvent.Error(message))
session.send(Frame.Text(text)) 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) { private suspend fun broadcastState(session: WebSocketServerSession) {
val current = audioPlayerQueue.currentQueueEntryFlow.value val current = audioPlayerQueue.currentQueueEntryFlow.value
val state = RemoteControlEvent.PlayerState( val state = RemoteControlEvent.PlayerState(
@ -357,62 +221,10 @@ class RemoteControlHandler(
currentTrackAlbum = current?.albumOrNull(), currentTrackAlbum = current?.albumOrNull(),
currentTrackCoverUrl = current?.coverUrlOrNull(), currentTrackCoverUrl = current?.coverUrlOrNull(),
) )
val text = json.encodeToString(RemoteControlEvent.serializer(), state) val text = json.encodeToString(RemoteControlEvent.PlayerState.serializer(), state)
session.send(Frame.Text(text)) 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) { private fun QueueEntry.mediaKey(): String = when (this) {
is QueueEntry.StreamingTrack -> track.id is QueueEntry.StreamingTrack -> track.id
is QueueEntry.LocalTrack -> url is QueueEntry.LocalTrack -> url
@ -434,8 +246,7 @@ class RemoteControlHandler(
} }
private fun QueueEntry.coverUrlOrNull(): String? = when (this) { private fun QueueEntry.coverUrlOrNull(): String? = when (this) {
is QueueEntry.StreamingTrack -> track.thumbnails?.maxByOrNull { it.width * it.height }?.url is QueueEntry.StreamingTrack -> track.thumbnails?.firstOrNull()?.url
?: track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url
is QueueEntry.LocalTrack -> null is QueueEntry.LocalTrack -> null
} }
} }
@ -446,17 +257,6 @@ data class CommandEnvelope(
val command: RemoteControlCommand, 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( data class ConnectionRequest(
val deviceId: String, val deviceId: String,
val deviceName: String, val deviceName: String,

View File

@ -17,7 +17,6 @@
package dev.krtirtho.spotube.core.remote package dev.krtirtho.spotube.core.remote
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@ -63,34 +62,6 @@ sealed class RemoteControlCommand {
@SerialName("addToQueue") @SerialName("addToQueue")
data class AddToQueue(val source: String) : RemoteControlCommand() 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 @Serializable
@SerialName("removeFromQueue") @SerialName("removeFromQueue")
data class RemoveFromQueue(val mediaUrl: String) : RemoteControlCommand() data class RemoveFromQueue(val mediaUrl: String) : RemoteControlCommand()

View File

@ -18,307 +18,100 @@
package dev.krtirtho.spotube.core.remote package dev.krtirtho.spotube.core.remote
import co.touchlab.kermit.Logger 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.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent 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,
}
/** /**
* A playback request awaiting a destination choice (local device vs a connected * Manages the play destination picker state and remote playback commands.
* remote device). [title] is the content label shown in the picker dialog. * Injected into ViewModels to handle playback actions when a remote device is connected.
*/ */
sealed interface PlaybackDestinationRequest { class RemotePlaybackController : KoinComponent {
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 logger = Logger.withTag("RemotePlaybackController")
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val remoteControlClient: RemoteControlClient by inject()
private val _pendingRequest = MutableStateFlow<PlaybackDestinationRequest?>(null) private val _showPicker = MutableStateFlow(false)
val pendingRequest: StateFlow<PlaybackDestinationRequest?> = _pendingRequest.asStateFlow() val showPicker: StateFlow<Boolean> = _showPicker.asStateFlow()
private var pendingAction: (() -> Unit)? = null
/**
* Checks if a remote device is connected.
*/
fun isRemoteConnected(): Boolean { fun isRemoteConnected(): Boolean {
return remoteControlClient.connectionState.value is ConnectionState.Connected return remoteControlClient.connectionState.value is ConnectionState.Connected
} }
// ---------- Collection actions ---------- /**
* Wraps a playback action. If a remote device is connected, shows the picker.
fun requestCollectionPlay( * Otherwise, executes the action immediately.
type: RemoteCollectionType, *
id: String, * @param action The action to execute if playing locally
title: String, */
startTrack: MetadataTrack? = null, fun wrapPlaybackAction(action: () -> Unit) {
) {
request(PlaybackDestinationRequest.Collection(title, PlaybackDestinationAction.Play, type, id, startTrack))
}
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() {
val request = _pendingRequest.value ?: return
_pendingRequest.value = null
executeLocally(request)
}
fun playOnRemote() {
val request = _pendingRequest.value ?: return
_pendingRequest.value = null
executeOnRemote(request)
}
fun dismissPicker() {
_pendingRequest.value = null
}
// ---------- Internals ----------
private fun request(request: PlaybackDestinationRequest) {
if (isRemoteConnected()) { if (isRemoteConnected()) {
_pendingRequest.value = request pendingAction = action
_showPicker.value = true
} else { } else {
executeLocally(request) action()
} }
} }
private fun executeLocally(request: PlaybackDestinationRequest) { /**
scope.launch { * Called when the user chooses to play locally.
when (request) { */
is PlaybackDestinationRequest.Collection -> { fun playLocally() {
val startTrack = request.startTrack _showPicker.value = false
when (request.type) { pendingAction?.invoke()
RemoteCollectionType.Playlist -> when (request.action) { pendingAction = null
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 { * 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 { try {
val command = when (request) { remoteControlClient.sendCommand(RemoteControlCommand.Play(source))
is PlaybackDestinationRequest.Collection -> { logger.i { "Sent play command for source: $source" }
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) { } catch (e: Exception) {
logger.e(e) { "Failed to send remote playback command" } logger.e(e) { "Failed to send play command" }
} }
} }
} }
private suspend fun isTrackBlacklisted(track: MetadataTrack): Boolean { /**
val trackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() * Called when the user dismisses the picker.
val artistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() */
return track.id in trackIds || track.artists.any { it.id in artistIds } fun dismissPicker() {
_showPicker.value = false
pendingAction = null
} }
private fun MetadataTrack.matchesTrack(other: MetadataTrack): Boolean { /**
if (id.isNotBlank() && other.id.isNotBlank()) return id == other.id * Sends an add-to-queue command to the remote device.
return title == other.title && *
durationMs == other.durationMs && * @param source The source identifier (e.g., playlist ID, album ID, track ID)
album?.id == other.album?.id && */
artists.map { it.id.ifBlank { it.name } } == other.artists.map { it.id.ifBlank { it.name } } 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" }
}
}
} }
} }

View File

@ -20,6 +20,7 @@ package dev.krtirtho.spotube.core.ui.component
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@ -28,9 +29,8 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper 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 dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
import kotlinx.coroutines.launch
import org.koin.compose.koinInject import org.koin.compose.koinInject
@Composable @Composable
@ -39,9 +39,9 @@ fun AlbumCard(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
audioPlayerQueue: AudioPlayerQueue = koinInject(), audioPlayerQueue: AudioPlayerQueue = koinInject(),
playbackHelper: CollectionPlaybackHelper = koinInject(), playbackHelper: CollectionPlaybackHelper = koinInject(),
navigationCommands: NavigationCommands = koinInject(), navigationCommands: NavigationCommands = koinInject()
remotePlaybackController: RemotePlaybackController = koinInject(),
) { ) {
val scope = rememberCoroutineScope()
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
PlayableCard( PlayableCard(
@ -54,18 +54,10 @@ fun AlbumCard(
}, },
onPlay = { onPlay = {
if (currentCollectionEntry?.id == album.id) return@PlayableCard if (currentCollectionEntry?.id == album.id) return@PlayableCard
remotePlaybackController.requestCollectionPlay( scope.launch { playbackHelper.playAlbum(album.id) }
RemoteCollectionType.Album,
album.id,
album.title,
)
}, },
onAddToQueue = { onAddToQueue = {
remotePlaybackController.requestCollectionAddToQueue( scope.launch { playbackHelper.addAlbumToQueue(album.id) }
RemoteCollectionType.Album,
album.id,
album.title,
)
}, },
modifier = modifier.width(160.dp), modifier = modifier.width(160.dp),
sharedElementKey = "album_art_${album.id}", sharedElementKey = "album_art_${album.id}",

View File

@ -27,8 +27,6 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper 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 dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.compose.koinInject import org.koin.compose.koinInject
@ -39,8 +37,7 @@ fun PlaylistCard(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
audioPlayerQueue: AudioPlayerQueue = koinInject(), audioPlayerQueue: AudioPlayerQueue = koinInject(),
playbackHelper: CollectionPlaybackHelper = koinInject(), playbackHelper: CollectionPlaybackHelper = koinInject(),
navigationCommands: NavigationCommands = koinInject(), navigationCommands: NavigationCommands = koinInject()
remotePlaybackController: RemotePlaybackController = koinInject(),
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
@ -55,18 +52,10 @@ fun PlaylistCard(
}, },
onPlay = { onPlay = {
if (audioPlayerQueue.isPlaylistPlaying(playlist.id)) return@PlayableCard if (audioPlayerQueue.isPlaylistPlaying(playlist.id)) return@PlayableCard
remotePlaybackController.requestCollectionPlay( scope.launch { playbackHelper.playPlaylist(playlist.id) }
RemoteCollectionType.Playlist,
playlist.id,
playlist.title,
)
}, },
onAddToQueue = { onAddToQueue = {
remotePlaybackController.requestCollectionAddToQueue( scope.launch { playbackHelper.addPlaylistToQueue(playlist.id) }
RemoteCollectionType.Playlist,
playlist.id,
playlist.title,
)
}, },
modifier = modifier, modifier = modifier,
sharedElementKey = "playlist_art_${playlist.id}", sharedElementKey = "playlist_art_${playlist.id}",

View File

@ -26,8 +26,6 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper 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.share.ShareService
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext
@ -99,7 +97,6 @@ class AlbumViewModel(
private val blacklistRepository: BlacklistRepository, private val blacklistRepository: BlacklistRepository,
private val shareService: ShareService, private val shareService: ShareService,
private val downloadManager: DownloadManager, private val downloadManager: DownloadManager,
private val remotePlaybackController: RemotePlaybackController,
) : ViewModel(), KoinComponent { ) : ViewModel(), KoinComponent {
private val logger by injectLogger<AlbumViewModel>() private val logger by injectLogger<AlbumViewModel>()
@ -220,28 +217,15 @@ class AlbumViewModel(
} }
fun playAlbum() { fun playAlbum() {
val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album" viewModelScope.launch { playbackHelper.playAlbum(albumId) }
remotePlaybackController.requestCollectionPlay(RemoteCollectionType.Album, albumId, title)
} }
fun addAlbumToQueue() { fun addAlbumToQueue() {
val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album" viewModelScope.launch { playbackHelper.addAlbumToQueue(albumId) }
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) { fun playAlbumFromTrack(track: MetadataTrack) {
val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album" viewModelScope.launch { playbackHelper.playAlbumFromTrack(albumId, track) }
remotePlaybackController.requestCollectionPlay(
type = RemoteCollectionType.Album,
id = albumId,
title = title,
startTrack = track,
)
} }
fun refresh() { fun refresh() {
@ -257,20 +241,14 @@ class AlbumViewModel(
is TrackOptionsAction.StartRadio -> {} is TrackOptionsAction.StartRadio -> {}
is TrackOptionsAction.PlayNext -> { is TrackOptionsAction.PlayNext -> {
val queue = audioPlayerQueue.getQueue() val queue = audioPlayerQueue.getQueue()
val existing = queue.find { entry -> queue.find { entry ->
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
} }?.let { audioPlayerQueue.removeFromQueue(it) }
if (existing != null) { audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
// 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 -> { is TrackOptionsAction.AddToQueue -> {
remotePlaybackController.requestTrackAddToQueue(track) audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = ""))
} }
is TrackOptionsAction.RemoveFromQueue -> { is TrackOptionsAction.RemoveFromQueue -> {
@ -334,13 +312,33 @@ class AlbumViewModel(
} }
fun addTracksToQueue(tracks: List<MetadataTrack>) { fun addTracksToQueue(tracks: List<MetadataTrack>) {
val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album" viewModelScope.launch {
remotePlaybackController.requestTracksAddToQueue(tracks, title) 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)
}
} }
fun playTracksNext(tracks: List<MetadataTrack>) { fun playTracksNext(tracks: List<MetadataTrack>) {
val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album" viewModelScope.launch {
remotePlaybackController.requestTracksPlayNext(tracks, title) 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)
}
} }
fun isTrackBlacklisted(track: MetadataTrack): Boolean { fun isTrackBlacklisted(track: MetadataTrack): Boolean {

View File

@ -27,8 +27,6 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.di.injectLogger 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.share.ShareService
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext
@ -75,7 +73,6 @@ class ArtistViewModel(
private val blacklistRepository: BlacklistRepository, private val blacklistRepository: BlacklistRepository,
private val shareService: ShareService, private val shareService: ShareService,
private val downloadManager: DownloadManager, private val downloadManager: DownloadManager,
private val remotePlaybackController: RemotePlaybackController,
) : ViewModel(), KoinComponent { ) : ViewModel(), KoinComponent {
private val logger by injectLogger<ArtistViewModel>() private val logger by injectLogger<ArtistViewModel>()
@ -250,40 +247,80 @@ class ArtistViewModel(
} }
fun addTopTracksToQueue() { fun addTopTracksToQueue() {
val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist" viewModelScope.launch {
remotePlaybackController.requestCollectionAddToQueue( val entries = resolveTopTrackEntries()
RemoteCollectionType.ArtistTopTracks, if (entries.isEmpty()) return@launch
artistId, audioPlayerQueue.addAllToQueue(entries)
artistName, }
)
} }
fun playTopTracks() { fun playTopTracks() {
val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist" viewModelScope.launch {
remotePlaybackController.requestCollectionPlay( val entries = resolveTopTrackEntries()
RemoteCollectionType.ArtistTopTracks, if (entries.isEmpty()) return@launch
artistId, audioPlayerQueue.load(
artistName, entries = entries,
) autoPlay = true,
startPosition = 0,
collectionEntry = null,
)
}
} }
fun playTopTracksFromTrack(track: MetadataTrack) { fun playTopTracksFromTrack(track: MetadataTrack) {
val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist" viewModelScope.launch {
remotePlaybackController.requestCollectionPlay( val queue = audioPlayerQueue.getQueue()
type = RemoteCollectionType.ArtistTopTracks, val queueIndex = queue.indexOfFirst { entry ->
id = artistId, (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
title = artistName, }
startTrack = track, 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,
)
}
} }
fun addTracksToQueue(tracks: List<MetadataTrack>) { fun addTracksToQueue(tracks: List<MetadataTrack>) {
val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist" viewModelScope.launch {
remotePlaybackController.requestTracksAddToQueue(tracks, artistName) 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)
}
} }
fun playTracksNext(tracks: List<MetadataTrack>) { fun playTracksNext(tracks: List<MetadataTrack>) {
val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist" viewModelScope.launch {
remotePlaybackController.requestTracksPlayNext(tracks, artistName) 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)
}
} }
fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) { fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) {
@ -292,19 +329,13 @@ class ArtistViewModel(
is TrackOptionsAction.StartRadio -> {} is TrackOptionsAction.StartRadio -> {}
is TrackOptionsAction.PlayNext -> { is TrackOptionsAction.PlayNext -> {
val queue = audioPlayerQueue.getQueue() val queue = audioPlayerQueue.getQueue()
val existing = queue.find { entry -> queue.find { entry ->
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
} }?.let { audioPlayerQueue.removeFromQueue(it) }
if (existing != null) { audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
// 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 -> { is TrackOptionsAction.AddToQueue -> {
remotePlaybackController.requestTrackAddToQueue(track) audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = ""))
} }
is TrackOptionsAction.RemoveFromQueue -> { is TrackOptionsAction.RemoveFromQueue -> {
val queue = audioPlayerQueue.getQueue() val queue = audioPlayerQueue.getQueue()

View File

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

View File

@ -17,10 +17,8 @@
package dev.krtirtho.spotube.modules.devices package dev.krtirtho.spotube.modules.devices
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@ -28,49 +26,38 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.krtirtho.spotube.core.remote.ConnectionState 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.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.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 import org.koin.compose.koinInject
/** /**
* Globally hosted dialog shown when a remote device is connected and the user * Dialog shown when a remote device is connected and the user tries to play/add to queue.
* tries to play / add to queue / play next. Lets the user choose between the * Allows the user to choose between playing on the local device or the remote device.
* local device and the connected remote device(s).
*/ */
@Composable @Composable
fun PlayDestinationPickerHost() { fun PlayDestinationPicker(
val controller = koinInject<RemotePlaybackController>() visible: Boolean,
onDismiss: () -> Unit,
onPlayLocally: () -> Unit,
onPlayOnRemote: () -> Unit,
) {
val remoteControlClient = koinInject<RemoteControlClient>() val remoteControlClient = koinInject<RemoteControlClient>()
val request by controller.pendingRequest.collectAsStateWithLifecycle()
val connectionState by remoteControlClient.connectionState.collectAsStateWithLifecycle() val connectionState by remoteControlClient.connectionState.collectAsStateWithLifecycle()
val pendingRequest = request ?: return if (!visible) return
val remoteDeviceName = when (val state = connectionState) { val remoteDeviceName = when (val state = connectionState) {
is ConnectionState.Connected -> "Remote Device (${state.host})" is ConnectionState.Connected -> "Remote Device (${state.host})"
else -> "Remote Device" else -> "Remote Device"
} }
val actionLabel = when (pendingRequest.action) {
PlaybackDestinationAction.Play -> "Play"
PlaybackDestinationAction.AddToQueue -> "Add to queue"
PlaybackDestinationAction.PlayNext -> "Play next"
}
ThemedDialog( ThemedDialog(
onDismissRequest = controller::dismissPicker, onDismissRequest = onDismiss,
title = { title = {
Text( Text(
text = "Where to $actionLabel?", text = "Play Where?",
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
@ -78,69 +65,24 @@ fun PlayDestinationPickerHost() {
content = { content = {
Column( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Text( Text(
text = "$actionLabel \"${pendingRequest.title}\" on:", text = "Choose where to play this content:",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, 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 = { actions = {
TextButton(onClick = controller::dismissPicker) { TextButton(onClick = onDismiss) {
Text("Cancel") Text("Cancel")
} }
TextButton(onClick = onPlayLocally) {
Text("This Device")
}
TextButton(onClick = onPlayOnRemote) {
Text(remoteDeviceName)
}
}, },
) )
} }

View File

@ -18,30 +18,21 @@
package dev.krtirtho.spotube.modules.devices package dev.krtirtho.spotube.modules.devices
import androidx.compose.foundation.background 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -49,12 +40,11 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@ -63,22 +53,12 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import dev.krtirtho.spotube.core.remote.ConnectionState 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.GhostIconButton
import dev.krtirtho.spotube.core.ui.base.IconButton 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.base.Slider
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar 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.Iconsax
import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore
import dev.krtirtho.spotube.resources.iconsax.IconsaxCloseSquare 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.IconsaxNext
import dev.krtirtho.spotube.resources.iconsax.IconsaxPause import dev.krtirtho.spotube.resources.iconsax.IconsaxPause
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay
@ -97,134 +77,73 @@ fun RemoteControlScreen(
val viewModel = koinViewModel<RemoteControlViewModel>() val viewModel = koinViewModel<RemoteControlViewModel>()
val playerState by viewModel.playerState.collectAsStateWithLifecycle() val playerState by viewModel.playerState.collectAsStateWithLifecycle()
val connectionState by viewModel.connectionState.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 Scaffold(
// stretch awkwardly on large screens. topBar = {
Box( ApplicationMainBar(
modifier = Modifier.fillMaxSize(), title = { Text("Remote Control") },
contentAlignment = Alignment.TopCenter, backButton = true,
) { actions = {
Scaffold( GhostIconButton(
modifier = Modifier onClick = {
.fillMaxHeight(), viewModel.disconnect()
topBar = { onDisconnect()
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",
)
}
}
)
}
) { 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...") Icon(
imageVector = Iconsax.IconsaxCloseSquare,
contentDescription = "Disconnect",
)
} }
} }
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}")
}
}
}
}
}
// 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,
) )
} }
) { 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)
)
}
is ConnectionState.Connecting -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
Text("Connecting...")
}
}
is ConnectionState.Disconnected -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
Text("Disconnected")
}
}
is ConnectionState.Error -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
Text("Connection error: ${(connectionState as ConnectionState.Error).message}")
}
}
}
} }
} }
@ -240,375 +159,212 @@ private fun RemoteControlContent(
onCycleLoopMode: () -> Unit, onCycleLoopMode: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Box (modifier = Modifier.fillMaxSize()) { 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))
) {
AsyncImage(
model = playerState.currentTrackCoverUrl,
contentDescription = "Album cover",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
}
Spacer(modifier = Modifier.height(32.dp))
// Track info
Column( Column(
modifier = modifier modifier = Modifier.fillMaxWidth(),
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp)
.widthIn(max = 480.dp)
.align(Alignment.TopCenter),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Spacer(modifier = Modifier.height(32.dp)) Text(
text = playerState.currentTrackTitle ?: "Unknown Track",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
// Album art Spacer(modifier = Modifier.height(8.dp))
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)) Text(
text = playerState.currentTrackArtists ?: "Unknown Artist",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
// Track info if (playerState.currentTrackAlbum != null) {
Column( Spacer(modifier = Modifier.height(4.dp))
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text( Text(
text = playerState.currentTrackTitle ?: "Unknown Track", text = playerState.currentTrackAlbum!!,
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
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, textAlign = TextAlign.Center,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, 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 // Seek bar
Column( Column(
modifier = Modifier.fillMaxWidth(),
) {
Slider(
value = playerState.positionMs.toFloat(),
onValueChange = { onSeek(it.toLong()) },
valueRange = 0f..playerState.durationMs.toFloat().coerceAtLeast(1f),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) { ) {
Slider( Text(
value = playerState.positionMs.toFloat(), text = formatDuration(playerState.positionMs),
onValueChange = { onSeek(it.toLong()) }, style = MaterialTheme.typography.bodySmall,
valueRange = 0f..playerState.durationMs.toFloat().coerceAtLeast(1f), color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth(), )
Text(
text = formatDuration(playerState.durationMs),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
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)) Spacer(modifier = Modifier.height(24.dp))
// Playback controls // Playback controls
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly, horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
// Shuffle // Shuffle
IconButton( IconButton(
onClick = onToggleShuffle, onClick = onToggleShuffle,
modifier = Modifier.size(48.dp), 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( Icon(
imageVector = Iconsax.IconsaxVolumeHigh, imageVector = Iconsax.IconsaxShuffle,
contentDescription = "Volume", contentDescription = "Shuffle",
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = if (playerState.shuffleEnabled) {
modifier = Modifier.size(24.dp), MaterialTheme.colorScheme.primary
) } else {
MaterialTheme.colorScheme.onSurfaceVariant
Slider( },
value = playerState.volume,
onValueChange = onSetVolume,
valueRange = 0f..1f,
modifier = Modifier.weight(1f),
) )
} }
Spacer(modifier = Modifier.height(32.dp)) // Skip previous
} IconButton(
} onClick = onSkipPrevious,
} modifier = Modifier.size(56.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( Icon(
items = queueState.entries, imageVector = Iconsax.IconsaxPrevious,
key = { index, entry -> "${entry.mediaUrl}@$index" }, contentDescription = "Previous",
) { index, entry -> modifier = Modifier.size(32.dp),
RemoteQueueItemRow( )
entry = entry,
index = index,
isCurrent = index == queueState.currentIndex,
onPlayClick = { onPlayQueueItem(index) },
onRemoveClick = { onRemoveQueueItem(entry.mediaUrl) },
)
}
} }
}
}
}
@Composable // Play/Pause
private fun RemoteQueueItemRow( IconButton(
entry: RemoteQueueEntry, onClick = onTogglePlayPause,
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 modifier = Modifier
.size(48.dp) .size(72.dp)
.clip(MaterialTheme.shapes.small) .background(
.background(MaterialTheme.colorScheme.surfaceVariant), color = MaterialTheme.colorScheme.primary,
contentAlignment = Alignment.Center, shape = CircleShape,
),
) { ) {
if (entry.coverUrl != null) { Icon(
AsyncImage( imageVector = if (playerState.isPlaying) {
model = entry.coverUrl, Iconsax.IconsaxPause
contentDescription = null, } else {
modifier = Modifier.fillMaxSize(), Iconsax.IconsaxPlay
contentScale = ContentScale.Crop, },
) contentDescription = if (playerState.isPlaying) "Pause" else "Play",
} else { tint = MaterialTheme.colorScheme.onPrimary,
Text( modifier = Modifier.size(40.dp),
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 { // Skip next
GhostIconButton( IconButton(
onClick = { showMenu = true }, onClick = onSkipNext,
modifier = Modifier.size(36.dp), modifier = Modifier.size(56.dp),
) { ) {
Icon( Icon(
Iconsax.Iconsax3DotsMore, imageVector = Iconsax.IconsaxNext,
contentDescription = "More options", contentDescription = "Next",
modifier = Modifier.size(18.dp), modifier = Modifier.size(32.dp),
) )
} }
DropdownMenu(
expanded = showMenu, // Loop mode
onDismissRequest = { showMenu = false }, IconButton(
) { onClick = onCycleLoopMode,
DropdownMenuItem( modifier = Modifier.size(48.dp),
text = { Text("Remove from queue") }, ) {
onClick = { Icon(
onRemoveClick() imageVector = Iconsax.IconsaxRepeateMusic,
showMenu = false contentDescription = "Loop mode",
}, tint = if (playerState.loopMode != "none") {
leadingIcon = { MaterialTheme.colorScheme.primary
Icon(Iconsax.IconsaxMusicSquareRemove, contentDescription = null) } 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))
}
} }
private fun formatDuration(ms: Long): String { 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.RemoteControlClient
import dev.krtirtho.spotube.core.remote.RemoteControlCommand import dev.krtirtho.spotube.core.remote.RemoteControlCommand
import dev.krtirtho.spotube.core.remote.RemoteControlEvent import dev.krtirtho.spotube.core.remote.RemoteControlEvent
import dev.krtirtho.spotube.core.remote.RemoteQueueEntry import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@ -47,11 +47,6 @@ data class RemotePlayerState(
val currentTrackCoverUrl: String? = null, val currentTrackCoverUrl: String? = null,
) )
data class RemoteQueueState(
val entries: List<RemoteQueueEntry> = emptyList(),
val currentIndex: Int = -1,
)
class RemoteControlViewModel : ViewModel(), KoinComponent { class RemoteControlViewModel : ViewModel(), KoinComponent {
private val logger = Logger.withTag("RemoteControlViewModel") private val logger = Logger.withTag("RemoteControlViewModel")
private val remoteControlClient: RemoteControlClient by inject() private val remoteControlClient: RemoteControlClient by inject()
@ -62,11 +57,7 @@ class RemoteControlViewModel : ViewModel(), KoinComponent {
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected) private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow() val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
private val _queueState = MutableStateFlow(RemoteQueueState()) private var stateUpdateJob: Job? = null
val queueState: StateFlow<RemoteQueueState> = _queueState.asStateFlow()
private val _isQueueVisible = MutableStateFlow(false)
val isQueueVisible: StateFlow<Boolean> = _isQueueVisible.asStateFlow()
init { init {
viewModelScope.launch { viewModelScope.launch {
@ -76,47 +67,52 @@ class RemoteControlViewModel : ViewModel(), KoinComponent {
} }
viewModelScope.launch { viewModelScope.launch {
remoteControlClient.latestPlayerState.collect { event -> remoteControlClient.stateUpdates.collect { event ->
if (event != null) { handleStateUpdate(event)
handlePlayerState(event)
}
}
}
viewModelScope.launch {
remoteControlClient.latestQueue.collect { event ->
if (event != null) {
handleQueueUpdated(event)
}
} }
} }
} }
private fun handlePlayerState(event: RemoteControlEvent.PlayerState) { private fun handleStateUpdate(event: RemoteControlEvent) {
_playerState.update { when (event) {
it.copy( is RemoteControlEvent.Connected -> {
isPlaying = event.isPlaying, // Connection already handled in RemoteControlClient
positionMs = event.positionMs, logger.d { "Connection confirmed" }
durationMs = event.durationMs, }
volume = event.volume, is RemoteControlEvent.WaitingForPermission -> {
shuffleEnabled = event.shuffleEnabled, // Waiting for permission - no action needed
loopMode = event.loopMode, logger.d { "Waiting for permission: ${event.message}" }
currentTrackId = event.currentTrackId, }
currentTrackTitle = event.currentTrackTitle, is RemoteControlEvent.PlayerState -> {
currentTrackArtists = event.currentTrackArtists, _playerState.update {
currentTrackAlbum = event.currentTrackAlbum, it.copy(
currentTrackCoverUrl = event.currentTrackCoverUrl, isPlaying = event.isPlaying,
) positionMs = event.positionMs,
durationMs = event.durationMs,
volume = event.volume,
shuffleEnabled = event.shuffleEnabled,
loopMode = event.loopMode,
currentTrackId = event.currentTrackId,
currentTrackTitle = event.currentTrackTitle,
currentTrackArtists = event.currentTrackArtists,
currentTrackAlbum = event.currentTrackAlbum,
currentTrackCoverUrl = event.currentTrackCoverUrl,
)
}
}
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() { fun togglePlayPause() {
viewModelScope.launch { viewModelScope.launch {
remoteControlClient.sendCommand(RemoteControlCommand.TogglePlayPause) remoteControlClient.sendCommand(RemoteControlCommand.TogglePlayPause)
@ -166,23 +162,6 @@ 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() { fun disconnect() {
viewModelScope.launch { viewModelScope.launch {
remoteControlClient.disconnect() remoteControlClient.disconnect()

View File

@ -38,6 +38,7 @@ import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.ui.base.OutlineButton import dev.krtirtho.spotube.core.ui.base.OutlineButton
import dev.krtirtho.spotube.core.ui.component.CollectionView 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.AddToPlaylistPicker
import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormData import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormData
import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormSheet import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormSheet
@ -59,6 +60,7 @@ fun PlaylistScreen(
val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle() val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle()
val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle() val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle()
val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.collectAsStateWithLifecycle() val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.collectAsStateWithLifecycle()
val showPlayDestinationPicker by viewModel.showPlayDestinationPicker.collectAsStateWithLifecycle()
var showEditPlaylist by remember { mutableStateOf(false) } var showEditPlaylist by remember { mutableStateOf(false) }
var showAddTracksDialog by remember { mutableStateOf(false) } var showAddTracksDialog by remember { mutableStateOf(false) }
@ -169,6 +171,13 @@ fun PlaylistScreen(
viewModel.refresh() viewModel.refresh()
}, },
) )
PlayDestinationPicker(
visible = showPlayDestinationPicker,
onDismiss = viewModel::dismissPlayPicker,
onPlayLocally = viewModel::playLocally,
onPlayOnRemote = viewModel::playOnRemote,
)
}, },
) )
} }

View File

@ -26,7 +26,6 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper 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.remote.RemotePlaybackController
import dev.krtirtho.spotube.core.share.ShareService import dev.krtirtho.spotube.core.share.ShareService
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
@ -118,6 +117,8 @@ class PlaylistViewModel(
private val _blacklistedArtistIds = MutableStateFlow<Set<String>>(emptySet()) private val _blacklistedArtistIds = MutableStateFlow<Set<String>>(emptySet())
val blacklistedArtistIds: StateFlow<Set<String>> = _blacklistedArtistIds.asStateFlow() val blacklistedArtistIds: StateFlow<Set<String>> = _blacklistedArtistIds.asStateFlow()
val showPlayDestinationPicker = remotePlaybackController.showPicker
private val _tracksToAddToPlaylist = MutableStateFlow<List<MetadataTrack>>(emptyList()) private val _tracksToAddToPlaylist = MutableStateFlow<List<MetadataTrack>>(emptyList())
private val _showAddToPlaylistPicker = MutableStateFlow(false) private val _showAddToPlaylistPicker = MutableStateFlow(false)
val showAddToPlaylistPicker: StateFlow<Boolean> = _showAddToPlaylistPicker.asStateFlow() val showAddToPlaylistPicker: StateFlow<Boolean> = _showAddToPlaylistPicker.asStateFlow()
@ -226,28 +227,35 @@ class PlaylistViewModel(
} }
fun playPlaylist() { fun playPlaylist() {
val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist" remotePlaybackController.wrapPlaybackAction {
remotePlaybackController.requestCollectionPlay(RemoteCollectionType.Playlist, playlistId, title) viewModelScope.launch { playbackHelper.playPlaylist(playlistId) }
}
} }
fun addPlaylistToQueue() { fun addPlaylistToQueue() {
val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist" if (remotePlaybackController.isRemoteConnected()) {
remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.Playlist, playlistId, title) remotePlaybackController.addToQueueOnRemote(playlistId)
} } else {
viewModelScope.launch { playbackHelper.addPlaylistToQueue(playlistId) }
fun playPlaylistNext() { }
val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist"
remotePlaybackController.requestCollectionPlayNext(RemoteCollectionType.Playlist, playlistId, title)
} }
fun playPlaylistFromTrack(track: MetadataTrack) { fun playPlaylistFromTrack(track: MetadataTrack) {
val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist" remotePlaybackController.wrapPlaybackAction {
remotePlaybackController.requestCollectionPlay( viewModelScope.launch { playbackHelper.playPlaylistFromTrack(playlistId, track) }
type = RemoteCollectionType.Playlist, }
id = playlistId, }
title = title,
startTrack = track, fun playLocally() {
) remotePlaybackController.playLocally()
}
fun playOnRemote() {
remotePlaybackController.playOnRemote(playlistId)
}
fun dismissPlayPicker() {
remotePlaybackController.dismissPicker()
} }
fun refresh() { fun refresh() {
@ -290,20 +298,14 @@ class PlaylistViewModel(
is TrackOptionsAction.StartRadio -> {} is TrackOptionsAction.StartRadio -> {}
is TrackOptionsAction.PlayNext -> { is TrackOptionsAction.PlayNext -> {
val queue = audioPlayerQueue.getQueue() val queue = audioPlayerQueue.getQueue()
val existing = queue.find { entry -> queue.find { entry ->
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
} }?.let { audioPlayerQueue.removeFromQueue(it) }
if (existing != null) { audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
// 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 -> { is TrackOptionsAction.AddToQueue -> {
remotePlaybackController.requestTrackAddToQueue(track) audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = ""))
} }
is TrackOptionsAction.RemoveFromQueue -> { is TrackOptionsAction.RemoveFromQueue -> {
@ -379,13 +381,33 @@ class PlaylistViewModel(
} }
fun addTracksToQueue(tracks: List<MetadataTrack>) { fun addTracksToQueue(tracks: List<MetadataTrack>) {
val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist" viewModelScope.launch {
remotePlaybackController.requestTracksAddToQueue(tracks, title) 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)
}
} }
fun playTracksNext(tracks: List<MetadataTrack>) { fun playTracksNext(tracks: List<MetadataTrack>) {
val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist" viewModelScope.launch {
remotePlaybackController.requestTracksPlayNext(tracks, title) 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 savedTrackIds val savedTrackIds

View File

@ -34,8 +34,6 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper 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.share.ShareService
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext
@ -99,7 +97,6 @@ class SavedTracksViewModel(
private val libraryRepository: LibraryRepository, private val libraryRepository: LibraryRepository,
private val shareService: ShareService, private val shareService: ShareService,
private val downloadManager: DownloadManager, private val downloadManager: DownloadManager,
private val remotePlaybackController: RemotePlaybackController,
) : ViewModel(), KoinComponent { ) : ViewModel(), KoinComponent {
private val logger by injectLogger<SavedTracksViewModel>() private val logger by injectLogger<SavedTracksViewModel>()
@ -203,20 +200,15 @@ class SavedTracksViewModel(
} }
fun playSavedTracks() { fun playSavedTracks() {
remotePlaybackController.requestCollectionPlay(RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, "Saved Tracks") viewModelScope.launch { playbackHelper.playSavedTracks() }
} }
fun addSavedTracksToQueue() { fun addSavedTracksToQueue() {
remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, "Saved Tracks") viewModelScope.launch { playbackHelper.addSavedTracksToQueue() }
} }
fun playSavedTracksFromTrack(track: MetadataTrack) { fun playSavedTracksFromTrack(track: MetadataTrack) {
remotePlaybackController.requestCollectionPlay( viewModelScope.launch { playbackHelper.playSavedTracksFromTrack(track) }
type = RemoteCollectionType.SavedTracks,
id = SAVED_TRACKS_COLLECTION_ID,
title = "Saved Tracks",
startTrack = track,
)
} }
fun refresh() { fun refresh() {
@ -232,20 +224,14 @@ class SavedTracksViewModel(
is TrackOptionsAction.StartRadio -> {} is TrackOptionsAction.StartRadio -> {}
is TrackOptionsAction.PlayNext -> { is TrackOptionsAction.PlayNext -> {
val queue = audioPlayerQueue.getQueue() val queue = audioPlayerQueue.getQueue()
val existing = queue.find { entry -> queue.find { entry ->
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
} }?.let { audioPlayerQueue.removeFromQueue(it) }
if (existing != null) { audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
// 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 -> { is TrackOptionsAction.AddToQueue -> {
remotePlaybackController.requestTrackAddToQueue(track) audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = ""))
} }
is TrackOptionsAction.RemoveFromQueue -> { is TrackOptionsAction.RemoveFromQueue -> {
@ -313,11 +299,33 @@ class SavedTracksViewModel(
} }
fun addTracksToQueue(tracks: List<MetadataTrack>) { fun addTracksToQueue(tracks: List<MetadataTrack>) {
remotePlaybackController.requestTracksAddToQueue(tracks, "Saved Tracks") 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)
}
} }
fun playTracksNext(tracks: List<MetadataTrack>) { fun playTracksNext(tracks: List<MetadataTrack>) {
remotePlaybackController.requestTracksPlayNext(tracks, "Saved Tracks") 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)
}
} }
suspend fun isSavedTracks(trackIds: List<String>): List<Boolean> { suspend fun isSavedTracks(trackIds: List<String>): List<Boolean> {

View File

@ -86,7 +86,6 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes 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.share.ShareService
import dev.krtirtho.spotube.core.ui.base.AutocompleteTextField import dev.krtirtho.spotube.core.ui.base.AutocompleteTextField
import dev.krtirtho.spotube.core.ui.base.ChipTab import dev.krtirtho.spotube.core.ui.base.ChipTab
@ -122,7 +121,6 @@ private val GridMinCellSize = 180.dp
fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
val audioPlayerQueue: AudioPlayerQueue = koinInject() val audioPlayerQueue: AudioPlayerQueue = koinInject()
val shareService: ShareService = koinInject() val shareService: ShareService = koinInject()
val remotePlaybackController: RemotePlaybackController = koinInject()
val downloadsViewModel: DownloadsViewModel = koinViewModel() val downloadsViewModel: DownloadsViewModel = koinViewModel()
val libraryRepository: LibraryRepository = koinInject() val libraryRepository: LibraryRepository = koinInject()
val blacklistRepository: BlacklistRepository = koinInject() val blacklistRepository: BlacklistRepository = koinInject()
@ -173,20 +171,14 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
is TrackOptionsAction.StartRadio -> {} is TrackOptionsAction.StartRadio -> {}
is TrackOptionsAction.PlayNext -> { is TrackOptionsAction.PlayNext -> {
val queue = audioPlayerQueue.getQueue() val queue = audioPlayerQueue.getQueue()
val existing = queue.find { entry -> queue.find { entry ->
(entry as? QueueEntry.StreamingTrack)?.track?.id == track.id (entry as? QueueEntry.StreamingTrack)?.track?.id == track.id
} }?.let { audioPlayerQueue.removeFromQueue(it) }
if (existing != null) { audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
// 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 -> { is TrackOptionsAction.AddToQueue -> {
remotePlaybackController.requestTrackAddToQueue(track) audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = ""))
} }
is TrackOptionsAction.RemoveFromQueue -> { is TrackOptionsAction.RemoveFromQueue -> {
@ -245,11 +237,33 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
} }
fun bulkAddToQueue(tracks: List<MetadataTrack>) { fun bulkAddToQueue(tracks: List<MetadataTrack>) {
remotePlaybackController.requestTracksAddToQueue(tracks, "Search results") 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)
}
} }
fun bulkPlayNext(tracks: List<MetadataTrack>) { fun bulkPlayNext(tracks: List<MetadataTrack>) {
remotePlaybackController.requestTracksPlayNext(tracks, "Search results") 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)
}
} }
Scaffold( Scaffold(

View File

@ -70,7 +70,6 @@ import dev.krtirtho.spotube.core.navigation.NavigationState
import dev.krtirtho.spotube.core.navigation.Navigator import dev.krtirtho.spotube.core.navigation.Navigator
import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.remote.ConnectionRequestDialogHost 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.lyrics.LyricsScreen
import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContent import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContent
import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContentViewModel import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContentViewModel
@ -119,7 +118,6 @@ fun AppShell(
} }
ConnectionRequestDialogHost() ConnectionRequestDialogHost()
PlayDestinationPickerHost()
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
val useSidebar = viewModel.useSidebar() val useSidebar = viewModel.useSidebar()