android auto: the card says what the engine is doing, and a skip no longer leaves the old song playing

Reaching a row whose bytes are not on the phone read as the player going
to the next track and freezing — the title changed, the position sat at
0:00, nothing said why — and after a skip the old song kept sounding under
the new title until the fetch delivered, because start_at notified the
card and spawned the resolver without stopping the engine (only the
natural boundary was silent).

The engine stops before it looks now: silence, the title, a spinner. The
card is built buffering from its first frame (headlessTrack carries
`resolving`), and the engine narrates on the artist line — "Signing in to
Soulseek…", "Searching Soulseek…", "45 copies · connecting to <peer>…",
"Streaming from <peer>…", "<peer> isn't sending · trying another copy…",
"No copy found · trying the next track…" — through headlessState, kept in
NowPlayingArgs.status and shown by SoulStreamPlayer while buffering; the
engine's own Playing event clears it. The first tap's reply builds a fresh
state after the JNI call the engine narrated inside, so it carries the
line over rather than showing the artist for a beat.

Verified on the head unit with a three-row test playlist: a cold tap reads
"Searching Soulseek…" at 0:00 and plays at 0:14 with its cover; a skip
into a row not fetched yet reads "Searching Soulseek…", then "Streaming
from nouvellevag0…", then the artist again at 0:07, with the prefetch
fetching the row after it.
This commit is contained in:
KeelrLeek committed 2026-09-18 15:44:25 -05:00
1 parent 9f7661afab
commit bb7f1f1b2d
5 files changed
+82 -9

No files matched your search

+40 -5
View File
@@ -305,8 +305,8 @@ impl Audio {
let fetch = t.path.as_ref().and_then(|p| q.fetching.get(p).cloned());
(t, q.gen, idx + 1 < q.items.len(), fetch)
};
notify_track(&track, more);
if let Some(p) = track.here() {
notify_track(&track, more, false);
self.player.play_local_from(p.clone(), track.duration, at_secs);
self.started(at_secs.unwrap_or(0.0), gen);
if track.art.is_none() {
@@ -315,7 +315,11 @@ impl Audio {
return;
}
if let Some(f) = fetch {
// already on its way: play the bytes as they land
// already on its way: play the bytes as they land, and say so
// on the card until they do
notify_track(&track, more, true);
let from = track.stream.as_ref().map(|s| s.username.clone()).unwrap_or_default();
notify_state("buffering", Some(&format!("Streaming from {from}…")));
self.player.play_stream(f.partial.clone(), f.progress.clone(), f.size, track.duration);
self.started(0.0, gen);
if track.art.is_none() {
@@ -323,7 +327,16 @@ impl Audio {
}
return;
}
notify_state("buffering", None);
// Nothing to play yet. The OLD track must not keep sounding under
// the new title while a copy is found — "it goes to the next track
// right away but the old song is still playing while it's showing
// the new song" (2026-09-18) — so: silence, the title, a spinner,
// and a line that says what is happening.
self.player.stop();
*self.progress.lock().unwrap_or_else(|e| e.into_inner()) = (0.0, false);
self.note_queue_from_engine();
notify_track(&track, more, true);
notify_state("buffering", Some("Searching Soulseek…"));
let me = Arc::clone(self);
let _ = std::thread::Builder::new()
.name("slsk-cold-resolve".into())
@@ -366,6 +379,9 @@ impl Audio {
slot: true,
});
}
if !crate::headless_slsk::connected() {
notify_state("buffering", Some("Signing in to Soulseek…"));
}
if let Err(e) = crate::headless_slsk::ensure_session(&dir) {
warn(&format!("cold queue: {e}"));
self.give_up(idx, gen, &format!("{e}"));
@@ -374,6 +390,7 @@ impl Audio {
if !current(self) {
return;
}
notify_state("buffering", Some("Searching Soulseek…"));
match crate::headless_slsk::find(&track.title, &track.artist, track.duration) {
Ok(found) => picks.extend(found.into_iter().filter(|p| {
!track.stream.as_ref().is_some_and(|s| s.username == p.username && s.path == p.path)
@@ -388,10 +405,19 @@ impl Audio {
self.give_up(idx, gen, &format!("No copy of “{}” found on the network right now.", track.title));
return;
}
let copies = picks.len();
for pick in picks.iter().take(3) {
if !current(self) {
return;
}
notify_state(
"buffering",
Some(&format!(
"{copies} {} · connecting to {}…",
if copies == 1 { "copy" } else { "copies" },
pick.username
)),
);
let Some(fetch) = self.begin_fetch(&dir, idx, pick) else {
continue;
};
@@ -415,6 +441,9 @@ impl Audio {
}
};
if delivered {
// the engine's own Playing event turns the card over once
// enough has landed to sound; until then, the peer's name
notify_state("buffering", Some(&format!("Streaming from {}…", pick.username)));
self.arm_ahead(gen);
if track.art.is_none() {
self.art_after_bytes(idx, gen, fetch);
@@ -422,6 +451,7 @@ impl Audio {
return;
}
warn(&format!("cold queue: {} sent nothing for '{}'; next copy", pick.username, track.title));
notify_state("buffering", Some(&format!("{} isn't sending · trying another copy…", pick.username)));
crate::headless_slsk::cancel(fetch.id);
self.q().fetching.remove(&crate::playback::cache_file_in(
&crate::playback::cache_dir_in(&dir),
@@ -588,6 +618,7 @@ impl Audio {
return;
}
if next {
notify_state("buffering", Some("No copy found · trying the next track…"));
self.start_at(idx + 1, None);
} else {
self.player.stop();
@@ -755,7 +786,7 @@ impl Audio {
}
(q.items[next].clone(), q.gen)
};
notify_track(&track, more_after);
notify_track(&track, more_after, false);
self.started(0.0, gen);
if track.art.is_none() {
self.art_from_catalog(next, gen);
@@ -962,7 +993,7 @@ fn resume_item(t: &PlayTrack) -> ResumeItem {
/// Every failure is swallowed. A card that misses an update is a cosmetic
/// problem; a panic across the JNI boundary on the thread that drives playback
/// is the music stopping.
fn notify_track(t: &PlayTrack, has_next: bool) {
fn notify_track(t: &PlayTrack, has_next: bool, resolving: bool) {
let json = serde_json::json!({
"title": t.title,
"artist": t.artist,
@@ -970,6 +1001,10 @@ fn notify_track(t: &PlayTrack, has_next: bool) {
"duration": t.duration,
"art": t.art,
"hasNext": has_next,
// the bytes are not here yet: the card is built BUFFERING from its
// first frame, never "playing at 0:00" for a beat before the
// spinner
"resolving": resolving,
})
.to_string();
call_auto_native("headlessTrack", &json);