From 8f46f9c663352cfa6391a7080b0b1cd8b0b10298 Mon Sep 17 00:00:00 2001 From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:01:43 -0500 Subject: [PATCH] android auto: a catalog row is handed to the app, not failed at MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every entry in a streamed playlist is `kind='stream'` with no file on this phone — only a catalog id. A service-only process cannot turn that into audio: the resolver is hundreds of lines of shared JS that exist while a WebView does and not otherwise. So the car tapped those rows and nothing happened, which is the worst of the three possible answers. Now the process asks whether the APP can do it instead. Rust, finding nothing local, reports WHICH playlist and entry were meant rather than just failing — a catalog row is not a broken row, it is one this process cannot do alone, and that difference is what decides between an error and a hand-off. Kotlin carries it over `MediaHub.onRemote`, which is non-null only while the plugin is loaded, which is only while a WebView exists: the same fact is both the channel and the test for "is the app warm". And the JS plays it with `playFrom(playlist, items, item)` — the exact call the half-sheet makes when the same row is tapped in the app, so the queue, the provenance and the failure message are the app's own. Nothing about the car is special once it arrives. Cold, it says so instead of pretending: the focus is handed back rather than held for silence, and the log names the row. A phone in a pocket genuinely cannot play a Deezer id, and a tap that quietly does nothing is worse than one that stops. Two things the same change fixed on the way past. A streamed entry has no cover FILE, only a remote URL, so `COALESCE(art_path, art_url)` gives those rows their sleeves and a remote uri now goes to the host untouched instead of through a FileProvider whose root check it could never pass. And Recent, playlists and an artist's albums join Albums as GRIDS — all four are things known by the picture, which is what the grid is for. `probe.sh` gained `SS_KEEP=1`, without which the warm path cannot be tested at all: the script force-stops SoulStream, which is precisely the app the hand-off needs alive. **Proven:** cold answers `auto cold, cannot resolve` and releases focus; warm answers `auto hand-off to the app` and the event reaches the JS with no console error. **Not proven:** that the audio then starts. Those Deezer ids have to go through the resolver exactly as they do when tapped in the app, and if the app cannot play them the car will not either — which is correct behaviour and indistinguishable, from here, from a bridge that silently failed. It wants checking with the app in front of you. --- .../soulstream/mediasession/MediaService.kt | 41 +++++++++++++++++- .../mediasession/MediaSessionPlugin.kt | 11 +++++ .../mediasession/SoulStreamPlayer.kt | 18 ++++++-- handoff/rounds/commit-msg56.txt | 42 +++++++++++++++++++ src-tauri/src/auto_browse.rs | 33 ++++++++++++++- src-tauri/src/headless.rs | 20 ++++++++- src/mobile/media.svelte.js | 32 ++++++++++++++ tools/auto-prober/probe.sh | 4 +- 8 files changed, 192 insertions(+), 9 deletions(-) create mode 100644 handoff/rounds/commit-msg56.txt 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 10ff82d..9537f5b 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 @@ -114,6 +114,16 @@ import org.json.JSONArray @OptIn(markerClass = [UnstableApi::class]) object MediaHub { @Volatile var onAction: ((String, Double) -> Unit)? = null + /** + * Play something this process cannot resolve on its own — a catalog or + * Soulseek row — by handing it to the app, which has the resolver. + * + * Non-null ONLY while the plugin is loaded, which means only while a + * WebView exists. That is exactly the test for "is the app warm", so it + * doubles as the discriminator: set, and the car's tap becomes the app's + * tap; null, and there is nothing on this phone that could play it. + */ + @Volatile var onRemote: ((Long, Long) -> Unit)? = null @Volatile var state: NowPlayingArgs? = null /** the cover as ENCODED bytes — media3 decodes them itself */ @Volatile var artBytes: ByteArray? = null @@ -478,6 +488,14 @@ object MediaHub { fun act(action: String, position: Double = -1.0) { onAction?.invoke(action, position) } + + /** True when the app is up and its resolver can be reached. */ + fun warm(): Boolean = onRemote != null + + /** Hand a playlist row to the app to play. */ + fun remote(playlist: Long, item: Long) { + onRemote?.invoke(playlist, item) + } } /** @@ -607,7 +625,15 @@ object LibraryTree : MediaLibrarySession.Callback { * the hint itself. Albums are sleeves — the tab that lists them, and each * artist whose children are albums. */ private fun styleExtras(id: String): android.os.Bundle? { - val grid = id == "ss:albums" || id.startsWith("ss:artist:") + // Anything whose children are SLEEVES. Recent is albums and + // playlists, a playlist is covers per track, and an artist's children + // are records — all of them are known by the picture, which is the + // whole reason the grid exists. + val grid = + id == "ss:albums" || + id == "ss:recent" || + id.startsWith("ss:artist:") || + id.startsWith("ss:playlist:") if (!grid) return null val b = android.os.Bundle() b.putInt(CONTENT_STYLE_BROWSABLE_HINT, CONTENT_STYLE_GRID) @@ -814,6 +840,19 @@ object LibraryTree : MediaLibrarySession.Callback { */ private fun artUri(ctx: Context, path: String?, grantTo: String?): android.net.Uri? { if (path.isNullOrEmpty()) return null + // A REMOTE cover is already a uri and needs no provider and no grant — + // the host fetches it. Streamed playlist entries have no cover file at + // all, only a URL, so routing one through FileProvider would fail the + // root check and lose the art of every catalog row. + if (path.startsWith("http://") || path.startsWith("https://") || + path.startsWith("content://") + ) { + return try { + android.net.Uri.parse(path) + } catch (e: Throwable) { + null + } + } return try { val uri = FileProvider.getUriForFile(ctx, ctx.packageName + ".fileprovider", File(path)) diff --git a/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/MediaSessionPlugin.kt b/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/MediaSessionPlugin.kt index 26014f0..015f3fa 100644 --- a/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/MediaSessionPlugin.kt +++ b/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/MediaSessionPlugin.kt @@ -80,6 +80,17 @@ class MediaSessionPlugin(private val activity: Activity) : Plugin(activity) { payload.put("position", position) trigger("action", payload) } + /* The car asking the APP to play something this process cannot + resolve. Setting this is also what tells the media service the app + is warm — it is null whenever there is no WebView. */ + android.util.Log.i("SoulStream", "auto plugin init — warm hand-off armed") + MediaHub.onRemote = { playlist, item -> + val payload = JSObject() + payload.put("action", "playRemote") + payload.put("playlist", playlist) + payload.put("item", item) + trigger("action", payload) + } } @Command 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 26b042b..deefe56 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 @@ -326,9 +326,21 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa 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) + // Nothing local. If the app is up, its resolver can play + // this — a catalog row is not a broken row, it is one this + // process cannot do alone. If the app is NOT up there is + // nothing on the phone that could play it, and the focus + // goes back rather than being held for silence. + val remote = o.optBoolean("remote", false) + if (remote && MediaHub.warm()) { + android.util.Log.i("SoulStream", "auto hand-off to the app: $id") + MediaHub.remote(o.optLong("playlist"), o.optLong("item")) + } else { + if (remote) { + android.util.Log.i("SoulStream", "auto cold, cannot resolve: $id") + } + MediaHub.dropFocus(ctx.applicationContext) + } } else { // Write the card ourselves. `getState()` mirrors // MediaHub.state, and the only thing that ever fills it is diff --git a/handoff/rounds/commit-msg56.txt b/handoff/rounds/commit-msg56.txt new file mode 100644 index 0000000..b80d264 --- /dev/null +++ b/handoff/rounds/commit-msg56.txt @@ -0,0 +1,42 @@ +android auto: a catalog row is handed to the app, not failed at + +Every entry in a streamed playlist is `kind='stream'` with no file on this +phone — only a catalog id. A service-only process cannot turn that into audio: +the resolver is hundreds of lines of shared JS that exist while a WebView does +and not otherwise. So the car tapped those rows and nothing happened, which is +the worst of the three possible answers. + +Now the process asks whether the APP can do it instead. Rust, finding nothing +local, reports WHICH playlist and entry were meant rather than just failing — +a catalog row is not a broken row, it is one this process cannot do alone, and +that difference is what decides between an error and a hand-off. Kotlin carries +it over `MediaHub.onRemote`, which is non-null only while the plugin is loaded, +which is only while a WebView exists: the same fact is both the channel and the +test for "is the app warm". And the JS plays it with `playFrom(playlist, items, +item)` — the exact call the half-sheet makes when the same row is tapped in the +app, so the queue, the provenance and the failure message are the app's own. +Nothing about the car is special once it arrives. + +Cold, it says so instead of pretending: the focus is handed back rather than +held for silence, and the log names the row. A phone in a pocket genuinely +cannot play a Deezer id, and a tap that quietly does nothing is worse than one +that stops. + +Two things the same change fixed on the way past. A streamed entry has no cover +FILE, only a remote URL, so `COALESCE(art_path, art_url)` gives those rows their +sleeves and a remote uri now goes to the host untouched instead of through a +FileProvider whose root check it could never pass. And Recent, playlists and an +artist's albums join Albums as GRIDS — all four are things known by the picture, +which is what the grid is for. + +`probe.sh` gained `SS_KEEP=1`, without which the warm path cannot be tested at +all: the script force-stops SoulStream, which is precisely the app the hand-off +needs alive. + +**Proven:** cold answers `auto cold, cannot resolve` and releases focus; warm +answers `auto hand-off to the app` and the event reaches the JS with no console +error. **Not proven:** that the audio then starts. Those Deezer ids have to go +through the resolver exactly as they do when tapped in the app, and if the app +cannot play them the car will not either — which is correct behaviour and +indistinguishable, from here, from a bridge that silently failed. It wants +checking with the app in front of you. diff --git a/src-tauri/src/auto_browse.rs b/src-tauri/src/auto_browse.rs index 675da26..ff831c3 100644 --- a/src-tauri/src/auto_browse.rs +++ b/src-tauri/src/auto_browse.rs @@ -325,7 +325,10 @@ fn playlists(conn: &Connection) -> rusqlite::Result> { let mut stmt = conn.prepare( "SELECT p.id, p.name, COUNT(i.id), SUM(CASE WHEN i.kind = 'local' AND i.path IS NOT NULL THEN 1 ELSE 0 END), - MAX(i.art_path) + -- A streamed entry has no cover FILE, it has a remote URL. + -- Auto fetches an https artwork uri itself, so the row gets a + -- sleeve either way rather than a grey tile. + COALESCE(MAX(i.art_path), MAX(i.art_url)) FROM playlists p LEFT JOIN playlist_items i ON i.playlist_id = p.id GROUP BY p.id ORDER BY p.created_at DESC @@ -363,7 +366,7 @@ fn playlists(conn: &Connection) -> rusqlite::Result> { /// are shown rather than hidden so the list matches the phone's. fn playlist_items(conn: &Connection, playlist_id: i64) -> rusqlite::Result> { let mut stmt = conn.prepare( - "SELECT id, title, artist, album, path, art_path, kind + "SELECT id, title, artist, album, path, COALESCE(art_path, art_url), kind FROM playlist_items WHERE playlist_id = ?1 ORDER BY position", @@ -753,6 +756,32 @@ pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_search( .unwrap_or(std::ptr::null_mut()) } +/// What a row would need the APP for, when it cannot play from here. +/// +/// A playlist entry with `kind='stream'` has no file on this phone — only a +/// catalog id — and turning that into audio is the resolver's job, which is +/// hundreds of lines of shared JS that exist only while a WebView does. So +/// this does not try. It reports WHICH playlist and entry were meant, and the +/// warm path hands that to the app, which already knows how to play it. +pub(crate) fn remote_ref(data_dir: &str, media_id: &str) -> Option<(i64, i64)> { + let conn = open_read_only(&db_path(Path::new(data_dir))).ok()?; + if let Some(id) = media_id.strip_prefix("ss:playlist:") { + return Some((id.parse::().ok()?, 0)); + } + if let Some(id) = media_id.strip_prefix("ss:pitem:") { + let item = id.parse::().ok()?; + let pl: i64 = conn + .query_row( + "SELECT playlist_id FROM playlist_items WHERE id = ?1", + [item], + |r| r.get(0), + ) + .ok()?; + return Some((pl, item)); + } + None +} + /// `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. diff --git a/src-tauri/src/headless.rs b/src-tauri/src/headless.rs index 18aa86a..169bf20 100644 --- a/src-tauri/src/headless.rs +++ b/src-tauri/src/headless.rs @@ -346,11 +346,27 @@ fn notify_track(t: &PlayTrack, has_next: bool) { /// 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 { + // Nothing local: say whether the APP could play it, and which row was + // meant. A catalog entry is not a failure, it is a thing this process + // cannot do — and the difference decides between an error on the car and + // a hand-off to the phone. + let needs_app = || -> String { + match crate::auto_browse::remote_ref(data_dir, media_id) { + Some((playlist, item)) => serde_json::json!({ + "queued": 0, + "remote": true, + "playlist": playlist, + "item": item, + }) + .to_string(), + None => "{\"queued\":0}".to_owned(), + } + }; let Some((items, idx)) = crate::auto_browse::resolve_playable(data_dir, media_id) else { - return "{\"queued\":0}".to_owned(); + return needs_app(); }; if items.is_empty() { - return "{\"queued\":0}".to_owned(); + return needs_app(); } let n = items.len(); let audio = audio(None); diff --git a/src/mobile/media.svelte.js b/src/mobile/media.svelte.js index d0cc65d..c5f60c4 100644 --- a/src/mobile/media.svelte.js +++ b/src/mobile/media.svelte.js @@ -7,6 +7,8 @@ import { invoke, addPluginListener } from "@tauri-apps/api/core"; import { player, hooks, toggle, next, prev, seekSecs } from "./player.svelte.js"; import { art } from "./art.svelte.js"; +import { playFrom } from "./playlists.svelte.js"; +import { listPlaylists, playlistItems } from "../lib/api.js"; /* THE QUEUE WINDOW. Every update carries a bounded slice of the queue — the current item and QUEUE_WINDOW either side. Since round forty-eight @@ -146,11 +148,41 @@ export async function bindMedia() { case "prev": prev(); break; case "seek": if (typeof p.position === "number" && p.position >= 0) seekSecs(p.position); break; case "stop": if (playing) toggle(); break; + case "playRemote": playRemote(p.playlist, p.item); break; } }); } catch { /* not a phone */ } +/** + * A row the CAR tapped that only the app can play. + * + * Android Auto can start this app with no UI, and a service-only process can + * play a local file but not a catalog one — that needs the resolver, which is + * here. So the car hands the row over instead of failing, and this plays it + * exactly the way tapping it in the app would: same `playFrom`, same queue, + * same provenance. Nothing about the car is special once it arrives. + */ +async function playRemote(playlistId, itemId) { + if (!playlistId) return; + try { + const pls = await listPlaylists(); + const pl = (pls || []).find((p) => Number(p.id) === Number(playlistId)); + if (!pl) return; + const items = (await playlistItems(pl.id)) || []; + /* item 0 means "the playlist", anything else is the row that was + tapped and the rest follows it — which is what playFrom's third + argument means. */ + const at = Number(itemId) ? items.find((i) => Number(i.id) === Number(itemId)) : null; + if (!playFrom(pl, items, at)) { + player.error = "Nothing in “" + pl.name + "” can play yet."; + } + } catch { + /* a playlist that has gone, or a resolver that said no — the car has + already been told this row did not start. */ + } +} + /* the card interpolates its progress from the last update; a top-up every half minute keeps a long track honest */ setInterval(() => { if (player.track) syncMedia(); }, 30_000); diff --git a/tools/auto-prober/probe.sh b/tools/auto-prober/probe.sh index 7f9fbe6..ad0c4e9 100644 --- a/tools/auto-prober/probe.sh +++ b/tools/auto-prober/probe.sh @@ -44,7 +44,9 @@ echo "== install $(date +%T)" echo "== cold: force-stopping SoulStream $(date +%T)" "$ADB" logcat -c -"$ADB" shell am force-stop com.soulstream.app +# SS_KEEP=1 leaves SoulStream running — the only way to test the WARM path, +# where the car hands a catalog row to an app that is up and has the resolver. +if [ -z "${SS_KEEP:-}" ]; then "$ADB" shell am force-stop com.soulstream.app; fi "$ADB" shell am start -W -n com.soulstream.autoprober/.ProbeActivity \ --ei depth "$DEPTH" --ez recent "$RECENT" ${PLAY:+--es play "$PLAY"} > /dev/null if [ -n "$PLAY" ]; then sleep 25; else sleep 7; fi