diff --git a/ANDROID.md b/ANDROID.md index 7e0911b..eca2596 100644 --- a/ANDROID.md +++ b/ANDROID.md @@ -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 …", "Streaming from …", " 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 …" 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, diff --git a/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/MediaService.kt b/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/MediaService.kt index 14d71e3..16da709 100644 --- a/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/MediaService.kt +++ b/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/MediaService.kt @@ -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) } diff --git a/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/MediaSessionPlugin.kt b/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/MediaSessionPlugin.kt index 2b42882..a316dc9 100644 --- a/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/MediaSessionPlugin.kt +++ b/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/MediaSessionPlugin.kt @@ -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 …", "Streaming from …" — 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. diff --git a/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/SoulStreamPlayer.kt b/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/SoulStreamPlayer.kt index c22b521..09cb035 100644 --- a/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/SoulStreamPlayer.kt +++ b/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/SoulStreamPlayer.kt @@ -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 diff --git a/src-tauri/src/headless.rs b/src-tauri/src/headless.rs index 4632cd7..fe8a106 100644 --- a/src-tauri/src/headless.rs +++ b/src-tauri/src/headless.rs @@ -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);