android auto: the position the engine is actually at, and search by voice

**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.
This commit is contained in:
KeelrLeek committed 2026-09-15 14:21:28 -05:00
1 parent 4d75395411
commit f59a2d773e
6 files changed
+327 -7

No files matched your search

@@ -664,7 +664,66 @@ object LibraryTree : MediaLibrarySession.Callback {
mediaSession: MediaSession,
controller: MediaSession.ControllerInfo,
mediaItems: MutableList<MediaItem>,
): ListenableFuture<MutableList<MediaItem>> = Futures.immediateFuture(mediaItems)
): ListenableFuture<MutableList<MediaItem>> =
Futures.submit<MutableList<MediaItem>>(
{
// "Play the Fall of Troy" arrives here as an item with NO
// media id and a searchQuery instead — Google's own samples
// disagree about whether voice uses this path or onSearch, so
// both are implemented. Resolving it to the best row is what
// turns a spoken sentence into a record.
val needsSearch =
mediaItems.isNotEmpty() &&
mediaItems[0].mediaId.isEmpty() &&
!mediaItems[0].requestMetadata.searchQuery.isNullOrEmpty()
if (!needsSearch) {
mediaItems
} else {
val q = mediaItems[0].requestMetadata.searchQuery ?: ""
val hits = searchRows(q, controller.packageName)
android.util.Log.i("SoulStream", "auto voice '" + q + "' n=" + hits.size)
if (hits.isEmpty()) mediaItems else ArrayList(hits)
}
},
io,
)
/**
* A query, typed on the car's search or spoken at it.
*
* The protocol is two calls: this one answers "how many", then the host
* asks for the rows. Both run the same query — it is a couple of LIKEs
* over a few hundred albums, and a cache between two calls that arrive
* milliseconds apart would be a cache to invalidate for nothing.
*/
override fun onSearch(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
query: String,
params: LibraryParams?,
): ListenableFuture<LibraryResult<Void>> =
Futures.submit<LibraryResult<Void>>(
{
val n = searchRows(query, browser.packageName).size
android.util.Log.i("SoulStream", "auto search '" + query + "' n=" + n)
session.notifySearchResultChanged(browser, query, n, params)
LibraryResult.ofVoid()
},
io,
)
override fun onGetSearchResult(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
query: String,
page: Int,
pageSize: Int,
params: LibraryParams?,
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> =
Futures.submit<LibraryResult<ImmutableList<MediaItem>>>(
{ LibraryResult.ofItemList(searchRows(query, browser.packageName), params) },
io,
)
/**
* Custom commands have to be GRANTED per controller or the button on the
@@ -805,6 +864,28 @@ object LibraryTree : MediaLibrarySession.Callback {
android.util.Log.w("SoulStream", "auto browse $parentId: " + e)
return ImmutableList.of()
}
return itemsFrom(ctx, json, grantTo, parentId)
}
/** Rows for a typed or spoken query. Same JSON, same conversion. */
private fun searchRows(query: String, grantTo: String?): ImmutableList<MediaItem> {
val ctx = MediaHub.appContext ?: return ImmutableList.of()
val json =
try {
AutoNative.search(ctx.dataDir.absolutePath, query)
} catch (e: Throwable) {
android.util.Log.w("SoulStream", "auto search: " + e)
return ImmutableList.of()
}
return itemsFrom(ctx, json, grantTo, "search")
}
private fun itemsFrom(
ctx: Context,
json: String,
grantTo: String?,
parentId: String,
): ImmutableList<MediaItem> {
val out = ImmutableList.Builder<MediaItem>()
try {
val arr = JSONArray(json)
@@ -1155,6 +1236,16 @@ object AutoNative {
/** Scrub the engine directly, in seconds. */
external fun seek(secs: Double)
/** Rows for a typed or spoken query; same JSON shape as [browse]. */
external fun search(dataDir: String, query: String): String
/** Where the engine actually is, in ms. Asked for on every card rebuild. */
external fun positionMs(): Long
/** Whether the engine is really sounding, rather than what it was last
* told to do. */
external fun sounding(): Boolean
/** True when the headless queue is what is playing — i.e. a car started a
* cold process and there is no WebView to route a transport command to.
* False in a normal phone session, where the JS owns the queue. */
@@ -95,7 +95,20 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
}
}
val posMs = if (np.position > 0) (np.position * 1000).toLong() else 0L
/* THE POSITION COMES FROM THE ENGINE, not from the card.
`np.position` is whatever the state was BUILT with, and on the
headless path that is zero for the life of the track — nothing
writes it again. media3 extrapolates while playing, so the bar
moved and looked correct, but every rebuild snapped back to zero:
pausing read as the track restarting, and a seek that genuinely
happened showed as nothing. Asking the engine costs one JNI call
per rebuild and is always true. */
var posMs = if (np.position > 0) (np.position * 1000).toLong() else 0L
var sounding = np.playing
if (headless()) {
posMs = AutoNative.positionMs()
sounding = AutoNative.sounding()
}
return SimpleBasePlayer.State.Builder()
.setAvailableCommands(commands(np))
/* STATE_READY even when paused. shouldShowNotification treats
@@ -104,7 +117,7 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
There is no buffering state to report either: media.svelte.js
already folds `buffering` into `playing`. */
.setPlaybackState(Player.STATE_READY)
.setPlayWhenReady(np.playing, Player.PLAY_WHEN_READY_CHANGE_REASON_REMOTE)
.setPlayWhenReady(sounding, Player.PLAY_WHEN_READY_CHANGE_REASON_REMOTE)
.setPlaylist(items)
.setCurrentMediaItemIndex(at)
/* The long overload, not a PositionSupplier: State's own
+42
View File
@@ -0,0 +1,42 @@
android auto: the position the engine is actually at, and search by voice
**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.
+119 -1
View File
@@ -394,6 +394,81 @@ pub(crate) fn resolve_playable(
None
}
/// What a spoken or typed query finds, in the order a driver means it.
///
/// Albums first, then artists, then tracks. "Play Discovery" almost always
/// means the record, and a driver who wanted the song would have said more —
/// so the row most likely to be right is the one Auto plays when it takes the
/// first result for a voice command.
///
/// `LIKE` with escaped wildcards rather than FTS: the library has no search
/// index, one exists nowhere in this schema, and a few hundred albums do not
/// need one. If that changes it changes here.
fn search_json(data_dir: &str, query: &str) -> String {
let q = query.trim();
if q.is_empty() {
// An empty query is "play some music", which the design answers with
// Recent rather than an error.
return browse_json(data_dir, "ss:recent");
}
let db = db_path(Path::new(data_dir));
let conn = match open_read_only(&db) {
Ok(c) => c,
Err(e) => {
warn(&format!("auto search: cannot open {}: {e}", db.display()));
return "[]".to_owned();
}
};
// `\` escapes the wildcards so a title containing % or _ is matched as
// itself rather than as a pattern.
let esc = q.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_");
let like = format!("%{esc}%");
let mut out: Vec<Node> = Vec::new();
let mut albums_stmt = match conn.prepare(
"SELECT a.id, a.title, a.artist, a.art_path, COUNT(t.id)
FROM albums a LEFT JOIN tracks t ON t.album_id = a.id
WHERE a.title LIKE ?1 ESCAPE '\\' OR a.artist LIKE ?1 ESCAPE '\\'
GROUP BY a.id ORDER BY a.added_at DESC LIMIT 20",
) {
Ok(s) => s,
Err(e) => {
warn(&format!("auto search: {e}"));
return "[]".to_owned();
}
};
if let Ok(rows) = albums_stmt.query_map([&like], |r| {
let id: i64 = r.get(0)?;
let title: String = r.get(1)?;
let artist: String = r.get(2)?;
let art: Option<String> = r.get(3)?;
let n: i64 = r.get(4)?;
Ok(Node {
id: format!("ss:album:{id}"),
title,
subtitle: format!("{artist} · {}", plural(n, "track", "tracks")),
browsable: true,
playable: false,
art,
path: None,
})
}) {
out.extend(rows.flatten());
}
if let Ok(mut stmt) = conn.prepare(
"SELECT t.id, t.title, t.artist, t.path, a.art_path
FROM tracks t JOIN albums a ON a.id = t.album_id
WHERE t.title LIKE ?1 ESCAPE '\\' OR t.artist LIKE ?1 ESCAPE '\\'
ORDER BY t.added_at DESC LIMIT 40",
) {
if let Ok(rows) = stmt.query_map([&like], track_node) {
out.extend(rows.flatten());
}
}
rows(&out)
}
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
@@ -499,6 +574,27 @@ pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_play(
.unwrap_or(std::ptr::null_mut())
}
/// `AutoNative.search(dataDir, query)` — rows for a typed or spoken query,
/// same JSON shape as a browse.
#[no_mangle]
pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_search(
mut env: JNIEnv,
_this: JObject,
data_dir: JString,
query: JString,
) -> jstring {
let dir: Option<String> = env.get_string(&data_dir).ok().map(Into::into);
let q: Option<String> = env.get_string(&query).ok().map(Into::into);
let out = match (dir, q) {
(Some(d), Some(q)) => search_json(&d, &q),
_ => "[]".to_owned(),
};
env.new_string(out)
.or_else(|_| env.new_string("[]"))
.map(|s| s.into_raw())
.unwrap_or(std::ptr::null_mut())
}
/// `AutoNative.shuffle()` — reorder what is still ahead in the headless
/// queue. Returns how many tracks were reordered, so the car can be told
/// nothing happened rather than showing a button that silently does nothing.
@@ -544,7 +640,29 @@ pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_seek(
_this: JObject,
secs: f64,
) {
crate::headless::audio(None).player.seek(secs);
crate::headless::audio(None).seek(secs);
}
/// `AutoNative.positionMs()` — where the engine actually is, in milliseconds.
/// The card asks for this every time it is rebuilt rather than carrying the
/// position it was created with, which is what made pausing look like a
/// restart.
#[no_mangle]
pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_positionMs(
_env: JNIEnv,
_this: JObject,
) -> jni::sys::jlong {
(crate::headless::audio(None).progress().0 * 1000.0) as jni::sys::jlong
}
/// `AutoNative.sounding()` — whether the engine is actually playing, as
/// opposed to what the card was last told.
#[no_mangle]
pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_sounding(
_env: JNIEnv,
_this: JObject,
) -> jni::sys::jboolean {
u8::from(crate::headless::audio(None).progress().1)
}
/// `AutoNative.driving()` — true when the headless queue is what is playing,
+47 -1
View File
@@ -34,6 +34,14 @@ pub struct Audio {
/// 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
@@ -73,6 +81,7 @@ pub fn audio(device: Option<String>) -> Arc<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()
@@ -87,6 +96,26 @@ pub fn audio(device: Option<String>) -> Arc<Audio> {
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;
@@ -151,7 +180,10 @@ impl Audio {
q.idx = idx;
}
match first {
Some(t) => self.player.play_local(t.path, t.duration),
Some(t) => {
self.player.play_local(t.path, t.duration);
*self.progress.lock().unwrap_or_else(|e| e.into_inner()) = (0.0, true);
}
None => {}
}
}
@@ -190,6 +222,14 @@ impl Audio {
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.
///
@@ -219,6 +259,7 @@ impl Audio {
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
}
@@ -226,6 +267,11 @@ impl Audio {
}
}
/// 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.
@@ -151,8 +151,18 @@ class ProbeActivity : Activity() {
// 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)
// `search:<words>` exercises the VOICE path — playFromSearch is
// what "play the fall of troy" becomes, and it lands on
// onAddMediaItems with a searchQuery and no media id, which is a
// different code path from a tapped row and worth its own proof.
val isSearch = playId.startsWith("search:")
val arg = if (isSearch) playId.removePrefix("search:") else playId
say(
JSONObject().put("ev", if (isSearch) "search" else "play")
.put("id", arg).put("ms", ms()),
)
if (isSearch) ctl.transportControls.playFromSearch(arg, null)
else ctl.transportControls.playFromMediaId(arg, null)
val report = { tag: String ->
val st = ctl.playbackState
val md = ctl.metadata