From e63dfa13e8c896ed598d7861db60fa024a4d329e Mon Sep 17 00:00:00 2001 From: birdbird <6892457-bird.bird@users.noreply.gitlab.com> Date: Thu, 1 Oct 2026 22:10:16 +0000 Subject: [PATCH] fix: jukebox fails to send start action when starting playback with empty queue --- .../ultrasonic/service/JukeboxMediaPlayer.kt | 63 ++++++++++++------- 1 file changed, 41 insertions(+), 22 deletions(-) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxMediaPlayer.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxMediaPlayer.kt index 00fa8c01..49b54953 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxMediaPlayer.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxMediaPlayer.kt @@ -174,13 +174,17 @@ class JukeboxMediaPlayer( if (mediaItemIndex !in playlist.indices) return tasks.remove(Skip::class.java) tasks.remove(Stop::class.java) - tasks.remove(Start::class.java) + // Preserve a start request that has not been sent yet, so a play() followed by a + // seek cannot silently cancel starting the jukebox. Event-sourced: only re-queued + // when a Start task is actually pending, never from derived "desired" state. + val startWasPending = tasks.remove(Start::class.java) startStatusUpdate() val positionSeconds = (positionMs / 1000).toInt() if (jukeboxStatus != null) { jukeboxStatus!!.positionSeconds = positionSeconds } tasks.add(Skip(mediaItemIndex, positionSeconds)) + if (startWasPending) tasks.add(Start()) currentIndex = mediaItemIndex updateAvailableCommands() } @@ -206,35 +210,43 @@ class JukeboxMediaPlayer( null, false -> Player.STATE_IDLE } + // Note: The transport commands (play/pause, prepare, stop, seek) must ALWAYS be + // advertised, even when the local playlist is still empty. + // + // Media3 silently drops player calls in the MediaController if the corresponding command + // is not in the controller's cached copy of the available commands. This cache is updated + // *asynchronously* from the session. When playback is started with an empty queue, the app + // calls setMediaItems/prepare/play in rapid succession. If the available commands here + // depended on the playlist being non-empty, the controller would evaluate the stale + // "empty" command set, silently drop the prepare/play calls, and the Start action would + // never be sent to the server (see https://github.com/ultrasonic/ultrasonic/issues/1008). + // + // For a remote jukebox the authoritative queue lives on the server, so a locally empty + // playlist says nothing about the ability to play. All methods below are guarded anyway. override fun getAvailableCommands(): Player.Commands { val commandsBuilder = Player.Commands.Builder().addAll( + Player.COMMAND_PLAY_PAUSE, + Player.COMMAND_PREPARE, + Player.COMMAND_STOP, Player.COMMAND_CHANGE_MEDIA_ITEMS, Player.COMMAND_GET_TIMELINE, + Player.COMMAND_GET_CURRENT_MEDIA_ITEM, + Player.COMMAND_GET_METADATA, + Player.COMMAND_SEEK_BACK, + Player.COMMAND_SEEK_FORWARD, + Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM, + Player.COMMAND_SEEK_TO_MEDIA_ITEM, + Player.COMMAND_SEEK_TO_PREVIOUS, + Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM, Player.COMMAND_GET_DEVICE_VOLUME, Player.COMMAND_ADJUST_DEVICE_VOLUME_WITH_FLAGS, Player.COMMAND_SET_DEVICE_VOLUME_WITH_FLAGS ) - if (isPlaying) commandsBuilder.add(Player.COMMAND_STOP) - if (playlist.isNotEmpty()) { + if (currentIndex < playlist.size - 1) { commandsBuilder.addAll( - Player.COMMAND_GET_CURRENT_MEDIA_ITEM, - Player.COMMAND_GET_METADATA, - Player.COMMAND_PLAY_PAUSE, - Player.COMMAND_PREPARE, - Player.COMMAND_SEEK_BACK, - Player.COMMAND_SEEK_FORWARD, - Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM, - Player.COMMAND_SEEK_TO_MEDIA_ITEM, - // Seeking back is always available - Player.COMMAND_SEEK_TO_PREVIOUS, - Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM + Player.COMMAND_SEEK_TO_NEXT, + Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM ) - if (currentIndex < playlist.size - 1) { - commandsBuilder.addAll( - Player.COMMAND_SEEK_TO_NEXT, - Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM - ) - } } return commandsBuilder.build() } @@ -678,7 +690,7 @@ class JukeboxMediaPlayer( if (!running.get()) return tasks.remove(Skip::class.java) tasks.remove(Stop::class.java) - tasks.remove(Start::class.java) + val startWasPending = tasks.remove(Start::class.java) val ids: MutableList = ArrayList() for (item in playlist) { ids.add(item.mediaId) @@ -686,6 +698,9 @@ class JukeboxMediaPlayer( if (ids.isNotEmpty()) { tasks.add(SetPlaylist(ids)) + // Preserve a start request that arrived while the queue was being replaced, + // so that a play() before/parallel to setMediaItems() cannot be lost. + if (startWasPending) tasks.add(Start()) } else { tasks.add(ClearPlaylist()) } @@ -730,17 +745,21 @@ class JukeboxMediaPlayer( fun poll(): JukeboxTask? = queue.poll(QUEUE_POLL_INTERVAL_SECONDS, TimeUnit.SECONDS) - fun remove(taskClass: Class) { + fun remove(taskClass: Class): Boolean { try { + var removed = false val iterator = queue.iterator() while (iterator.hasNext()) { val task = iterator.next() if (taskClass == task.javaClass) { iterator.remove() + removed = true } } + return removed } catch (x: Throwable) { Timber.w(x, "Failed to clean-up task queue.") + return false } }