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:
1 parent
4d75395411
commit
f59a2d773e
6 files changed
+327
-7
No files matched your search
+92
-1
@@ -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. */
|
||||
|
||||
+15
-2
@@ -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
|
||||
|
||||
Reference in new issue
Block a user