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

@@ -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