Merge branch 'jukeboxstart' into 'develop'

fix: jukebox fails to send start action when starting playback with empty queue

Closes #1266

See merge request ultrasonic/ultrasonic!1437
This commit is contained in:
birdbird committed 2026-10-01 22:10:17 +00:00
commit cd30bcfd2f
1 file changed
+41 -22
@@ -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<String> = 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<out JukeboxTask?>) {
fun remove(taskClass: Class<out JukeboxTask?>): 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
}
}