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

+109 -5
View File
@@ -376,7 +376,7 @@ function advanceGaplessly(i) {
}
/* ── the queue ───────────────────────────────────────────────────────── */
export function playEntries(entries, at = 0, from = "") {
export function playEntries(entries, at = 0, from = "", startSecs = null) {
original = null;
if (player.shuffle && entries.length > 1) {
/* a predetermined order: the chosen track first, the rest shuffled */
@@ -389,8 +389,9 @@ export function playEntries(entries, at = 0, from = "") {
player.qi = at;
player.from = from;
/* the REACTIVE entry, not the raw one: a path-less first entry gets its
stream and path from the resolver, and the bar must see them land */
return startEntry(player.queue[at]);
stream and path from the resolver, and the bar must see them land.
`startSecs` is where a resumed session picks up; null is the top. */
return startEntry(player.queue[at], startSecs);
}
export function playAlbum(album, tracks, at = 0) {
return playEntries(tracks.map((t) => ({ t, album })), at, album.title);
@@ -650,9 +651,13 @@ function packEntry(e) {
album: a ? { id: a.id ?? null, title: a.title, artist: a.artist, year: a.year ?? null, art_path: a.art_path ?? null, artist_art_path: a.artist_art_path ?? null } : null,
};
}
/* set once restoreSession has READ the saved session, so a write that lands
before it (a visibility change under a launch with the screen off — seen
2026-09-18) cannot remove what it is about to restore */
let sessionBooted = false;
export function writeSession() {
try {
if (!player.track) { localStorage.removeItem(SESSION_KEY); return; }
if (!player.track) { if (sessionBooted) localStorage.removeItem(SESSION_KEY); return; }
localStorage.setItem(SESSION_KEY, JSON.stringify({
v: 1,
queue: player.queue.map(packEntry),
@@ -665,6 +670,68 @@ export function writeSession() {
original: original ? original.map(packEntry) : null,
}));
} catch { /* quota, or storage off: the session is a convenience */ }
noteResume();
}
/* ── the resume point ────────────────────────────────────────────────
The same session, in the Rust side's shape (resume.rs), for everything
that can ask "what was playing?" with no page alive to answer: Android's
own resume card, a head unit's PLAY into a cold app, Android Auto's
autoplay on connection. A bounded window around the current track — the
whole queue is not a thing to write every ten seconds — and each entry
named by what plays it cold: a file on the phone, or the peer file whose
bytes may be in the stream cache. The engine's pump keeps the position;
this names the queue. Fire and forget: the desktop and the mock refuse it
and neither is a reason to fail the session write. */
const RESUME_WINDOW = 25;
function resumeItem(e) {
const t = e?.t || {};
const a = e?.album || {};
const local = !!t.path && !t.stream;
return {
id: t.id != null ? String(t.id) : null,
title: t.title || "",
artist: t.artist || a.artist || "",
album: a.title || "",
duration: t.duration_secs ?? null,
art: a.art_path || null,
path: local ? t.path : null,
stream: t.stream?.username && t.path ? { username: t.stream.username, path: t.path, size: t.stream.size || 0 } : null,
};
}
/** The resume shape back into queue entries. A cached stream comes back as
* a plain file, which is what it is on this phone; a streamed entry whose
* cache is gone keeps its peer identity so the resolver can find it. */
export function entriesFromResume(items) {
return (items || []).map((it) => {
const local = !!it.path;
return {
t: {
id: it.id ?? it.path ?? it.title,
title: it.title || "",
artist: it.artist || "",
path: local ? it.path : (it.stream?.path || null),
duration_secs: it.duration ?? null,
stream: !local && it.stream?.username ? { username: it.stream.username, size: it.stream.size || 0 } : null,
catalog: null,
alts: [],
},
album: { id: null, catalogId: null, title: it.album || "", artist: it.artist || "", art_path: it.art || null },
};
});
}
function noteResume() {
/* NEVER cleared from here. A page with nothing on its bar is not proof
that there is nothing to continue — it is the state of every boot
before the session is restored, and a visibility change in that
window (the screen going off under a launch) wrote the resume point
away on 2026-09-18. A stale resume point costs a wrong "Continue"; a
wiped one costs the feature. */
if (!player.track || player.qi < 0) return;
const lo = Math.max(0, player.qi - RESUME_WINDOW);
const win = player.queue.slice(lo, player.qi + RESUME_WINDOW + 1);
api.playbackNoteQueue(win.map(resumeItem), player.qi - lo, player.position || 0, player.from || "").catch(() => {});
}
/** Put back what the last launch left behind. The audio thread is the
@@ -673,9 +740,42 @@ export function writeSession() {
export async function restoreSession() {
let saved = null;
try { saved = JSON.parse(localStorage.getItem(SESSION_KEY) || "null"); } catch { saved = null; }
if (!saved || saved.v !== 1 || !Array.isArray(saved.queue) || !saved.queue[saved.qi]?.t?.path) return false;
sessionBooted = true;
let transport = null;
try { transport = await api.playerState(); } catch { transport = null; }
/* A CAR was driving the engine when this page came up (a cold start
from Android Auto, then the app opened over it): the backend hands
that queue over exactly once, and it beats whatever this page's own
storage remembers — restoring an older session over live audio puts
the wrong title on the bar and the wrong record on the ground. The
entries arrive in the resume shape (resume.rs) and are turned back
into queue entries here; a cached stream comes back as a plain file,
which is what it is on this phone. */
const hand = transport?.handover;
if (hand && Array.isArray(hand.items) && hand.items[hand.idx]) {
const entries = entriesFromResume(hand.items);
const g = ++gen;
player.queue = entries;
player.qi = hand.idx;
player.from = hand.from || "";
original = null;
const e = entries[hand.idx];
player.track = e.t;
player.album = e.album;
const live = !!transport && transport.status !== "idle";
player.status = live ? transport.status : "paused";
player.position = live ? (transport.position ?? 0) : (hand.position ?? 0);
player.duration = live && transport.duration != null ? transport.duration : (e.t.duration_secs ?? null);
player.needsResource = !live;
player.phase = "ready";
void loadArt(e.album?.art_path, true);
if (e.t.path) loadWave(g, e.t.path);
if (live) armPreload();
writeSession();
mediaSync();
return true;
}
if (!saved || saved.v !== 1 || !Array.isArray(saved.queue) || !saved.queue[saved.qi]?.t?.path) return false;
const g = ++gen;
player.queue = saved.queue;
player.qi = saved.qi;
@@ -696,6 +796,10 @@ export async function restoreSession() {
loadWave(g, e.t.path);
if (live) armPreload();
mediaSync();
/* the resume point too: a restored-but-paused session is exactly what a
car connecting after a restart should be able to continue, and until
something played or changed nothing had written it */
noteResume();
return true;
}