Files
SoulStream/src-tauri/src/headless.rs
T
KeelrLeek 4d75395411 android auto: the car's own controls, the covers, and four tabs with faces
Everything here came from putting the thing on a real head unit and looking at
it, which is the only reason any of it was found.

**The transport did nothing.** Pause, seek, next and previous all routed
through `MediaHub.act`, which is a round trip out to the WebView — and a car
that started a cold process has no WebView, so every button drew, moved, and
sent a message to nobody. `AutoNative.driving()` reports whether the headless
queue is what is playing, and when it is, the transport goes straight to the
engine instead: pause/resume, a real seek, and next/previous that move the
fallback queue and push a card update after them. A normal phone session is
untouched and still goes through the JS, which owns shuffle, repeat, `alts`
failover and the real queue.

That is the THIRD instance of one bug: the headless path silently skipping
something the WebView path does for free. Audio focus was the first (a live
AAudio track, muted, audible to nobody), the card was the second (STATE_NONE
over playing music), the transport is this one. Anything the JS does on the
way to making a sound has to have an answer here too, and the failure is always
silence rather than an error.

**The covers, and the colour.** The playback screen was showing the app icon,
because nothing filled the card's art. Android Auto washes that screen with a
colour it extracts from whatever artwork it is handed — so every record was
tinted the falcon's blue. The engine already knows the album's cover path, so
it now travels with the track and the card is built from it. Random Access
Memories comes up gold. The app derives an accent per cover and the car cannot
be told to, but it turns out the car does it anyway, for free, given art.

**Four tabs with icons**, which is what every other media app in the car has:
a root browsable item's artwork is drawn as its tab icon, and ours had none, so
they rendered as bare words. `android.resource://` URIs rather than the
FileProvider — these are our own resources, identical on every install, and
granting a per-connection URI for an icon that never changes would be wrong.

**The grid, in the right place.** The hint was set on each node's own
`LibraryParams` when returning its children; Auto reads it from the ROOT as a
default and from the PARENT ITEM as an override. So the albums drew as a list
with the grid hint set correctly and delivered nowhere. The root declares
`CONTENT_STYLE_SUPPORTED` now and the `ss:albums` tab carries the override,
along with each artist, whose children are also sleeves.

**Shuffle** is a `CommandButton` on `setMediaButtonPreferences` — both spellings
exist in 1.10.1 and `setCustomLayout` is the older one. It uses the PREDEFINED
`ICON_SHUFFLE_ON` rather than a drawable of ours: a custom resource the host
does not recognise is dropped silently, which is what happened the first time,
and `ICON_SHUFFLE_OFF` names the state and is drawn as a circular arrow that
reads as repeat. It reorders only what is AHEAD, so the current song does not
restart under the driver's hands and "previous" still goes back to what was
actually heard.

The accent is `#31C0CB` in a car theme named by
`com.google.android.gms.car.application.theme`. Its parent is a FRAMEWORK theme
because the style is resolved inside the car's process, where AppCompat and
Material are not guaranteed to exist.

Proven on the Pixel 6a through a real Android Auto session, driven from the
DHU: the four tabs render with their icons, Albums is a grid of full covers,
the playback screen shows the album art with the colour pulled from it, and a
media `next` moves the queue and lands the card on "The Game of Love".

**What this does not touch, and it is the next question.** Everything in the
tree is a local file. Playlists are in the database, are local, and would play
from cold exactly as albums do — they are absent only because Auto gives four
root slots and four are taken, which is a trade to make rather than a limit.
Catalog and Soulseek rows genuinely cannot play from a cold process, but they
CAN whenever the app is warm, through the same `MediaHub.act` round trip the
transport just stopped depending on — browsable always, honest error only when
there is no WebView. That is a better answer than the design's "never" and it
is where this goes next.
2026-09-15 13:30:54 -05:00

325 lines
12 KiB
Rust

