android auto: the car plays what is here, resumes, and is told the rest — soulseek dials back, and the storm is over

Four things the car could not do, all from a process with no Activity.

A streamed playlist row plays cold when its bytes are already here. The
page's memory of which peer file a catalog track resolved to lived only in
localStorage; it is mirrored now to `<data>/stream-sources.json`
(stream_sources.rs) — every remembered source, and the whole store once at
boot — and auto_browse.rs looks a row up there, computes its cache file
with the same hash playback::cache_path uses, and calls it playable when
the file is byte-complete. GhettoTechie reads "8 of 40 here" cold and a
tapped row plays from the cache with its catalog cover and colour.

The car resumes, the way every other player does. Where playback was is in
`<data>/resume.json` (resume.rs): the page names the queue whenever it
writes its session, the engine's pump keeps the position. media3's
MediaButtonReceiver is declared, so a PLAY key into a dead app — a
Bluetooth head unit, the Quick Settings card, Auto's autoplay on
connection — starts the service and lands in onPlaybackResumption, which
answers with the `ss:resume` row; the RECENT root serves the same row, and
the Recent tab lists it first. A force-stopped app came back PLAYING where
it left off from one `cmd media_session dispatch play`.

The car is told. A tap that cannot play — a streamed row with no cache copy
while the app is cold, a hand-off the resolver refused — used to be
silence. MediaHub.fail puts the reason in the playback state (STATE_ERROR,
which Auto draws as a card) and sends a SessionError carrying "Open
SoulStream"; the next track withdraws it. The page reports its own verdict
through the new `remote_failed` command.

Every tap while the app is UP is the page's. A library row tapped in the
car used to start on the engine underneath the page's own queue — the bar
naming one track while another played. `AutoNative.describe` spells the
row for the page and `playMediaId` plays it as the page's queue; the plugin
disarms the hand-off in onDestroy, and with the page gone the card's
transport goes to the engine. When the app opens over a car session, the
headless queue is handed to the page once (`playback::init` takes it before
clearing) and restoreSession builds the bar from it.

Every playlist row is playable: Auto draws a non-playable, non-browsable
item as a tile and BROWSES it on tap ("No items"). Rows that need the app
say so and fail on tap with the card; rows whose file is here wear the
host's own downloaded mark. The Recent tab's album subtitle counted played
tracks; it counts the album's now. The page never clears the resume point
and only drops its own session once it has booted — a visibility change
under a launch with the screen off wiped both.

The Soulseek session dials itself back after a drop. A server connection
dies for reasons that are nobody's — the phone changes networks, Android
freezes a backgrounded process past the five-minute keepalive — and until
now every one of those ended as `Event::Disconnected`, the page stamped
offline, and every streamed track after it refused with "sign in on
Community"; in a car nobody opens the login card. `schedule_reconnect`
redials with the stored credentials at 5, 15, 45, 120, then 300 s with
jitter, under the server's rapid-login throttle. Every session carries a
generation: an explicit login or disconnect bumps it, a redial loop stands
down when the user acts, and a drop reported by an OLDER session's pump
(login_inner shuts the old client down and its reader reports after the new
session is up) no longer stamps the fresh session offline — that race was
there all along. The page shows "connecting" on `reconnecting`, the
resolver waits up to twenty seconds for a dial in progress, and debug
builds expose `debug_drop_connection`. Verified by the debug command (drop →
redial 1 in 5 s → online at 7 s) and by a real drop when the head unit
connected: redial 1 failed on DNS, redial 2 at +15 s logged in.

The notification storm was media3 restarting the service into
onStartCommand. The morning's logcat had the card re-posted six times a
second for over a minute ("Package enqueue rate is 6.6 … Shedding"), then
"Too many PendingIntent created for uid" (media3 mints a fresh one for the
custom button on every rebuild), then twenty refused foreground starts in
two hundred milliseconds. media3 starts this service ITSELF on every
foreground notification update — MediaNotificationManager.startForeground
calls startForegroundService(startSelfIntent), a bare component intent with
nothing but a uid extra — and onStartCommand promoted on every intent it
saw, which re-posted the card, which made media3 start the service again.
It ran for as long as the service was foreground: while a track played,
and for the ten-minute tail after a pause (the capture stops at pause +
10:00 exactly). onStartCommand now promotes only for an intent that carries
an action or a data uri. A 250 ms coalescer on card rebuilds
(MediaHub.refreshSoon) logs the rate; it stayed silent through the storm,
which is what pointed at the restart. A full minute of playback after the
fix: zero shedding lines, zero PendingIntent complaints, zero refused
starts.

Verified on the Desktop Head Unit and the Pixel: the tree, the Continue
row, cold cached playback, the error card and recovery from it, the warm
hand-off, the media-key resume, the redial; mock walk 255/255, phone walk
142/142.
This commit is contained in:
KeelrLeek committed 2026-09-18 12:35:54 -05:00
1 parent 8f46f9c663
commit 17942fc04b
28 files changed
+2600 -396

