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