fix(android): update playback state management to ensure guests follow host's play state

This commit is contained in:
Kingkor Roy Tirtho 2026-10-20 14:28:18 +06:00
parent 0b21eff4c7
commit facc8ed0b9
6 changed files with 81 additions and 34 deletions

View File

@ -389,6 +389,14 @@ android {
isMinifyEnabled = false
signingConfig = signingConfigs.getByName("release")
}
getByName("debug") {
// Debug and release share the application id, so sign both with the release key
// when it's configured; otherwise one can't be installed over the other.
// Without local.properties signing (contributors) keep the default debug keystore.
if (signingConfigs.getByName("release").storeFile != null) {
signingConfig = signingConfigs.getByName("release")
}
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11

View File

@ -45,7 +45,7 @@ sealed class JamMessage {
* merely auto-advanced because its song ended (guests stay put).
*/
val follow: Boolean = false,
/** Host's live play state — late-joining guests start with it. */
/** Room-wide play/pause; guests follow every change of it. */
val isPlaying: Boolean = false) : JamMessage()
@Serializable
@ -76,11 +76,15 @@ sealed class JamMessage {
}
/**
* Playback commands. Only queue navigation is global — play/pause, seek,
* Playback commands. Queue navigation and play/pause are global — seek,
* volume, shuffle and loop are local to each participant.
*/
@Serializable
sealed class PlaybackCmd {
@Serializable
@SerialName("setPlaying")
data class SetPlaying(val playing: Boolean) : PlaybackCmd()
@Serializable
@SerialName("skipNext")
data object SkipNext : PlaybackCmd()

View File

@ -33,11 +33,14 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlin.random.Random
import org.jetbrains.compose.resources.getString
@ -52,9 +55,9 @@ import kotlin.time.Clock
* Sync rules:
* - The queue list and current index are global. [skipNext]/[skipPrevious]/[jumpTo]
* from anyone are applied by the host, then broadcast via the retained state topic.
* - Play/pause, seek, volume and loop are local to each device — never broadcast.
* When the queue moves on, a paused participant stays paused; a playing one
* keeps playing the new current item.
* - Play/pause is global: anyone's play/pause is sent to the host, which applies it
* and broadcasts it; guests follow every change of the host's play state.
* - Seek, volume and loop are local to each device — never broadcast.
* - Shuffle is host-only; guests mirror the host's shuffle setting.
* - Guests can only add to the queue (suggest); the host applies suggestions.
* - If the host leaves, the participant with the lowest client id takes over.
@ -63,7 +66,7 @@ private data class PlaybackBroadcast(
val queue: List<QueueEntry>,
val current: QueueEntry?,
val shuffle: Boolean,
val playerState: PlayerState)
val isPlaying: Boolean)
class JamRoomService(
private val jamClient: JamRoomClient,
@ -73,6 +76,20 @@ class JamRoomService(
private val log = Logger.withTag("JamRoomService")
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
/**
* Play/pause intent. Transient states (buffering between tracks, loading, completion)
* keep the last value, so they never pause or resume the room.
*/
private val playIntent: StateFlow<Boolean> = audioPlayer.playerStateFlow
.mapNotNull { state ->
when (state) {
PlayerState.PLAYING -> true
PlayerState.PAUSED -> false
else -> null
}
}
.stateIn(scope, SharingStarted.Eagerly, audioPlayer.playerStateFlow.value == PlayerState.PLAYING)
private val _role = MutableStateFlow<JamRole?>(null)
val role: StateFlow<JamRole?> = _role.asStateFlow()
@ -116,8 +133,8 @@ class JamRoomService(
*/
private var autoAdvanceDeadlineMs = 0L
/** Guest side: playback has started at least once (local control is the guest's own). */
private var hasStartedPlayback = false
/** Guest side: the room's play state from the last host broadcast (null until the first one). */
private var lastHostIsPlaying: Boolean? = null
/** Host side: client ids banned for this session. */
private val bannedClientIds = mutableSetOf<String>()
@ -145,12 +162,14 @@ class JamRoomService(
audioPlayer.completionFlow
.onEach { autoAdvanceDeadlineMs = now() + AUTO_ADVANCE_WINDOW_MS }
.launchIn(scope)
// Once a guest has played on its own (or was started by the host), its
// play/pause is its own — the host's play state only starts fresh joiners.
audioPlayer.playerStateFlow
.onEach { state ->
if (_role.value == JamRole.Guest && state == PlayerState.PLAYING) {
hasStartedPlayback = true
// A guest's own play/pause (UI, notification, headset) goes to the host, which
// applies it for the whole room. Intent that matches the room's state came from
// following the host, so it isn't echoed back.
playIntent
.onEach { playing ->
val roomPlaying = lastHostIsPlaying
if (_role.value == JamRole.Guest && !leaving && roomPlaying != null && playing != roomPlaying) {
publishCommand(PlaybackCmd.SetPlaying(playing))
}
}
.launchIn(scope)
@ -182,7 +201,7 @@ class JamRoomService(
bannedClientIds.clear()
leaving = false
autoAdvanceDeadlineMs = 0L
hasStartedPlayback = false
lastHostIsPlaying = null
startHostBroadcast()
persistLastCode(code)
code
@ -214,7 +233,7 @@ class JamRoomService(
lastAppliedItems = emptyList()
lastAppliedIndex = -1
leaving = false
hasStartedPlayback = false
lastHostIsPlaying = null
persistLastCode(normalized)
}
}
@ -232,7 +251,7 @@ class JamRoomService(
lastAppliedItems = emptyList()
lastAppliedIndex = -1
bannedClientIds.clear()
hasStartedPlayback = false
lastHostIsPlaying = null
}
// ---------- Controls (called from the UI) ----------
@ -294,8 +313,8 @@ class JamRoomService(
audioPlayerQueue.queueFlow,
audioPlayerQueue.currentQueueEntryFlow,
audioPlayer.shuffleModeFlow,
audioPlayer.playerStateFlow) { queue, current, shuffle, playerState ->
PlaybackBroadcast(queue, current, shuffle, playerState)
playIntent) { queue, current, shuffle, isPlaying ->
PlaybackBroadcast(queue, current, shuffle, isPlaying)
}
.onEach { broadcast ->
if (_role.value != JamRole.Host) return@onEach
@ -311,7 +330,7 @@ class JamRoomService(
currentIndex = index.coerceAtLeast(0),
shuffleEnabled = broadcast.shuffle,
follow = now() > autoAdvanceDeadlineMs,
isPlaying = broadcast.playerState == PlayerState.PLAYING)
isPlaying = broadcast.isPlaying)
)
}
.launchIn(this)
@ -332,15 +351,17 @@ class JamRoomService(
_shuffleEnabled.value = state.shuffleEnabled
runCatching { audioPlayer.shuffle(state.shuffleEnabled) }
// Late joiner: the host is already playing, so start immediately. Once
// this guest has played on its own, the host's play state is ignored.
if (state.isPlaying && !hasStartedPlayback) {
runCatching { audioPlayer.play() }
.onFailure { log.w(it) { "Failed to start playback on host play state" } }
// Play/pause is room-wide: follow every change of the host's play state. The room
// state is recorded first, so the resulting local intent isn't sent back as a command.
if (state.isPlaying != lastHostIsPlaying) {
lastHostIsPlaying = state.isPlaying
if (state.isPlaying != playIntent.value) {
runCatching { if (state.isPlaying) audioPlayer.play() else audioPlayer.pause() }
.onFailure { log.w(it) { "Failed to follow the host's play state" } }
}
}
val items = state.items.filter { it.trackId.isNotBlank() || it.url.isNotBlank() }
val wasPlaying = audioPlayer.playerStateFlow.value == PlayerState.PLAYING
if (items != lastAppliedItems) {
val previous = lastAppliedItems
@ -369,7 +390,7 @@ class JamRoomService(
runCatching {
audioPlayerQueue.load(
entries = items.map { it.toQueueEntry() },
autoPlay = wasPlaying,
autoPlay = state.isPlaying,
startPosition = startIndex)
}.onFailure { log.w(it) { "Failed to apply jam queue" } }
return
@ -419,6 +440,10 @@ class JamRoomService(
private suspend fun applyCommand(command: PlaybackCmd) {
when (command) {
// Applied locally; the host broadcast carries the new play state to every guest
is PlaybackCmd.SetPlaying -> runCatching {
if (command.playing) audioPlayer.play() else audioPlayer.pause()
}
PlaybackCmd.SkipNext -> runCatching { audioPlayer.skipToNext() }
PlaybackCmd.SkipPrevious -> runCatching { audioPlayer.skipToPrevious() }
is PlaybackCmd.JumpTo -> runCatching { audioPlayer.jumpTo(command.index) }

View File

@ -431,6 +431,7 @@ fun AppExpandedPlayer(
val albumId = currentTrack?.album?.id
if (albumId != null) {
navigationCommands.navigateTo(Routes.Album(albumId))
onCollapse()
}
onGoToAlbum()
})
@ -535,6 +536,8 @@ fun AppExpandedPlayer(
artists = playerUiState.artistList,
onArtistClick = { artistId ->
navigationCommands.navigateTo(Routes.Artist(artistId))
// The page opens beneath the player, so get out of the way
onCollapse()
},
style = AppTheme.typography.bodyLarge,
color = AppTheme.colors.onSurfaceVariant,

View File

@ -17,7 +17,10 @@
package org.spotube.spotube.modules.shell.player_queue
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
@ -34,9 +37,6 @@ import org.spotube.millennium.composables.MillenniumModalBottomSheet
import org.spotube.millennium.composables.rememberMillenniumDrawerState
private val SlidingSheetBreakpoint = 840.dp
private val MaxHeightSheetDetent = SheetDetent("max-height-85-percent") { containerHeight, sheetHeight ->
minOf(sheetHeight, containerHeight * 0.85f)
}
@Composable
fun QueueSheet(
@ -88,9 +88,11 @@ internal fun BottomSheetContent(
content: @Composable () -> Unit
) {
if (!isVisible) return
// Show the whole sheet and cap the content instead: a detent that reveals only part of a
// full-height sheet leaves the bottom of the queue list off-screen and unreachable.
val sheetState = rememberModalBottomSheetState(
initialDetent = MaxHeightSheetDetent,
detents = listOf(SheetDetent.Hidden, MaxHeightSheetDetent)
initialDetent = SheetDetent.FullyExpanded,
detents = listOf(SheetDetent.Hidden, SheetDetent.FullyExpanded)
)
LaunchedEffect(sheetState.bottomSheetState.currentDetent) {
if (sheetState.bottomSheetState.currentDetent == SheetDetent.Hidden) {
@ -102,6 +104,8 @@ internal fun BottomSheetContent(
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
contentSpacing = 8.dp
) {
content()
Box(Modifier.fillMaxWidth().fillMaxHeight(0.85f)) {
content()
}
}
}

View File

@ -34,4 +34,7 @@ android.builtInKotlin=false
android.newDsl=false
android.sourceset.disallowProvider=false
org.gradle.vfs.watch=true
org.gradle.vfs.watch=true
nightly=true
buildNumber=999