diff --git a/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/MediaService.kt b/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/MediaService.kt index 6e398b1..10ff82d 100644 --- a/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/MediaService.kt +++ b/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/MediaService.kt @@ -664,7 +664,66 @@ object LibraryTree : MediaLibrarySession.Callback { mediaSession: MediaSession, controller: MediaSession.ControllerInfo, mediaItems: MutableList, - ): ListenableFuture> = Futures.immediateFuture(mediaItems) + ): ListenableFuture> = + Futures.submit>( + { + // "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> = + Futures.submit>( + { + 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>> = + Futures.submit>>( + { 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 { + 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 { val out = ImmutableList.Builder() 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. */ diff --git a/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/SoulStreamPlayer.kt b/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/SoulStreamPlayer.kt index 8ab9d06..a7d3cb7 100644 --- a/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/SoulStreamPlayer.kt +++ b/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/SoulStreamPlayer.kt @@ -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 diff --git a/handoff/rounds/commit-msg53.txt b/handoff/rounds/commit-msg53.txt new file mode 100644 index 0000000..8e30922 --- /dev/null +++ b/handoff/rounds/commit-msg53.txt @@ -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. diff --git a/src-tauri/src/auto_browse.rs b/src-tauri/src/auto_browse.rs index 8f8ff8e..6e578fe 100644 --- a/src-tauri/src/auto_browse.rs +++ b/src-tauri/src/auto_browse.rs @@ -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 = 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 = 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 = env.get_string(&data_dir).ok().map(Into::into); + let q: Option = 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, diff --git a/src-tauri/src/headless.rs b/src-tauri/src/headless.rs index 9f20b6f..18aa86a 100644 --- a/src-tauri/src/headless.rs +++ b/src-tauri/src/headless.rs @@ -34,6 +34,14 @@ pub struct Audio { /// buffered for a UI that may never exist. subscriber: Mutex>>, queue: Mutex, + /// 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) -> Arc