mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-09-20 14:44:00 +00:00
feat(remote-control): enhance queue management and player state synchronization
This commit is contained in:
parent
c5d35bfc93
commit
1c169d007a
@ -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 -> {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user