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.
784 lines
28 KiB
Plaintext
784 lines
28 KiB
Plaintext
//! 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) {}
|
|
}
|