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:
1 parent
9f7661afab
commit
bb7f1f1b2d
5 files changed
+82
-9
No files matched your search
+13
@@ -1159,6 +1159,19 @@ counts cached streams as "here". A tap while the app is UP is still handed
|
||||
to the page (`AutoNative.describe` → `playMediaId`), which owns the queue
|
||||
then.
|
||||
|
||||
**What the card says while a row is found.** Reaching a row whose bytes
|
||||
are not here used to read as the player going to the next track and
|
||||
freezing — and, after a skip, the OLD song kept sounding under the new
|
||||
title until the fetch delivered. Now `start_at` stops the engine before
|
||||
it looks (silence, not the wrong song), the card is built BUFFERING from
|
||||
its first frame (`headlessTrack` `resolving`), and the engine narrates on
|
||||
the card's artist line (`NowPlayingArgs.status`, sent through
|
||||
`headlessState`): "Signing in to Soulseek…", "Searching Soulseek…",
|
||||
"3 copies · connecting to <peer>…", "Streaming from <peer>…", "<peer> isn't
|
||||
sending · trying another copy…", "No copy found · trying the next track…".
|
||||
The engine's own Playing event clears the line and the artist returns. A
|
||||
row fetched ahead says "Streaming from <peer>…" for the beat it takes.
|
||||
|
||||
**Covers, always.** The car draws the app icon over a row with no cover,
|
||||
and thirty of GhettoTechie's forty rows had none — pasted-tracklist rows
|
||||
the catalog never matched. Two hands fill them. In the cold process,
|
||||
|
||||
+14
-3
@@ -1608,11 +1608,17 @@ object AutoNative {
|
||||
try {
|
||||
val ctx = MediaHub.appContext ?: return
|
||||
val o = org.json.JSONObject(json)
|
||||
val message = if (o.isNull("message")) null else o.optString("message").ifEmpty { null }
|
||||
when (o.optString("state")) {
|
||||
"buffering" -> MediaHub.state?.let { it.buffering = true; MediaHub.update(ctx, it) }
|
||||
"playing" -> MediaHub.state?.let { it.buffering = false; it.playing = true; MediaHub.update(ctx, it) }
|
||||
"buffering" -> MediaHub.state?.let {
|
||||
it.buffering = true
|
||||
if (message != null) it.status = message
|
||||
MediaHub.update(ctx, it)
|
||||
}
|
||||
"playing" -> MediaHub.state?.let { it.buffering = false; it.playing = true; it.status = null; MediaHub.update(ctx, it) }
|
||||
"failed" -> {
|
||||
MediaHub.state?.buffering = false
|
||||
MediaHub.state?.status = null
|
||||
val why = if (o.isNull("message")) null else o.optString("message").ifEmpty { null }
|
||||
MediaHub.fail(ctx, why ?: "SoulStream could not play that.")
|
||||
}
|
||||
@@ -1689,11 +1695,16 @@ object AutoNative {
|
||||
np.playing = true
|
||||
np.hasNext = o.optBoolean("hasNext", false)
|
||||
np.hasPrev = true
|
||||
/* a row whose bytes are not here yet: BUFFERING from the first
|
||||
frame, with a line that says so, rather than "playing at
|
||||
0:00" for a beat before the spinner */
|
||||
np.buffering = o.optBoolean("resolving", false)
|
||||
np.status = if (np.buffering) "Finding a copy…" else null
|
||||
val art = if (o.isNull("art")) null else o.optString("art").ifEmpty { null }
|
||||
np.artKey = art
|
||||
np.art = artB64(art)
|
||||
MediaHub.update(ctx, np)
|
||||
android.util.Log.i("SoulStream", "auto advanced to " + np.title)
|
||||
android.util.Log.i("SoulStream", "auto advanced to " + np.title + (if (np.buffering) " (resolving)" else ""))
|
||||
} catch (e: Throwable) {
|
||||
android.util.Log.w("SoulStream", "auto advance card failed: " + e)
|
||||
}
|
||||
|
||||
+6
@@ -32,6 +32,12 @@ class NowPlayingArgs {
|
||||
* the car shows a spinner rather than a position. Never sent by the
|
||||
* page — its own player folds buffering into `playing`. */
|
||||
var buffering: Boolean = false
|
||||
/** what the engine is doing while `buffering` — "Searching Soulseek…",
|
||||
* "3 copies · connecting to <peer>…", "Streaming from <peer>…" — shown
|
||||
* on the card's artist line until the bytes sound. A card that went
|
||||
* to the next title and sat at 0:00 read as the player freezing
|
||||
* (2026-09-18); this is the difference. */
|
||||
var status: String? = null
|
||||
/** the queue around the current item — it and up to ten either side.
|
||||
* Since round forty-eight SoulStreamPlayer.getState() turns this into
|
||||
* the media3 playlist the card is built from, and the index into it.
|
||||
|
||||
+9
-1
@@ -188,9 +188,13 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
|
||||
artKey: String?,
|
||||
current: Boolean,
|
||||
): SimpleBasePlayer.MediaItemData {
|
||||
/* while the engine is finding the current row's bytes, its status
|
||||
line rides the artist line — the one line the car shows under
|
||||
the title */
|
||||
val status = if (current) MediaHub.state?.let { s -> if (s.buffering) s.status else null } else null
|
||||
val md = MediaMetadata.Builder()
|
||||
.setTitle(if (current) (title ?: "SoulStream") else (title ?: ""))
|
||||
.setArtist(artist ?: "")
|
||||
.setArtist(status?.takeIf { it.isNotEmpty() } ?: (artist ?: ""))
|
||||
.setAlbumTitle(album ?: "")
|
||||
.setIsBrowsable(false)
|
||||
.setIsPlayable(true)
|
||||
@@ -425,6 +429,10 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
|
||||
/* a cold fetch: the title now, a spinner until the first
|
||||
bytes play (headlessState turns it off) */
|
||||
np.buffering = o.optBoolean("resolving", false)
|
||||
/* the engine narrated inside the call that just returned
|
||||
(headlessState ran before this line): keep its line,
|
||||
or the first one, rather than the artist for a beat */
|
||||
np.status = if (np.buffering) (MediaHub.state?.status ?: "Finding a copy…") else null
|
||||
np.hasNext = o.optBoolean("hasNext", queued > 1)
|
||||
np.hasPrev = false
|
||||
// The cover, which is also the colour: Android Auto washes
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user