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
@@ -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}}}"),
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user