From e69a5204dba098f66b0b63bde53846b8fc001821 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 09:48:38 -0400 Subject: [PATCH] fix(android): PlayerController.setQueue dispatches to controller thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Crash on cold boot: ResumeController.restore is suspend, lands on Dispatchers.Default after awaitReady() unblocks (drift #562), and calls PlayerController.setQueue which calls MediaController.setMediaItems — MediaController enforces application-thread access and throws IllegalStateException 'method is called from a wrong thread'. Drift #562 added awaitReady() to fix the race where setQueue early-returned on null controller and silently dropped the persisted queue. That fix exposed the next bug down the stack: the threading violation that was previously masked by the early-return. setQueue now posts the MediaController calls to the controller's applicationLooper if we're not already on it. UI callers (already Main) run inline with no re-dispatch latency. ResumeController's cold-boot path lands on the right thread. Discovered on-device 2026-06-03 during like-button verification on the Pixel 6 Pro emulator — crash log at PlayerController.kt:190. --- .../minstrel/player/PlayerController.kt | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 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 102bcba1..0604cd03 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 @@ -3,6 +3,8 @@ package com.fabledsword.minstrel.player import android.content.ComponentName import android.content.Context import android.os.Bundle +import android.os.Handler +import android.os.Looper import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata import androidx.media3.common.Player @@ -187,9 +189,26 @@ class PlayerController @Inject constructor( val controller = mediaController ?: return queueRefs = tracks val items = tracks.map { it.toMediaItem(source) } - controller.setMediaItems(items, initialIndex, /* startPositionMs = */ 0L) - controller.prepare() - if (autoplay) controller.play() + // Drift #562 cold-boot resume calls this from a non-Main suspend + // context after awaitReady() unblocks (ResumeController launches + // on Dispatchers.Default by the time it reaches us). MediaController + // enforces application-thread access and throws + // IllegalStateException otherwise — post to its applicationLooper + // if we're already there, run directly to avoid the re-dispatch + // latency UI callers depend on. + runOnControllerThread(controller) { + controller.setMediaItems(items, initialIndex, /* startPositionMs = */ 0L) + controller.prepare() + if (autoplay) controller.play() + } + } + + private fun runOnControllerThread(controller: MediaController, block: () -> Unit) { + if (Looper.myLooper() == controller.applicationLooper) { + block() + } else { + Handler(controller.applicationLooper).post(block) + } } /**