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 isMinifyEnabled = false
signingConfig = signingConfigs.getByName("release") 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 { compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_11

View File

@ -45,7 +45,7 @@ sealed class JamMessage {
* merely auto-advanced because its song ended (guests stay put). * merely auto-advanced because its song ended (guests stay put).
*/ */
val follow: Boolean = false, 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() val isPlaying: Boolean = false) : JamMessage()
@Serializable @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. * volume, shuffle and loop are local to each participant.
*/ */
@Serializable @Serializable
sealed class PlaybackCmd { sealed class PlaybackCmd {
@Serializable
@SerialName("setPlaying")
data class SetPlaying(val playing: Boolean) : PlaybackCmd()
@Serializable @Serializable
@SerialName("skipNext") @SerialName("skipNext")
data object SkipNext : PlaybackCmd() data object SkipNext : PlaybackCmd()

View File

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

View File

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

View File

@ -17,7 +17,10 @@
package org.spotube.spotube.modules.shell.player_queue 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.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -34,9 +37,6 @@ import org.spotube.millennium.composables.MillenniumModalBottomSheet
import org.spotube.millennium.composables.rememberMillenniumDrawerState import org.spotube.millennium.composables.rememberMillenniumDrawerState
private val SlidingSheetBreakpoint = 840.dp private val SlidingSheetBreakpoint = 840.dp
private val MaxHeightSheetDetent = SheetDetent("max-height-85-percent") { containerHeight, sheetHeight ->
minOf(sheetHeight, containerHeight * 0.85f)
}
@Composable @Composable
fun QueueSheet( fun QueueSheet(
@ -88,9 +88,11 @@ internal fun BottomSheetContent(
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
if (!isVisible) return 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( val sheetState = rememberModalBottomSheetState(
initialDetent = MaxHeightSheetDetent, initialDetent = SheetDetent.FullyExpanded,
detents = listOf(SheetDetent.Hidden, MaxHeightSheetDetent) detents = listOf(SheetDetent.Hidden, SheetDetent.FullyExpanded)
) )
LaunchedEffect(sheetState.bottomSheetState.currentDetent) { LaunchedEffect(sheetState.bottomSheetState.currentDetent) {
if (sheetState.bottomSheetState.currentDetent == SheetDetent.Hidden) { if (sheetState.bottomSheetState.currentDetent == SheetDetent.Hidden) {
@ -102,6 +104,8 @@ internal fun BottomSheetContent(
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
contentSpacing = 8.dp contentSpacing = 8.dp
) { ) {
Box(Modifier.fillMaxWidth().fillMaxHeight(0.85f)) {
content() content()
} }
}
} }

View File

@ -35,3 +35,6 @@ android.newDsl=false
android.sourceset.disallowProvider=false android.sourceset.disallowProvider=false
org.gradle.vfs.watch=true org.gradle.vfs.watch=true
nightly=true
buildNumber=999