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.
This commit is contained in:
1 parent
f50cf2018a
commit
4d75395411
13 files changed
+587
-5
No files matched your search
@@ -355,7 +355,7 @@ pub(crate) fn resolve_playable(
|
||||
};
|
||||
let album_tracks = |album_id: i64| -> rusqlite::Result<Vec<(i64, PlayTrack)>> {
|
||||
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<i64>>(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())
|
||||
}
|
||||
+144
-1
@@ -45,6 +45,11 @@ pub struct PlayTrack {
|
||||
pub artist: String,
|
||||
pub album: String,
|
||||
pub duration: Option<f64>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
#[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}}}"),
|
||||
|
||||
Reference in new issue
Block a user