**Pausing looked like the track restarting, and it was not only a look.** The card carried the position it was BUILT with — zero, on the headless path, because nothing wrote it again. media3 extrapolates a position while playing, so the bar moved and seemed right, but every state rebuild snapped back to that stale zero. Worse than cosmetic: media3 then BELIEVED the player was at zero, and reconciled on resume by seeking there, so the track really did start again. The same staleness made a seek look like it had done nothing — the engine moved and the next rebuild reported zero. `getState()` asks the engine now, on every rebuild, through `positionMs()` and `sounding()`. One JNI call and always true, instead of a number remembered from the moment playback began. A seek moves the reported position immediately rather than waiting for the engine's next event, so the scrubber does not lag a beat behind and read as snapping back. **Search, which is the half of round fifty-two that needed no decisions.** `onSearch` answers the count and `onGetSearchResult` the rows; both run the same couple of LIKEs over the library, because there is no search index in this schema and a few hundred albums do not need one. Albums rank before tracks: "play Discovery" almost always means the record, and whoever wanted the song would have said more — which matters because a voice command takes the first result. Voice comes in by a different door and it is implemented too. "Play X" arrives at `onAddMediaItems` as an item with NO media id and a `searchQuery` instead; Google's own samples disagree about whether Auto uses that or `onSearch`, so both are answered. An empty query is "play some music" and gets Recent rather than an error. The prober gained a `search:` prefix for it — `probe.sh 2 false "search:discovery"` issues `playFromSearch` through the session token, which is the path a spoken sentence actually takes. It answers `auto voice 'discovery' n=1`, `auto play ss:album:2 queued=14`, and the controller reports "One More Time" playing: a sentence turned into a record, in a process with no Activity. Still library-only, and that is now the whole remaining question. Playlists are local and would play from cold exactly as albums do, blocked only on which four root slots to spend. Catalog and Soulseek rows cannot resolve without the WebView, but can whenever the app is warm — browsable always, an honest error only when cold.
371 lines
15 KiB
Rust
371 lines
15 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>,
|
|
/// Where the engine actually is, in seconds, and whether it is sounding.
|
|
///
|
|
/// The card used to carry the position it was BUILT with — zero — for the
|
|
/// life of a track. media3 extrapolates a position while playing, so the
|
|
/// bar moved and looked right, but every state re-read snapped back to
|
|
/// that stale zero: pausing looked like the track restarting, and a seek
|
|
/// that really happened showed as nothing at all.
|
|
progress: Mutex<(f64, bool)>,
|
|
}
|
|
|
|
/// 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()),
|
|
progress: Mutex::new((0.0, false)),
|
|
});
|
|
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();
|
|
}
|
|
// Keep the engine's own truth current. This is the
|
|
// only place that knows it, and the card asks for it
|
|
// rather than being pushed at — a position event is
|
|
// frequent, and a JNI hop per event would cost more
|
|
// than the card is worth.
|
|
{
|
|
let mut p =
|
|
pump.progress.lock().unwrap_or_else(|e| e.into_inner());
|
|
match &ev {
|
|
PlayerEvent::Position { secs, .. } => p.0 = *secs,
|
|
PlayerEvent::Playing => p.1 = true,
|
|
PlayerEvent::Paused => p.1 = false,
|
|
PlayerEvent::Stopped => {
|
|
p.0 = 0.0;
|
|
p.1 = false;
|
|
}
|
|
PlayerEvent::TrackEnded { .. } => p.0 = 0.0,
|
|
_ => {}
|
|
}
|
|
}
|
|
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);
|
|
*self.progress.lock().unwrap_or_else(|e| e.into_inner()) = (0.0, true);
|
|
}
|
|
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
|
|
}
|
|
|
|
/// Seek, and move the reported position with it. Waiting for the engine's
|
|
/// next position event would leave the card showing the OLD spot for a
|
|
/// beat, which reads as the scrubber snapping back.
|
|
pub fn seek(&self, secs: f64) {
|
|
self.player.seek(secs);
|
|
self.progress.lock().unwrap_or_else(|e| e.into_inner()).0 = secs;
|
|
}
|
|
|
|
/// 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);
|
|
*self.progress.lock().unwrap_or_else(|e| e.into_inner()) = (0.0, true);
|
|
notify_track(&t, more);
|
|
true
|
|
}
|
|
None => false,
|
|
}
|
|
}
|
|
|
|
/// Where the engine is now: seconds, and whether it is sounding.
|
|
pub fn progress(&self) -> (f64, bool) {
|
|
*self.progress.lock().unwrap_or_else(|e| e.into_inner())
|
|
}
|
|
|
|
/// 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}}}"),
|
|
}
|
|
}
|