feat(remote-control): enhance queue management and player state synchronization

This commit is contained in:
Kingkor Roy Tirtho 2026-09-04 09:24:15 +06:00
parent c5d35bfc93
commit 1c169d007a
5 changed files with 406 additions and 69 deletions

View File

@ -33,11 +33,8 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
@ -69,8 +66,11 @@ class RemoteControlClient {
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
private val _stateUpdates = MutableSharedFlow<RemoteControlEvent>(extraBufferCapacity = 32)
val stateUpdates: SharedFlow<RemoteControlEvent> = _stateUpdates.asSharedFlow()
private val _latestPlayerState = MutableStateFlow<RemoteControlEvent.PlayerState?>(null)
val latestPlayerState: StateFlow<RemoteControlEvent.PlayerState?> = _latestPlayerState.asStateFlow()
private val _latestQueue = MutableStateFlow<RemoteControlEvent.QueueUpdated?>(null)
val latestQueue: StateFlow<RemoteControlEvent.QueueUpdated?> = _latestQueue.asStateFlow()
suspend fun connect(host: String, port: Int, deviceId: String, deviceName: String) {
if (_connectionState.value is ConnectionState.Connected) {
@ -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 -> {}
}
}
}
}

View File

@ -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 {
// 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
}
}

View File

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

View File

@ -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<RemoteControlViewModel>()
val playerState by viewModel.playerState.collectAsStateWithLifecycle()
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
val queueState by viewModel.queueState.collectAsStateWithLifecycle()
val isQueueVisible by viewModel.isQueueVisible.collectAsStateWithLifecycle()
val shellBottomInset = LocalAppShellBottomInset.current
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),
) {
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,6 +430,150 @@ 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

View File

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