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
+32
@@ -567,6 +567,23 @@ object LibraryTree : MediaLibrarySession.Callback {
|
||||
io,
|
||||
)
|
||||
|
||||
/**
|
||||
* A controller handing us items that carry a media id and no URI — which
|
||||
* is every tap on a browse row, because the tree publishes `ss:` ids and
|
||||
* never a file path.
|
||||
*
|
||||
* media3's default answers UnsupportedOperationException here and the tap
|
||||
* dies before it ever reaches the player. Passing the items straight
|
||||
* through is the right move for this app: resolution happens in
|
||||
* [SoulStreamPlayer.handleSetMediaItems], against the library, where an id
|
||||
* is checked rather than believed.
|
||||
*/
|
||||
override fun onAddMediaItems(
|
||||
mediaSession: MediaSession,
|
||||
controller: MediaSession.ControllerInfo,
|
||||
mediaItems: MutableList<MediaItem>,
|
||||
): ListenableFuture<MutableList<MediaItem>> = Futures.immediateFuture(mediaItems)
|
||||
|
||||
/**
|
||||
* How the car should DRAW this node's children — the only say the app has
|
||||
* over the layout, since the host builds the UI and we supply content.
|
||||
@@ -979,4 +996,19 @@ object AutoNative {
|
||||
|
||||
/** Returns a JSON array of rows; never null, `[]` when anything failed. */
|
||||
external fun browse(dataDir: String, parentId: String): String
|
||||
|
||||
/**
|
||||
* Start playback from a browse row, in a process that may have no
|
||||
* Activity. Returns how many tracks were queued — 0 means the row
|
||||
* resolved to nothing, which the car should be told about rather than
|
||||
* left in silence.
|
||||
*
|
||||
* A tapped track brings its album with it, so the record keeps playing
|
||||
* without any JS alive to advance it.
|
||||
*/
|
||||
external fun play(dataDir: String, mediaId: String): String
|
||||
|
||||
/** pause / resume / stop against the process's engine, for when there is
|
||||
* no WebView to take the usual round trip. */
|
||||
external fun transport(action: String)
|
||||
}
|
||||
+82
-4
@@ -203,10 +203,16 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
|
||||
* then sets the button's command to the _MEDIA_ITEM one: advertise only
|
||||
* the plain one and the button appears and does nothing.
|
||||
*
|
||||
* Not here, on purpose: COMMAND_SET_MEDIA_ITEM, COMMAND_CHANGE_MEDIA_ITEMS
|
||||
* and COMMAND_SEEK_TO_MEDIA_ITEM. Tapping a row of the queue is Android
|
||||
* Auto's job and needs a window-index → queue-index mapping; `queueBase`
|
||||
* is half of it and it lands with the round that uses it. */
|
||||
* COMMAND_SET_MEDIA_ITEM arrived with the round that plays from the car:
|
||||
* it is what lets a controller hand us a media id at all, and without it
|
||||
* a browse row is drawn as playable and does nothing when tapped. It is
|
||||
* safe to advertise unconditionally because [handleSetMediaItems]
|
||||
* resolves the id against the library itself rather than trusting a URI.
|
||||
*
|
||||
* Still not here, on purpose: COMMAND_CHANGE_MEDIA_ITEMS and
|
||||
* COMMAND_SEEK_TO_MEDIA_ITEM. Tapping a row of the QUEUE is a different
|
||||
* thing from tapping a row of the library — it needs a window-index →
|
||||
* queue-index mapping, and `queueBase` is only half of that. */
|
||||
private fun commands(np: NowPlayingArgs?): Player.Commands {
|
||||
val b = Player.Commands.Builder()
|
||||
.add(Player.COMMAND_PLAY_PAUSE)
|
||||
@@ -220,6 +226,7 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
|
||||
.add(Player.COMMAND_GET_METADATA)
|
||||
.add(Player.COMMAND_GET_AUDIO_ATTRIBUTES)
|
||||
.add(Player.COMMAND_SET_AUDIO_ATTRIBUTES)
|
||||
.add(Player.COMMAND_SET_MEDIA_ITEM)
|
||||
if (np != null) {
|
||||
b.addIf(Player.COMMAND_SEEK_TO_NEXT, np.hasNext)
|
||||
b.addIf(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM, np.hasNext)
|
||||
@@ -242,6 +249,77 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
|
||||
* `handle*` is `throw IllegalStateException`. */
|
||||
override fun handlePrepare(): ListenableFuture<*> = Futures.immediateVoidFuture()
|
||||
|
||||
/**
|
||||
* A row tapped in the car.
|
||||
*
|
||||
* The id is resolved against the library IN RUST, not from a URI on the
|
||||
* item: the browse tree hands out `ss:track:<id>` and nothing else, so
|
||||
* there is no file path to trust and no way for a controller to name a
|
||||
* file we did not publish.
|
||||
*
|
||||
* It runs off the caller's thread because the first tap in a cold process
|
||||
* does two slow things — opens the database and starts the audio engine —
|
||||
* and this is called on the application thread, where ten seconds is an
|
||||
* ANR. The future resolving is what tells media3 the command took.
|
||||
*
|
||||
* `startIndex` can be `INDEX_UNSET`; that means "play the list from the
|
||||
* top", which for a single tapped row is the row itself.
|
||||
*/
|
||||
override fun handleSetMediaItems(
|
||||
mediaItems: MutableList<MediaItem>,
|
||||
startIndex: Int,
|
||||
startPositionMs: Long,
|
||||
): ListenableFuture<*> {
|
||||
val i = if (startIndex == androidx.media3.common.C.INDEX_UNSET) 0 else startIndex
|
||||
val id = mediaItems.getOrNull(i)?.mediaId
|
||||
if (id.isNullOrEmpty()) return Futures.immediateVoidFuture()
|
||||
return Futures.submit<Unit>(
|
||||
{
|
||||
// FOCUS FIRST, and it is not optional. Without it the track
|
||||
// opens into a port the framework keeps muted: the first run
|
||||
// of this path showed a live AAudio track on our uid with
|
||||
// PortMuted=true and the gain at -inf — decoding perfectly,
|
||||
// audible to nobody. On the WebView path the JS asks for
|
||||
// focus; a car tapping a cold process has no JS to do it.
|
||||
MediaHub.requestFocus(ctx.applicationContext)
|
||||
val reply = AutoNative.play(ctx.dataDir.absolutePath, id)
|
||||
val o = try { org.json.JSONObject(reply) } catch (e: Throwable) { org.json.JSONObject() }
|
||||
val queued = o.optInt("queued", 0)
|
||||
android.util.Log.i("SoulStream", "auto play $id queued=$queued")
|
||||
if (queued == 0) {
|
||||
// Nothing resolved: give the focus back rather than
|
||||
// holding another app's audio hostage for silence.
|
||||
MediaHub.dropFocus(ctx.applicationContext)
|
||||
} else {
|
||||
// Write the card ourselves. `getState()` mirrors
|
||||
// MediaHub.state, and the only thing that ever fills it is
|
||||
// the WebView — so a cold car tap played audio while the
|
||||
// session still said STATE_NONE with no title, which is a
|
||||
// head unit showing a blank player over music. The engine
|
||||
// knows what it started; this is that, in the shape the
|
||||
// card already reads.
|
||||
val np = NowPlayingArgs()
|
||||
np.title = o.optString("title").ifEmpty { null }
|
||||
np.artist = o.optString("artist").ifEmpty { null }
|
||||
np.album = o.optString("album").ifEmpty { null }
|
||||
np.duration = if (o.isNull("duration")) null else o.optDouble("duration")
|
||||
np.position = 0.0
|
||||
np.playing = true
|
||||
np.hasNext = queued > 1
|
||||
np.hasPrev = false
|
||||
MediaHub.update(ctx.applicationContext, np)
|
||||
}
|
||||
},
|
||||
playIo,
|
||||
)
|
||||
}
|
||||
|
||||
/** One thread, for the tap that opens a database and starts an engine. */
|
||||
private val playIo =
|
||||
java.util.concurrent.Executors.newSingleThreadExecutor { r ->
|
||||
Thread(r, "soulstream-auto-play").apply { isDaemon = true }
|
||||
}
|
||||
|
||||
override fun handleStop(): ListenableFuture<*> {
|
||||
MediaHub.act("stop")
|
||||
return awaitUpdate()
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
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.
|
||||
@@ -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;
|
||||
|
||||
@@ -21,6 +21,10 @@ source src-tauri/android/env.sh
|
||||
|
||||
DEPTH="${1:-2}"
|
||||
RECENT="${2:-false}"
|
||||
# A media id here plays it instead of walking the tree — the only way to prove
|
||||
# a car's tap without a car. The run takes ~25s because the claim being tested
|
||||
# is that the SECOND track starts on its own.
|
||||
PLAY="${3:-}"
|
||||
APK=tools/auto-prober/prober/build/outputs/apk/debug/prober-debug.apk
|
||||
GRADLEW="$ROOT/src-tauri/gen/android/gradlew.bat"
|
||||
|
||||
@@ -42,8 +46,8 @@ echo "== cold: force-stopping SoulStream $(date +%T)"
|
||||
"$ADB" logcat -c
|
||||
"$ADB" shell am force-stop com.soulstream.app
|
||||
"$ADB" shell am start -W -n com.soulstream.autoprober/.ProbeActivity \
|
||||
--ei depth "$DEPTH" --ez recent "$RECENT" > /dev/null
|
||||
sleep 7
|
||||
--ei depth "$DEPTH" --ez recent "$RECENT" --es play "$PLAY" > /dev/null
|
||||
if [ -n "$PLAY" ]; then sleep 25; else sleep 7; fi
|
||||
|
||||
echo
|
||||
echo "== the probe (one JSON object per event)"
|
||||
|
||||
@@ -60,6 +60,8 @@ class ProbeActivity : Activity() {
|
||||
private var browser: MediaBrowser? = null
|
||||
private var t0 = 0L
|
||||
private var depth = 2
|
||||
/** A media id to play instead of walking the tree. Empty = walk. */
|
||||
private var playId = ""
|
||||
/** subscriptions asked for and not yet answered; zero means the walk is
|
||||
* over. Main thread only — every callback lands there. */
|
||||
private var pending = 0
|
||||
@@ -76,6 +78,7 @@ class ProbeActivity : Activity() {
|
||||
super.onCreate(saved)
|
||||
t0 = System.currentTimeMillis()
|
||||
depth = intent?.getIntExtra("depth", 2) ?: 2
|
||||
playId = intent?.getStringExtra("play") ?: ""
|
||||
val recent = intent?.getBooleanExtra("recent", false) ?: false
|
||||
|
||||
say(
|
||||
@@ -123,9 +126,51 @@ class ProbeActivity : Activity() {
|
||||
.put("id", root).put("tokenOk", token),
|
||||
)
|
||||
if (root.isEmpty()) { fail("no root id"); return }
|
||||
if (playId.isNotEmpty()) { play(b); return }
|
||||
walk(root, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* `playFromMediaId`, the way a car plays a browse row, and the only
|
||||
* way to prove the tap works without a head unit.
|
||||
*
|
||||
* It goes through the session token the browser handed back rather
|
||||
* than through any interface of ours: that is the path Android Auto
|
||||
* takes, so a shortcut here would prove a path nothing uses. The
|
||||
* state is read back twice — once shortly after, once late enough for
|
||||
* the NEXT track — because the interesting claim is not that a file
|
||||
* played but that the queue advanced with no JS alive to advance it.
|
||||
*/
|
||||
private fun play(b: android.media.browse.MediaBrowser) {
|
||||
val ctl = try {
|
||||
android.media.session.MediaController(this@ProbeActivity, b.sessionToken)
|
||||
} catch (e: Exception) {
|
||||
fail("no controller: $e"); return
|
||||
}
|
||||
// The bind watchdog has done its job by now, and the whole point
|
||||
// of this path is to outlive it — a second track does not start
|
||||
// inside eight seconds.
|
||||
main.removeCallbacks(watchdog)
|
||||
say(JSONObject().put("ev", "play").put("id", playId).put("ms", ms()))
|
||||
ctl.transportControls.playFromMediaId(playId, null)
|
||||
val report = { tag: String ->
|
||||
val st = ctl.playbackState
|
||||
val md = ctl.metadata
|
||||
say(
|
||||
JSONObject().put("ev", tag).put("ms", ms())
|
||||
.put("state", st?.state ?: -1)
|
||||
.put("pos", st?.position ?: -1)
|
||||
.put(
|
||||
"title",
|
||||
md?.getString(android.media.MediaMetadata.METADATA_KEY_TITLE) ?: "",
|
||||
),
|
||||
)
|
||||
}
|
||||
main.postDelayed({ report("playing") }, 4000)
|
||||
main.postDelayed({ report("still") }, 12000)
|
||||
main.postDelayed({ report("advanced"); stop() }, 20000)
|
||||
}
|
||||
|
||||
/* the two ways a bind ends badly. "connection failed" with nothing
|
||||
else in the log is usually package visibility — see <queries> in
|
||||
the manifest — or a service that answered null from onGetSession. */
|
||||
|
||||
Reference in new issue
Block a user