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:
1 parent
8f46f9c663
commit
17942fc04b
28 files changed
+2600
-396
No files matched your search
+109
-5
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user