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
+350
-93
@@ -18,6 +18,11 @@
|
||||
//! Every failure path returns an empty-but-valid JSON array. A head unit that
|
||||
//! is handed nothing draws an empty folder; a head unit that is left waiting
|
||||
//! drops the app. Empty is always the better answer.
|
||||
//!
|
||||
//! Streamed playlist rows are the one place this reaches past the database:
|
||||
//! a row whose remembered peer file (`stream_sources.rs`) is byte-complete in
|
||||
//! the stream cache plays from here like a library track, because it IS a
|
||||
//! file on this phone. Everything else streamed is handed to the app.
|
||||
|
||||
use jni::objects::{JObject, JString};
|
||||
use jni::sys::jstring;
|
||||
@@ -29,6 +34,11 @@ use std::path::{Path, PathBuf};
|
||||
/// with a media id that happens to be a file path.
|
||||
pub const ROOT: &str = "ss:root";
|
||||
|
||||
/// The root a host asks for with `EXTRA_RECENT`: Android's own "continue
|
||||
/// where you left off" surfaces and Android Auto's resumption row. It holds
|
||||
/// exactly one playable row, the resume point, or nothing.
|
||||
pub const RECENT_ROOT: &str = "ss:recent-root";
|
||||
|
||||
/// The four tabs, in the order Kyle picked them (Recent · Albums · Artists ·
|
||||
/// Saved). Playlists is deliberately absent: the phone holds exactly one, and
|
||||
/// an empty tab is a dead quarter of the only four root slots Auto gives us.
|
||||
@@ -50,11 +60,10 @@ struct Node {
|
||||
subtitle: String,
|
||||
browsable: bool,
|
||||
playable: bool,
|
||||
/// Absolute path to a cover, when the row has one. Kotlin decides what a
|
||||
/// Absolute path to a cover, or an https URL. Kotlin decides what a
|
||||
/// path can become on the car's side; this layer only reports it.
|
||||
art: Option<String>,
|
||||
/// Absolute path to the audio file, for playable rows. Round fifty-one is
|
||||
/// what does anything with it.
|
||||
/// Absolute path to the audio file, for playable rows.
|
||||
path: Option<String>,
|
||||
}
|
||||
|
||||
@@ -264,7 +273,7 @@ fn saved(conn: &Connection) -> rusqlite::Result<Vec<Node>> {
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
fn recent(conn: &Connection) -> rusqlite::Result<Vec<Node>> {
|
||||
fn recent(conn: &Connection, data_dir: &Path) -> rusqlite::Result<Vec<Node>> {
|
||||
// RECENTLY PLAYED ALBUMS, not tracks. A car's "Recent" is "put that back
|
||||
// on", and what a driver means by that is the record — a list of single
|
||||
// songs plays one and stops.
|
||||
@@ -274,8 +283,15 @@ fn recent(conn: &Connection) -> rusqlite::Result<Vec<Node>> {
|
||||
// that has since left the library drops out of the join, which is right
|
||||
// for a row whose whole promise is that tapping it works.
|
||||
let mut out: Vec<Node> = Vec::new();
|
||||
// First of all: where playback was left. It is the row a driver wants
|
||||
// most often, and it is the only one that knows a position.
|
||||
out.extend(resume_row(data_dir));
|
||||
// The count is the RECORD's tracks, not how many of them were played —
|
||||
// "Daft Punk · 3 tracks" under a thirteen-track album was the played
|
||||
// count leaking into the subtitle.
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT a.id, a.title, a.artist, a.art_path, COUNT(DISTINCT t.id), MAX(p.started_at)
|
||||
"SELECT a.id, a.title, a.artist, a.art_path,
|
||||
(SELECT COUNT(*) FROM tracks x WHERE x.album_id = a.id), MAX(p.started_at)
|
||||
FROM plays p
|
||||
JOIN tracks t
|
||||
ON t.title = p.title COLLATE NOCASE
|
||||
@@ -304,7 +320,7 @@ fn recent(conn: &Connection) -> rusqlite::Result<Vec<Node>> {
|
||||
out.extend(rows.flatten());
|
||||
}
|
||||
|
||||
out.extend(playlists(conn).unwrap_or_default());
|
||||
out.extend(playlists(conn, data_dir).unwrap_or_default());
|
||||
|
||||
// A library that has never been played is not an empty shelf. On a fresh
|
||||
// install — or a phone whose history was wiped — "Recent" would otherwise
|
||||
@@ -316,39 +332,89 @@ fn recent(conn: &Connection) -> rusqlite::Result<Vec<Node>> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The one row the recent root holds: "continue", with the track that was
|
||||
/// playing, where it was. Nothing when there is no resume point or none of
|
||||
/// it can play from here.
|
||||
fn resume_row(data_dir: &Path) -> Option<Node> {
|
||||
let (tracks, idx, position, from) = crate::headless::resume_queue(data_dir)?;
|
||||
let t = tracks.get(idx)?;
|
||||
let mins = (position / 60.0).floor() as i64;
|
||||
let secs = (position % 60.0).floor() as i64;
|
||||
let at = if position > 0.5 { format!(" · {mins}:{secs:02}") } else { String::new() };
|
||||
let source = if from.is_empty() { t.artist.clone() } else { from };
|
||||
Some(Node {
|
||||
id: crate::headless::RESUME_ID.to_owned(),
|
||||
title: t.title.clone(),
|
||||
subtitle: format!("Continue · {source}{at}"),
|
||||
browsable: false,
|
||||
playable: true,
|
||||
art: t.art.clone(),
|
||||
path: Some(t.path.to_string_lossy().into_owned()),
|
||||
})
|
||||
}
|
||||
|
||||
/// A playlist row's playable file from this process: its local path when it
|
||||
/// has one, else the byte-complete cache copy of its remembered peer file.
|
||||
fn row_file(
|
||||
data_dir: &Path,
|
||||
kind: &str,
|
||||
path: Option<&str>,
|
||||
track_id: Option<&str>,
|
||||
artist: &str,
|
||||
title: &str,
|
||||
) -> Option<(PathBuf, Option<crate::resume::StreamRef>)> {
|
||||
if kind == "local" {
|
||||
return path.map(|p| (PathBuf::from(p), None));
|
||||
}
|
||||
if kind != "stream" {
|
||||
return None;
|
||||
}
|
||||
let src = crate::stream_sources::lookup(data_dir, track_id, Some(artist), Some(title))?;
|
||||
let file = crate::stream_sources::cached_file(data_dir, &src)?;
|
||||
Some((
|
||||
file,
|
||||
Some(crate::resume::StreamRef {
|
||||
username: src.username,
|
||||
path: src.path,
|
||||
size: src.size,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
/// Playlists, newest first, as browsable rows.
|
||||
///
|
||||
/// Counted out of `playlist_items` rather than `playlist_tracks`: it is the
|
||||
/// richer table, and the one that knows whether an entry is a local file
|
||||
/// (`kind`/`path`) or something that needs the resolver.
|
||||
fn playlists(conn: &Connection) -> rusqlite::Result<Vec<Node>> {
|
||||
/// (`kind`/`path`) or something that needs the resolver. A streamed entry
|
||||
/// whose cache copy is whole counts as here.
|
||||
fn playlists(conn: &Connection, data_dir: &Path) -> rusqlite::Result<Vec<Node>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT p.id, p.name, COUNT(i.id),
|
||||
SUM(CASE WHEN i.kind = 'local' AND i.path IS NOT NULL THEN 1 ELSE 0 END),
|
||||
"SELECT p.id, p.name,
|
||||
-- A streamed entry has no cover FILE, it has a remote URL.
|
||||
-- Auto fetches an https artwork uri itself, so the row gets a
|
||||
-- sleeve either way rather than a grey tile.
|
||||
COALESCE(MAX(i.art_path), MAX(i.art_url))
|
||||
FROM playlists p LEFT JOIN playlist_items i ON i.playlist_id = p.id
|
||||
GROUP BY p.id
|
||||
(SELECT COALESCE(i.art_path, i.art_url) FROM playlist_items i
|
||||
WHERE i.playlist_id = p.id AND COALESCE(i.art_path, i.art_url) IS NOT NULL
|
||||
ORDER BY i.position LIMIT 1)
|
||||
FROM playlists p
|
||||
ORDER BY p.created_at DESC
|
||||
LIMIT ?1",
|
||||
)?;
|
||||
let rows = stmt.query_map([ROW_CAP as i64], |r| {
|
||||
let id: i64 = r.get(0)?;
|
||||
let name: String = r.get(1)?;
|
||||
let n: i64 = r.get(2)?;
|
||||
let local: i64 = r.get(3).unwrap_or(0);
|
||||
let art: Option<String> = r.get(4)?;
|
||||
let heads: Vec<(i64, String, Option<String>)> = stmt
|
||||
.query_map([ROW_CAP as i64], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
let mut out = Vec::with_capacity(heads.len());
|
||||
for (id, name, art) in heads {
|
||||
let (n, here) = playlist_counts(conn, data_dir, id)?;
|
||||
// Say plainly when part of a playlist cannot play here. A row that
|
||||
// silently plays four of its nine tracks is worse than one that says
|
||||
// so before it is tapped.
|
||||
let sub = if local < n {
|
||||
format!("Playlist · {} of {} here", local, n)
|
||||
let sub = if here < n {
|
||||
format!("Playlist · {} of {} here", here, n)
|
||||
} else {
|
||||
format!("Playlist · {}", plural(n, "track", "tracks"))
|
||||
};
|
||||
Ok(Node {
|
||||
out.push(Node {
|
||||
id: format!("ss:playlist:{id}"),
|
||||
title: name,
|
||||
subtitle: sub,
|
||||
@@ -356,17 +422,44 @@ fn playlists(conn: &Connection) -> rusqlite::Result<Vec<Node>> {
|
||||
playable: false,
|
||||
art,
|
||||
path: None,
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A playlist's entries, in order. Only the local ones are playable; the rest
|
||||
/// need the resolver, which does not exist in a process with no WebView, and
|
||||
/// are shown rather than hidden so the list matches the phone's.
|
||||
fn playlist_items(conn: &Connection, playlist_id: i64) -> rusqlite::Result<Vec<Node>> {
|
||||
/// (all rows, rows that play from this process) for one playlist.
|
||||
fn playlist_counts(conn: &Connection, data_dir: &Path, playlist_id: i64) -> rusqlite::Result<(i64, i64)> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, title, artist, album, path, COALESCE(art_path, art_url), kind
|
||||
"SELECT kind, path, track_id, artist, title FROM playlist_items WHERE playlist_id = ?1",
|
||||
)?;
|
||||
let mut n = 0i64;
|
||||
let mut here = 0i64;
|
||||
let rows = stmt.query_map([playlist_id], |r| {
|
||||
Ok((
|
||||
r.get::<_, String>(0)?,
|
||||
r.get::<_, Option<String>>(1)?,
|
||||
r.get::<_, Option<String>>(2)?,
|
||||
r.get::<_, String>(3)?,
|
||||
r.get::<_, String>(4)?,
|
||||
))
|
||||
})?;
|
||||
for row in rows.flatten() {
|
||||
n += 1;
|
||||
let (kind, path, track_id, artist, title) = row;
|
||||
if row_file(data_dir, &kind, path.as_deref(), track_id.as_deref(), &artist, &title).is_some() {
|
||||
here += 1;
|
||||
}
|
||||
}
|
||||
Ok((n, here))
|
||||
}
|
||||
|
||||
/// A playlist's entries, in order. The local ones and the streamed ones whose
|
||||
/// cache copy is whole are playable; the rest need the resolver, which does
|
||||
/// not exist in a process with no WebView, and are shown rather than hidden
|
||||
/// so the list matches the phone's.
|
||||
fn playlist_items(conn: &Connection, data_dir: &Path, playlist_id: i64) -> rusqlite::Result<Vec<Node>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, title, artist, album, path, COALESCE(art_path, art_url), kind, track_id
|
||||
FROM playlist_items
|
||||
WHERE playlist_id = ?1
|
||||
ORDER BY position",
|
||||
@@ -379,20 +472,34 @@ fn playlist_items(conn: &Connection, playlist_id: i64) -> rusqlite::Result<Vec<N
|
||||
let path: Option<String> = r.get(4)?;
|
||||
let art: Option<String> = r.get(5)?;
|
||||
let kind: String = r.get(6)?;
|
||||
let local = kind == "local" && path.is_some();
|
||||
let sub = match (&album, local) {
|
||||
(_, false) => format!("{artist} · on your phone"),
|
||||
(Some(al), true) if !al.is_empty() => format!("{artist} · {al}"),
|
||||
_ => artist.clone(),
|
||||
let track_id: Option<String> = r.get(7)?;
|
||||
let file = row_file(data_dir, &kind, path.as_deref(), track_id.as_deref(), &artist, &title);
|
||||
// Every real row is PLAYABLE, whether or not its file is here: a
|
||||
// non-playable, non-browsable item is drawn by the host as a bare
|
||||
// tile in a list of rows (the first head-unit run showed four rows
|
||||
// and thirty-six tiles). The row that will need the app says so in
|
||||
// its subtitle, and a tap on it is the hand-off (warm) or the error
|
||||
// card (cold) — never a silent nothing. Kotlin marks the ones whose
|
||||
// file is here with the host's own "downloaded" indicator.
|
||||
let unresolved = kind == "unresolved";
|
||||
let sub = if unresolved {
|
||||
format!("{artist} · unresolved")
|
||||
} else if file.is_none() {
|
||||
format!("{artist} · needs the app")
|
||||
} else {
|
||||
match &album {
|
||||
Some(al) if !al.is_empty() => format!("{artist} · {al}"),
|
||||
_ => artist.clone(),
|
||||
}
|
||||
};
|
||||
Ok(Node {
|
||||
id: format!("ss:pitem:{id}"),
|
||||
title,
|
||||
subtitle: sub,
|
||||
browsable: false,
|
||||
playable: local,
|
||||
playable: !unresolved,
|
||||
art,
|
||||
path,
|
||||
path: file.map(|(p, _)| p.to_string_lossy().into_owned()),
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
@@ -418,13 +525,13 @@ fn track_node(r: &rusqlite::Row<'_>) -> rusqlite::Result<Node> {
|
||||
/// The whole tree, dispatched on the parent id. Unknown ids answer with
|
||||
/// nothing, which is what a head unit asking about a node we have since
|
||||
/// stopped publishing should get.
|
||||
fn children(conn: &Connection, parent: &str) -> rusqlite::Result<Vec<Node>> {
|
||||
fn children(conn: &Connection, data_dir: &Path, parent: &str) -> rusqlite::Result<Vec<Node>> {
|
||||
match parent {
|
||||
ROOT => Ok(tabs()),
|
||||
"ss:albums" => albums(conn),
|
||||
"ss:artists" => artists(conn),
|
||||
"ss:saved" => saved(conn),
|
||||
"ss:recent" => recent(conn),
|
||||
"ss:recent" => recent(conn, data_dir),
|
||||
_ => {
|
||||
if let Some(id) = parent.strip_prefix("ss:album:") {
|
||||
match id.parse::<i64>() {
|
||||
@@ -433,7 +540,7 @@ fn children(conn: &Connection, parent: &str) -> rusqlite::Result<Vec<Node>> {
|
||||
}
|
||||
} else if let Some(id) = parent.strip_prefix("ss:playlist:") {
|
||||
match id.parse::<i64>() {
|
||||
Ok(n) => playlist_items(conn, n),
|
||||
Ok(n) => playlist_items(conn, data_dir, n),
|
||||
Err(_) => Ok(Vec::new()),
|
||||
}
|
||||
} else if let Some(name) = parent.strip_prefix("ss:artist:") {
|
||||
@@ -448,7 +555,7 @@ fn children(conn: &Connection, parent: &str) -> rusqlite::Result<Vec<Node>> {
|
||||
}
|
||||
|
||||
/// Turn a media id from the browse tree into something playable: the files, in
|
||||
/// order, and which one the tap meant.
|
||||
/// order, which one the tap meant, and the name of what it came from.
|
||||
///
|
||||
/// A tapped TRACK brings its whole album with it at the right index, because
|
||||
/// "the album keeps going" is what a record player does and what a driver
|
||||
@@ -461,9 +568,10 @@ fn children(conn: &Connection, parent: &str) -> rusqlite::Result<Vec<Node>> {
|
||||
pub(crate) fn resolve_playable(
|
||||
data_dir: &str,
|
||||
media_id: &str,
|
||||
) -> Option<(Vec<crate::headless::PlayTrack>, usize)> {
|
||||
) -> Option<(Vec<crate::headless::PlayTrack>, usize, String)> {
|
||||
use crate::headless::PlayTrack;
|
||||
let db = db_path(Path::new(data_dir));
|
||||
let dir = Path::new(data_dir);
|
||||
let db = db_path(dir);
|
||||
let conn = match open_read_only(&db) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
@@ -471,7 +579,7 @@ pub(crate) fn resolve_playable(
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let album_tracks = |album_id: i64| -> rusqlite::Result<Vec<(i64, PlayTrack)>> {
|
||||
let album_tracks = |album_id: i64| -> rusqlite::Result<(Vec<(i64, PlayTrack)>, String)> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT t.id, t.path, t.title, t.artist, t.duration_secs, a.title, a.art_path
|
||||
FROM tracks t JOIN albums a ON a.id = t.album_id
|
||||
@@ -479,54 +587,84 @@ pub(crate) fn resolve_playable(
|
||||
ORDER BY t.disc_no, t.track_no, t.title",
|
||||
)?;
|
||||
let rows = stmt.query_map([album_id], |r| {
|
||||
let id = r.get::<_, i64>(0)?;
|
||||
Ok((
|
||||
r.get::<_, i64>(0)?,
|
||||
id,
|
||||
PlayTrack {
|
||||
id: Some(id.to_string()),
|
||||
path: PathBuf::from(r.get::<_, String>(1)?),
|
||||
title: r.get(2)?,
|
||||
artist: r.get(3)?,
|
||||
duration: r.get::<_, Option<i64>>(4)?.map(|s| s as f64),
|
||||
album: r.get(5)?,
|
||||
art: r.get(6)?,
|
||||
stream: None,
|
||||
},
|
||||
))
|
||||
})?;
|
||||
rows.collect()
|
||||
let rows: Vec<(i64, PlayTrack)> = rows.collect::<rusqlite::Result<_>>()?;
|
||||
let from = rows.first().map(|(_, t)| t.album.clone()).unwrap_or_default();
|
||||
Ok((rows, from))
|
||||
};
|
||||
|
||||
if let Some(id) = media_id.strip_prefix("ss:album:") {
|
||||
let rows = album_tracks(id.parse::<i64>().ok()?).ok()?;
|
||||
return Some((rows.into_iter().map(|(_, t)| t).collect(), 0));
|
||||
let (rows, from) = album_tracks(id.parse::<i64>().ok()?).ok()?;
|
||||
return Some((rows.into_iter().map(|(_, t)| t).collect(), 0, from));
|
||||
}
|
||||
// A playlist plays its LOCAL entries, in order, skipping the ones that
|
||||
// need the resolver. Silently dropping them is the right call here: the
|
||||
// row that led here already said "4 of 9 here", so the gap was declared
|
||||
// before the tap rather than discovered after it.
|
||||
let playlist_local = |playlist_id: i64| -> rusqlite::Result<Vec<(i64, PlayTrack)>> {
|
||||
// A playlist plays the entries this process can: its local files and the
|
||||
// streamed rows whose cache copy is whole, in order, skipping the ones
|
||||
// that need the resolver. Silently dropping those is the right call
|
||||
// here: the row that led here already said "4 of 9 here", so the gap was
|
||||
// declared before the tap rather than discovered after it.
|
||||
let playlist_here = |playlist_id: i64| -> rusqlite::Result<(Vec<(i64, PlayTrack)>, String)> {
|
||||
let name: String = conn
|
||||
.query_row("SELECT name FROM playlists WHERE id = ?1", [playlist_id], |r| r.get(0))
|
||||
.unwrap_or_default();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, title, artist, album, duration_secs, path, art_path
|
||||
"SELECT id, title, artist, album, duration_secs, path, COALESCE(art_path, art_url), kind, track_id
|
||||
FROM playlist_items
|
||||
WHERE playlist_id = ?1 AND kind = 'local' AND path IS NOT NULL
|
||||
WHERE playlist_id = ?1
|
||||
ORDER BY position",
|
||||
)?;
|
||||
let rows = stmt.query_map([playlist_id], |r| {
|
||||
Ok((
|
||||
r.get::<_, i64>(0)?,
|
||||
PlayTrack {
|
||||
title: r.get(1)?,
|
||||
artist: r.get(2)?,
|
||||
album: r.get::<_, Option<String>>(3)?.unwrap_or_default(),
|
||||
duration: r.get::<_, Option<i64>>(4)?.map(|s| s as f64),
|
||||
path: PathBuf::from(r.get::<_, String>(5)?),
|
||||
art: r.get(6)?,
|
||||
},
|
||||
))
|
||||
let id: i64 = r.get(0)?;
|
||||
let title: String = r.get(1)?;
|
||||
let artist: String = r.get(2)?;
|
||||
let album: Option<String> = r.get(3)?;
|
||||
let duration = r.get::<_, Option<i64>>(4)?.map(|s| s as f64);
|
||||
let path: Option<String> = r.get(5)?;
|
||||
let art: Option<String> = r.get(6)?;
|
||||
let kind: String = r.get(7)?;
|
||||
let track_id: Option<String> = r.get(8)?;
|
||||
Ok((id, title, artist, album, duration, path, art, kind, track_id))
|
||||
})?;
|
||||
rows.collect()
|
||||
let mut out = Vec::new();
|
||||
for row in rows.flatten() {
|
||||
let (id, title, artist, album, duration, path, art, kind, track_id) = row;
|
||||
let Some((file, stream)) =
|
||||
row_file(dir, &kind, path.as_deref(), track_id.as_deref(), &artist, &title)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
out.push((
|
||||
id,
|
||||
PlayTrack {
|
||||
id: Some(format!("pl{id}")),
|
||||
path: file,
|
||||
title,
|
||||
artist,
|
||||
album: album.unwrap_or_default(),
|
||||
duration,
|
||||
art,
|
||||
stream,
|
||||
},
|
||||
));
|
||||
}
|
||||
Ok((out, name))
|
||||
};
|
||||
if let Some(id) = media_id.strip_prefix("ss:playlist:") {
|
||||
let rows = playlist_local(id.parse::<i64>().ok()?).ok()?;
|
||||
return Some((rows.into_iter().map(|(_, t)| t).collect(), 0));
|
||||
let (rows, from) = playlist_here(id.parse::<i64>().ok()?).ok()?;
|
||||
return Some((rows.into_iter().map(|(_, t)| t).collect(), 0, from));
|
||||
}
|
||||
if let Some(id) = media_id.strip_prefix("ss:pitem:") {
|
||||
let item_id = id.parse::<i64>().ok()?;
|
||||
@@ -537,9 +675,12 @@ pub(crate) fn resolve_playable(
|
||||
|r| r.get(0),
|
||||
)
|
||||
.ok()?;
|
||||
let rows = playlist_local(playlist_id).ok()?;
|
||||
let idx = rows.iter().position(|(i, _)| *i == item_id).unwrap_or(0);
|
||||
return Some((rows.into_iter().map(|(_, t)| t).collect(), idx));
|
||||
let (rows, from) = playlist_here(playlist_id).ok()?;
|
||||
// The tapped row itself must be playable here, or the tap means a
|
||||
// hand-off — starting the playlist from a different row than the one
|
||||
// under the driver's finger would be a lie.
|
||||
let idx = rows.iter().position(|(i, _)| *i == item_id)?;
|
||||
return Some((rows.into_iter().map(|(_, t)| t).collect(), idx, from));
|
||||
}
|
||||
if let Some(id) = media_id.strip_prefix("ss:track:") {
|
||||
let track_id = id.parse::<i64>().ok()?;
|
||||
@@ -548,9 +689,9 @@ pub(crate) fn resolve_playable(
|
||||
r.get(0)
|
||||
})
|
||||
.ok()?;
|
||||
let rows = album_tracks(album_id).ok()?;
|
||||
let (rows, from) = album_tracks(album_id).ok()?;
|
||||
let idx = rows.iter().position(|(id, _)| *id == track_id).unwrap_or(0);
|
||||
return Some((rows.into_iter().map(|(_, t)| t).collect(), idx));
|
||||
return Some((rows.into_iter().map(|(_, t)| t).collect(), idx, from));
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -639,9 +780,16 @@ fn browse_json(data_dir: &str, parent: &str) -> String {
|
||||
if parent == ROOT {
|
||||
return rows(&tabs());
|
||||
}
|
||||
let db = db_path(Path::new(data_dir));
|
||||
let dir = Path::new(data_dir);
|
||||
// Nor does the recent root: it is one row out of resume.json, and a
|
||||
// host asking for it is asking "can you continue?", not "show me the
|
||||
// library".
|
||||
if parent == RECENT_ROOT {
|
||||
return rows(&resume_row(dir).into_iter().collect::<Vec<_>>());
|
||||
}
|
||||
let db = db_path(dir);
|
||||
let nodes = match open_read_only(&db) {
|
||||
Ok(conn) => children(&conn, parent).unwrap_or_else(|e| {
|
||||
Ok(conn) => children(&conn, dir, parent).unwrap_or_else(|e| {
|
||||
warn(&format!("auto browse {parent}: {e}"));
|
||||
Vec::new()
|
||||
}),
|
||||
@@ -663,7 +811,7 @@ fn rows(nodes: &[Node]) -> String {
|
||||
/// A cold service process has never run Tauri, so nothing has installed an
|
||||
/// `android_logger` and `log::warn!` goes nowhere — which is why the first
|
||||
/// failure here was silent. Write to the platform log directly.
|
||||
fn warn(msg: &str) {
|
||||
pub(crate) fn warn(msg: &str) {
|
||||
let tag = std::ffi::CString::new("SoulStream").unwrap_or_default();
|
||||
if let Ok(m) = std::ffi::CString::new(msg) {
|
||||
// SAFETY: both pointers are valid NUL-terminated strings for the call.
|
||||
@@ -697,7 +845,10 @@ pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_browse(
|
||||
let dir: Option<String> = env.get_string(&data_dir).ok().map(Into::into);
|
||||
let par: Option<String> = env.get_string(&parent).ok().map(Into::into);
|
||||
let out = match (dir, par) {
|
||||
(Some(d), Some(p)) => browse_json(&d, &p),
|
||||
(Some(d), Some(p)) => {
|
||||
crate::headless::set_data_dir(Path::new(&d));
|
||||
browse_json(&d, &p)
|
||||
}
|
||||
_ => "[]".to_owned(),
|
||||
};
|
||||
// Even the hand-back has to not panic across the boundary: an empty array
|
||||
@@ -708,25 +859,28 @@ pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_browse(
|
||||
.unwrap_or(std::ptr::null_mut())
|
||||
}
|
||||
|
||||
/// `AutoNative.play(dataDir, mediaId)` — start playback from a browse row, in
|
||||
/// a process that may have no Activity and no WebView.
|
||||
/// `AutoNative.play(dataDir, mediaId, startMs)` — start playback from a
|
||||
/// browse row, in a process that may have no Activity and no WebView.
|
||||
/// `startMs` below zero means "where it was left" (the resume row) or the
|
||||
/// top (everything else).
|
||||
///
|
||||
/// Returns how many tracks were queued, so Kotlin can answer the car with an
|
||||
/// error state instead of silence when a row resolves to nothing. Catalog and
|
||||
/// Soulseek rows are not in the tree yet and will resolve to nothing by
|
||||
/// construction, which is the correct answer for them: they need the resolver,
|
||||
/// which needs the WebView.
|
||||
/// Soulseek rows with no cache copy resolve to nothing by construction, which
|
||||
/// is the correct answer for them: they need the resolver, which needs the
|
||||
/// WebView.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_play(
|
||||
mut env: JNIEnv,
|
||||
_this: JObject,
|
||||
data_dir: JString,
|
||||
media_id: JString,
|
||||
start_ms: jni::sys::jlong,
|
||||
) -> jstring {
|
||||
let dir: Option<String> = env.get_string(&data_dir).ok().map(Into::into);
|
||||
let id: Option<String> = env.get_string(&media_id).ok().map(Into::into);
|
||||
let out = match (dir, id) {
|
||||
(Some(d), Some(m)) => crate::headless::play_media_id(&d, &m),
|
||||
(Some(d), Some(m)) => crate::headless::play_media_id(&d, &m, start_ms),
|
||||
_ => "{\"queued\":0}".to_owned(),
|
||||
};
|
||||
env.new_string(out)
|
||||
@@ -735,6 +889,30 @@ pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_play(
|
||||
.unwrap_or(std::ptr::null_mut())
|
||||
}
|
||||
|
||||
/// `AutoNative.resumeInfo(dataDir)` — what would resume, and where, as one
|
||||
/// JSON object; `{"ok":false}` when nothing can. Never starts anything:
|
||||
/// Android's own resumption surfaces ask this to DRAW a card long before
|
||||
/// anyone presses play on it.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_resumeInfo(
|
||||
mut env: JNIEnv,
|
||||
_this: JObject,
|
||||
data_dir: JString,
|
||||
) -> jstring {
|
||||
let dir: Option<String> = env.get_string(&data_dir).ok().map(Into::into);
|
||||
let out = match dir {
|
||||
Some(d) => {
|
||||
crate::headless::set_data_dir(Path::new(&d));
|
||||
crate::headless::resume_info_json(Path::new(&d))
|
||||
}
|
||||
None => "{\"ok\":false}".to_owned(),
|
||||
};
|
||||
env.new_string(out)
|
||||
.or_else(|_| env.new_string("{\"ok\":false}"))
|
||||
.map(|s| s.into_raw())
|
||||
.unwrap_or(std::ptr::null_mut())
|
||||
}
|
||||
|
||||
/// `AutoNative.search(dataDir, query)` — rows for a typed or spoken query,
|
||||
/// same JSON shape as a browse.
|
||||
#[no_mangle]
|
||||
@@ -756,13 +934,88 @@ pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_search(
|
||||
.unwrap_or(std::ptr::null_mut())
|
||||
}
|
||||
|
||||
/// What a media id IS, for the page.
|
||||
///
|
||||
/// While the app is up the page owns the queue — shuffle, repeat, failover,
|
||||
/// the resolver, the session it restores — so a car's tap is handed to it
|
||||
/// whole rather than started on the engine underneath it, which would leave
|
||||
/// the bar naming one track while another played. The page cannot parse
|
||||
/// our ids and should not: this turns one into the thing the page already
|
||||
/// knows how to play, and the id stays a thing the Rust side minted.
|
||||
pub(crate) fn describe_json(data_dir: &str, media_id: &str) -> String {
|
||||
if media_id == crate::headless::RESUME_ID {
|
||||
return serde_json::json!({ "kind": "resume" }).to_string();
|
||||
}
|
||||
if let Some(id) = media_id.strip_prefix("ss:album:") {
|
||||
if let Ok(n) = id.parse::<i64>() {
|
||||
return serde_json::json!({ "kind": "album", "albumId": n }).to_string();
|
||||
}
|
||||
}
|
||||
if let Some(id) = media_id.strip_prefix("ss:playlist:") {
|
||||
if let Ok(n) = id.parse::<i64>() {
|
||||
return serde_json::json!({ "kind": "playlist", "playlistId": n, "itemId": 0 }).to_string();
|
||||
}
|
||||
}
|
||||
let conn = match open_read_only(&db_path(Path::new(data_dir))) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return serde_json::json!({ "kind": "unknown" }).to_string(),
|
||||
};
|
||||
if let Some(id) = media_id.strip_prefix("ss:track:") {
|
||||
if let Ok(track) = id.parse::<i64>() {
|
||||
if let Ok(album) = conn.query_row(
|
||||
"SELECT album_id FROM tracks WHERE id = ?1",
|
||||
[track],
|
||||
|r| r.get::<_, i64>(0),
|
||||
) {
|
||||
return serde_json::json!({ "kind": "track", "albumId": album, "trackId": track })
|
||||
.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(id) = media_id.strip_prefix("ss:pitem:") {
|
||||
if let Ok(item) = id.parse::<i64>() {
|
||||
if let Ok(pl) = conn.query_row(
|
||||
"SELECT playlist_id FROM playlist_items WHERE id = ?1",
|
||||
[item],
|
||||
|r| r.get::<_, i64>(0),
|
||||
) {
|
||||
return serde_json::json!({ "kind": "playlist", "playlistId": pl, "itemId": item })
|
||||
.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
serde_json::json!({ "kind": "unknown" }).to_string()
|
||||
}
|
||||
|
||||
/// `AutoNative.describe(dataDir, mediaId)` — the page's view of a browse
|
||||
/// row, for the warm hand-off. See [`describe_json`].
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_describe(
|
||||
mut env: JNIEnv,
|
||||
_this: JObject,
|
||||
data_dir: JString,
|
||||
media_id: JString,
|
||||
) -> jstring {
|
||||
let dir: Option<String> = env.get_string(&data_dir).ok().map(Into::into);
|
||||
let id: Option<String> = env.get_string(&media_id).ok().map(Into::into);
|
||||
let out = match (dir, id) {
|
||||
(Some(d), Some(m)) => describe_json(&d, &m),
|
||||
_ => "{\"kind\":\"unknown\"}".to_owned(),
|
||||
};
|
||||
env.new_string(out)
|
||||
.or_else(|_| env.new_string("{\"kind\":\"unknown\"}"))
|
||||
.map(|s| s.into_raw())
|
||||
.unwrap_or(std::ptr::null_mut())
|
||||
}
|
||||
|
||||
/// What a row would need the APP for, when it cannot play from here.
|
||||
///
|
||||
/// A playlist entry with `kind='stream'` has no file on this phone — only a
|
||||
/// catalog id — and turning that into audio is the resolver's job, which is
|
||||
/// hundreds of lines of shared JS that exist only while a WebView does. So
|
||||
/// this does not try. It reports WHICH playlist and entry were meant, and the
|
||||
/// warm path hands that to the app, which already knows how to play it.
|
||||
/// A playlist entry with `kind='stream'` and no whole cache copy has no file
|
||||
/// on this phone — only a catalog id — and turning that into audio is the
|
||||
/// resolver's job, which is hundreds of lines of shared JS that exist only
|
||||
/// while a WebView does. So this does not try. It reports WHICH playlist and
|
||||
/// entry were meant, and the warm path hands that to the app, which already
|
||||
/// knows how to play it.
|
||||
pub(crate) fn remote_ref(data_dir: &str, media_id: &str) -> Option<(i64, i64)> {
|
||||
let conn = open_read_only(&db_path(Path::new(data_dir))).ok()?;
|
||||
if let Some(id) = media_id.strip_prefix("ss:playlist:") {
|
||||
@@ -840,7 +1093,10 @@ pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_positionMs(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
) -> jni::sys::jlong {
|
||||
(crate::headless::audio(None).progress().0 * 1000.0) as jni::sys::jlong
|
||||
match crate::headless::audio_if_up() {
|
||||
Some(a) => (a.progress().0 * 1000.0) as jni::sys::jlong,
|
||||
None => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// `AutoNative.sounding()` — whether the engine is actually playing, as
|
||||
@@ -850,16 +1106,17 @@ pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_sounding(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
) -> jni::sys::jboolean {
|
||||
u8::from(crate::headless::audio(None).progress().1)
|
||||
u8::from(crate::headless::audio_if_up().is_some_and(|a| a.progress().1))
|
||||
}
|
||||
|
||||
/// `AutoNative.driving()` — true when the headless queue is what is playing,
|
||||
/// so Kotlin can send a transport command to Rust instead of to a WebView
|
||||
/// that may not exist.
|
||||
/// that may not exist. Asked on every card rebuild, so it must never CREATE
|
||||
/// the engine: a process with no engine is not driving.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_driving(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
) -> jni::sys::jboolean {
|
||||
u8::from(crate::headless::audio(None).driving())
|
||||
u8::from(crate::headless::audio_if_up().is_some_and(|a| a.driving()))
|
||||
}
|
||||
+304
-5
@@ -15,13 +15,21 @@ use crate::state::AppState;
|
||||
pub struct AppStatus {
|
||||
pub version: &'static str,
|
||||
pub connected_as: Option<String>,
|
||||
/// A dropped session is being dialled back (state.rs `Reconnect`).
|
||||
pub redialing: bool,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn app_status(state: State<'_, AppState>) -> Result<AppStatus, String> {
|
||||
let redialing = state
|
||||
.reconnect
|
||||
.lock()
|
||||
.map(|r| r.dialing)
|
||||
.unwrap_or(false);
|
||||
Ok(AppStatus {
|
||||
version: env!("CARGO_PKG_VERSION"),
|
||||
connected_as: state.connected_as.lock().await.clone(),
|
||||
redialing,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -456,6 +464,32 @@ pub async fn connect_and_login(
|
||||
password: String,
|
||||
remember: bool,
|
||||
) -> Result<String, String> {
|
||||
// An explicit login is a new generation: whatever redial loop the last
|
||||
// session's drop may have started stands down, and the old pump's own
|
||||
// Disconnected (from the shutdown inside login_inner) schedules nothing.
|
||||
let generation = {
|
||||
let mut r = state.reconnect.lock().unwrap_or_else(|e| e.into_inner());
|
||||
r.generation += 1;
|
||||
r.wanted = true;
|
||||
r.attempt = 0;
|
||||
r.dialing = false;
|
||||
r.generation
|
||||
};
|
||||
login_inner(app, username, password, remember, generation).await
|
||||
}
|
||||
|
||||
/// The login itself, shared by the user's own tap and the automatic redial
|
||||
/// (`schedule_reconnect`). `generation` is the session this login belongs
|
||||
/// to: the event pump it spawns carries it, and a drop reported by a pump
|
||||
/// of an older generation redials nothing and says nothing.
|
||||
pub(crate) async fn login_inner(
|
||||
app: tauri::AppHandle,
|
||||
username: String,
|
||||
password: String,
|
||||
remember: bool,
|
||||
generation: u64,
|
||||
) -> Result<String, String> {
|
||||
let state = app.state::<AppState>();
|
||||
// Drop any previous session first.
|
||||
if let Some(old) = state.client.lock().await.take() {
|
||||
old.shutdown();
|
||||
@@ -533,6 +567,12 @@ pub async fn connect_and_login(
|
||||
|
||||
*state.client.lock().await = Some(client);
|
||||
*state.connected_as.lock().await = Some(username.clone());
|
||||
{
|
||||
let mut r = state.reconnect.lock().unwrap_or_else(|e| e.into_inner());
|
||||
r.connected_at = crate::resume::now_secs();
|
||||
r.dialing = false;
|
||||
r.wanted = true;
|
||||
}
|
||||
|
||||
// Forward client events to the webview for the life of this session.
|
||||
let event_app = app.clone();
|
||||
@@ -855,10 +895,45 @@ pub async fn connect_and_login(
|
||||
}
|
||||
}
|
||||
Event::Disconnected { reason } => {
|
||||
let _ = event_app.emit("soulseek:state", UiEvent::Disconnected { reason });
|
||||
// A pump outlives the session it served for a moment:
|
||||
// login_inner shuts the old client down and its reader
|
||||
// reports the drop AFTER the new session has logged in.
|
||||
// Until 2026-09-18 that stale drop stamped the fresh
|
||||
// session offline and cleared its username. Only the
|
||||
// pump of the current generation may say "disconnected".
|
||||
let (current, wanted) = event_app
|
||||
.try_state::<AppState>()
|
||||
.and_then(|st| st.reconnect.lock().ok().map(|r| (r.generation, r.wanted)))
|
||||
.unwrap_or((generation, true));
|
||||
if current != generation {
|
||||
// Two ways to be stale: replaced by a newer login
|
||||
// (say nothing — the page already heard "connected"
|
||||
// from it), or ended by the user's own Disconnect,
|
||||
// which the page still deserves to hear.
|
||||
if !wanted {
|
||||
let _ = event_app.emit(
|
||||
"soulseek:state",
|
||||
UiEvent::Disconnected {
|
||||
reason: reason.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
log::info!("soulseek: an older session's pump ended ({reason})");
|
||||
break;
|
||||
}
|
||||
log::warn!("soulseek: disconnected ({reason})");
|
||||
let _ = event_app.emit(
|
||||
"soulseek:state",
|
||||
UiEvent::Disconnected {
|
||||
reason: reason.clone(),
|
||||
},
|
||||
);
|
||||
if let Some(st) = event_app.try_state::<AppState>() {
|
||||
*st.connected_as.lock().await = None;
|
||||
}
|
||||
// The drop belongs to THIS session: dial it back unless
|
||||
// the user ended it on purpose.
|
||||
schedule_reconnect(event_app.clone(), generation);
|
||||
break;
|
||||
}
|
||||
Event::ExcludedSearchPhrases(p) => {
|
||||
@@ -1033,6 +1108,209 @@ fn now_ms() -> u64 {
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The wait before redial number `attempt` (1-based), in seconds.
|
||||
///
|
||||
/// 5 · 15 · 45 · 120 · 300 · 300 …, each with up to a fifth of jitter so
|
||||
/// two instances on one address never dial in step. The server drops the
|
||||
/// handshake of anyone who logs in eight to ten times in quick succession
|
||||
/// (paid for on 2026-08-03), and eight of these span a quarter of an hour.
|
||||
fn redial_wait_secs(attempt: u32) -> u64 {
|
||||
let base: u64 = match attempt {
|
||||
0 | 1 => 5,
|
||||
2 => 15,
|
||||
3 => 45,
|
||||
4 => 120,
|
||||
_ => 300,
|
||||
};
|
||||
let jitter = (now_ms() % 1000) * base / 5000; // 0 ..= base/5
|
||||
base + jitter
|
||||
}
|
||||
|
||||
/// Dial the dropped session back, on a schedule the server tolerates.
|
||||
///
|
||||
/// One loop per generation: it stands down the moment the user connects or
|
||||
/// disconnects by hand (either bumps the generation), the moment a login
|
||||
/// lands (its own or anyone's), and when there are no stored credentials to
|
||||
/// dial with. Every attempt is announced to the page as
|
||||
/// `soulseek:state { kind: "reconnecting", attempt, in_secs }`, so the
|
||||
/// network row can say "reconnecting…" instead of "offline".
|
||||
///
|
||||
/// The attempt counter is reset by a drop that FOLLOWED a stable session
|
||||
/// (two minutes or more), and kept by a drop that followed a redial which
|
||||
/// lasted seconds — so a link that flaps keeps backing off rather than
|
||||
/// hammering.
|
||||
pub(crate) fn schedule_reconnect(app: tauri::AppHandle, generation: u64) {
|
||||
{
|
||||
let state = app.state::<AppState>();
|
||||
let mut r = state.reconnect.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if !r.wanted || r.generation != generation || r.dialing {
|
||||
return;
|
||||
}
|
||||
let now = crate::resume::now_secs();
|
||||
if r.connected_at > 0 && now.saturating_sub(r.connected_at) >= 120 {
|
||||
r.attempt = 0;
|
||||
}
|
||||
r.dialing = true;
|
||||
}
|
||||
tauri::async_runtime::spawn(async move {
|
||||
loop {
|
||||
let (attempt, wait) = {
|
||||
let state = app.state::<AppState>();
|
||||
let mut r = state.reconnect.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if !r.wanted || r.generation != generation {
|
||||
r.dialing = false;
|
||||
return;
|
||||
}
|
||||
r.attempt += 1;
|
||||
(r.attempt, redial_wait_secs(r.attempt))
|
||||
};
|
||||
log::info!("soulseek: redial {attempt} in {wait}s");
|
||||
let _ = app.emit(
|
||||
"soulseek:state",
|
||||
serde_json::json!({ "kind": "reconnecting", "attempt": attempt, "in_secs": wait }),
|
||||
);
|
||||
tokio::time::sleep(std::time::Duration::from_secs(wait)).await;
|
||||
{
|
||||
let state = app.state::<AppState>();
|
||||
let stale = {
|
||||
let r = state.reconnect.lock().unwrap_or_else(|e| e.into_inner());
|
||||
!r.wanted || r.generation != generation
|
||||
};
|
||||
if stale || state.connected_as.lock().await.is_some() {
|
||||
let mut r = state.reconnect.lock().unwrap_or_else(|e| e.into_inner());
|
||||
r.dialing = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
let cfg = config::load(&app);
|
||||
let Some(password) = config::load_password(&cfg.username).filter(|_| !cfg.username.is_empty())
|
||||
else {
|
||||
// Nothing to dial with: the user signed in without
|
||||
// remembering. Say so once and stop wanting.
|
||||
log::warn!("soulseek: cannot redial, no stored credentials");
|
||||
let state = app.state::<AppState>();
|
||||
let mut r = state.reconnect.lock().unwrap_or_else(|e| e.into_inner());
|
||||
r.wanted = false;
|
||||
r.dialing = false;
|
||||
return;
|
||||
};
|
||||
match login_inner(app.clone(), cfg.username.clone(), password, false, generation).await {
|
||||
Ok(_) => {
|
||||
log::info!("soulseek: redial {attempt} succeeded");
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("soulseek: redial {attempt} failed: {e}");
|
||||
// login_inner cleared connected_as; the loop goes round.
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// DEBUG ONLY: sever the live server connection the way a network drop
|
||||
/// would, without touching the user's intent — so the redial can be watched
|
||||
/// from the page. A release build refuses.
|
||||
#[tauri::command]
|
||||
pub async fn debug_drop_connection(state: State<'_, AppState>) -> Result<(), String> {
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
let guard = state.client.lock().await;
|
||||
let client = guard.as_ref().ok_or("not connected")?;
|
||||
client.shutdown();
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(debug_assertions))]
|
||||
{
|
||||
let _ = state;
|
||||
Err("debug builds only".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// The page naming its queue for the resume point (`resume.rs`): the window
|
||||
/// around the current track, the index, the position, and what the queue
|
||||
/// was built from. A no-op off Android — the desktop restores from its own
|
||||
/// session and nothing there starts the app without a page.
|
||||
#[tauri::command]
|
||||
pub fn playback_note_queue(
|
||||
app: tauri::AppHandle,
|
||||
items: Vec<crate::resume::ResumeItem>,
|
||||
idx: usize,
|
||||
position: f64,
|
||||
from: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
let rp = crate::resume::ResumePoint {
|
||||
items,
|
||||
idx,
|
||||
position: position.max(0.0),
|
||||
from: from.unwrap_or_default(),
|
||||
updated_at: crate::resume::now_secs(),
|
||||
};
|
||||
if let Ok(dir) = crate::paths::app_data(&app) {
|
||||
crate::headless::set_data_dir(&dir);
|
||||
}
|
||||
// An empty queue is a no-op, never a clear: a page with nothing on
|
||||
// its bar is the state of every boot before the session is restored,
|
||||
// and a stale resume point costs a wrong "Continue" where a wiped one
|
||||
// costs the feature (2026-09-18).
|
||||
if rp.items.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
match crate::headless::audio_if_up() {
|
||||
Some(audio) => audio.note_queue(rp),
|
||||
None => {
|
||||
if let Ok(dir) = crate::paths::app_data(&app) {
|
||||
crate::resume::save(&dir, &rp);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(target_os = "android"))]
|
||||
{
|
||||
let _ = (app, items, idx, position, from);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The playlists kept offline (config.rs `offline_playlists`).
|
||||
#[tauri::command]
|
||||
pub fn playlist_offline_list(app: tauri::AppHandle) -> Vec<i64> {
|
||||
config::load(&app).offline_playlists
|
||||
}
|
||||
|
||||
/// Keep a playlist offline, or stop. The fetching itself is the page's
|
||||
/// (src/mobile/offline.svelte.js) — this only records the wish.
|
||||
#[tauri::command]
|
||||
pub fn playlist_offline_set(app: tauri::AppHandle, id: i64, keep: bool) -> Vec<i64> {
|
||||
let mut cfg = config::load(&app);
|
||||
cfg.offline_playlists.retain(|p| *p != id);
|
||||
if keep {
|
||||
cfg.offline_playlists.push(id);
|
||||
}
|
||||
config::save(&app, &cfg);
|
||||
cfg.offline_playlists
|
||||
}
|
||||
|
||||
/// Where playback was left (`resume.rs`), for the page: a car's PLAY into a
|
||||
/// warm app with nothing on the bar is handed to the page as "resume", and
|
||||
/// the page builds its queue from this. `None` when there is nothing, and
|
||||
/// always `None` off Android.
|
||||
#[tauri::command]
|
||||
pub fn playback_resume_point(app: tauri::AppHandle) -> Option<crate::resume::ResumePoint> {
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
crate::paths::app_data(&app).ok().and_then(|d| crate::resume::load(&d))
|
||||
}
|
||||
#[cfg(not(target_os = "android"))]
|
||||
{
|
||||
let _ = app;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn room_user_json(u: &soulseek_client::RoomUser) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"username": u.username,
|
||||
@@ -4073,6 +4351,14 @@ pub async fn catalog_album_info(
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn disconnect(state: State<'_, AppState>) -> Result<(), String> {
|
||||
// The user's own choice: the redial stands down and the pump's coming
|
||||
// Disconnected is an older generation's, which schedules nothing.
|
||||
{
|
||||
let mut r = state.reconnect.lock().unwrap_or_else(|e| e.into_inner());
|
||||
r.wanted = false;
|
||||
r.generation += 1;
|
||||
r.dialing = false;
|
||||
}
|
||||
if let Some(client) = state.client.lock().await.take() {
|
||||
client.shutdown();
|
||||
}
|
||||
@@ -5317,12 +5603,25 @@ pub async fn waveform(
|
||||
/// under the words "Nothing playing". This is the boot-time answer, the same
|
||||
/// shape of recovery `app_status` already provides for the Soulseek connection.
|
||||
#[tauri::command]
|
||||
pub fn player_state(players: State<'_, PlayerState>) -> crate::playback::PlaybackSnapshot {
|
||||
players
|
||||
pub fn player_state(players: State<'_, PlayerState>) -> PlayerStateReply {
|
||||
let snapshot = players
|
||||
.last_transport
|
||||
.lock()
|
||||
.map(|snapshot| snapshot.clone())
|
||||
.unwrap_or_default()
|
||||
.unwrap_or_default();
|
||||
// Taken, not read: the page that asks first owns the handover, and a
|
||||
// reload later must not restore a car queue that is long gone.
|
||||
let handover = players.handover.lock().ok().and_then(|mut h| h.take());
|
||||
PlayerStateReply { snapshot, handover }
|
||||
}
|
||||
|
||||
/// The transport, plus — once, on Android, after a car started the engine —
|
||||
/// the queue it was driving (`playback.rs` `handover`).
|
||||
#[derive(Serialize)]
|
||||
pub struct PlayerStateReply {
|
||||
#[serde(flatten)]
|
||||
pub snapshot: crate::playback::PlaybackSnapshot,
|
||||
pub handover: Option<crate::resume::ResumePoint>,
|
||||
}
|
||||
|
||||
/// Player transport: action ∈ pause | resume | stop, or seek/volume with value.
|
||||
@@ -5502,7 +5801,7 @@ fn set_transitions_disabled(hwnd: *mut std::ffi::c_void, disabled: bool) {
|
||||
type DwmSet =
|
||||
unsafe extern "system" fn(*mut std::ffi::c_void, u32, *const std::ffi::c_void, u32) -> i32;
|
||||
unsafe {
|
||||
let dwm = LoadLibraryA(b"dwmapi.dll | ||||