android auto: a tap in the car plays, with no Activity anywhere in the process
Android Auto starts this app by binding a service. There is no Activity, no WebView, no `tauri::AppHandle` — and until now no way to make a sound, because the engine lived in Tauri's managed state. `src-tauri/src/headless.rs` moves it into a `OnceLock` owned by the PROCESS: whoever needs audio first builds it, `playback::init` on a normal launch or a car tapping a row into a process with no UI, and everyone after that gets the same one. **That singleton is the whole safety story.** `Player::spawn` opens an output stream, so two of them in one process is two AAudio streams playing over each other — the failure this module exists to make impossible. The event receiver is single-consumer, so the pump lives there too and offers a subscription slot for the WebView to claim when it finally appears, rather than a second engine being stood up beside the first. The queue here is deliberately not the app's queue. The JS one keeps shuffle, repeat, `alts` failover, provenance and the three-ahead preload; this is a vector and an index whose only job is that the album keeps playing when there is no JS alive to advance it. Its metadata is read ONCE, when the queue is built, so advancing never touches the database again — which matters when the thing advancing is a background thread in a process with no UI. Resolution happens in Rust, against the library, read-only. A tapped `ss:track:<id>` brings its whole album at the right index, because selecting one song and getting silence after it is not what a record does. Nothing trusts a URI from the controller: the tree only ever publishes `ss:` ids, so there is no file path for anyone to hand back, and an id that resolves to nothing returns a count of zero rather than a guess. Kotlin's half is three things that each fail invisibly on their own. `COMMAND_SET_MEDIA_ITEM` is advertised now — without it a browse row is drawn as playable and does nothing when tapped. `onAddMediaItems` passes ids through instead of media3's default `UnsupportedOperationException`, which kills the tap before it reaches the player. And `handleSetMediaItems` does the work on its own thread, because the first tap in a cold process opens a database AND starts an audio engine, and it is called on the application thread where ten seconds is an ANR. **Two things the device taught, both of which looked like silence.** The headless path has to request audio focus itself: the first run showed a live AAudio track on our uid, decoding perfectly, with `PortMuted=true` and the gain at `-inf` — audible to nobody. On the WebView path the JS asks for focus; a car tapping a cold process has no JS to ask. And it has to write the card itself: `getState()` mirrors `MediaHub.state`, which only the WebView ever fills, so the second run played audio while the session still reported `STATE_NONE` with no title — a head unit showing a blank player over music. The engine knows what it started, and that now goes into `MediaHub.update` in the shape the card already reads. Proven by the prober, which gained a `play` mode for it: bind cold, issue `playFromMediaId` through the session token the browser handed back — Auto's own path, not an interface of ours — and read the state back. `ss:track:1` answers `queued=13` (the album behind it), and the controller reports `state=3` with the title and a position moving 3.8 s → 11.8 s → 19.8 s across the run. `probe.sh <depth> <recent> <mediaId>` is the invocation. **Not done, and it is the display rather than the audio.** Nothing tells Kotlin when the fallback queue advances, so the card will keep showing track one while track two plays. The position moves on its own — `SimpleBasePlayer` extrapolates it while playing — but a track change and a seek both need the pump to push, and the pump currently only speaks to a subscriber that exists when there is a WebView. The handover the design asks for (a UI arriving over live headless playback, and exactly one queue driver surviving) is untested for the same reason.
This commit is contained in:
1 parent
3f56f274c5
commit
f50cf2018a
8 files changed
+527
-6
No files matched your search
@@ -329,6 +329,70 @@ fn children(conn: &Connection, parent: &str) -> rusqlite::Result<Vec<Node>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn a media id from the browse tree into something playable: the files, in
|
||||
/// order, and which one the tap meant.
|
||||
///
|
||||
/// A tapped TRACK brings its whole album with it at the right index, because
|
||||
/// "the album keeps going" is what a record player does and what a driver
|
||||
/// expects — selecting one song should not leave silence after it. A tapped
|
||||
/// ALBUM starts at the top.
|
||||
///
|
||||
/// Read-only like everything else here, and it returns `None` rather than an
|
||||
/// empty queue when the library cannot be opened, so the caller can tell
|
||||
/// "nothing matched" from "could not look".
|
||||
pub(crate) fn resolve_playable(
|
||||
data_dir: &str,
|
||||
media_id: &str,
|
||||
) -> Option<(Vec<crate::headless::PlayTrack>, usize)> {
|
||||
use crate::headless::PlayTrack;
|
||||
let db = db_path(Path::new(data_dir));
|
||||
let conn = match open_read_only(&db) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
warn(&format!("auto play: cannot open {}: {e}", db.display()));
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let album_tracks = |album_id: i64| -> rusqlite::Result<Vec<(i64, PlayTrack)>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT t.id, t.path, t.title, t.artist, t.duration_secs, a.title
|
||||
FROM tracks t JOIN albums a ON a.id = t.album_id
|
||||
WHERE t.album_id = ?1
|
||||
ORDER BY t.disc_no, t.track_no, t.title",
|
||||
)?;
|
||||
let rows = stmt.query_map([album_id], |r| {
|
||||
Ok((
|
||||
r.get::<_, i64>(0)?,
|
||||
PlayTrack {
|
||||
path: PathBuf::from(r.get::<_, String>(1)?),
|
||||
title: r.get(2)?,
|
||||
artist: r.get(3)?,
|
||||
duration: r.get::<_, Option<i64>>(4)?.map(|s| s as f64),
|
||||
album: r.get(5)?,
|
||||
},
|
||||
))
|
||||
})?;
|
||||
rows.collect()
|
||||
};
|
||||
|
||||
if let Some(id) = media_id.strip_prefix("ss:album:") {
|
||||
let rows = album_tracks(id.parse::<i64>().ok()?).ok()?;
|
||||
return Some((rows.into_iter().map(|(_, t)| t).collect(), 0));
|
||||
}
|
||||
if let Some(id) = media_id.strip_prefix("ss:track:") {
|
||||
let track_id = id.parse::<i64>().ok()?;
|
||||
let album_id: i64 = conn
|
||||
.query_row("SELECT album_id FROM tracks WHERE id = ?1", [track_id], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.ok()?;
|
||||
let rows = album_tracks(album_id).ok()?;
|
||||
let idx = rows.iter().position(|(id, _)| *id == track_id).unwrap_or(0);
|
||||
return Some((rows.into_iter().map(|(_, t)| t).collect(), idx));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn browse_json(data_dir: &str, parent: &str) -> String {
|
||||
// THE ROOT NEVER TOUCHES THE DATABASE. The four tabs are a constant, and
|
||||
// gating them on a connection means one unreadable library costs the car
|
||||
@@ -406,3 +470,51 @@ pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_browse(
|
||||
.map(|s| s.into_raw())
|
||||
.unwrap_or(std::ptr::null_mut())
|
||||
}
|
||||
|
||||
/// `AutoNative.play(dataDir, mediaId)` — start playback from a browse row, in
|
||||
/// a process that may have no Activity and no WebView.
|
||||
///
|
||||
/// Returns how many tracks were queued, so Kotlin can answer the car with an
|
||||
/// error state instead of silence when a row resolves to nothing. Catalog and
|
||||
/// Soulseek rows are not in the tree yet and will resolve to nothing by
|
||||
/// construction, which is the correct answer for them: they need the resolver,
|
||||
/// which needs the WebView.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_play(
|
||||
mut env: JNIEnv,
|
||||
_this: JObject,
|
||||
data_dir: JString,
|
||||
media_id: JString,
|
||||
) -> jstring {
|
||||
let dir: Option<String> = env.get_string(&data_dir).ok().map(Into::into);
|
||||
let id: Option<String> = env.get_string(&media_id).ok().map(Into::into);
|
||||
let out = match (dir, id) {
|
||||
(Some(d), Some(m)) => crate::headless::play_media_id(&d, &m),
|
||||
_ => "{\"queued\":0}".to_owned(),
|
||||
};
|
||||
env.new_string(out)
|
||||
.or_else(|_| env.new_string("{\"queued\":0}"))
|
||||
.map(|s| s.into_raw())
|
||||
.unwrap_or(std::ptr::null_mut())
|
||||
}
|
||||
|
||||
/// `AutoNative.transport(action)` — pause / resume / stop against the
|
||||
/// process's engine, for the card's own buttons when no WebView is alive to
|
||||
/// take the usual round trip.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_transport(
|
||||
mut env: JNIEnv,
|
||||
_this: JObject,
|
||||
action: JString,
|
||||
) {
|
||||
let Some(a) = env.get_string(&action).ok().map(String::from) else {
|
||||
return;
|
||||
};
|
||||
let audio = crate::headless::audio(None);
|
||||
match a.as_str() {
|
||||
"pause" => audio.player.pause(),
|
||||
"resume" => audio.player.resume(),
|
||||
"stop" => audio.player.stop(),
|
||||
_ => warn(&format!("auto transport: unknown action {a}")),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
//! The audio engine, owned by the PROCESS rather than by Tauri.
|
||||
//!
|
||||
//! Android Auto can start this app with no Activity: the car binds
|
||||
//! `MediaService`, the service starts the process, and there is no WebView and
|
||||
//! no `tauri::AppHandle` anywhere in it. Playback still has to work, so the
|
||||
//! engine cannot live in Tauri's managed state the way it does on the desktop.
|
||||
//!
|
||||
//! **One `Player` per process, and that is the whole point.** `Player::spawn`
|
||||
//! opens an output stream; two of them in one process is two AAudio streams
|
||||
//! playing over each other, which is the failure this module exists to make
|
||||
//! impossible. Whoever needs audio first creates it — `playback::init` when the
|
||||
//! app launches normally, or [`play_media_id`] when a car taps a row into a
|
||||
//! process that has no UI — and everyone after that gets the same one.
|
||||
//!
|
||||
//! The event receiver is single-consumer, so the pump lives here too and
|
||||
//! offers a **subscription slot**: when the WebView does come up,
|
||||
//! `playback::init` registers its forwarder and starts seeing events, without
|
||||
//! a second engine and without the events it missed being replayed at it.
|
||||
//!
|
||||
//! The queue here is deliberately NOT the app's queue. The JS one keeps
|
||||
//! shuffle, repeat, `alts` failover, provenance and the three-ahead preload;
|
||||
//! this is a `Vec<PathBuf>` and an index whose only job is that the album keeps
|
||||
//! playing when there is no JS alive to advance it.
|
||||
|
||||
use player::{Player, PlayerEvent};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc::Sender;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
pub struct Audio {
|
||||
pub player: Player,
|
||||
/// Where the WebView's pump listens, once there is a WebView. `None` in a
|
||||
/// service-only process, and every event is simply dropped — nothing is
|
||||
/// buffered for a UI that may never exist.
|
||||
subscriber: Mutex<Option<Sender<PlayerEvent>>>,
|
||||
queue: Mutex<Queue>,
|
||||
}
|
||||
|
||||
/// Enough of a track to play it and to describe it on a card, read once when
|
||||
/// the queue is built so that advancing never needs the database again.
|
||||
#[derive(Clone)]
|
||||
pub struct PlayTrack {
|
||||
pub path: PathBuf,
|
||||
pub title: String,
|
||||
pub artist: String,
|
||||
pub album: String,
|
||||
pub duration: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Queue {
|
||||
items: Vec<PlayTrack>,
|
||||
idx: usize,
|
||||
}
|
||||
|
||||
static AUDIO: OnceLock<Arc<Audio>> = OnceLock::new();
|
||||
|
||||
/// The process's one engine, created on first use.
|
||||
///
|
||||
/// `device` is honoured only by the caller that wins the race to create it;
|
||||
/// afterwards it is ignored, because changing the output device is
|
||||
/// `Player::set_output_device` on the live engine rather than a new engine.
|
||||
pub fn audio(device: Option<String>) -> Arc<Audio> {
|
||||
AUDIO
|
||||
.get_or_init(|| {
|
||||
let (player, events) = Player::spawn_with_device(device);
|
||||
let audio = Arc::new(Audio {
|
||||
player,
|
||||
subscriber: Mutex::new(None),
|
||||
queue: Mutex::new(Queue::default()),
|
||||
});
|
||||
let pump = Arc::clone(&audio);
|
||||
let _ = std::thread::Builder::new()
|
||||
.name("audio-pump".into())
|
||||
.spawn(move || {
|
||||
for ev in events {
|
||||
// The headless queue advances FIRST, so that a track
|
||||
// ending is followed by the next one whether or not
|
||||
// anything is listening. When the WebView is up it
|
||||
// drives its own queue and this one is empty, so this
|
||||
// costs a lock and nothing else.
|
||||
if matches!(ev, PlayerEvent::TrackEnded { .. }) {
|
||||
pump.advance();
|
||||
}
|
||||
let sub = pump.subscriber.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(tx) = sub.as_ref() {
|
||||
// A closed channel means the WebView went away;
|
||||
// the engine outlives it and keeps playing.
|
||||
let _ = tx.send(ev);
|
||||
}
|
||||
}
|
||||
});
|
||||
audio
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
impl Audio {
|
||||
/// Point the pump at the WebView's forwarder. Replaces any previous one,
|
||||
/// which is what a reload should do.
|
||||
pub fn subscribe(&self, tx: Sender<PlayerEvent>) {
|
||||
*self.subscriber.lock().unwrap_or_else(|e| e.into_inner()) = Some(tx);
|
||||
}
|
||||
|
||||
/// Hand the queue over to the UI: from here the JS decides what plays next,
|
||||
/// so the fallback must stop advancing or both would.
|
||||
pub fn clear_queue(&self) {
|
||||
let mut q = self.queue.lock().unwrap_or_else(|e| e.into_inner());
|
||||
q.items.clear();
|
||||
q.idx = 0;
|
||||
}
|
||||
|
||||
fn advance(&self) {
|
||||
let next = {
|
||||
let mut q = self.queue.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if q.items.is_empty() {
|
||||
None
|
||||
} else {
|
||||
q.idx += 1;
|
||||
q.items.get(q.idx).cloned()
|
||||
}
|
||||
};
|
||||
match next {
|
||||
Some(t) => self.player.play_local(t.path, t.duration),
|
||||
// The end of the album is the end of the album. Leaving the queue
|
||||
// in place means a later `advance` finds the index past the end and
|
||||
// does nothing, rather than wrapping round to track one.
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn start(&self, items: Vec<PlayTrack>, idx: usize) {
|
||||
let first = items.get(idx).cloned();
|
||||
{
|
||||
let mut q = self.queue.lock().unwrap_or_else(|e| e.into_inner());
|
||||
q.items = items;
|
||||
q.idx = idx;
|
||||
}
|
||||
match first {
|
||||
Some(t) => self.player.play_local(t.path, t.duration),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// What is playing right now, as the card needs it. `None` when this
|
||||
/// engine has never been given a queue — which is the normal state in a
|
||||
/// process the WebView drives, and the signal to leave the card alone.
|
||||
pub fn now_playing(&self) -> Option<PlayTrack> {
|
||||
let q = self.queue.lock().unwrap_or_else(|e| e.into_inner());
|
||||
q.items.get(q.idx).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
/// Play what a car tapped: the media id from the browse tree, resolved against
|
||||
/// the library read-only, with the rest of its album behind it so the record
|
||||
/// keeps going.
|
||||
///
|
||||
/// Returns the number of tracks queued; 0 means nothing playable was found,
|
||||
/// which the caller turns into an error state on the car rather than silence.
|
||||
pub fn play_media_id(data_dir: &str, media_id: &str) -> String {
|
||||
let Some((items, idx)) = crate::auto_browse::resolve_playable(data_dir, media_id) else {
|
||||
return "{\"queued\":0}".to_owned();
|
||||
};
|
||||
if items.is_empty() {
|
||||
return "{\"queued\":0}".to_owned();
|
||||
}
|
||||
let n = items.len();
|
||||
let audio = audio(None);
|
||||
audio.start(items, idx);
|
||||
match audio.now_playing() {
|
||||
Some(t) => serde_json::json!({
|
||||
"queued": n,
|
||||
"title": t.title,
|
||||
"artist": t.artist,
|
||||
"album": t.album,
|
||||
"duration": t.duration,
|
||||
})
|
||||
.to_string(),
|
||||
None => format!("{{\"queued\":{n}}}"),
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,10 @@ mod android_glue;
|
||||
#[cfg(target_os = "android")]
|
||||
mod auto_browse;
|
||||
mod commands;
|
||||
/// The audio engine as a process-wide singleton, so a car can play into a
|
||||
/// process that has no Activity — and so there is never a second one.
|
||||
#[cfg(target_os = "android")]
|
||||
mod headless;
|
||||
mod community;
|
||||
mod config;
|
||||
mod cover_logo;
|
||||
|
||||
Reference in new issue
Block a user