diff --git a/crates/tauri-plugin-media-session/android/src/main/AndroidManifest.xml b/crates/tauri-plugin-media-session/android/src/main/AndroidManifest.xml index f3300f1..f2f7898 100644 --- a/crates/tauri-plugin-media-session/android/src/main/AndroidManifest.xml +++ b/crates/tauri-plugin-media-session/android/src/main/AndroidManifest.xml @@ -56,5 +56,13 @@ + + + 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 c7d49bd..6e398b1 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 @@ -28,12 +28,15 @@ import androidx.core.content.ContextCompat import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata import androidx.media3.common.util.UnstableApi +import androidx.media3.session.CommandButton import androidx.media3.session.DefaultMediaNotificationProvider import androidx.media3.session.LibraryResult import androidx.media3.session.MediaLibraryService import androidx.media3.session.MediaLibraryService.LibraryParams import androidx.media3.session.MediaLibraryService.MediaLibrarySession import androidx.media3.session.MediaSession +import androidx.media3.session.SessionCommand +import androidx.media3.session.SessionResult import androidx.core.content.FileProvider import com.google.common.collect.ImmutableList import com.google.common.util.concurrent.Futures @@ -271,6 +274,32 @@ object MediaHub { val p = SoulStreamPlayer(app) player = p val builder = MediaLibrarySession.Builder(app, p, LibraryTree).setId("SoulStream") + // The transport's custom half. Auto allows a handful of these and + // draws what fits; the layout is what puts the button there at all, + // and LibraryTree.onConnect is what makes pressing it work. + // A PREDEFINED icon, not our drawable. media3 maps these to the glyph + // the host already uses for that function, so the button matches every + // other app's shuffle in the car instead of being a stranger — and a + // host that does not recognise a custom resource simply drops the + // button, which is what happened when this shipped with + // setIconResId: the action was registered and nothing was drawn. + // + // setMediaButtonPreferences rather than setCustomLayout: both exist in + // 1.10.1, but the layout API is the older spelling and preferences is + // what current hosts read. + val shuffle = + // ICON_SHUFFLE_ON, not _OFF. The constants name the STATE, and the + // host draws _OFF as a circular arrow that reads as repeat — which + // is what the car showed. This is a button that performs a shuffle, + // not a toggle reporting one, so the plain crossing-arrows glyph is + // the honest one. + CommandButton.Builder(CommandButton.ICON_SHUFFLE_ON) + .setDisplayName("Shuffle") + .setSessionCommand( + SessionCommand(LibraryTree.SHUFFLE_ACTION, android.os.Bundle.EMPTY), + ) + .build() + builder.setMediaButtonPreferences(ImmutableList.of(shuffle)) val launch = app.packageManager.getLaunchIntentForPackage(app.packageName) if (launch != null) { builder.setSessionActivity( @@ -479,6 +508,12 @@ object LibraryTree : MediaLibrarySession.Callback { * with a media id that happens to be a file path. */ const val ROOT = "ss:root" + /** The one custom action on the car's transport. Two of the six slots + * Auto allows were chosen; this is the half that can be built, because + * the other — Save — has nothing to act on until catalog rows exist in + * the tree. A button that cannot do anything is worse than no button. */ + const val SHUFFLE_ACTION = "com.soulstream.auto.SHUFFLE" + /* The content-style extras. These are plain string keys rather than a constant from a library because they belong to the Auto host, not to media3 — the same keys the legacy MediaBrowserService protocol has @@ -529,7 +564,54 @@ object LibraryTree : MediaLibrarySession.Callback { " offline=" + (params?.isOffline ?: false) + " suggested=" + (params?.isSuggested ?: false), ) - return Futures.immediateFuture(LibraryResult.ofItem(rootItem(), params)) + // The content-style DEFAULTS belong here, on the root, and declaring + // support at all is what makes a host read the rest. Per-node + // overrides ride on the parent ITEM (see styleExtras) rather than on + // the reply that carries its children — which is the mistake that had + // the albums drawing as a list with the grid hint set correctly but in + // the wrong place. + val b = android.os.Bundle() + b.putBoolean(CONTENT_STYLE_SUPPORTED, true) + b.putInt(CONTENT_STYLE_BROWSABLE_HINT, CONTENT_STYLE_LIST) + b.putInt(CONTENT_STYLE_PLAYABLE_HINT, CONTENT_STYLE_LIST) + val rootParams = LibraryParams.Builder().setExtras(b).build() + return Futures.immediateFuture(LibraryResult.ofItem(rootItem(), rootParams)) + } + + /** + * The icon a root tab is drawn with — the thing that turns four words into + * four tabs with pictures over them, which is what every other media app + * in the car has. + * + * An `android.resource://` URI rather than a file: these are OUR + * resources, they are the same for every install, and routing them through + * the FileProvider would mean granting a URI per connection for an icon + * that never changes. A host that cannot load one simply draws the label, + * which is exactly what happens today. + */ + private fun tabIcon(ctx: Context, id: String): android.net.Uri? { + val name = + when (id) { + "ss:recent" -> "ic_car_tab_recent" + "ss:albums" -> "ic_car_tab_albums" + "ss:artists" -> "ic_car_tab_artists" + "ss:saved" -> "ic_car_tab_saved" + else -> return null + } + return android.net.Uri.parse( + "android.resource://" + ctx.packageName + "/drawable/" + name, + ) + } + + /** The per-item override: a node whose CHILDREN should be a grid carries + * 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:") + if (!grid) return null + val b = android.os.Bundle() + b.putInt(CONTENT_STYLE_BROWSABLE_HINT, CONTENT_STYLE_GRID) + return b } /** One thread for the life of the process. A car browses one node at a @@ -584,6 +666,57 @@ object LibraryTree : MediaLibrarySession.Callback { mediaItems: MutableList, ): ListenableFuture> = Futures.immediateFuture(mediaItems) + /** + * Custom commands have to be GRANTED per controller or the button on the + * car is drawn and then rejected when pressed. The default set is taken + * as the base so nothing media3 normally offers is lost by adding ours. + */ + override fun onConnect( + session: MediaSession, + controller: MediaSession.ControllerInfo, + ): MediaSession.ConnectionResult { + val commands = + MediaSession.ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS + .buildUpon() + .add(SessionCommand(SHUFFLE_ACTION, android.os.Bundle.EMPTY)) + .build() + return MediaSession.ConnectionResult.AcceptedResultBuilder(session) + .setAvailableSessionCommands(commands) + .build() + } + + /** + * Shuffle, which is what a driver reaches for. It reorders only what is + * still AHEAD — shuffling the whole queue would restart the song under + * their hands, which is not what the button means. + * + * Off the calling thread because it takes the queue lock the audio pump + * also takes on every track change. + */ + override fun onCustomCommand( + session: MediaSession, + controller: MediaSession.ControllerInfo, + customCommand: SessionCommand, + args: android.os.Bundle, + ): ListenableFuture { + if (customCommand.customAction != SHUFFLE_ACTION) { + return Futures.immediateFuture( + SessionResult(SessionResult.RESULT_ERROR_NOT_SUPPORTED), + ) + } + return Futures.submit( + { + val moved = AutoNative.shuffle() + android.util.Log.i("SoulStream", "auto shuffle moved=" + moved) + SessionResult( + if (moved > 0) SessionResult.RESULT_SUCCESS + else SessionResult.RESULT_ERROR_INVALID_STATE, + ) + }, + io, + ) + } + /** * 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. @@ -700,7 +833,10 @@ object LibraryTree : MediaLibrarySession.Callback { // exactly how every row lost its cover while the database, the // res overlay and the APK all checked out. val artPath = if (o.isNull("art")) null else o.optString("art").ifEmpty { null } - artUri(ctx, artPath, grantTo)?.let { md.setArtworkUri(it) } + val tab = tabIcon(ctx, id) + if (tab != null) md.setArtworkUri(tab) + else artUri(ctx, artPath, grantTo)?.let { md.setArtworkUri(it) } + styleExtras(id)?.let { md.setExtras(it) } out.add(MediaItem.Builder().setMediaId(id).setMediaMetadata(md.build()).build()) } } catch (e: Throwable) { @@ -1011,4 +1147,70 @@ object AutoNative { /** 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) + + /** Reorder what is still ahead in the headless queue; returns how many + * tracks moved, so a no-op can be reported rather than hidden. */ + external fun shuffle(): Int + + /** Scrub the engine directly, in seconds. */ + external fun seek(secs: Double) + + /** 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. */ + external fun driving(): Boolean + + /** + * Called FROM Rust, on the audio pump, when the fallback queue moves to + * the next track. Without it the card sits on track one for the rest of + * the album while the right audio plays underneath it. + * + * Static on purpose: the caller is a native thread that has just attached + * to the VM and has no object to call through. It must not throw — an + * exception here surfaces on a thread that is driving playback — so + * everything is inside one catch. + */ + /** + * A cover as the bytes `MediaHub.update` expects. + * + * The card path takes base64 because that is the shape the WebView sends, + * and reusing it means the headless path and the normal one produce a + * byte-identical card rather than two subtly different ones. Capped, + * because a cover is decoded into a bitmap on the other side and some + * scans are enormous. + */ + fun artB64(path: String?): String? { + if (path.isNullOrEmpty()) return null + return try { + val f = java.io.File(path) + if (!f.isFile || f.length() > 4_000_000L) null + else Base64.encodeToString(f.readBytes(), Base64.NO_WRAP) + } catch (e: Throwable) { + null + } + } + + @JvmStatic + fun headlessTrack(json: String) { + try { + val ctx = MediaHub.appContext ?: return + val o = org.json.JSONObject(json) + 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 = o.optBoolean("hasNext", false) + np.hasPrev = true + val art = if (o.isNull("art")) null else o.optString("art").ifEmpty { null } + np.artKey = art + np.art = artB64(art) + MediaHub.update(ctx, np) + android.util.Log.i("SoulStream", "auto advanced to " + np.title) + } catch (e: Throwable) { + android.util.Log.w("SoulStream", "auto advance card failed: " + e) + } + } } 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 c19fe7c..8ab9d06 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 @@ -239,10 +239,32 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa /* ── the transport, back out to the WebView ──────────────────────── */ override fun handleSetPlayWhenReady(playWhenReady: Boolean): ListenableFuture<*> { + if (headless()) { + AutoNative.transport(if (playWhenReady) "resume" else "pause") + return Futures.immediateVoidFuture() + } MediaHub.act(if (playWhenReady) "play" else "pause") return awaitUpdate() } + /** + * True when the engine in this process is the thing playing and there is + * no WebView to send a transport command to. + * + * Every control here used to be a round trip out to the JS. In a car that + * started a cold process there is no JS, so pause, seek, next and previous + * were all messages to nobody — the buttons drew, moved, and did nothing. + * `driving()` is true only once the headless queue holds something, so a + * normal phone session is untouched and still goes through the WebView, + * which owns shuffle, repeat, `alts` failover and the real queue. + */ + private fun headless(): Boolean = + try { + AutoNative.driving() + } catch (e: Throwable) { + false + } + /** Nothing to prepare: the Rust side sources a track on its own, and * `getState` never reports STATE_IDLE while there is one. Answered * rather than left to throw, because the default body of every @@ -307,6 +329,13 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa np.playing = true np.hasNext = queued > 1 np.hasPrev = false + // The cover, which is also the colour: Android Auto washes + // the playback screen with whatever artwork it is handed, + // so shipping no art tinted every record with the app + // icon's blue. + val art = if (o.isNull("art")) null else o.optString("art").ifEmpty { null } + np.artKey = art + np.art = AutoNative.artB64(art) MediaHub.update(ctx.applicationContext, np) } }, @@ -361,10 +390,21 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa * `prev()` seeks to zero. The gate is the COMMAND being advertised, * and nothing else. */ override fun handleSeek(mediaItemIndex: Int, positionMs: Long, seekCommand: Int): ListenableFuture<*> { + val secs = if (positionMs == C.TIME_UNSET) 0.0 else positionMs / 1000.0 + if (headless()) { + when (seekCommand) { + Player.COMMAND_SEEK_TO_NEXT, Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM -> + AutoNative.transport("next") + Player.COMMAND_SEEK_TO_PREVIOUS, Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM -> + AutoNative.transport("prev") + else -> AutoNative.seek(secs) + } + return Futures.immediateVoidFuture() + } when (seekCommand) { Player.COMMAND_SEEK_TO_NEXT, Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM -> MediaHub.act("next") Player.COMMAND_SEEK_TO_PREVIOUS, Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM -> MediaHub.act("prev") - else -> MediaHub.act("seek", if (positionMs == C.TIME_UNSET) 0.0 else positionMs / 1000.0) + else -> MediaHub.act("seek", secs) } return awaitUpdate() } diff --git a/crates/tauri-plugin-media-session/android/src/main/res/drawable/ic_car_shuffle.xml b/crates/tauri-plugin-media-session/android/src/main/res/drawable/ic_car_shuffle.xml new file mode 100644 index 0000000..f624ad1 --- /dev/null +++ b/crates/tauri-plugin-media-session/android/src/main/res/drawable/ic_car_shuffle.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/crates/tauri-plugin-media-session/android/src/main/res/drawable/ic_car_tab_albums.xml b/crates/tauri-plugin-media-session/android/src/main/res/drawable/ic_car_tab_albums.xml new file mode 100644 index 0000000..abdff89 --- /dev/null +++ b/crates/tauri-plugin-media-session/android/src/main/res/drawable/ic_car_tab_albums.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/crates/tauri-plugin-media-session/android/src/main/res/drawable/ic_car_tab_artists.xml b/crates/tauri-plugin-media-session/android/src/main/res/drawable/ic_car_tab_artists.xml new file mode 100644 index 0000000..6375ddb --- /dev/null +++ b/crates/tauri-plugin-media-session/android/src/main/res/drawable/ic_car_tab_artists.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/crates/tauri-plugin-media-session/android/src/main/res/drawable/ic_car_tab_recent.xml b/crates/tauri-plugin-media-session/android/src/main/res/drawable/ic_car_tab_recent.xml new file mode 100644 index 0000000..995e4d5 --- /dev/null +++ b/crates/tauri-plugin-media-session/android/src/main/res/drawable/ic_car_tab_recent.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/crates/tauri-plugin-media-session/android/src/main/res/drawable/ic_car_tab_saved.xml b/crates/tauri-plugin-media-session/android/src/main/res/drawable/ic_car_tab_saved.xml new file mode 100644 index 0000000..a9003e8 --- /dev/null +++ b/crates/tauri-plugin-media-session/android/src/main/res/drawable/ic_car_tab_saved.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/crates/tauri-plugin-media-session/android/src/main/res/values/car_theme.xml b/crates/tauri-plugin-media-session/android/src/main/res/values/car_theme.xml new file mode 100644 index 0000000..5a63f67 --- /dev/null +++ b/crates/tauri-plugin-media-session/android/src/main/res/values/car_theme.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/crates/tauri-plugin-media-session/android/src/main/res/values/colors.xml b/crates/tauri-plugin-media-session/android/src/main/res/values/colors.xml new file mode 100644 index 0000000..ba3c8de --- /dev/null +++ b/crates/tauri-plugin-media-session/android/src/main/res/values/colors.xml @@ -0,0 +1,15 @@ + + + + #31C0CB + + #05080B + diff --git a/handoff/rounds/commit-msg52.txt b/handoff/rounds/commit-msg52.txt new file mode 100644 index 0000000..aa4a504 --- /dev/null +++ b/handoff/rounds/commit-msg52.txt @@ -0,0 +1,71 @@ +android auto: the car's own controls, the covers, and four tabs with faces + +Everything here came from putting the thing on a real head unit and looking at +it, which is the only reason any of it was found. + +**The transport did nothing.** Pause, seek, next and previous all routed +through `MediaHub.act`, which is a round trip out to the WebView — and a car +that started a cold process has no WebView, so every button drew, moved, and +sent a message to nobody. `AutoNative.driving()` reports whether the headless +queue is what is playing, and when it is, the transport goes straight to the +engine instead: pause/resume, a real seek, and next/previous that move the +fallback queue and push a card update after them. A normal phone session is +untouched and still goes through the JS, which owns shuffle, repeat, `alts` +failover and the real queue. + +That is the THIRD instance of one bug: the headless path silently skipping +something the WebView path does for free. Audio focus was the first (a live +AAudio track, muted, audible to nobody), the card was the second (STATE_NONE +over playing music), the transport is this one. Anything the JS does on the +way to making a sound has to have an answer here too, and the failure is always +silence rather than an error. + +**The covers, and the colour.** The playback screen was showing the app icon, +because nothing filled the card's art. Android Auto washes that screen with a +colour it extracts from whatever artwork it is handed — so every record was +tinted the falcon's blue. The engine already knows the album's cover path, so +it now travels with the track and the card is built from it. Random Access +Memories comes up gold. The app derives an accent per cover and the car cannot +be told to, but it turns out the car does it anyway, for free, given art. + +**Four tabs with icons**, which is what every other media app in the car has: +a root browsable item's artwork is drawn as its tab icon, and ours had none, so +they rendered as bare words. `android.resource://` URIs rather than the +FileProvider — these are our own resources, identical on every install, and +granting a per-connection URI for an icon that never changes would be wrong. + +**The grid, in the right place.** The hint was set on each node's own +`LibraryParams` when returning its children; Auto reads it from the ROOT as a +default and from the PARENT ITEM as an override. So the albums drew as a list +with the grid hint set correctly and delivered nowhere. The root declares +`CONTENT_STYLE_SUPPORTED` now and the `ss:albums` tab carries the override, +along with each artist, whose children are also sleeves. + +**Shuffle** is a `CommandButton` on `setMediaButtonPreferences` — both spellings +exist in 1.10.1 and `setCustomLayout` is the older one. It uses the PREDEFINED +`ICON_SHUFFLE_ON` rather than a drawable of ours: a custom resource the host +does not recognise is dropped silently, which is what happened the first time, +and `ICON_SHUFFLE_OFF` names the state and is drawn as a circular arrow that +reads as repeat. It reorders only what is AHEAD, so the current song does not +restart under the driver's hands and "previous" still goes back to what was +actually heard. + +The accent is `#31C0CB` in a car theme named by +`com.google.android.gms.car.application.theme`. Its parent is a FRAMEWORK theme +because the style is resolved inside the car's process, where AppCompat and +Material are not guaranteed to exist. + +Proven on the Pixel 6a through a real Android Auto session, driven from the +DHU: the four tabs render with their icons, Albums is a grid of full covers, +the playback screen shows the album art with the colour pulled from it, and a +media `next` moves the queue and lands the card on "The Game of Love". + +**What this does not touch, and it is the next question.** Everything in the +tree is a local file. Playlists are in the database, are local, and would play +from cold exactly as albums do — they are absent only because Auto gives four +root slots and four are taken, which is a trade to make rather than a limit. +Catalog and Soulseek rows genuinely cannot play from a cold process, but they +CAN whenever the app is warm, through the same `MediaHub.act` round trip the +transport just stopped depending on — browsable always, honest error only when +there is no WebView. That is a better answer than the design's "never" and it +is where this goes next. diff --git a/src-tauri/src/auto_browse.rs b/src-tauri/src/auto_browse.rs index 39a2994..8f8ff8e 100644 --- a/src-tauri/src/auto_browse.rs +++ b/src-tauri/src/auto_browse.rs @@ -355,7 +355,7 @@ pub(crate) fn resolve_playable( }; 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 + "SELECT t.id, t.path, t.title, t.artist, t.duration_secs, a.title, a.art_path 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", @@ -369,6 +369,7 @@ pub(crate) fn resolve_playable( artist: r.get(3)?, duration: r.get::<_, Option>(4)?.map(|s| s as f64), album: r.get(5)?, + art: r.get(6)?, }, )) })?; @@ -498,6 +499,17 @@ pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_play( .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. +#[no_mangle] +pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_shuffle( + _env: JNIEnv, + _this: JObject, +) -> jni::sys::jint { + crate::headless::audio(None).shuffle_rest() as jni::sys::jint +} + /// `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. @@ -515,6 +527,33 @@ pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_transport( "pause" => audio.player.pause(), "resume" => audio.player.resume(), "stop" => audio.player.stop(), + "next" => { + audio.skip(1); + } + "prev" => { + audio.skip(-1); + } _ => warn(&format!("auto transport: unknown action {a}")), } } + +/// `AutoNative.seek(secs)` — the car's scrubber, straight to the engine. +#[no_mangle] +pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_seek( + _env: JNIEnv, + _this: JObject, + secs: f64, +) { + crate::headless::audio(None).player.seek(secs); +} + +/// `AutoNative.driving()` — true when the headless queue is what is playing, +/// so Kotlin can send a transport command to Rust instead of to a WebView +/// that may not exist. +#[no_mangle] +pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_driving( + _env: JNIEnv, + _this: JObject, +) -> jni::sys::jboolean { + u8::from(crate::headless::audio(None).driving()) +} diff --git a/src-tauri/src/headless.rs b/src-tauri/src/headless.rs index 560af11..9f20b6f 100644 --- a/src-tauri/src/headless.rs +++ b/src-tauri/src/headless.rs @@ -45,6 +45,11 @@ pub struct PlayTrack { pub artist: String, pub album: String, pub duration: Option, + /// Absolute path to the album's cover. The card is built from this, and + /// so is the colour Android Auto washes the playback screen with — it + /// extracts from whatever artwork it is given, which is why supplying the + /// app icon tinted every record the same blue. + pub art: Option, } #[derive(Default)] @@ -120,8 +125,17 @@ impl Audio { q.items.get(q.idx).cloned() } }; + let more = { + let q = self.queue.lock().unwrap_or_else(|e| e.into_inner()); + q.idx + 1 < q.items.len() + }; match next { - Some(t) => self.player.play_local(t.path, t.duration), + Some(t) => { + self.player.play_local(t.path.clone(), t.duration); + // Tell Kotlin, or the card sits on track one for the rest of + // the album while the right audio plays under it. + notify_track(&t, more); + } // The end of the album is the end of the album. Leaving the queue // in place means a later `advance` finds the index past the end and // does nothing, rather than wrapping round to track one. @@ -142,6 +156,83 @@ impl Audio { } } + /// Shuffle what is still AHEAD, leaving the current track playing. + /// + /// Shuffling the whole queue would restart the song under the driver's + /// hands, which is not what anyone means by the button. Nothing before the + /// index moves either, so "previous" still goes back to what was actually + /// heard. + /// + /// The generator is a xorshift seeded from the clock rather than a crate: + /// this decides the order of an album, not anything anyone should be able + /// to bet on, and a dependency for it would be the wrong trade. + pub fn shuffle_rest(&self) -> usize { + let mut q = self.queue.lock().unwrap_or_else(|e| e.into_inner()); + let start = q.idx + 1; + let len = q.items.len(); + if len <= start + 1 { + return 0; + } + let mut s = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0x9E37_79B9_7F4A_7C15) + | 1; + let mut i = len; + while i > start + 1 { + i -= 1; + s ^= s << 13; + s ^= s >> 7; + s ^= s << 17; + let j = start + (s % ((i - start + 1) as u64)) as usize; + q.items.swap(i, j); + } + len - start + } + + /// Move by one track in the fallback queue. Returns false at either end, + /// so the caller can leave the card alone rather than pretend. + /// + /// This exists because the car's next/previous buttons go through the + /// player, and the player's usual answer is a round trip to the WebView — + /// which in a service-only process is a message to nobody. Every transport + /// control was dead here until this. + pub fn skip(&self, delta: i64) -> bool { + let picked = { + let mut q = self.queue.lock().unwrap_or_else(|e| e.into_inner()); + if q.items.is_empty() { + None + } else { + let target = q.idx as i64 + delta; + if target < 0 || target >= q.items.len() as i64 { + None + } else { + q.idx = target as usize; + q.items.get(q.idx).cloned() + } + } + }; + let more = { + let q = self.queue.lock().unwrap_or_else(|e| e.into_inner()); + q.idx + 1 < q.items.len() + }; + match picked { + Some(t) => { + self.player.play_local(t.path.clone(), t.duration); + notify_track(&t, more); + true + } + None => false, + } + } + + /// True when this engine is the one driving playback — i.e. a car started + /// it and no WebView has taken over. The transport uses it to decide + /// whether a button belongs to Rust or to the JS. + pub fn driving(&self) -> bool { + !self.queue.lock().unwrap_or_else(|e| e.into_inner()).items.is_empty() + } + /// What is playing right now, as the card needs it. `None` when this /// engine has never been given a queue — which is the normal state in a /// process the WebView drives, and the signal to leave the card alone. @@ -151,6 +242,57 @@ impl Audio { } } +/// Hand a track change up to Kotlin so the card can follow the fallback queue. +/// +/// This runs on the audio pump — a native thread with no Java on its stack — +/// so it has to attach to the VM itself. The VM is there because `JNI_OnLoad` +/// published it to `ndk-context` at `System.loadLibrary` time, before any +/// Activity existed, which is the same hand-off the audio backend relies on +/// and the reason a service-only process can do this at all. +/// +/// Every failure is swallowed. A card that misses an update is a cosmetic +/// problem; a panic across the JNI boundary on the thread that drives playback +/// is the music stopping. +fn notify_track(t: &PlayTrack, has_next: bool) { + let json = serde_json::json!({ + "title": t.title, + "artist": t.artist, + "album": t.album, + "duration": t.duration, + "art": t.art, + "hasNext": has_next, + }) + .to_string(); + + let ctx = ndk_context::android_context(); + if ctx.vm().is_null() { + return; + } + // SAFETY: the pointer came from ndk-context, which android_glue filled + // with the real JavaVM and never replaces. + let Ok(vm) = (unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }) else { + return; + }; + // The pump is not a Java thread; attaching is what makes it callable. + let Ok(mut env) = vm.attach_current_thread() else { + return; + }; + let Ok(arg) = env.new_string(&json) else { + return; + }; + let called = env.call_static_method( + "com/soulstream/mediasession/AutoNative", + "headlessTrack", + "(Ljava/lang/String;)V", + &[(&arg).into()], + ); + if called.is_err() { + // An exception left pending would be thrown at whatever Java code ran + // next on this thread, which would be something entirely unrelated. + let _ = env.exception_clear(); + } +} + /// Play what a car tapped: the media id from the browse tree, resolved against /// the library read-only, with the rest of its album behind it so the record /// keeps going. @@ -174,6 +316,7 @@ pub fn play_media_id(data_dir: &str, media_id: &str) -> String { "artist": t.artist, "album": t.album, "duration": t.duration, + "art": t.art, }) .to_string(), None => format!("{{\"queued\":{n}}}"),