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.
This commit is contained in:
KeelrLeek committed 2026-09-15 14:47:40 -05:00
1 parent f59a2d773e
commit 062e1845e0
4 files changed
+56

No files matched your search

@@ -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()
+28
View File
@@ -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.
+1
View File
@@ -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(),
+23
View File
@@ -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,