From 062e1845e0b6f49859e9c05281baedf839ac1a82 Mon Sep 17 00:00:00 2001 From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:47:40 -0500 Subject: [PATCH] =?UTF-8?q?android=20auto:=20one=20engine=20per=20process,?= =?UTF-8?q?=20actually=20=E2=80=94=20init=20was=20still=20spawning=20its?= =?UTF-8?q?=20own?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `headless.rs` was written around a guarantee its own doc comment made and the code did not keep: `playback::init` still called `Player::spawn_with_device` directly. So a car could start a cold process, play a record into it, and the moment the app's Activity came up the process built a SECOND player — two AAudio streams out of one app, playing over each other. That is precisely the failure the module exists to prevent, and nothing would have reported it but Kyle's ears. On Android `init` now takes the process's engine instead. The event receiver is single-consumer and the headless pump already owns it, so the UI SUBSCRIBES to the pump rather than reading the engine directly; and the fallback queue is cleared on the way in, because from that point the JS decides what plays next and two things advancing one engine would skip a track each time. The desktop path is untouched — it still spawns its own, because there is nothing else in that process to share with. Also here: a log line on the transport's routing decision (`auto playWhenReady=… headless=…` and `auto transport …`), added while chasing a pause that turned out to be working. It is kept because the question "did this button go to Rust or to a WebView that is not there" has now come up three times and is invisible without it. The pause was real, and the measurement was not. "One active AAudio track after a pause" proves nothing — a paused sink still shows as active. The honest test is that the session reports PAUSED(2) and the position stops moving, which it does: 24873 ms, unchanged five seconds later. --- .../mediasession/SoulStreamPlayer.kt | 4 +++ handoff/rounds/commit-msg54.txt | 28 +++++++++++++++++++ src-tauri/src/auto_browse.rs | 1 + src-tauri/src/playback.rs | 23 +++++++++++++++ 4 files changed, 56 insertions(+) create mode 100644 handoff/rounds/commit-msg54.txt diff --git a/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/SoulStreamPlayer.kt b/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/SoulStreamPlayer.kt index a7d3cb7..26b042b 100644 --- a/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/SoulStreamPlayer.kt +++ b/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/SoulStreamPlayer.kt @@ -252,6 +252,10 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa /* ── the transport, back out to the WebView ──────────────────────── */ override fun handleSetPlayWhenReady(playWhenReady: Boolean): ListenableFuture<*> { + android.util.Log.i( + "SoulStream", + "auto playWhenReady=" + playWhenReady + " headless=" + headless(), + ) if (headless()) { AutoNative.transport(if (playWhenReady) "resume" else "pause") return Futures.immediateVoidFuture() diff --git a/handoff/rounds/commit-msg54.txt b/handoff/rounds/commit-msg54.txt new file mode 100644 index 0000000..678156e --- /dev/null +++ b/handoff/rounds/commit-msg54.txt @@ -0,0 +1,28 @@ +android auto: one engine per process, actually — init was still spawning its own + +`headless.rs` was written around a guarantee its own doc comment made and the +code did not keep: `playback::init` still called `Player::spawn_with_device` +directly. So a car could start a cold process, play a record into it, and the +moment the app's Activity came up the process built a SECOND player — two +AAudio streams out of one app, playing over each other. That is precisely the +failure the module exists to prevent, and nothing would have reported it but +Kyle's ears. + +On Android `init` now takes the process's engine instead. The event receiver is +single-consumer and the headless pump already owns it, so the UI SUBSCRIBES to +the pump rather than reading the engine directly; and the fallback queue is +cleared on the way in, because from that point the JS decides what plays next +and two things advancing one engine would skip a track each time. The desktop +path is untouched — it still spawns its own, because there is nothing else in +that process to share with. + +Also here: a log line on the transport's routing decision +(`auto playWhenReady=… headless=…` and `auto transport …`), added while +chasing a pause that turned out to be working. It is kept because the question +"did this button go to Rust or to a WebView that is not there" has now come up +three times and is invisible without it. + +The pause was real, and the measurement was not. "One active AAudio track after +a pause" proves nothing — a paused sink still shows as active. The honest test +is that the session reports PAUSED(2) and the position stops moving, which it +does: 24873 ms, unchanged five seconds later. diff --git a/src-tauri/src/auto_browse.rs b/src-tauri/src/auto_browse.rs index 6e578fe..88b8862 100644 --- a/src-tauri/src/auto_browse.rs +++ b/src-tauri/src/auto_browse.rs @@ -619,6 +619,7 @@ pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_transport( return; }; let audio = crate::headless::audio(None); + warn(&format!("auto transport {a}")); match a.as_str() { "pause" => audio.player.pause(), "resume" => audio.player.resume(), diff --git a/src-tauri/src/playback.rs b/src-tauri/src/playback.rs index 86d6395..6a04007 100644 --- a/src-tauri/src/playback.rs +++ b/src-tauri/src/playback.rs @@ -186,6 +186,29 @@ enum PlayerUiEvent { /// Create the audio thread and forward its events to the webview. pub fn init(app: &tauri::AppHandle) { let output_device = crate::config::load(app).audio_output_device; + + /* ANDROID TAKES THE PROCESS'S ONE ENGINE, it does not make a second. + A car can start this app by binding the media service, play a record + into a process with no Activity, and have the user open the app on top + of it minutes later — and `Player::spawn` opens an output stream, so a + second one here is two AAudio streams playing over each other out of one + process. `headless::audio` builds it on first use, whoever gets there + first, and hands back the same one afterwards. + + The engine's event receiver is single-consumer and the headless pump + already owns it, so the UI subscribes rather than reading it directly; + and the fallback queue is cleared, because from here the JS decides what + plays next and two things advancing one engine would skip a track each + time. Nothing about the desktop changes. */ + #[cfg(target_os = "android")] + let (player, events) = { + let audio = crate::headless::audio(output_device.clone()); + let (tx, rx) = std::sync::mpsc::channel(); + audio.subscribe(tx); + audio.clear_queue(); + (audio.player.clone(), rx) + }; + #[cfg(not(target_os = "android"))] let (player, events) = Player::spawn_with_device(output_device); app.manage(PlayerState { player,