No files matched your search

+45 -6
View File
@@ -157,6 +157,13 @@ pub struct PlayerState {
/// "what is the audio thread doing right now", mirrored here as the events
/// go past and read back by `player_state` at boot.
pub last_transport: Mutex<PlaybackSnapshot>,
/// The queue a CAR was driving when the app came up over it (Android
/// only; always `None` elsewhere). `init` takes it off the headless
/// engine at the moment the page takes the queue over, and the page's
/// `restoreSession` builds its queue from it instead of from its own
/// localStorage — which would describe an older session and put the
/// wrong title over the right audio. Read once through `player_state`.
pub handover: Mutex<Option<crate::resume::ResumePoint>>,
}
/// What the audio thread is doing, as of its last event. Deliberately only
@@ -201,16 +208,28 @@ pub fn init(app: &tauri::AppHandle) {
plays next and two things advancing one engine would skip a track each
time. Nothing about the desktop changes. */
#[cfg(target_os = "android")]
let (player, events) = {
let (player, events, handover) = {
if let Ok(dir) = crate::paths::app_data(app) {
crate::headless::set_data_dir(&dir);
}
let audio = crate::headless::audio(output_device.clone());
let (tx, rx) = std::sync::mpsc::channel();
audio.subscribe(tx);
// If a car was driving this engine, the page is about to take over a
// queue it has never heard of. Hand it the queue BEFORE clearing it,
// so the page can pick it up where it is rather than restoring an
// older session of its own over live audio.
let handover = if audio.driving() { audio.resume_point() } else { None };
audio.clear_queue();
(audio.player.clone(), rx)
(audio.player.clone(), rx, handover)
};
#[cfg(not(target_os = "android"))]
let (player, events) = Player::spawn_with_device(output_device);
let (player, events, handover) = {
let (p, e) = Player::spawn_with_device(output_device);
(p, e, None)
};
app.manage(PlayerState {
handover: Mutex::new(handover),
player,
streams: Mutex::new(HashMap::new()),
stream_caches: Mutex::new(HashMap::new()),
@@ -369,11 +388,23 @@ impl PlayerState {
}
}
/// The stream cache under an instance's data directory. Pure, so a process
/// that has no `AppHandle` (the media service, cold) reaches the same
/// files the app does.
pub fn cache_dir_in(data_dir: &std::path::Path) -> PathBuf {
data_dir.join("stream-cache")
}
/// Stable cache filename for a remote file: short hash + original name.
pub fn cache_path(app: &tauri::AppHandle, username: &str, remote_path: &str) -> Option<PathBuf> {
let dir = crate::paths::app_data(app).ok()?.join("stream-cache");
let dir = cache_dir_in(&crate::paths::app_data(app).ok()?);
std::fs::create_dir_all(&dir).ok()?;
Some(cache_file_in(&dir, username, remote_path))
}
/// The same name, computed without touching the disk. `stream_sources.rs`
/// uses it to ask whether a remembered peer file is already here.
pub fn cache_file_in(dir: &std::path::Path, username: &str, remote_path: &str) -> PathBuf {
let mut hash: u64 = 0xcbf29ce484222325;
for b in username.bytes().chain(remote_path.bytes()) {
hash ^= b as u64;
@@ -388,7 +419,7 @@ pub fn cache_path(app: &tauri::AppHandle, username: &str, remote_path: &str) ->
c => c,
})
.collect();
Some(dir.join(format!("{hash:016x} {safe}")))
dir.join(format!("{hash:016x} {safe}"))
}
/// The in-flight download target for a cache file: `<name>.<ext>.partial`.
@@ -421,12 +452,17 @@ pub fn prune_cache(app: &tauri::AppHandle) {
// a transfer buffer — age expiry off, size cap still applies.
let age_prune = !crate::config::stream_keep_forever(&cfg);
let Ok(dir) = crate::paths::app_data(app).map(|d| d.join("stream-cache")) else {
let Ok(data_dir) = crate::paths::app_data(app) else {
return;
};
let dir = cache_dir_in(&data_dir);
let Ok(entries) = std::fs::read_dir(&dir) else {
return;
};
// A track fetched because its playlist is kept offline is not a cache
// entry, it is the offline copy — the retention window and the size cap
// both leave it alone, or "keep offline" would quietly stop being true.
let kept = crate::stream_sources::kept_cache_files(&data_dir);
let mut files: Vec<(std::time::SystemTime, u64, PathBuf)> = entries
.flatten()
@@ -440,6 +476,9 @@ pub fn prune_cache(app: &tauri::AppHandle) {
// entries. Deleting one can make its final rename fail.
return None;
}
if kept.contains(&path) {
return None;
}
let md = e.metadata().ok()?;
if !md.is_file() {
return None;