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 7ba05e5..c7d49bd 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 @@ -567,6 +567,23 @@ object LibraryTree : MediaLibrarySession.Callback { io, ) + /** + * A controller handing us items that carry a media id and no URI — which + * is every tap on a browse row, because the tree publishes `ss:` ids and + * never a file path. + * + * media3's default answers UnsupportedOperationException here and the tap + * dies before it ever reaches the player. Passing the items straight + * through is the right move for this app: resolution happens in + * [SoulStreamPlayer.handleSetMediaItems], against the library, where an id + * is checked rather than believed. + */ + override fun onAddMediaItems( + mediaSession: MediaSession, + controller: MediaSession.ControllerInfo, + mediaItems: MutableList, + ): ListenableFuture> = Futures.immediateFuture(mediaItems) + /** * How the car should DRAW this node's children — the only say the app has * over the layout, since the host builds the UI and we supply content. @@ -979,4 +996,19 @@ object AutoNative { /** Returns a JSON array of rows; never null, `[]` when anything failed. */ external fun browse(dataDir: String, parentId: String): String + + /** + * Start playback from a browse row, in a process that may have no + * Activity. Returns how many tracks were queued — 0 means the row + * resolved to nothing, which the car should be told about rather than + * left in silence. + * + * A tapped track brings its album with it, so the record keeps playing + * without any JS alive to advance it. + */ + external fun play(dataDir: String, mediaId: String): String + + /** pause / resume / stop against the process's engine, for when there is + * no WebView to take the usual round trip. */ + external fun transport(action: String) } 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 cfed560..c19fe7c 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 @@ -203,10 +203,16 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa * then sets the button's command to the _MEDIA_ITEM one: advertise only * the plain one and the button appears and does nothing. * - * Not here, on purpose: COMMAND_SET_MEDIA_ITEM, COMMAND_CHANGE_MEDIA_ITEMS - * and COMMAND_SEEK_TO_MEDIA_ITEM. Tapping a row of the queue is Android - * Auto's job and needs a window-index → queue-index mapping; `queueBase` - * is half of it and it lands with the round that uses it. */ + * COMMAND_SET_MEDIA_ITEM arrived with the round that plays from the car: + * it is what lets a controller hand us a media id at all, and without it + * a browse row is drawn as playable and does nothing when tapped. It is + * safe to advertise unconditionally because [handleSetMediaItems] + * resolves the id against the library itself rather than trusting a URI. + * + * Still not here, on purpose: COMMAND_CHANGE_MEDIA_ITEMS and + * COMMAND_SEEK_TO_MEDIA_ITEM. Tapping a row of the QUEUE is a different + * thing from tapping a row of the library — it needs a window-index → + * queue-index mapping, and `queueBase` is only half of that. */ private fun commands(np: NowPlayingArgs?): Player.Commands { val b = Player.Commands.Builder() .add(Player.COMMAND_PLAY_PAUSE) @@ -220,6 +226,7 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa .add(Player.COMMAND_GET_METADATA) .add(Player.COMMAND_GET_AUDIO_ATTRIBUTES) .add(Player.COMMAND_SET_AUDIO_ATTRIBUTES) + .add(Player.COMMAND_SET_MEDIA_ITEM) if (np != null) { b.addIf(Player.COMMAND_SEEK_TO_NEXT, np.hasNext) b.addIf(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM, np.hasNext) @@ -242,6 +249,77 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa * `handle*` is `throw IllegalStateException`. */ override fun handlePrepare(): ListenableFuture<*> = Futures.immediateVoidFuture() + /** + * A row tapped in the car. + * + * The id is resolved against the library IN RUST, not from a URI on the + * item: the browse tree hands out `ss:track:` and nothing else, so + * there is no file path to trust and no way for a controller to name a + * file we did not publish. + * + * It runs off the caller's thread because the first tap in a cold process + * does two slow things — opens the database and starts the audio engine — + * and this is called on the application thread, where ten seconds is an + * ANR. The future resolving is what tells media3 the command took. + * + * `startIndex` can be `INDEX_UNSET`; that means "play the list from the + * top", which for a single tapped row is the row itself. + */ + override fun handleSetMediaItems( + mediaItems: MutableList, + startIndex: Int, + startPositionMs: Long, + ): ListenableFuture<*> { + val i = if (startIndex == androidx.media3.common.C.INDEX_UNSET) 0 else startIndex + val id = mediaItems.getOrNull(i)?.mediaId + if (id.isNullOrEmpty()) return Futures.immediateVoidFuture() + return Futures.submit( + { + // FOCUS FIRST, and it is not optional. Without it the track + // opens into a port the framework keeps muted: the first run + // of this path showed a live AAudio track on our uid with + // PortMuted=true and the gain at -inf — decoding perfectly, + // audible to nobody. On the WebView path the JS asks for + // focus; a car tapping a cold process has no JS to do it. + MediaHub.requestFocus(ctx.applicationContext) + val reply = AutoNative.play(ctx.dataDir.absolutePath, id) + val o = try { org.json.JSONObject(reply) } catch (e: Throwable) { org.json.JSONObject() } + 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) + } else { + // Write the card ourselves. `getState()` mirrors + // MediaHub.state, and the only thing that ever fills it is + // the WebView — so a cold car tap played audio while the + // session still said STATE_NONE with no title, which is a + // head unit showing a blank player over music. The engine + // knows what it started; this is that, in the shape the + // card already reads. + val np = NowPlayingArgs() + np.title = o.optString("title").ifEmpty { null } + np.artist = o.optString("artist").ifEmpty { null } + np.album = o.optString("album").ifEmpty { null } + np.duration = if (o.isNull("duration")) null else o.optDouble("duration") + np.position = 0.0 + np.playing = true + np.hasNext = queued > 1 + np.hasPrev = false + MediaHub.update(ctx.applicationContext, np) + } + }, + playIo, + ) + } + + /** One thread, for the tap that opens a database and starts an engine. */ + private val playIo = + java.util.concurrent.Executors.newSingleThreadExecutor { r -> + Thread(r, "soulstream-auto-play").apply { isDaemon = true } + } + override fun handleStop(): ListenableFuture<*> { MediaHub.act("stop") return awaitUpdate() diff --git a/handoff/rounds/commit-msg51.txt b/handoff/rounds/commit-msg51.txt new file mode 100644 index 0000000..6cfcf12 --- /dev/null +++ b/handoff/rounds/commit-msg51.txt @@ -0,0 +1,65 @@ +android auto: a tap in the car plays, with no Activity anywhere in the process + +Android Auto starts this app by binding a service. There is no Activity, no +WebView, no `tauri::AppHandle` — and until now no way to make a sound, because +the engine lived in Tauri's managed state. `src-tauri/src/headless.rs` moves it +into a `OnceLock` owned by the PROCESS: whoever needs audio first builds it, +`playback::init` on a normal launch or a car tapping a row into a process with +no UI, and everyone after that gets the same one. + +**That singleton is the whole safety story.** `Player::spawn` opens an output +stream, so two of them in one process is two AAudio streams playing over each +other — the failure this module exists to make impossible. The event receiver +is single-consumer, so the pump lives there too and offers a subscription slot +for the WebView to claim when it finally appears, rather than a second engine +being stood up beside the first. + +The queue here is deliberately not the app's queue. The JS one keeps shuffle, +repeat, `alts` failover, provenance and the three-ahead preload; this is a +vector and an index whose only job is that the album keeps playing when there +is no JS alive to advance it. Its metadata is read ONCE, when the queue is +built, so advancing never touches the database again — which matters when the +thing advancing is a background thread in a process with no UI. + +Resolution happens in Rust, against the library, read-only. A tapped +`ss:track:` brings its whole album at the right index, because selecting +one song and getting silence after it is not what a record does. Nothing +trusts a URI from the controller: the tree only ever publishes `ss:` ids, so +there is no file path for anyone to hand back, and an id that resolves to +nothing returns a count of zero rather than a guess. + +Kotlin's half is three things that each fail invisibly on their own. +`COMMAND_SET_MEDIA_ITEM` is advertised now — without it a browse row is drawn +as playable and does nothing when tapped. `onAddMediaItems` passes ids through +instead of media3's default `UnsupportedOperationException`, which kills the +tap before it reaches the player. And `handleSetMediaItems` does the work on +its own thread, because the first tap in a cold process opens a database AND +starts an audio engine, and it is called on the application thread where ten +seconds is an ANR. + +**Two things the device taught, both of which looked like silence.** The +headless path has to request audio focus itself: the first run showed a live +AAudio track on our uid, decoding perfectly, with `PortMuted=true` and the gain +at `-inf` — audible to nobody. On the WebView path the JS asks for focus; a car +tapping a cold process has no JS to ask. And it has to write the card itself: +`getState()` mirrors `MediaHub.state`, which only the WebView ever fills, so +the second run played audio while the session still reported `STATE_NONE` with +no title — a head unit showing a blank player over music. The engine knows what +it started, and that now goes into `MediaHub.update` in the shape the card +already reads. + +Proven by the prober, which gained a `play` mode for it: bind cold, issue +`playFromMediaId` through the session token the browser handed back — Auto's +own path, not an interface of ours — and read the state back. `ss:track:1` +answers `queued=13` (the album behind it), and the controller reports +`state=3` with the title and a position moving 3.8 s → 11.8 s → 19.8 s across +the run. `probe.sh ` is the invocation. + +**Not done, and it is the display rather than the audio.** Nothing tells Kotlin +when the fallback queue advances, so the card will keep showing track one while +track two plays. The position moves on its own — `SimpleBasePlayer` +extrapolates it while playing — but a track change and a seek both need the +pump to push, and the pump currently only speaks to a subscriber that exists +when there is a WebView. The handover the design asks for (a UI arriving over +live headless playback, and exactly one queue driver surviving) is untested for +the same reason. diff --git a/src-tauri/src/auto_browse.rs b/src-tauri/src/auto_browse.rs index c22e9e3..39a2994 100644 --- a/src-tauri/src/auto_browse.rs +++ b/src-tauri/src/auto_browse.rs @@ -329,6 +329,70 @@ fn children(conn: &Connection, parent: &str) -> rusqlite::Result> { } } +/// Turn a media id from the browse tree into something playable: the files, in +/// order, and which one the tap meant. +/// +/// A tapped TRACK brings its whole album with it at the right index, because +/// "the album keeps going" is what a record player does and what a driver +/// expects — selecting one song should not leave silence after it. A tapped +/// ALBUM starts at the top. +/// +/// Read-only like everything else here, and it returns `None` rather than an +/// empty queue when the library cannot be opened, so the caller can tell +/// "nothing matched" from "could not look". +pub(crate) fn resolve_playable( + data_dir: &str, + media_id: &str, +) -> Option<(Vec, usize)> { + use crate::headless::PlayTrack; + let db = db_path(Path::new(data_dir)); + let conn = match open_read_only(&db) { + Ok(c) => c, + Err(e) => { + warn(&format!("auto play: cannot open {}: {e}", db.display())); + return None; + } + }; + let album_tracks = |album_id: i64| -> rusqlite::Result> { + let mut stmt = conn.prepare( + "SELECT t.id, t.path, t.title, t.artist, t.duration_secs, a.title + FROM tracks t JOIN albums a ON a.id = t.album_id + WHERE t.album_id = ?1 + ORDER BY t.disc_no, t.track_no, t.title", + )?; + let rows = stmt.query_map([album_id], |r| { + Ok(( + r.get::<_, i64>(0)?, + PlayTrack { + path: PathBuf::from(r.get::<_, String>(1)?), + title: r.get(2)?, + artist: r.get(3)?, + duration: r.get::<_, Option>(4)?.map(|s| s as f64), + album: r.get(5)?, + }, + )) + })?; + rows.collect() + }; + + if let Some(id) = media_id.strip_prefix("ss:album:") { + let rows = album_tracks(id.parse::().ok()?).ok()?; + return Some((rows.into_iter().map(|(_, t)| t).collect(), 0)); + } + if let Some(id) = media_id.strip_prefix("ss:track:") { + let track_id = id.parse::().ok()?; + let album_id: i64 = conn + .query_row("SELECT album_id FROM tracks WHERE id = ?1", [track_id], |r| { + r.get(0) + }) + .ok()?; + let rows = album_tracks(album_id).ok()?; + let idx = rows.iter().position(|(id, _)| *id == track_id).unwrap_or(0); + return Some((rows.into_iter().map(|(_, t)| t).collect(), idx)); + } + None +} + 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 @@ -406,3 +470,51 @@ pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_browse( .map(|s| s.into_raw()) .unwrap_or(std::ptr::null_mut()) } + +/// `AutoNative.play(dataDir, mediaId)` — start playback from a browse row, in +/// a process that may have no Activity and no WebView. +/// +/// Returns how many tracks were queued, so Kotlin can answer the car with an +/// error state instead of silence when a row resolves to nothing. Catalog and +/// Soulseek rows are not in the tree yet and will resolve to nothing by +/// construction, which is the correct answer for them: they need the resolver, +/// which needs the WebView. +#[no_mangle] +pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_play( + mut env: JNIEnv, + _this: JObject, + data_dir: JString, + media_id: JString, +) -> jstring { + let dir: Option = env.get_string(&data_dir).ok().map(Into::into); + let id: Option = env.get_string(&media_id).ok().map(Into::into); + let out = match (dir, id) { + (Some(d), Some(m)) => crate::headless::play_media_id(&d, &m), + _ => "{\"queued\":0}".to_owned(), + }; + env.new_string(out) + .or_else(|_| env.new_string("{\"queued\":0}")) + .map(|s| s.into_raw()) + .unwrap_or(std::ptr::null_mut()) +} + +/// `AutoNative.transport(action)` — pause / resume / stop against the +/// process's engine, for the card's own buttons when no WebView is alive to +/// take the usual round trip. +#[no_mangle] +pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_transport( + mut env: JNIEnv, + _this: JObject, + action: JString, +) { + let Some(a) = env.get_string(&action).ok().map(String::from) else { + return; + }; + let audio = crate::headless::audio(None); + match a.as_str() { + "pause" => audio.player.pause(), + "resume" => audio.player.resume(), + "stop" => audio.player.stop(), + _ => warn(&format!("auto transport: unknown action {a}")), + } +} diff --git a/src-tauri/src/headless.rs b/src-tauri/src/headless.rs new file mode 100644 index 0000000..560af11 --- /dev/null +++ b/src-tauri/src/headless.rs @@ -0,0 +1,181 @@ +//! The audio engine, owned by the PROCESS rather than by Tauri. +//! +//! Android Auto can start this app with no Activity: the car binds +//! `MediaService`, the service starts the process, and there is no WebView and +//! no `tauri::AppHandle` anywhere in it. Playback still has to work, so the +//! engine cannot live in Tauri's managed state the way it does on the desktop. +//! +//! **One `Player` per process, and that is the whole point.** `Player::spawn` +//! opens an output stream; two of them in one process is two AAudio streams +//! playing over each other, which is the failure this module exists to make +//! impossible. Whoever needs audio first creates it — `playback::init` when the +//! app launches normally, or [`play_media_id`] when a car taps a row into a +//! process that has no UI — and everyone after that gets the same one. +//! +//! The event receiver is single-consumer, so the pump lives here too and +//! offers a **subscription slot**: when the WebView does come up, +//! `playback::init` registers its forwarder and starts seeing events, without +//! a second engine and without the events it missed being replayed at it. +//! +//! The queue here is deliberately NOT the app's queue. The JS one keeps +//! shuffle, repeat, `alts` failover, provenance and the three-ahead preload; +//! this is a `Vec` and an index whose only job is that the album keeps +//! playing when there is no JS alive to advance it. + +use player::{Player, PlayerEvent}; +use std::path::PathBuf; +use std::sync::mpsc::Sender; +use std::sync::{Arc, Mutex, OnceLock}; + +pub struct Audio { + pub player: Player, + /// Where the WebView's pump listens, once there is a WebView. `None` in a + /// service-only process, and every event is simply dropped — nothing is + /// buffered for a UI that may never exist. + subscriber: Mutex>>, + queue: Mutex, +} + +/// Enough of a track to play it and to describe it on a card, read once when +/// the queue is built so that advancing never needs the database again. +#[derive(Clone)] +pub struct PlayTrack { + pub path: PathBuf, + pub title: String, + pub artist: String, + pub album: String, + pub duration: Option, +} + +#[derive(Default)] +struct Queue { + items: Vec, + idx: usize, +} + +static AUDIO: OnceLock> = OnceLock::new(); + +/// The process's one engine, created on first use. +/// +/// `device` is honoured only by the caller that wins the race to create it; +/// afterwards it is ignored, because changing the output device is +/// `Player::set_output_device` on the live engine rather than a new engine. +pub fn audio(device: Option) -> Arc