android: the handoff — the pending rounds, the pipeline, and what is unverified
Everything written this session that has not been through the device: rounds 43 to 48 (each patch adversarially verified, 43's transient-notice expectation fixed), a partial round 49, the pipeline scripts, the Android Auto design and the icon comparison mock. Also the two harness changes that were never A/B'd — adaptive waits, which may only shorten a declared wait and never lengthen it, and a ship that runs the mock walk and the deploy at the same time. The A/B and its 255-step baseline are in handoff/tools; main stays at what the phone has actually walked.
This commit is contained in:
1 parent
867fd42972
commit
9f0601027c
400 files changed
+26401
-3
No files matched your search
@@ -0,0 +1,783 @@
|
||||
//! A tap in the car, with the phone in a pocket.
|
||||
//!
|
||||
//! Android Auto can start this app's media service with no Activity, no
|
||||
//! Tauri and no WebView anywhere in the process — the browse tree is served
|
||||
//! by Kotlin over a `MediaLibraryService`, and a tap on a row is a play
|
||||
//! request that arrives before anything the app normally is has been built.
|
||||
//! This module is what answers it.
|
||||
//!
|
||||
//! Three things make that possible, and all three were already true:
|
||||
//!
|
||||
//! * `JNI_OnLoad` publishes the JavaVM and the app Context to `ndk-context`
|
||||
//! at `System.loadLibrary` time — before any Activity exists — so cpal's
|
||||
//! AAudio backend can open an output stream in a service-started process;
|
||||
//! * `crates/library` is plain rusqlite with no Tauri dependency, so the
|
||||
//! database can be opened and read without an `AppHandle`;
|
||||
//! * `crates/player` is plain rodio with no Tauri dependency, so the audio
|
||||
//! thread can be driven the same way.
|
||||
//!
|
||||
//! What is NOT here, deliberately:
|
||||
//!
|
||||
//! * this is not "the queue moved into Rust". Shuffle, repeat, the alts
|
||||
//! failover, provenance and the three-ahead preload all stay in
|
||||
//! `src/mobile/player.svelte.js`, which is the ONLY queue while a WebView
|
||||
//! is alive. The fallback below is a `Vec` and an index that stops at the
|
||||
//! end of the record — no shuffle, no repeat, no preload;
|
||||
//! * catalog and Soulseek items cannot play from cold. The resolver is
|
||||
//! eight hundred lines of shared JS and Soulseek wants a live session;
|
||||
//! an id this module cannot turn into a local file resolves to nothing
|
||||
//! and the browse side says so. Round fifty owns that label;
|
||||
//! * loudness normalisation. `commands::play_local` wraps every start in
|
||||
//! `loudness::on_track_started`, which needs an `AppHandle`. A headless
|
||||
//! track plays at unity gain until the WebView adopts it. Harmless —
|
||||
//! every `Cmd::Play` resets the normalisation gain to 0 dB anyway — but
|
||||
//! it is a real difference and it is written down in ANDROID.md.
|
||||
//!
|
||||
//! THE DATABASE IS OPENED READ-ONLY, and that is not a nicety.
|
||||
//! `Library::open` runs `PRAGMA journal_mode = WAL` and a batch of
|
||||
//! `CREATE TABLE IF NOT EXISTS` — writes, both of them, from a process that
|
||||
//! may be racing the real app. `Library::open_read_only` does neither.
|
||||
//! Reading a WAL database read-only is legal from SQLite 3.22 (this tree
|
||||
//! bundles 3.46) as long as the `-shm`/`-wal` files exist and are readable,
|
||||
//! OR there is write permission on the DIRECTORY holding the database.
|
||||
//! The second always holds here: the headless process is the app's own uid
|
||||
//! and owns `…/Library/.soulstream/`.
|
||||
//!
|
||||
//! THE LOG GOES THROUGH KOTLIN. `tauri_plugin_log` is registered inside
|
||||
//! Tauri's `.setup()`, so in a service-started process `log::info!` reaches
|
||||
//! nowhere `adb logcat` can be pointed at. Every line this module wants read
|
||||
//! back on the device goes out through `NativeAuto.log`, which prints it
|
||||
//! under the tag `SoulStream` with an `auto ` prefix. None of those lines
|
||||
//! may contain the substring `media window `: `mediaWindow()` in
|
||||
//! tests/mobile/smoke.mjs filters on exactly that and would swallow ours
|
||||
//! into the `queue-window` assertion.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use base64::Engine as _;
|
||||
use tauri_plugin_media_session::{NowPlaying, QueueItem};
|
||||
|
||||
/// The current item and ten either side — the same cap `media.svelte.js`
|
||||
/// uses, so the card and Auto's queue list are the same shape whichever side
|
||||
/// is driving.
|
||||
const WINDOW: usize = 10;
|
||||
/// A cover bigger than this is not a cover. The bytes cross two hops as
|
||||
/// base64 and are decoded by media3; the phone's own art is well under it.
|
||||
const MAX_ART_BYTES: u64 = 3 * 1024 * 1024;
|
||||
|
||||
/// One playable row of the fallback queue: a local file and the little that
|
||||
/// a media3 `MediaItem` and the phone's own queue entry are built from.
|
||||
///
|
||||
/// Serialized camelCase because the SAME rows go to the WebView twice — as
|
||||
/// the `soulseek:auto-play` event when the app is already up, and as
|
||||
/// `headless_state()` when it has just come up and is adopting what the car
|
||||
/// started.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Row {
|
||||
pub id: i64,
|
||||
pub album_id: i64,
|
||||
pub path: String,
|
||||
pub title: String,
|
||||
pub artist: String,
|
||||
pub album: String,
|
||||
pub duration: Option<f64>,
|
||||
/// The album's cover, as a local path — `NowPlaying::art_key`'s kind of
|
||||
/// key, and what the phone's `album.art_path` is.
|
||||
pub art_key: Option<String>,
|
||||
}
|
||||
|
||||
/// The handover, in one answer. `live` says whether the fallback was still
|
||||
/// driving when the WebView asked; `rows`/`index` are what it was playing.
|
||||
#[derive(Debug, Clone, Default, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HeadlessState {
|
||||
pub live: bool,
|
||||
pub index: usize,
|
||||
pub rows: Vec<Row>,
|
||||
}
|
||||
|
||||
struct Fallback {
|
||||
rows: Vec<Row>,
|
||||
index: usize,
|
||||
/// Is this the thing driving the engine RIGHT NOW? Exactly one driver
|
||||
/// exists at a time: while this is true the WebView has no queue, and
|
||||
/// `playback::init` clears it before the WebView can arm a preload.
|
||||
live: bool,
|
||||
}
|
||||
|
||||
static FALLBACK: Mutex<Fallback> = Mutex::new(Fallback {
|
||||
rows: Vec::new(),
|
||||
index: 0,
|
||||
live: false,
|
||||
});
|
||||
/// The cover last sent across, so the bytes ride along once per record
|
||||
/// rather than once per transport change — `media.svelte.js` keeps the same
|
||||
/// mirror for the same reason.
|
||||
static LAST_ART: Mutex<Option<String>> = Mutex::new(None);
|
||||
|
||||
/// A poisoned lock here is not worth losing the music over: every field is
|
||||
/// plain data and the worst a panic mid-update leaves is a stale index.
|
||||
fn fb() -> std::sync::MutexGuard<'static, Fallback> {
|
||||
FALLBACK.lock().unwrap_or_else(|p| p.into_inner())
|
||||
}
|
||||
|
||||
/* ── media ids ─────────────────────────────────────────────────────────
|
||||
ROUND FIFTY OWNS THE GRAMMAR, and this parser is deliberately loose
|
||||
about it. What it needs is the last integer of the id and enough of the
|
||||
prefix to say whether that integer names an album row or a track row, so
|
||||
`alb:12`, `album/12`, `soulstream://album/12` and `trk:9` all land, and a
|
||||
bare `12` is read as a track. Anything else — `cat:5`, `pl3`, a search
|
||||
token — resolves to nothing, which is the correct answer: a catalog or
|
||||
Soulseek item cannot play from cold and the browse side labels it.
|
||||
If round fifty's grammar turns out to be something else entirely, this
|
||||
function is the only thing that changes. */
|
||||
enum MediaId {
|
||||
Album(i64),
|
||||
Track(i64),
|
||||
Path(String),
|
||||
}
|
||||
|
||||
fn parse_media_id(raw: &str) -> Option<MediaId> {
|
||||
let s = raw.trim();
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// An absolute path is its own id — Android's own are absolute, and a
|
||||
// Windows one is only ever seen by a desktop test.
|
||||
let bytes = s.as_bytes();
|
||||
if bytes[0] == b'/' || s.starts_with("\\\\") || (bytes.len() > 2 && bytes[1] == b':' && bytes[2] == b'\\') {
|
||||
return Some(MediaId::Path(s.to_string()));
|
||||
}
|
||||
let tail = s.rsplit(|c| c == ':' || c == '/').next()?;
|
||||
let n: i64 = tail.parse().ok()?;
|
||||
let head = s[..s.len() - tail.len()].to_ascii_lowercase();
|
||||
if head.contains("alb") {
|
||||
Some(MediaId::Album(n))
|
||||
} else if head.is_empty() || head.contains("tr") {
|
||||
// "tr" rather than "trk" so `track/`, `trk:` and
|
||||
// `soulstream://track/` all land. The album test runs FIRST because
|
||||
// the scheme `soulstream` happens to contain "tr" as well.
|
||||
Some(MediaId::Track(n))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/* ── the library, read-only ────────────────────────────────────────────── */
|
||||
|
||||
/// Where the database is, from the app's data directory alone.
|
||||
///
|
||||
/// `dataDir`, NOT `filesDir`: Tauri's `app_data_dir()` on Android resolves to
|
||||
/// `PathPlugin.getDataDir` → `activity.dataDir`, so the library root is
|
||||
/// `/data/user/0/com.soulstream.app/Library` and `filesDir` would be one
|
||||
/// level too deep. The failure is silent — the open answers "unable to open
|
||||
/// database file" and browse and play both come back empty with nothing
|
||||
/// obviously wrong — which is why it is spelled out here and in ANDROID.md.
|
||||
fn library_db(data_dir: &Path) -> Option<PathBuf> {
|
||||
let cfg = crate::config::load_at(data_dir);
|
||||
let root = crate::config::download_dir_at(data_dir, &cfg)?;
|
||||
Some(root
|
||||
.join(crate::config::LIBRARY_DATA_DIRNAME)
|
||||
.join("library.db"))
|
||||
}
|
||||
|
||||
fn rows_for_album(lib: &library::Library, album_id: i64) -> Vec<Row> {
|
||||
let Ok(Some((album, _mbid))) = lib.get_album(album_id) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(tracks) = lib.album_tracks(album_id) else {
|
||||
return Vec::new();
|
||||
};
|
||||
tracks
|
||||
.into_iter()
|
||||
.map(|t| Row {
|
||||
id: t.id,
|
||||
album_id,
|
||||
path: t.path,
|
||||
title: t.title,
|
||||
artist: if t.artist.is_empty() {
|
||||
album.artist.clone()
|
||||
} else {
|
||||
t.artist
|
||||
},
|
||||
album: album.title.clone(),
|
||||
duration: t.duration_secs.map(|s| s as f64),
|
||||
art_key: album.art_path.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Turn media ids into playable rows, and say where to start.
|
||||
///
|
||||
/// ONE id is the car's case and is expanded: an album plays whole, a track
|
||||
/// plays its record FROM that track — which is the phone's own behaviour
|
||||
/// (`playIndexRow` in player.svelte.js), and the reason a tap in a car does
|
||||
/// not leave you with a one-song queue. Several ids are taken literally, one
|
||||
/// row each, and `start_index` picks among them.
|
||||
pub fn resolve(data_dir: &Path, ids: &[String], start_index: i32) -> (Vec<Row>, usize) {
|
||||
let empty = (Vec::new(), 0usize);
|
||||
let Some(db) = library_db(data_dir) else {
|
||||
return empty;
|
||||
};
|
||||
let Ok(lib) = library::Library::open_read_only(&db) else {
|
||||
log(&format!("library would not open read-only at {}", db.display()));
|
||||
return empty;
|
||||
};
|
||||
let start = start_index.max(0) as usize;
|
||||
|
||||
if ids.len() == 1 {
|
||||
return match parse_media_id(&ids[0]) {
|
||||
Some(MediaId::Album(id)) => {
|
||||
let rows = rows_for_album(&lib, id);
|
||||
let at = if start < rows.len() { start } else { 0 };
|
||||
(rows, at)
|
||||
}
|
||||
Some(MediaId::Track(id)) => {
|
||||
let Ok(Some(album_id)) = lib.track_album(id) else {
|
||||
return empty;
|
||||
};
|
||||
let rows = rows_for_album(&lib, album_id);
|
||||
let at = rows.iter().position(|r| r.id == id).unwrap_or(0);
|
||||
(rows, at)
|
||||
}
|
||||
Some(MediaId::Path(p)) => (loose_row(&p).into_iter().collect(), 0),
|
||||
None => empty,
|
||||
};
|
||||
}
|
||||
|
||||
let mut rows = Vec::new();
|
||||
for id in ids {
|
||||
match parse_media_id(id) {
|
||||
Some(MediaId::Album(a)) => {
|
||||
if let Some(first) = rows_for_album(&lib, a).into_iter().next() {
|
||||
rows.push(first);
|
||||
}
|
||||
}
|
||||
Some(MediaId::Track(t)) => {
|
||||
if let Ok(Some(album_id)) = lib.track_album(t) {
|
||||
if let Some(row) = rows_for_album(&lib, album_id)
|
||||
.into_iter()
|
||||
.find(|r| r.id == t)
|
||||
{
|
||||
rows.push(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(MediaId::Path(p)) => {
|
||||
if let Some(row) = loose_row(&p) {
|
||||
rows.push(row);
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
let at = if start < rows.len() { start } else { 0 };
|
||||
(rows, at)
|
||||
}
|
||||
|
||||
/// A file with no library row behind it. Everything a tag would have given
|
||||
/// is missing, so the card shows the file's own name — which is still better
|
||||
/// than refusing to play a file that plainly exists.
|
||||
fn loose_row(path: &str) -> Option<Row> {
|
||||
if !std::fs::metadata(path).map(|m| m.is_file()).unwrap_or(false) {
|
||||
return None;
|
||||
}
|
||||
let title = Path::new(path)
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| path.to_string());
|
||||
Some(Row {
|
||||
id: 0,
|
||||
album_id: 0,
|
||||
path: path.to_string(),
|
||||
title,
|
||||
artist: String::new(),
|
||||
album: String::new(),
|
||||
duration: None,
|
||||
art_key: None,
|
||||
})
|
||||
}
|
||||
|
||||
/* ── the fallback queue ────────────────────────────────────────────────── */
|
||||
|
||||
/// A play request off the media session. Returns what happened, which
|
||||
/// Kotlin logs: `headless` (we are driving), `webview` (the app is up and
|
||||
/// was handed the rows), `empty` (nothing resolved).
|
||||
///
|
||||
/// THE TWO DRIVERS NEVER OVERLAP. `has_app()` is the switch, and it is true
|
||||
/// from `playback::init` onward for the life of the process — so once Tauri
|
||||
/// has run, a tap in the car is an EVENT to the WebView and never a second
|
||||
/// driver, and the JS queue keeps shuffle, repeat, alts failover, provenance
|
||||
/// and its three-ahead preload.
|
||||
pub fn start(data_dir: &Path, ids: &[String], start_index: i32, start_at_ms: i64) -> String {
|
||||
let (rows, at) = resolve(data_dir, ids, start_index);
|
||||
if rows.is_empty() {
|
||||
return "empty".to_string();
|
||||
}
|
||||
let start_at = if start_at_ms > 0 {
|
||||
Some(start_at_ms as f64 / 1000.0)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if crate::playback::has_app() {
|
||||
let payload = serde_json::json!({ "rows": rows, "index": at, "startAt": start_at });
|
||||
if crate::playback::emit_to_app("soulseek:auto-play", payload) {
|
||||
return "webview".to_string();
|
||||
}
|
||||
// An app handle that will not emit is not an app any more. Fall
|
||||
// through rather than answer silence to somebody sitting in a car.
|
||||
}
|
||||
{
|
||||
let mut f = fb();
|
||||
f.rows = rows;
|
||||
f.index = at;
|
||||
f.live = true;
|
||||
}
|
||||
play_current(start_at);
|
||||
"headless".to_string()
|
||||
}
|
||||
|
||||
/// Start whatever the index points at, and tell the card about it.
|
||||
fn play_current(start_at: Option<f64>) {
|
||||
let started = {
|
||||
let f = fb();
|
||||
f.rows
|
||||
.get(f.index)
|
||||
.map(|r| (r.path.clone(), r.duration, r.title.clone(), f.rows.len(), f.index))
|
||||
};
|
||||
let Some((path, duration, title, n, index)) = started else {
|
||||
return;
|
||||
};
|
||||
crate::playback::process_player(None)
|
||||
.player
|
||||
.play_local_from(path, duration, start_at);
|
||||
// `at=` LAST, because a title may hold anything — spaces and `=`
|
||||
// included — and the prober reads i= off this line to prove the queue
|
||||
// advanced with nobody pressing anything.
|
||||
log(&format!("headless n={n} i={index} at={title}"));
|
||||
push_state(true, start_at.unwrap_or(0.0));
|
||||
}
|
||||
|
||||
/// The engine finished a track. A no-op unless the fallback is the driver —
|
||||
/// when the WebView is, `ended()` in player.svelte.js owns this and knows
|
||||
/// about repeat, shuffle and the armed preload; this one deliberately does
|
||||
/// not, and stops at the end of the record.
|
||||
pub fn on_track_ended() {
|
||||
let advanced = {
|
||||
let mut f = fb();
|
||||
if !f.live {
|
||||
return;
|
||||
}
|
||||
if f.index + 1 >= f.rows.len() {
|
||||
f.live = false;
|
||||
false
|
||||
} else {
|
||||
f.index += 1;
|
||||
true
|
||||
}
|
||||
};
|
||||
if advanced {
|
||||
play_current(None);
|
||||
} else {
|
||||
log("headless queue ended");
|
||||
}
|
||||
}
|
||||
|
||||
/// Buffering / playing / paused / idle off the engine, mirrored to the card.
|
||||
/// Position ticks do NOT come through here: media3 extrapolates position
|
||||
/// from the last `setContentPositionMs` while it believes we are playing,
|
||||
/// exactly as the WebView's own card does.
|
||||
pub fn on_transport(status: &str) {
|
||||
{
|
||||
let mut f = fb();
|
||||
if !f.live {
|
||||
return;
|
||||
}
|
||||
if status == "idle" {
|
||||
// A `Stop` or an error. `Stopped` is emitted by `Cmd::Stop`
|
||||
// alone — a new `Play` does not pass through it — so this really
|
||||
// is the end and the card comes down with it.
|
||||
f.live = false;
|
||||
f.rows.clear();
|
||||
f.index = 0;
|
||||
drop(f);
|
||||
clear_card();
|
||||
return;
|
||||
}
|
||||
}
|
||||
let snap = crate::playback::snapshot();
|
||||
push_state(
|
||||
snap.status == "playing" || snap.status == "buffering",
|
||||
snap.position,
|
||||
);
|
||||
}
|
||||
|
||||
/// A button, when there is no WebView to give it to. `false` means "not
|
||||
/// mine" and Kotlin should use `MediaHub.onAction` instead — which is what
|
||||
/// it tried first anyway.
|
||||
pub fn transport(action: &str, position: f64) -> bool {
|
||||
if crate::playback::has_app() {
|
||||
return false;
|
||||
}
|
||||
if !fb().live {
|
||||
return false;
|
||||
}
|
||||
let player = crate::playback::process_player(None).player.clone();
|
||||
match action {
|
||||
"play" => player.resume(),
|
||||
"pause" => player.pause(),
|
||||
"toggle" => {
|
||||
if crate::playback::snapshot().status == "playing" {
|
||||
player.pause()
|
||||
} else {
|
||||
player.resume()
|
||||
}
|
||||
}
|
||||
"stop" => player.stop(),
|
||||
"next" => step(1),
|
||||
"prev" => {
|
||||
// The app's own five-second restart rule, kept: past five
|
||||
// seconds previous is a seek to zero, not a step back.
|
||||
if crate::playback::snapshot().position > 5.0 {
|
||||
player.seek(0.0);
|
||||
} else {
|
||||
step(-1);
|
||||
}
|
||||
}
|
||||
"seek" => {
|
||||
if position >= 0.0 {
|
||||
player.seek(position);
|
||||
}
|
||||
}
|
||||
"queue-at" => {
|
||||
if position >= 0.0 {
|
||||
jump(position.round() as usize);
|
||||
}
|
||||
}
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn step(delta: i64) {
|
||||
let moved = {
|
||||
let mut f = fb();
|
||||
let n = f.rows.len() as i64;
|
||||
let i = f.index as i64 + delta;
|
||||
if !f.live || i < 0 || i >= n {
|
||||
false
|
||||
} else {
|
||||
f.index = i as usize;
|
||||
true
|
||||
}
|
||||
};
|
||||
if moved {
|
||||
play_current(None);
|
||||
}
|
||||
}
|
||||
|
||||
fn jump(i: usize) {
|
||||
let moved = {
|
||||
let mut f = fb();
|
||||
if f.live && i < f.rows.len() {
|
||||
f.index = i;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
if moved {
|
||||
play_current(None);
|
||||
}
|
||||
}
|
||||
|
||||
/// The WebView has come up. Stop DRIVING, do not stop the MUSIC: the whole
|
||||
/// point of the handover is that the album keeps going while the app builds
|
||||
/// itself around it.
|
||||
///
|
||||
/// The rows deliberately SURVIVE this. `headless_state()` runs a second or
|
||||
/// two later, from `restoreSession()`, and it is the only thing that can
|
||||
/// tell the WebView what is actually sounding — `PlaybackSnapshot` carries
|
||||
/// transport and nothing else, on purpose.
|
||||
pub fn stand_down() {
|
||||
let was = {
|
||||
let mut f = fb();
|
||||
let was = f.live;
|
||||
f.live = false;
|
||||
was
|
||||
};
|
||||
if was {
|
||||
log("headless standing down");
|
||||
}
|
||||
}
|
||||
|
||||
/// What the car left sounding, handed over ONCE. A second ask answers
|
||||
/// nothing: the WebView owns the queue from the first answer on, and a later
|
||||
/// reload must restore what the USER has played since, not what the car
|
||||
/// started an hour ago.
|
||||
pub fn take_state() -> HeadlessState {
|
||||
let mut f = fb();
|
||||
let out = HeadlessState {
|
||||
live: f.live,
|
||||
index: f.index,
|
||||
rows: std::mem::take(&mut f.rows),
|
||||
};
|
||||
f.index = 0;
|
||||
f.live = false;
|
||||
out
|
||||
}
|
||||
|
||||
/* ── the card ──────────────────────────────────────────────────────────── */
|
||||
|
||||
/// Fill the SAME `NowPlayingArgs` the WebView fills, through the same
|
||||
/// `MediaHub.update`. That is the one idea that keeps this round small:
|
||||
/// `SoulStreamPlayer.getState()`, the card, the notification, audio focus
|
||||
/// and the walk's `one-card` / `one-session` checks all keep working by
|
||||
/// construction, because there is exactly one writer at a time and nothing
|
||||
/// in round forty-eight's file needs a second reading path.
|
||||
fn push_state(playing: bool, position: f64) {
|
||||
let json = {
|
||||
let f = fb();
|
||||
let Some(cur) = f.rows.get(f.index) else {
|
||||
return;
|
||||
};
|
||||
let lo = f.index.saturating_sub(WINDOW);
|
||||
let hi = (f.index + WINDOW + 1).min(f.rows.len());
|
||||
let queue: Vec<QueueItem> = f.rows[lo..hi]
|
||||
.iter()
|
||||
.map(|r| QueueItem {
|
||||
id: Some(r.id.to_string()),
|
||||
title: Some(r.title.clone()),
|
||||
artist: Some(r.artist.clone()),
|
||||
album: Some(r.album.clone()),
|
||||
duration: r.duration,
|
||||
art_key: r.art_key.clone(),
|
||||
})
|
||||
.collect();
|
||||
let np = NowPlaying {
|
||||
title: Some(cur.title.clone()),
|
||||
artist: Some(cur.artist.clone()),
|
||||
album: Some(cur.album.clone()),
|
||||
playing,
|
||||
position,
|
||||
duration: cur.duration,
|
||||
art_key: cur.art_key.clone(),
|
||||
art: cover_once(cur.art_key.as_deref()),
|
||||
has_next: f.index + 1 < f.rows.len(),
|
||||
has_prev: true,
|
||||
queue,
|
||||
queue_index: Some((f.index - lo) as u32),
|
||||
queue_base: Some(lo as u32),
|
||||
// No repeat and no shuffle in the fallback, and saying so is
|
||||
// what keeps media3's view of "is there a next item" honest.
|
||||
repeat: 0,
|
||||
};
|
||||
serde_json::to_string(&np).ok()
|
||||
};
|
||||
if let Some(json) = json {
|
||||
bridge::push_state(&json);
|
||||
}
|
||||
}
|
||||
|
||||
/// The bar emptied. Kotlin reads an empty string as `MediaHub.clear`.
|
||||
fn clear_card() {
|
||||
if let Ok(mut last) = LAST_ART.lock() {
|
||||
*last = None;
|
||||
}
|
||||
bridge::push_state("");
|
||||
}
|
||||
|
||||
/// The cover's bytes, once per record. `MediaHub` keeps them until `artKey`
|
||||
/// changes, so re-reading a two-megabyte JPEG on every transport change
|
||||
/// would be pure waste — `media.svelte.js` keeps the same mirror.
|
||||
fn cover_once(key: Option<&str>) -> Option<String> {
|
||||
let key = key.filter(|k| !k.is_empty() && !k.starts_with("http"))?;
|
||||
{
|
||||
let mut last = LAST_ART.lock().ok()?;
|
||||
if last.as_deref() == Some(key) {
|
||||
return None;
|
||||
}
|
||||
*last = Some(key.to_string());
|
||||
}
|
||||
let md = std::fs::metadata(key).ok()?;
|
||||
if !md.is_file() || md.len() > MAX_ART_BYTES {
|
||||
return None;
|
||||
}
|
||||
let bytes = std::fs::read(key).ok()?;
|
||||
Some(base64::engine::general_purpose::STANDARD.encode(bytes))
|
||||
}
|
||||
|
||||
/// One line, under the tag `SoulStream`, prefixed `auto ` by Kotlin. Never
|
||||
/// containing `media window `; see the module header.
|
||||
pub fn log(msg: &str) {
|
||||
bridge::log(msg);
|
||||
}
|
||||
|
||||
/* ── JNI ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
/// Cache what the callbacks need, from `JNI_OnLoad` and nowhere else.
|
||||
///
|
||||
/// Android's JNI Tips: a thread attached with `AttachCurrentThread` starts in
|
||||
/// the SYSTEM class loader, so `FindClass` from it cannot see app classes at
|
||||
/// all. The remedy the platform documents is to do the lookup once in
|
||||
/// `JNI_OnLoad`, which runs on the class loader of whoever called
|
||||
/// `System.loadLibrary`, and to hold the result with `NewGlobalRef`. This is
|
||||
/// not theoretical here: the `player-events` pump is a `std::thread`, so the
|
||||
/// track-ended callback runs on exactly such a thread.
|
||||
#[cfg(target_os = "android")]
|
||||
pub fn cache_jni(vm: *mut jni::sys::JavaVM) {
|
||||
bridge::cache(vm);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
mod bridge {
|
||||
use jni::objects::{GlobalRef, JClass, JObjectArray, JString, JValue};
|
||||
use jni::sys::{jboolean, jdouble, jint, jlong, jstring, JNI_FALSE, JNI_TRUE};
|
||||
use jni::{JNIEnv, JavaVM};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Reached by `FindClass` from native code, which is why it is named in
|
||||
/// the plugin's consumer-rules.pro: R8 renaming it breaks this at
|
||||
/// RUNTIME, in a release build, with nothing in any log until somebody
|
||||
/// taps something in a car.
|
||||
const CLASS: &str = "com/soulstream/mediasession/NativeAuto";
|
||||
|
||||
struct Hooks {
|
||||
vm: JavaVM,
|
||||
class: GlobalRef,
|
||||
}
|
||||
static HOOKS: OnceLock<Hooks> = OnceLock::new();
|
||||
|
||||
pub(super) fn cache(vm: *mut jni::sys::JavaVM) {
|
||||
if HOOKS.get().is_some() {
|
||||
return;
|
||||
}
|
||||
let Ok(jvm) = (unsafe { JavaVM::from_raw(vm) }) else {
|
||||
return;
|
||||
};
|
||||
let class = {
|
||||
let Ok(mut env) = jvm.get_env() else {
|
||||
return;
|
||||
};
|
||||
let found = match env.find_class(CLASS) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
let _ = env.exception_clear();
|
||||
eprintln!("android: {CLASS} not found ({e}); the car cannot reach us");
|
||||
return;
|
||||
}
|
||||
};
|
||||
match env.new_global_ref(&found) {
|
||||
Ok(g) => g,
|
||||
Err(_) => return,
|
||||
}
|
||||
};
|
||||
let _ = HOOKS.set(Hooks { vm: jvm, class });
|
||||
}
|
||||
|
||||
/// Every call from Rust into Kotlin, on whatever thread we happen to be.
|
||||
/// The pump thread is not a Java thread, so it is attached AS A DAEMON —
|
||||
/// a plain attach would keep the VM from ever shutting down, and an
|
||||
/// `AttachGuard` would attach and detach on every track change.
|
||||
fn call(name: &str, arg: &str) {
|
||||
let Some(hooks) = HOOKS.get() else {
|
||||
return;
|
||||
};
|
||||
let Ok(mut env) = hooks.vm.attach_current_thread_as_daemon() else {
|
||||
return;
|
||||
};
|
||||
let Ok(text) = env.new_string(arg) else {
|
||||
return;
|
||||
};
|
||||
let result = env.call_static_method(
|
||||
&hooks.class,
|
||||
name,
|
||||
"(Ljava/lang/String;)V",
|
||||
&[JValue::from(&text)],
|
||||
);
|
||||
if result.is_err() {
|
||||
let _ = env.exception_describe();
|
||||
let _ = env.exception_clear();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn push_state(json: &str) {
|
||||
call("onHeadlessState", json);
|
||||
}
|
||||
|
||||
pub(super) fn log(msg: &str) {
|
||||
call("log", msg);
|
||||
}
|
||||
|
||||
fn text(env: &mut JNIEnv, s: &JString) -> String {
|
||||
env.get_string(s).map(String::from).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// `NativeAuto.play(dataDir, ids, startIndex, startAtMs)`.
|
||||
///
|
||||
/// `dataDir` is `ctx.dataDir`, NOT `ctx.filesDir` — see `library_db`.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_soulstream_mediasession_NativeAuto_play<'local>(
|
||||
mut env: JNIEnv<'local>,
|
||||
_class: JClass<'local>,
|
||||
data_dir: JString<'local>,
|
||||
ids: JObjectArray<'local>,
|
||||
start_index: jint,
|
||||
start_at_ms: jlong,
|
||||
) -> jstring {
|
||||
let dir = text(&mut env, &data_dir);
|
||||
let mut list: Vec<String> = Vec::new();
|
||||
if let Ok(n) = env.get_array_length(&ids) {
|
||||
for i in 0..n {
|
||||
let Ok(object) = env.get_object_array_element(&ids, i) else {
|
||||
continue;
|
||||
};
|
||||
if object.is_null() {
|
||||
continue;
|
||||
}
|
||||
let one = JString::from(object);
|
||||
let s = text(&mut env, &one);
|
||||
if !s.is_empty() {
|
||||
list.push(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
let answer = super::start(
|
||||
std::path::Path::new(&dir),
|
||||
&list,
|
||||
start_index,
|
||||
start_at_ms,
|
||||
);
|
||||
env.new_string(answer)
|
||||
.map(|s| s.into_raw())
|
||||
.unwrap_or(std::ptr::null_mut())
|
||||
}
|
||||
|
||||
/// `NativeAuto.transport(action, positionSecs)` — true when the headless
|
||||
/// side took it, false when Kotlin should use `MediaHub.onAction`.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_soulstream_mediasession_NativeAuto_transport<'local>(
|
||||
mut env: JNIEnv<'local>,
|
||||
_class: JClass<'local>,
|
||||
action: JString<'local>,
|
||||
position_secs: jdouble,
|
||||
) -> jboolean {
|
||||
let name = text(&mut env, &action);
|
||||
if super::transport(&name, position_secs) {
|
||||
JNI_TRUE
|
||||
} else {
|
||||
JNI_FALSE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Off the phone there is no Kotlin to call and no car to call it from. The
|
||||
/// rest of this module still compiles — and type-checks on the desktop,
|
||||
/// which is most of why it is one file rather than two.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
mod bridge {
|
||||
pub(super) fn push_state(_json: &str) {}
|
||||
pub(super) fn log(_msg: &str) {}
|
||||
}
|
||||
Reference in new issue
Block a user