mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
fix(android): update playback state management to ensure guests follow host's play state
This commit is contained in:
parent
0b21eff4c7
commit
facc8ed0b9
@ -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
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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) }
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
Loading…
Reference in New Issue
Block a user