diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index ef0ab92f..b717523d 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -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 diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamProtocol.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamProtocol.kt index 8e268e38..03231e66 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamProtocol.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamProtocol.kt @@ -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() diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomService.kt index f9963e90..f317e6ec 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomService.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomService.kt @@ -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, 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 = 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(null) val role: StateFlow = _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() @@ -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) } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppExpandedPlayer.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppExpandedPlayer.kt index a95bb526..2270c98e 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppExpandedPlayer.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppExpandedPlayer.kt @@ -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, diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/player_queue/QueueSheet.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/player_queue/QueueSheet.kt index 4f9d09c1..a15efb6a 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/player_queue/QueueSheet.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/player_queue/QueueSheet.kt @@ -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() + } } } diff --git a/gradle.properties b/gradle.properties index a49b0977..dc5eecc2 100644 --- a/gradle.properties +++ b/gradle.properties @@ -34,4 +34,7 @@ android.builtInKotlin=false android.newDsl=false android.sourceset.disallowProvider=false -org.gradle.vfs.watch=true \ No newline at end of file +org.gradle.vfs.watch=true + +nightly=true +buildNumber=999 \ No newline at end of file