diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlClient.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlClient.kt index b88fca90..8f2adeea 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlClient.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlClient.kt @@ -33,11 +33,8 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.serialization.json.Json @@ -69,8 +66,11 @@ class RemoteControlClient { private val _connectionState = MutableStateFlow(ConnectionState.Disconnected) val connectionState: StateFlow = _connectionState.asStateFlow() - private val _stateUpdates = MutableSharedFlow(extraBufferCapacity = 32) - val stateUpdates: SharedFlow = _stateUpdates.asSharedFlow() + private val _latestPlayerState = MutableStateFlow(null) + val latestPlayerState: StateFlow = _latestPlayerState.asStateFlow() + + private val _latestQueue = MutableStateFlow(null) + val latestQueue: StateFlow = _latestQueue.asStateFlow() suspend fun connect(host: String, port: Int, deviceId: String, deviceName: String) { if (_connectionState.value is ConnectionState.Connected) { @@ -121,9 +121,17 @@ class RemoteControlClient { // Keep showing connecting state } else -> { - // Only emit state updates after connection is established + // Only store state updates after connection is established if (_connectionState.value is ConnectionState.Connected) { - _stateUpdates.emit(event) + when (event) { + is RemoteControlEvent.PlayerState -> { + _latestPlayerState.value = event + } + is RemoteControlEvent.QueueUpdated -> { + _latestQueue.value = event + } + else -> {} + } } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt index 3c709a95..89be1235 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt @@ -30,8 +30,14 @@ import io.ktor.websocket.Frame import io.ktor.websocket.close import io.ktor.websocket.readText import kotlin.coroutines.resume +import kotlin.time.TimeSource +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json @@ -104,9 +110,20 @@ class RemoteControlHandler( // Broadcast initial player state so the controller shows current track info broadcastState(session) + broadcastQueue(session) try { - handleControlLoop(session) + // Periodically push player state so the controller's progress bar + // stays in sync even when no commands are being sent. + coroutineScope { + launch { + while (isActive) { + delay(1_000) + broadcastState(session) + } + } + handleControlLoop(session) + } } catch (e: Exception) { logger.w(e) { "Error in remote control session" } } finally { @@ -159,10 +176,13 @@ class RemoteControlHandler( audioPlayer.pause() } is RemoteControlCommand.TogglePlayPause -> { - if (audioPlayer.playerStateFlow.value == AudioPlayerState.PLAYING) { + val isPlaying = audioPlayer.playerStateFlow.value == AudioPlayerState.PLAYING + if (isPlaying) { audioPlayer.pause() + waitForPlaybackState(expectPlaying = false) } else { audioPlayer.play() + waitForPlaybackState(expectPlaying = true) } } is RemoteControlCommand.Seek -> { @@ -195,12 +215,27 @@ class RemoteControlHandler( is RemoteControlCommand.AddToQueue -> { logger.d { "Remote add to queue: ${command.source} (source parsing not yet implemented)" } } + is RemoteControlCommand.PlayIndex -> { + audioPlayerQueue.jumpTo(command.index) + } is RemoteControlCommand.RemoveFromQueue -> { - audioPlayerQueue.removeFromQueueByMediaUrl(command.mediaUrl) + val queue = audioPlayerQueue.queueFlow.value + val entry = queue.firstOrNull { candidate -> + when (candidate) { + is QueueEntry.StreamingTrack -> candidate.track.id == command.mediaUrl + is QueueEntry.LocalTrack -> candidate.url == command.mediaUrl + } + } + if (entry != null) { + audioPlayerQueue.removeFromQueue(entry) + } else { + logger.w { "Remote remove: no matching queue entry for ${command.mediaUrl}" } + } } } sendAck(session, envelope.commandId) broadcastState(session) + broadcastQueue(session) } private suspend fun sendAck(session: WebSocketServerSession, commandId: String) { @@ -213,6 +248,20 @@ class RemoteControlHandler( 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) + } + } + private suspend fun broadcastState(session: WebSocketServerSession) { val current = audioPlayerQueue.currentQueueEntryFlow.value val state = RemoteControlEvent.PlayerState( @@ -232,6 +281,58 @@ class RemoteControlHandler( session.send(Frame.Text(text)) } + private suspend fun broadcastQueue(session: WebSocketServerSession) { + val queue = audioPlayerQueue.queueFlow.value + val current = audioPlayerQueue.currentQueueEntryFlow.value + val currentIndex = if (current != null) { + queue.indexOfFirst { it.matchesCurrent(current) } + } else { + -1 + } + val event = RemoteControlEvent.QueueUpdated( + entries = queue.map { it.toRemoteQueueEntry() }, + currentIndex = currentIndex, + ) + val text = json.encodeToString(RemoteControlEvent.serializer(), event) + session.send(Frame.Text(text)) + } + + private fun QueueEntry.matchesCurrent(current: QueueEntry): Boolean { + return when { + this is QueueEntry.StreamingTrack && current is QueueEntry.StreamingTrack -> { + this.track.id == current.track.id + } + + this is QueueEntry.LocalTrack && current is QueueEntry.LocalTrack -> { + this.url == current.url && this.name == current.name + } + + else -> false + } + } + + private fun QueueEntry.toRemoteQueueEntry(): RemoteQueueEntry = when (this) { + is QueueEntry.StreamingTrack -> RemoteQueueEntry( + mediaUrl = track.id, + trackId = track.id, + title = track.title, + artists = track.artists.joinToString(", ") { artist -> artist.name }, + album = track.album?.title, + coverUrl = coverUrlOrNull(), + durationMs = track.durationMs, + ) + + is QueueEntry.LocalTrack -> RemoteQueueEntry( + mediaUrl = url, + trackId = url, + title = name, + artists = artists.joinToString(", "), + album = album, + coverUrl = null, + durationMs = duration, + ) + } + private fun QueueEntry.mediaKey(): String = when (this) { is QueueEntry.StreamingTrack -> track.id is QueueEntry.LocalTrack -> url @@ -253,7 +354,8 @@ class RemoteControlHandler( } private fun QueueEntry.coverUrlOrNull(): String? = when (this) { - is QueueEntry.StreamingTrack -> track.thumbnails?.firstOrNull()?.url + is QueueEntry.StreamingTrack -> track.thumbnails?.maxByOrNull { it.width * it.height }?.url + ?: track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url is QueueEntry.LocalTrack -> null } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt index 5e63d305..f2739792 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt @@ -62,6 +62,10 @@ sealed class RemoteControlCommand { @SerialName("addToQueue") data class AddToQueue(val source: String) : RemoteControlCommand() + @Serializable + @SerialName("playIndex") + data class PlayIndex(val index: Int) : RemoteControlCommand() + @Serializable @SerialName("removeFromQueue") data class RemoveFromQueue(val mediaUrl: String) : RemoteControlCommand() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt index 5880ee1f..ed03f6f3 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt @@ -21,6 +21,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio @@ -29,10 +30,14 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -40,11 +45,12 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -53,13 +59,22 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import dev.krtirtho.spotube.core.remote.ConnectionState +import dev.krtirtho.spotube.core.remote.RemoteQueueEntry +import dev.krtirtho.spotube.core.ui.base.BaseUITheme import dev.krtirtho.spotube.core.ui.base.GhostIconButton import dev.krtirtho.spotube.core.ui.base.IconButton +import dev.krtirtho.spotube.core.ui.base.ListRowTile +import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme +import dev.krtirtho.spotube.core.ui.base.PrimaryIconButton import dev.krtirtho.spotube.core.ui.base.Slider import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset +import dev.krtirtho.spotube.modules.shell.player_queue.QueueSheet import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore import dev.krtirtho.spotube.resources.iconsax.IconsaxCloseSquare +import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicFilter +import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemove import dev.krtirtho.spotube.resources.iconsax.IconsaxNext import dev.krtirtho.spotube.resources.iconsax.IconsaxPause import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay @@ -78,6 +93,8 @@ fun RemoteControlScreen( val viewModel = koinViewModel() val playerState by viewModel.playerState.collectAsStateWithLifecycle() val connectionState by viewModel.connectionState.collectAsStateWithLifecycle() + val queueState by viewModel.queueState.collectAsStateWithLifecycle() + val isQueueVisible by viewModel.isQueueVisible.collectAsStateWithLifecycle() val shellBottomInset = LocalAppShellBottomInset.current Scaffold( @@ -86,6 +103,19 @@ fun RemoteControlScreen( 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() @@ -150,6 +180,18 @@ fun RemoteControlScreen( } } } + + QueueSheet( + isVisible = isQueueVisible, + onDismiss = { viewModel.toggleQueueVisibility() }, + modifier = Modifier.fillMaxSize(), + ) { + RemoteQueueSection( + queueState = queueState, + onPlayQueueItem = viewModel::playQueueItem, + onRemoveQueueItem = viewModel::removeQueueItem, + ) + } } @Composable @@ -179,13 +221,28 @@ private fun RemoteControlContent( .fillMaxWidth() .aspectRatio(1f) .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), ) { - AsyncImage( - model = playerState.currentTrackCoverUrl, - contentDescription = "Album cover", - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) + 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)) @@ -295,14 +352,16 @@ private fun RemoteControlContent( } // Play/Pause - IconButton( + val baseTheme = LocalBaseUITheme.current + val circlePrimaryIconTheme = remember(baseTheme) { + baseTheme.iconButtons.primary.copy( + shape = BaseUITheme.InteractionState.fromSingleValue(CircleShape) + ) + } + PrimaryIconButton( onClick = onTogglePlayPause, - modifier = Modifier - .size(72.dp) - .background( - color = MaterialTheme.colorScheme.primary, - shape = CircleShape, - ), + modifier = Modifier.size(72.dp), + theme = circlePrimaryIconTheme, ) { Icon( imageVector = if (playerState.isPlaying) { @@ -311,7 +370,6 @@ private fun RemoteControlContent( Iconsax.IconsaxPlay }, contentDescription = if (playerState.isPlaying) "Pause" else "Play", - tint = MaterialTheme.colorScheme.onPrimary, modifier = Modifier.size(40.dp), ) } @@ -372,9 +430,153 @@ private fun RemoteControlContent( } } +@Composable +private fun RemoteQueueSection( + queueState: RemoteQueueState, + onPlayQueueItem: (Int) -> Unit, + onRemoveQueueItem: (String) -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = "Queue", + style = MaterialTheme.typography.titleLarge, + ) + + if (queueState.entries.isEmpty()) { + Text( + text = "No queue entries", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + itemsIndexed( + items = queueState.entries, + key = { index, entry -> "${entry.mediaUrl}@$index" }, + ) { index, entry -> + RemoteQueueItemRow( + entry = entry, + index = index, + isCurrent = index == queueState.currentIndex, + onPlayClick = { onPlayQueueItem(index) }, + onRemoveClick = { onRemoveQueueItem(entry.mediaUrl) }, + ) + } + } + } + } +} + +@Composable +private fun RemoteQueueItemRow( + entry: RemoteQueueEntry, + index: Int, + isCurrent: Boolean, + onPlayClick: () -> Unit, + onRemoveClick: () -> Unit, +) { + var showMenu by remember { mutableStateOf(false) } + + ListRowTile( + onClick = onPlayClick, + selected = isCurrent, + modifier = Modifier, + leading = { + Box( + modifier = Modifier + .size(48.dp) + .clip(MaterialTheme.shapes.small) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { + if (entry.coverUrl != null) { + AsyncImage( + model = entry.coverUrl, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + } else { + Text( + text = "${index + 1}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + title = { + Text( + text = entry.title, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = if (isCurrent) { + MaterialTheme.colorScheme.onSecondaryContainer + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + }, + subtitle = { + Text( + text = entry.artists, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + trailing = { + Text( + text = formatDuration(entry.durationMs), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Box { + GhostIconButton( + onClick = { showMenu = true }, + modifier = Modifier.size(36.dp), + ) { + Icon( + Iconsax.Iconsax3DotsMore, + contentDescription = "More options", + modifier = Modifier.size(18.dp), + ) + } + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false }, + ) { + DropdownMenuItem( + text = { Text("Remove from queue") }, + onClick = { + onRemoveClick() + showMenu = false + }, + leadingIcon = { + Icon(Iconsax.IconsaxMusicSquareRemove, contentDescription = null) + }, + ) + } + } + } + ) +} + private fun formatDuration(ms: Long): String { val duration = ms.milliseconds val minutes = duration.inWholeMinutes val seconds = duration.inWholeSeconds % 60 return "$minutes:${seconds.toString().padStart(2, '0')}" -} +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlViewModel.kt index 65983a86..9e329f26 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlViewModel.kt @@ -24,7 +24,7 @@ import dev.krtirtho.spotube.core.remote.ConnectionState import dev.krtirtho.spotube.core.remote.RemoteControlClient import dev.krtirtho.spotube.core.remote.RemoteControlCommand import dev.krtirtho.spotube.core.remote.RemoteControlEvent -import kotlinx.coroutines.Job +import dev.krtirtho.spotube.core.remote.RemoteQueueEntry import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -47,6 +47,11 @@ data class RemotePlayerState( val currentTrackCoverUrl: String? = null, ) +data class RemoteQueueState( + val entries: List = emptyList(), + val currentIndex: Int = -1, +) + class RemoteControlViewModel : ViewModel(), KoinComponent { private val logger = Logger.withTag("RemoteControlViewModel") private val remoteControlClient: RemoteControlClient by inject() @@ -57,7 +62,11 @@ class RemoteControlViewModel : ViewModel(), KoinComponent { private val _connectionState = MutableStateFlow(ConnectionState.Disconnected) val connectionState: StateFlow = _connectionState.asStateFlow() - private var stateUpdateJob: Job? = null + private val _queueState = MutableStateFlow(RemoteQueueState()) + val queueState: StateFlow = _queueState.asStateFlow() + + private val _isQueueVisible = MutableStateFlow(false) + val isQueueVisible: StateFlow = _isQueueVisible.asStateFlow() init { viewModelScope.launch { @@ -67,52 +76,47 @@ class RemoteControlViewModel : ViewModel(), KoinComponent { } viewModelScope.launch { - remoteControlClient.stateUpdates.collect { event -> - handleStateUpdate(event) + remoteControlClient.latestPlayerState.collect { event -> + if (event != null) { + handlePlayerState(event) + } + } + } + + viewModelScope.launch { + remoteControlClient.latestQueue.collect { event -> + if (event != null) { + handleQueueUpdated(event) + } } } } - private fun handleStateUpdate(event: RemoteControlEvent) { - when (event) { - is RemoteControlEvent.Connected -> { - // Connection already handled in RemoteControlClient - logger.d { "Connection confirmed" } - } - is RemoteControlEvent.WaitingForPermission -> { - // Waiting for permission - no action needed - logger.d { "Waiting for permission: ${event.message}" } - } - is RemoteControlEvent.PlayerState -> { - _playerState.update { - it.copy( - 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 handlePlayerState(event: RemoteControlEvent.PlayerState) { + _playerState.update { + it.copy( + 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, + ) } } + private fun handleQueueUpdated(event: RemoteControlEvent.QueueUpdated) { + _queueState.value = RemoteQueueState( + entries = event.entries, + currentIndex = event.currentIndex, + ) + } + fun togglePlayPause() { viewModelScope.launch { remoteControlClient.sendCommand(RemoteControlCommand.TogglePlayPause) @@ -162,6 +166,23 @@ class RemoteControlViewModel : ViewModel(), KoinComponent { } } + fun playQueueItem(index: Int) { + if (index < 0) return + viewModelScope.launch { + remoteControlClient.sendCommand(RemoteControlCommand.PlayIndex(index)) + } + } + + fun removeQueueItem(mediaUrl: String) { + viewModelScope.launch { + remoteControlClient.sendCommand(RemoteControlCommand.RemoveFromQueue(mediaUrl)) + } + } + + fun toggleQueueVisibility() { + _isQueueVisible.update { !it } + } + fun disconnect() { viewModelScope.launch { remoteControlClient.disconnect()