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.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
@ -69,8 +66,11 @@ 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 _stateUpdates = MutableSharedFlow<RemoteControlEvent>(extraBufferCapacity = 32) private val _latestPlayerState = MutableStateFlow<RemoteControlEvent.PlayerState?>(null)
val stateUpdates: SharedFlow<RemoteControlEvent> = _stateUpdates.asSharedFlow() 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) { suspend fun connect(host: String, port: Int, deviceId: String, deviceName: String) {
if (_connectionState.value is ConnectionState.Connected) { if (_connectionState.value is ConnectionState.Connected) {
@ -121,9 +121,17 @@ class RemoteControlClient {
// Keep showing connecting state // Keep showing connecting state
} }
else -> { else -> {
// Only emit state updates after connection is established // Only store state updates after connection is established
if (_connectionState.value is ConnectionState.Connected) { 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.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
@ -104,9 +110,20 @@ class RemoteControlHandler(
// Broadcast initial player state so the controller shows current track info // Broadcast initial player state so the controller shows current track info
broadcastState(session) broadcastState(session)
broadcastQueue(session)
try { 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) { } catch (e: Exception) {
logger.w(e) { "Error in remote control session" } logger.w(e) { "Error in remote control session" }
} finally { } finally {
@ -159,10 +176,13 @@ class RemoteControlHandler(
audioPlayer.pause() audioPlayer.pause()
} }
is RemoteControlCommand.TogglePlayPause -> { is RemoteControlCommand.TogglePlayPause -> {
if (audioPlayer.playerStateFlow.value == AudioPlayerState.PLAYING) { val isPlaying = 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 -> {
@ -195,12 +215,27 @@ class RemoteControlHandler(
is RemoteControlCommand.AddToQueue -> { is RemoteControlCommand.AddToQueue -> {
logger.d { "Remote add to queue: ${command.source} (source parsing not yet implemented)" } logger.d { "Remote add to queue: ${command.source} (source parsing not yet implemented)" }
} }
is RemoteControlCommand.PlayIndex -> {
audioPlayerQueue.jumpTo(command.index)
}
is RemoteControlCommand.RemoveFromQueue -> { 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) 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) {
@ -213,6 +248,20 @@ class RemoteControlHandler(
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)
}
}
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(
@ -232,6 +281,58 @@ class RemoteControlHandler(
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
@ -253,7 +354,8 @@ class RemoteControlHandler(
} }
private fun QueueEntry.coverUrlOrNull(): String? = when (this) { 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 is QueueEntry.LocalTrack -> null
} }
} }

View File

@ -62,6 +62,10 @@ sealed class RemoteControlCommand {
@SerialName("addToQueue") @SerialName("addToQueue")
data class AddToQueue(val source: String) : RemoteControlCommand() data class AddToQueue(val source: String) : 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

@ -21,6 +21,7 @@ import androidx.compose.foundation.background
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
@ -29,10 +30,14 @@ 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.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
@ -40,11 +45,12 @@ 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
@ -53,13 +59,22 @@ 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.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
@ -78,6 +93,8 @@ 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 val shellBottomInset = LocalAppShellBottomInset.current
Scaffold( Scaffold(
@ -86,6 +103,19 @@ fun RemoteControlScreen(
title = { Text("Remote Control") }, title = { Text("Remote Control") },
backButton = true, backButton = true,
actions = { actions = {
GhostIconButton(
onClick = viewModel::toggleQueueVisibility,
) {
Icon(
imageVector = Iconsax.IconsaxMusicFilter,
contentDescription = "Queue",
tint = if (isQueueVisible) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
GhostIconButton( GhostIconButton(
onClick = { onClick = {
viewModel.disconnect() 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 @Composable
@ -179,13 +221,28 @@ private fun RemoteControlContent(
.fillMaxWidth() .fillMaxWidth()
.aspectRatio(1f) .aspectRatio(1f)
.clip(RoundedCornerShape(16.dp)) .clip(RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
) { ) {
AsyncImage( if (playerState.currentTrackCoverUrl != null) {
model = playerState.currentTrackCoverUrl, AsyncImage(
contentDescription = "Album cover", model = playerState.currentTrackCoverUrl,
modifier = Modifier.fillMaxSize(), contentDescription = "Album cover",
contentScale = ContentScale.Crop, 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)) Spacer(modifier = Modifier.height(32.dp))
@ -295,14 +352,16 @@ private fun RemoteControlContent(
} }
// Play/Pause // Play/Pause
IconButton( val baseTheme = LocalBaseUITheme.current
val circlePrimaryIconTheme = remember(baseTheme) {
baseTheme.iconButtons.primary.copy(
shape = BaseUITheme.InteractionState.fromSingleValue(CircleShape)
)
}
PrimaryIconButton(
onClick = onTogglePlayPause, onClick = onTogglePlayPause,
modifier = Modifier modifier = Modifier.size(72.dp),
.size(72.dp) theme = circlePrimaryIconTheme,
.background(
color = MaterialTheme.colorScheme.primary,
shape = CircleShape,
),
) { ) {
Icon( Icon(
imageVector = if (playerState.isPlaying) { imageVector = if (playerState.isPlaying) {
@ -311,7 +370,6 @@ private fun RemoteControlContent(
Iconsax.IconsaxPlay Iconsax.IconsaxPlay
}, },
contentDescription = if (playerState.isPlaying) "Pause" else "Play", contentDescription = if (playerState.isPlaying) "Pause" else "Play",
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(40.dp), 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 { private fun formatDuration(ms: Long): String {
val duration = ms.milliseconds val duration = ms.milliseconds
val minutes = duration.inWholeMinutes 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.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 kotlinx.coroutines.Job import dev.krtirtho.spotube.core.remote.RemoteQueueEntry
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,6 +47,11 @@ 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()
@ -57,7 +62,11 @@ 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 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 { init {
viewModelScope.launch { viewModelScope.launch {
@ -67,52 +76,47 @@ class RemoteControlViewModel : ViewModel(), KoinComponent {
} }
viewModelScope.launch { viewModelScope.launch {
remoteControlClient.stateUpdates.collect { event -> remoteControlClient.latestPlayerState.collect { event ->
handleStateUpdate(event) if (event != null) {
handlePlayerState(event)
}
}
}
viewModelScope.launch {
remoteControlClient.latestQueue.collect { event ->
if (event != null) {
handleQueueUpdated(event)
}
} }
} }
} }
private fun handleStateUpdate(event: RemoteControlEvent) { private fun handlePlayerState(event: RemoteControlEvent.PlayerState) {
when (event) { _playerState.update {
is RemoteControlEvent.Connected -> { it.copy(
// Connection already handled in RemoteControlClient isPlaying = event.isPlaying,
logger.d { "Connection confirmed" } positionMs = event.positionMs,
} durationMs = event.durationMs,
is RemoteControlEvent.WaitingForPermission -> { volume = event.volume,
// Waiting for permission - no action needed shuffleEnabled = event.shuffleEnabled,
logger.d { "Waiting for permission: ${event.message}" } loopMode = event.loopMode,
} currentTrackId = event.currentTrackId,
is RemoteControlEvent.PlayerState -> { currentTrackTitle = event.currentTrackTitle,
_playerState.update { currentTrackArtists = event.currentTrackArtists,
it.copy( currentTrackAlbum = event.currentTrackAlbum,
isPlaying = event.isPlaying, currentTrackCoverUrl = event.currentTrackCoverUrl,
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)
@ -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() { fun disconnect() {
viewModelScope.launch { viewModelScope.launch {
remoteControlClient.disconnect() remoteControlClient.disconnect()