//! 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>,
/// Absolute path to the album's cover. The card is built from this, and
/// so is the colour Android Auto washes the playback screen with — it
/// extracts from whatever artwork it is given, which is why supplying the
/// app icon tinted every record the same blue.
pub art: Option<String>,
}
#[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()
}
};
let more = {
let q = self.queue.lock().unwrap_or_else(|e| e.into_inner());
q.idx + 1 < q.items.len()
};
match next {
Some(t) => {
self.player.play_local(t.path.clone(), t.duration);
// Tell Kotlin, or the card sits on track one for the rest of
// the album while the right audio plays under it.
notify_track(&t, more);
}
// 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 => {}
}
}
/// Shuffle what is still AHEAD, leaving the current track playing.
///
/// Shuffling the whole queue would restart the song under the driver's
/// hands, which is not what anyone means by the button. Nothing before the
/// index moves either, so "previous" still goes back to what was actually
/// heard.
///
/// The generator is a xorshift seeded from the clock rather than a crate:
/// this decides the order of an album, not anything anyone should be able
/// to bet on, and a dependency for it would be the wrong trade.
pub fn shuffle_rest(&self) -> usize {
let mut q = self.queue.lock().unwrap_or_else(|e| e.into_inner());
let start = q.idx + 1;
let len = q.items.len();
if len <= start + 1 {
return 0;
}
let mut s = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0x9E37_79B9_7F4A_7C15)
| 1;
let mut i = len;
while i > start + 1 {
i -= 1;
s ^= s << 13;
s ^= s >> 7;
s ^= s << 17;
let j = start + (s % ((i - start + 1) as u64)) as usize;
q.items.swap(i, j);
}
len - start
}
/// Move by one track in the fallback queue. Returns false at either end,
/// so the caller can leave the card alone rather than pretend.
///
/// This exists because the car's next/previous buttons go through the
/// player, and the player's usual answer is a round trip to the WebView —
/// which in a service-only process is a message to nobody. Every transport
/// control was dead here until this.
pub fn skip(&self, delta: i64) -> bool {
let picked = {
let mut q = self.queue.lock().unwrap_or_else(|e| e.into_inner());
if q.items.is_empty() {
None
} else {
let target = q.idx as i64 + delta;
if target < 0 || target >= q.items.len() as i64 {
None
} else {
q.idx = target as usize;
q.items.get(q.idx).cloned()
}
}
};
let more = {
let q = self.queue.lock().unwrap_or_else(|e| e.into_inner());
q.idx + 1 < q.items.len()
};
match picked {
Some(t) => {
self.player.play_local(t.path.clone(), t.duration);
notify_track(&t, more);
true
}
None => false,
}
}
/// True when this engine is the one driving playback — i.e. a car started
/// it and no WebView has taken over. The transport uses it to decide
/// whether a button belongs to Rust or to the JS.
pub fn driving(&self) -> bool {
!self.queue.lock().unwrap_or_else(|e| e.into_inner()).items.is_empty()
}
/// 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()
}
}
/// Hand a track change up to Kotlin so the card can follow the fallback queue.
///
/// This runs on the audio pump — a native thread with no Java on its stack —
/// so it has to attach to the VM itself. The VM is there because `JNI_OnLoad`
/// published it to `ndk-context` at `System.loadLibrary` time, before any
/// Activity existed, which is the same hand-off the audio backend relies on
/// and the reason a service-only process can do this at all.
///
/// Every failure is swallowed. A card that misses an update is a cosmetic
/// problem; a panic across the JNI boundary on the thread that drives playback
/// is the music stopping.
fn notify_track(t: &PlayTrack, has_next: bool) {
let json = serde_json::json!({
"title": t.title,
"artist": t.artist,
"album": t.album,
"duration": t.duration,
"art": t.art,
"hasNext": has_next,
})
.to_string();
let ctx = ndk_context::android_context();
if ctx.vm().is_null() {
return;
}
// SAFETY: the pointer came from ndk-context, which android_glue filled
// with the real JavaVM and never replaces.
let Ok(vm) = (unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }) else {
return;
};
// The pump is not a Java thread; attaching is what makes it callable.
let Ok(mut env) = vm.attach_current_thread() else {
return;
};
let Ok(arg) = env.new_string(&json) else {
return;
};
let called = env.call_static_method(
"com/soulstream/mediasession/AutoNative",
"headlessTrack",
"(Ljava/lang/String;)V",
&[(&arg).into()],
);
if called.is_err() {
// An exception left pending would be thrown at whatever Java code ran
// next on this thread, which would be something entirely unrelated.
let _ = env.exception_clear();
}
}
/// 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,
"art": t.art,
})
.to_string(),
None => format!("{{\"queued\":{n}}}"),
}
}