From 574bf29a7e203465a173cae8dec5b958deb71729 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 16:00:53 -0400 Subject: [PATCH] fix(android): PlayerController transport methods dispatch to controller thread After DIDL fix, Sonos accepted SetAVTransportURI + Play, but playerController.pause() threw IllegalStateException 'method is called from a wrong thread' because selectUpnp runs the whole UPnP-selection flow on Dispatchers.Default. UI tap handlers were fine - they're already on Main - but the cross-thread background call from OutputPickerController.selectUpnp hit the MediaController's application-thread guard. Same fix as the cold-boot resume one earlier today (commit e69a5204 wrapped setQueue): pause / play / seekTo / skipToNext / skipToPrevious now route through runOnControllerThread, which is a no-op when already on the application looper and Handler.post otherwise. Logcat from on-device confirmed Sonos plays after this fix lands - SetAVTransportURI -> 200, Play -> 200, then the IllegalStateException was the last failure path. --- .../minstrel/player/PlayerController.kt | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt index 0604cd03..5c71f4a1 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt @@ -130,11 +130,24 @@ class PlayerController @Inject constructor( // ── Transport (no-op until the controller is connected) ────────────── - fun play() { mediaController?.play() } - fun pause() { mediaController?.pause() } - fun seekTo(positionMs: Long) { mediaController?.seekTo(positionMs) } - fun skipToNext() { mediaController?.seekToNextMediaItem() } - fun skipToPrevious() { mediaController?.seekToPreviousMediaItem() } + // Each transport call must run on the MediaController's + // applicationLooper; calling from a background coroutine throws + // IllegalStateException (see PlayerController.setQueue's note). + // UI tap handlers are already on Main so the in-place branch hits; + // the background path only fires for cross-thread callers like + // OutputPickerController.selectUpnp (which calls pause() after + // handing playback off to a UPnP renderer). + fun play() { mediaController?.let { runOnControllerThread(it) { it.play() } } } + fun pause() { mediaController?.let { runOnControllerThread(it) { it.pause() } } } + fun seekTo(positionMs: Long) { + mediaController?.let { runOnControllerThread(it) { it.seekTo(positionMs) } } + } + fun skipToNext() { + mediaController?.let { runOnControllerThread(it) { it.seekToNextMediaItem() } } + } + fun skipToPrevious() { + mediaController?.let { runOnControllerThread(it) { it.seekToPreviousMediaItem() } } + } /** Flip shuffle on/off. Media3 emits onEvents → uiState reflects. */ fun toggleShuffle() {