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:
KeelrLeek committed 2026-09-18 12:35:54 -05:00
1 parent 8f46f9c663
commit 17942fc04b
28 files changed
+2600 -396

No files matched your search

+350 -93
View File
@@ -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
View File
@@ -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".as_ptr());
let dwm = LoadLibraryA(b"dwmapi.dll\0".as_ptr());
if dwm.is_null() {
return;
}
+6
View File
@@ -144,6 +144,12 @@ pub struct AppConfig {
/// base surfaces thinned in CSS so the desktop glows through. None → false.
#[serde(default)]
pub glass_mode: Option<bool>,
/// Playlists the phone keeps offline: every streamed row fetched into the
/// stream cache in the background and pinned there, so the whole list
/// plays with the app shut — in a car, in a pocket. Ids from
/// `playlists.id`; a deleted playlist's id is harmless here.
#[serde(default)]
pub offline_playlists: Vec<i64>,
/// The Soulseek hub's buddy list — usernames watched on every login so
/// their status and stats stream in while connected.
#[serde(default)]
+297 -38
View File
@@ -19,13 +19,19 @@
//!
//! The queue here is deliberately NOT the app's queue. The JS one keeps
//! shuffle, repeat, `alts` failover, provenance and the three-ahead preload;
//! this is a `Vec<PathBuf>` and an index whose only job is that the album keeps
//! this is a `Vec` and an index whose only job is that the album keeps
//! playing when there is no JS alive to advance it.
//!
//! The pump is also the one place that always knows where the engine is, in
//! both modes, so it is what keeps the **resume point** (`resume.rs`) honest:
//! the page names the queue, the pump writes the position.
use crate::resume::{ResumeItem, ResumePoint, StreamRef};
use player::{Player, PlayerEvent};
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::mpsc::Sender;
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant};
pub struct Audio {
pub player: Player,
@@ -42,31 +48,64 @@ pub struct Audio {
/// that stale zero: pausing looked like the track restarting, and a seek
/// that really happened showed as nothing at all.
progress: Mutex<(f64, bool)>,
/// What would resume if the process were started cold right now — the
/// queue as the page (or this module) last described it, with the
/// position the pump keeps current. `None` until somebody names a queue.
resume: Mutex<Option<ResumePoint>>,
/// When the resume file was last written; the pump flushes the position
/// at most every few seconds while playing, and at once on a pause.
flushed: Mutex<Instant>,
}
/// Enough of a track to play it and to describe it on a card, read once when
/// the queue is built so that advancing never needs the database again.
#[derive(Clone)]
pub struct PlayTrack {
/// The page's own id for the entry, so a handover keeps the identity —
/// a library track's number, `pl<n>` for a playlist row.
pub id: Option<String>,
pub path: PathBuf,
pub title: String,
pub artist: String,
pub album: String,
pub duration: Option<f64>,
/// Absolute path to the album's cover. The card is built from this, and
/// so is the colour Android Auto washes the playback screen with — it
/// extracts from whatever artwork it is given, which is why supplying the
/// app icon tinted every record the same blue.
/// Absolute path to the album's cover, or an https URL for a streamed
/// record. The card is built from this, and so is the colour Android Auto
/// washes the playback screen with — it extracts from whatever artwork it
/// is given, which is why supplying the app icon tinted every record the
/// same blue.
pub art: Option<String>,
/// The peer file this is a cache copy of, when it is one. Carried into
/// the resume point so a later cold start can check the cache again
/// rather than trust a path the pruner may have taken since.
pub stream: Option<StreamRef>,
}
#[derive(Default)]
struct Queue {
items: Vec<PlayTrack>,
idx: usize,
/// The record's or playlist's name, for the resume point.
from: String,
}
static AUDIO: OnceLock<Arc<Audio>> = OnceLock::new();
/// Where this instance keeps its data. Set by whoever gets here first — the
/// car's tap hands it down from Kotlin, the app's own init from Tauri — and
/// the same directory either way.
static DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
/// How often the position is written while playing. Five seconds is the
/// most a resumed track can be behind where it really was.
const FLUSH_EVERY: Duration = Duration::from_secs(5);
pub fn set_data_dir(dir: &Path) {
let _ = DATA_DIR.set(dir.to_path_buf());
}
fn data_dir() -> Option<PathBuf> {
DATA_DIR.get().cloned()
}
/// The process's one engine, created on first use.
///
@@ -82,6 +121,8 @@ pub fn audio(device: Option<String>) -> Arc<Audio> {
subscriber: Mutex::new(None),
queue: Mutex::new(Queue::default()),
progress: Mutex::new((0.0, false)),
resume: Mutex::new(data_dir().as_deref().and_then(crate::resume::load)),
flushed: Mutex::new(Instant::now()),
});
let pump = Arc::clone(&audio);
let _ = std::thread::Builder::new()
@@ -116,6 +157,16 @@ pub fn audio(device: Option<String>) -> Arc<Audio> {
_ => {}
}
}
// The resume point follows the engine: the position on
// a schedule, the rest at once.
match &ev {
PlayerEvent::Position { secs, .. } => pump.note_position(*secs, false),
PlayerEvent::Paused | PlayerEvent::Stopped => {
let pos = pump.progress.lock().unwrap_or_else(|e| e.into_inner()).0;
pump.note_position(pos, true);
}
_ => {}
}
let sub = pump.subscriber.lock().unwrap_or_else(|e| e.into_inner());
if let Some(tx) = sub.as_ref() {
// A closed channel means the WebView went away;
@@ -129,6 +180,12 @@ pub fn audio(device: Option<String>) -> Arc<Audio> {
.clone()
}
/// The engine if one has been created, without creating one. A JNI call
/// asking "are you driving?" must not open an output stream to answer no.
pub fn audio_if_up() -> Option<Arc<Audio>> {
AUDIO.get().cloned()
}
impl Audio {
/// Point the pump at the WebView's forwarder. Replaces any previous one,
/// which is what a reload should do.
@@ -161,6 +218,7 @@ impl Audio {
match next {
Some(t) => {
self.player.play_local(t.path.clone(), t.duration);
self.note_queue_from_engine();
// Tell Kotlin, or the card sits on track one for the rest of
// the album while the right audio plays under it.
notify_track(&t, more);
@@ -172,17 +230,20 @@ impl Audio {
}
}
fn start(&self, items: Vec<PlayTrack>, idx: usize) {
fn start(&self, items: Vec<PlayTrack>, idx: usize, from: &str, at_secs: Option<f64>) {
let first = items.get(idx).cloned();
{
let mut q = self.queue.lock().unwrap_or_else(|e| e.into_inner());
q.items = items;
q.idx = idx;
q.from = from.to_owned();
}
match first {
Some(t) => {
self.player.play_local(t.path, t.duration);
*self.progress.lock().unwrap_or_else(|e| e.into_inner()) = (0.0, true);
self.player.play_local_from(t.path, t.duration, at_secs);
*self.progress.lock().unwrap_or_else(|e| e.into_inner()) =
(at_secs.unwrap_or(0.0), true);
self.note_queue_from_engine();
}
None => {}
}
@@ -199,27 +260,31 @@ impl Audio {
/// this decides the order of an album, not anything anyone should be able
/// to bet on, and a dependency for it would be the wrong trade.
pub fn shuffle_rest(&self) -> usize {
let mut q = self.queue.lock().unwrap_or_else(|e| e.into_inner());
let start = q.idx + 1;
let len = q.items.len();
if len <= start + 1 {
return 0;
}
let mut s = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0x9E37_79B9_7F4A_7C15)
| 1;
let mut i = len;
while i > start + 1 {
i -= 1;
s ^= s << 13;
s ^= s >> 7;
s ^= s << 17;
let j = start + (s % ((i - start + 1) as u64)) as usize;
q.items.swap(i, j);
}
len - start
let moved = {
let mut q = self.queue.lock().unwrap_or_else(|e| e.into_inner());
let start = q.idx + 1;
let len = q.items.len();
if len <= start + 1 {
return 0;
}
let mut s = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0x9E37_79B9_7F4A_7C15)
| 1;
let mut i = len;
while i > start + 1 {
i -= 1;
s ^= s << 13;
s ^= s >> 7;
s ^= s << 17;
let j = start + (s % ((i - start + 1) as u64)) as usize;
q.items.swap(i, j);
}
len - start
};
self.note_queue_from_engine();
moved
}
/// Seek, and move the reported position with it. Waiting for the engine's
@@ -228,6 +293,7 @@ impl Audio {
pub fn seek(&self, secs: f64) {
self.player.seek(secs);
self.progress.lock().unwrap_or_else(|e| e.into_inner()).0 = secs;
self.note_position(secs, true);
}
/// Move by one track in the fallback queue. Returns false at either end,
@@ -260,6 +326,7 @@ impl Audio {
Some(t) => {
self.player.play_local(t.path.clone(), t.duration);
*self.progress.lock().unwrap_or_else(|e| e.into_inner()) = (0.0, true);
self.note_queue_from_engine();
notify_track(&t, more);
true
}
@@ -286,6 +353,89 @@ impl Audio {
let q = self.queue.lock().unwrap_or_else(|e| e.into_inner());
q.items.get(q.idx).cloned()
}
/* ── the resume point ────────────────────────────────────────────── */
/// The page named its queue (or re-named it: a track change, a queue
/// edit, the ten-second write). Replaces what this engine knew and
/// writes the file. The position the page sends is taken as-is — it is
/// the page's read of the same engine, and a pump tick will correct it
/// within a second anyway.
pub fn note_queue(&self, rp: ResumePoint) {
*self.resume.lock().unwrap_or_else(|e| e.into_inner()) = Some(rp.clone());
if let Some(dir) = data_dir() {
crate::resume::save(&dir, &rp);
*self.flushed.lock().unwrap_or_else(|e| e.into_inner()) = Instant::now();
}
}
/// The headless queue as the resume point: a car started or moved it,
/// and there is no page to write one.
fn note_queue_from_engine(&self) {
let rp = {
let q = self.queue.lock().unwrap_or_else(|e| e.into_inner());
if q.items.is_empty() {
return;
}
let lo = q.idx.saturating_sub(crate::resume::WINDOW);
let hi = (q.idx + crate::resume::WINDOW + 1).min(q.items.len());
ResumePoint {
items: q.items[lo..hi].iter().map(resume_item).collect(),
idx: q.idx - lo,
position: self.progress.lock().unwrap_or_else(|e| e.into_inner()).0,
from: q.from.clone(),
updated_at: crate::resume::now_secs(),
}
};
self.note_queue(rp);
}
/// The pump's position tick. Written through on a pause, a seek or a
/// stop; otherwise at most every [`FLUSH_EVERY`] — a phone in a pocket
/// does not need a file write five times a second.
fn note_position(&self, secs: f64, now: bool) {
let rp = {
let mut guard = self.resume.lock().unwrap_or_else(|e| e.into_inner());
let Some(rp) = guard.as_mut() else {
return;
};
rp.position = secs;
rp.updated_at = crate::resume::now_secs();
rp.clone()
};
let due = {
let mut f = self.flushed.lock().unwrap_or_else(|e| e.into_inner());
if now || f.elapsed() >= FLUSH_EVERY {
*f = Instant::now();
true
} else {
false
}
};
if due {
if let Some(dir) = data_dir() {
crate::resume::save(&dir, &rp);
}
}
}
/// The resume point as this engine holds it.
pub fn resume_point(&self) -> Option<ResumePoint> {
self.resume.lock().unwrap_or_else(|e| e.into_inner()).clone()
}
}
fn resume_item(t: &PlayTrack) -> ResumeItem {
ResumeItem {
id: t.id.clone(),
title: t.title.clone(),
artist: t.artist.clone(),
album: t.album.clone(),
duration: t.duration,
art: t.art.clone(),
path: Some(t.path.to_string_lossy().into_owned()),
stream: t.stream.clone(),
}
}
/// Hand a track change up to Kotlin so the card can follow the fallback queue.
@@ -339,13 +489,108 @@ fn notify_track(t: &PlayTrack, has_next: bool) {
}
}
/// The media id a resumed session is played under. The recent root lists
/// one row with this id; media3's resumption hands the same id back through
/// `handleSetMediaItems`; both land in [`play_media_id`].
pub const RESUME_ID: &str = "ss:resume";
/// The resume point turned into something the engine can play NOW: every
/// entry whose file is here (a library track, or a stream whose cache is
/// byte-complete), in order, with the index moved to the nearest playable
/// entry at or after the one that was playing. `None` when nothing in it
/// can play from cold.
///
/// Returns the tracks, the index, and the position to start at — zero when
/// the track that was playing is not the one being started, because
/// resuming three minutes into a DIFFERENT song is not resuming.
pub fn resume_queue(data_dir: &Path) -> Option<(Vec<PlayTrack>, usize, f64, String)> {
let rp = crate::resume::load(data_dir)?;
let mut tracks: Vec<PlayTrack> = Vec::new();
// the playable index each original entry landed at, or None
let mut landed: Vec<Option<usize>> = Vec::with_capacity(rp.items.len());
for it in &rp.items {
let path = it
.path
.as_deref()
.map(PathBuf::from)
.filter(|p| p.is_file())
.or_else(|| {
let s = it.stream.as_ref()?;
crate::stream_sources::cached_file(
data_dir,
&crate::stream_sources::StreamSource {
username: s.username.clone(),
path: s.path.clone(),
size: s.size,
duration: it.duration,
keep: false,
},
)
});
match path {
Some(p) => {
landed.push(Some(tracks.len()));
tracks.push(PlayTrack {
id: it.id.clone(),
path: p,
title: it.title.clone(),
artist: it.artist.clone(),
album: it.album.clone(),
duration: it.duration,
art: it.art.clone(),
stream: it.stream.clone(),
});
}
None => landed.push(None),
}
}
if tracks.is_empty() {
return None;
}
// the entry that was playing, else the first playable one after it,
// else the first playable one at all
let exact = landed.get(rp.idx).copied().flatten();
let idx = exact
.or_else(|| landed.iter().skip(rp.idx).find_map(|l| *l))
.unwrap_or(0);
let position = if exact.is_some() { rp.position.max(0.0) } else { 0.0 };
Some((tracks, idx, position, rp.from))
}
/// The resume row as the car's recent root and media3's resumption want it:
/// what would play, and where. `{"ok":false}` when nothing can.
pub fn resume_info_json(data_dir: &Path) -> String {
match resume_queue(data_dir) {
Some((tracks, idx, position, from)) => {
let t = &tracks[idx];
serde_json::json!({
"ok": true,
"id": RESUME_ID,
"title": t.title,
"artist": t.artist,
"album": t.album,
"art": t.art,
"duration": t.duration,
"positionMs": (position * 1000.0) as i64,
"from": from,
"queued": tracks.len(),
})
.to_string()
}
None => "{\"ok\":false}".to_owned(),
}
}
/// Play what a car tapped: the media id from the browse tree, resolved against
/// the library read-only, with the rest of its album behind it so the record
/// keeps going.
/// keeps going. `start_ms` below zero means "wherever this row was left" —
/// only the resume row has an answer to that; every other row starts at the
/// top.
///
/// Returns the number of tracks queued; 0 means nothing playable was found,
/// which the caller turns into an error state on the car rather than silence.
pub fn play_media_id(data_dir: &str, media_id: &str) -> String {
pub fn play_media_id(data_dir: &str, media_id: &str, start_ms: i64) -> String {
set_data_dir(Path::new(data_dir));
// Nothing local: say whether the APP could play it, and which row was
// meant. A catalog entry is not a failure, it is a thing this process
// cannot do — and the difference decides between an error on the car and
@@ -362,15 +607,27 @@ pub fn play_media_id(data_dir: &str, media_id: &str) -> String {
None => "{\"queued\":0}".to_owned(),
}
};
let Some((items, idx)) = crate::auto_browse::resolve_playable(data_dir, media_id) else {
return needs_app();
let (items, idx, at_secs, from) = if media_id == RESUME_ID {
match resume_queue(Path::new(data_dir)) {
Some((items, idx, saved, from)) => {
let at = if start_ms >= 0 { start_ms as f64 / 1000.0 } else { saved };
(items, idx, Some(at).filter(|s| *s > 0.5), from)
}
None => return "{\"queued\":0}".to_owned(),
}
} else {
let Some((items, idx, from)) = crate::auto_browse::resolve_playable(data_dir, media_id) else {
return needs_app();
};
if items.is_empty() {
return needs_app();
}
let at = if start_ms > 0 { Some(start_ms as f64 / 1000.0) } else { None };
(items, idx, at, from)
};
if items.is_empty() {
return needs_app();
}
let n = items.len();
let audio = audio(None);
audio.start(items, idx);
audio.start(items, idx, &from, at_secs);
match audio.now_playing() {
Some(t) => serde_json::json!({
"queued": n,
@@ -379,6 +636,8 @@ pub fn play_media_id(data_dir: &str, media_id: &str) -> String {
"album": t.album,
"duration": t.duration,
"art": t.art,
"positionMs": (at_secs.unwrap_or(0.0) * 1000.0) as i64,
"hasNext": idx + 1 < n,
})
.to_string(),
None => format!("{{\"queued\":{n}}}"),
+13
View File
@@ -36,6 +36,12 @@ mod lyrics;
mod paths;
mod pipeline;
mod playback;
/// Where playback was, on disk, for a process with no page to ask: media
/// resumption, a head unit's PLAY into a cold app, Android Auto's autoplay.
mod resume;
/// The remembered peer file of every streamed track, mirrored to disk so the
/// cold browse can tell which cache files are whole playlist rows.
mod stream_sources;
mod profile;
mod playlist_formats;
mod playlists;
@@ -355,6 +361,13 @@ pub fn run() {
commands::preload_cached_stream,
commands::stream_waveform,
commands::stream_cache_ready,
commands::playback_note_queue,
commands::playback_resume_point,
commands::playlist_offline_list,
commands::playlist_offline_set,
commands::debug_drop_connection,
stream_sources::stream_source_remember,
stream_sources::stream_source_keep,
freenet::freenet_status,
freenet::freenet_set_enabled,
freenet::freenet_detect,
+45 -6
View File
@@ -157,6 +157,13 @@ pub struct PlayerState {
/// "what is the audio thread doing right now", mirrored here as the events
/// go past and read back by `player_state` at boot.
pub last_transport: Mutex<PlaybackSnapshot>,
/// The queue a CAR was driving when the app came up over it (Android
/// only; always `None` elsewhere). `init` takes it off the headless
/// engine at the moment the page takes the queue over, and the page's
/// `restoreSession` builds its queue from it instead of from its own
/// localStorage — which would describe an older session and put the
/// wrong title over the right audio. Read once through `player_state`.
pub handover: Mutex<Option<crate::resume::ResumePoint>>,
}
/// What the audio thread is doing, as of its last event. Deliberately only
@@ -201,16 +208,28 @@ pub fn init(app: &tauri::AppHandle) {
plays next and two things advancing one engine would skip a track each
time. Nothing about the desktop changes. */
#[cfg(target_os = "android")]
let (player, events) = {
let (player, events, handover) = {
if let Ok(dir) = crate::paths::app_data(app) {
crate::headless::set_data_dir(&dir);
}
let audio = crate::headless::audio(output_device.clone());
let (tx, rx) = std::sync::mpsc::channel();
audio.subscribe(tx);
// If a car was driving this engine, the page is about to take over a
// queue it has never heard of. Hand it the queue BEFORE clearing it,
// so the page can pick it up where it is rather than restoring an
// older session of its own over live audio.
let handover = if audio.driving() { audio.resume_point() } else { None };
audio.clear_queue();
(audio.player.clone(), rx)
(audio.player.clone(), rx, handover)
};
#[cfg(not(target_os = "android"))]
let (player, events) = Player::spawn_with_device(output_device);
let (player, events, handover) = {
let (p, e) = Player::spawn_with_device(output_device);
(p, e, None)
};
app.manage(PlayerState {
handover: Mutex::new(handover),
player,
streams: Mutex::new(HashMap::new()),
stream_caches: Mutex::new(HashMap::new()),
@@ -369,11 +388,23 @@ impl PlayerState {
}
}
/// The stream cache under an instance's data directory. Pure, so a process
/// that has no `AppHandle` (the media service, cold) reaches the same
/// files the app does.
pub fn cache_dir_in(data_dir: &std::path::Path) -> PathBuf {
data_dir.join("stream-cache")
}
/// Stable cache filename for a remote file: short hash + original name.
pub fn cache_path(app: &tauri::AppHandle, username: &str, remote_path: &str) -> Option<PathBuf> {
let dir = crate::paths::app_data(app).ok()?.join("stream-cache");
let dir = cache_dir_in(&crate::paths::app_data(app).ok()?);
std::fs::create_dir_all(&dir).ok()?;
Some(cache_file_in(&dir, username, remote_path))
}
/// The same name, computed without touching the disk. `stream_sources.rs`
/// uses it to ask whether a remembered peer file is already here.
pub fn cache_file_in(dir: &std::path::Path, username: &str, remote_path: &str) -> PathBuf {
let mut hash: u64 = 0xcbf29ce484222325;
for b in username.bytes().chain(remote_path.bytes()) {
hash ^= b as u64;
@@ -388,7 +419,7 @@ pub fn cache_path(app: &tauri::AppHandle, username: &str, remote_path: &str) ->
c => c,
})
.collect();
Some(dir.join(format!("{hash:016x} {safe}")))
dir.join(format!("{hash:016x} {safe}"))
}
/// The in-flight download target for a cache file: `<name>.<ext>.partial`.
@@ -421,12 +452,17 @@ pub fn prune_cache(app: &tauri::AppHandle) {
// a transfer buffer — age expiry off, size cap still applies.
let age_prune = !crate::config::stream_keep_forever(&cfg);
let Ok(dir) = crate::paths::app_data(app).map(|d| d.join("stream-cache")) else {
let Ok(data_dir) = crate::paths::app_data(app) else {
return;
};
let dir = cache_dir_in(&data_dir);
let Ok(entries) = std::fs::read_dir(&dir) else {
return;
};
// A track fetched because its playlist is kept offline is not a cache
// entry, it is the offline copy — the retention window and the size cap
// both leave it alone, or "keep offline" would quietly stop being true.
let kept = crate::stream_sources::kept_cache_files(&data_dir);
let mut files: Vec<(std::time::SystemTime, u64, PathBuf)> = entries
.flatten()
@@ -440,6 +476,9 @@ pub fn prune_cache(app: &tauri::AppHandle) {
// entries. Deleting one can make its final rename fail.
return None;
}
if kept.contains(&path) {
return None;
}
let md = e.metadata().ok()?;
if !md.is_file() {
return None;
+143
View File
@@ -0,0 +1,143 @@
//! Where playback was — on disk, in a shape a process with no page can
//! turn back into music.
//!
//! The phone's player keeps its session in the WebView's localStorage: the
//! queue, the index, the position. That is the right home for the page's
//! own restart, and the wrong one for everything else that can ask "what
//! was playing?": Android's own media resumption (the card in Quick
//! Settings after a reboot), a Bluetooth head unit sending PLAY to an app
//! that is not running, and Android Auto's autoplay on connection, which
//! is exactly the same media-button PLAY arriving through media3's
//! `MediaButtonReceiver` into a cold `MediaService`. None of those can
//! read localStorage. All of them read this.
//!
//! Two writers, one file:
//! · the page, through `playback_note_queue`, whenever it writes its own
//! session — a track change, a queue edit, every ten seconds while
//! playing, and on its way to the background;
//! · the engine's own pump (`headless.rs` on Android), which knows the
//! true position at all times and the whole queue when it is the one
//! driving (a car started the process and there is no page).
//!
//! An entry names what it needs to play again with nothing else alive: a
//! local file, or the peer file whose bytes may already be in the stream
//! cache. Resolving that is the reader's job (`headless::resume_queue`),
//! because whether a cache file is complete is only knowable at the moment
//! of asking.
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
/// The peer identity of a streamed entry, enough to find its cache file.
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct StreamRef {
pub username: String,
pub path: String,
pub size: u64,
}
/// One queue entry as the page (or the headless queue) described it.
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct ResumeItem {
/// The page's own id for the entry (a library track id, `cat:<n>`,
/// `pl<n>`), carried back so a handover keeps the identity.
#[serde(default)]
pub id: Option<String>,
#[serde(default)]
pub title: String,
#[serde(default)]
pub artist: String,
#[serde(default)]
pub album: String,
#[serde(default)]
pub duration: Option<f64>,
/// A cover: a local path, or an https URL for a catalog record.
#[serde(default)]
pub art: Option<String>,
/// A file on this device — a library track, or a stream that was
/// already served from its cache file.
#[serde(default)]
pub path: Option<String>,
/// A peer file; plays cold only when its cache is byte-complete.
#[serde(default)]
pub stream: Option<StreamRef>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct ResumePoint {
#[serde(default)]
pub items: Vec<ResumeItem>,
#[serde(default)]
pub idx: usize,
/// Seconds into `items[idx]`.
#[serde(default)]
pub position: f64,
/// What the queue was built from — the record's or playlist's name —
/// for the card's kicker and the car's "recent" row.
#[serde(default)]
pub from: String,
/// Unix seconds of the last write.
#[serde(default)]
pub updated_at: u64,
}
/// The page sends at most this many entries either side of the current
/// one. A two-thousand-track queue is not a thing to write every ten
/// seconds, and a resumed session that runs out after fifty tracks has
/// resumed.
pub const WINDOW: usize = 25;
pub fn file(data_dir: &Path) -> PathBuf {
data_dir.join("resume.json")
}
pub fn load(data_dir: &Path) -> Option<ResumePoint> {
let s = std::fs::read_to_string(file(data_dir)).ok()?;
let rp: ResumePoint = serde_json::from_str(&s).ok()?;
if rp.items.is_empty() {
return None;
}
Some(rp)
}
pub fn save(data_dir: &Path, rp: &ResumePoint) {
let path = file(data_dir);
if let Ok(json) = serde_json::to_string(rp) {
// Beside-and-rename, so a cold reader never sees half a file.
let tmp = path.with_extension("json.tmp");
if std::fs::write(&tmp, json).is_ok() {
let _ = std::fs::rename(&tmp, &path);
}
}
}
pub fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trips_and_empty_reads_as_none() {
let d = std::env::temp_dir().join(format!("ss-resume-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
assert!(load(&d).is_none());
let rp = ResumePoint {
items: vec![ResumeItem { title: "One".into(), path: Some("/a.flac".into()), ..Default::default() }],
idx: 0,
position: 12.5,
from: "Discovery".into(),
updated_at: 1,
};
save(&d, &rp);
assert_eq!(load(&d), Some(rp));
save(&d, &ResumePoint::default());
assert!(load(&d).is_none(), "an empty queue is no resume point");
}
}
+43
View File
@@ -9,6 +9,9 @@ pub struct AppState {
/// never routes it — so the resolver strips these terms up front instead
/// of discovering the silence by timeout. Lowercased on receipt.
pub excluded_search_phrases: Mutex<Vec<String>>,
/// The session the user asked for, kept so a drop can be dialled back.
/// See [`Reconnect`] and `commands::schedule_reconnect`.
pub reconnect: std::sync::Mutex<Reconnect>,
/// The experimental Freenet layer's one handle (default: disabled, inert).
/// The privilege boundary: node operations never leave the Rust side.
pub freenet: freenet_adapter::FreenetAdapter,
@@ -16,3 +19,43 @@ pub struct AppState {
/// started by hand is not here — the adapter finds it all the same.
pub freenet_node: std::sync::Mutex<Option<std::process::Child>>,
}
/// What the app remembers about the session the user wanted, so that losing
/// it is followed by getting it back rather than by a grey "offline" the
/// user has to notice and tap through.
///
/// A Soulseek server connection dies for reasons that have nothing to do
/// with the user: the phone changes networks, Android freezes a backgrounded
/// process for long enough that the five-minute keepalive never goes out and
/// the server closes the socket, a laptop sleeps. Until 2026-09-18 every one
/// of those ended the same way — `Event::Disconnected`, the page stamped
/// offline, and every streamed track after that refused with "sign in on
/// Community" until somebody opened the login card again. On a phone in a
/// car that "somebody" does not exist.
///
/// So the backend keeps dialling, with a backoff the server tolerates. The
/// throttle is real and was paid for on 2026-08-03: eight to ten rapid
/// logins from one address and the server silently drops the handshake, and
/// every attempt during that silence reads as "timed out" and extends it.
/// The schedule is 5 s, 15 s, 45 s, 2 min, then 5 min for as long as it
/// takes, with jitter — eight attempts span a quarter of an hour, which is
/// well under the rate that trips it.
#[derive(Default)]
pub struct Reconnect {
/// True from a successful login until the user disconnects on purpose.
/// The only thing that makes a drop worth redialling.
pub wanted: bool,
/// Bumped by every explicit connect and disconnect. A redial loop, and
/// the event pump of the session it belongs to, both carry the
/// generation they were born with and stand down when it moves — which
/// is how a manual re-login never races the automatic one.
pub generation: u64,
/// Consecutive redials since the last session that stayed up. Reset by a
/// drop that follows a stable session, kept by a drop that follows a
/// redial that lasted seconds — so a flapping link keeps backing off.
pub attempt: u32,
/// When the current (or last) session logged in, unix seconds.
pub connected_at: u64,
/// A redial loop is running for this generation.
pub dialing: bool,
}
+309
View File
@@ -0,0 +1,309 @@
//! The remembered peer copy of every streamed track — on disk, where a
//! process with no WebView can read it.
//!
//! The phone's resolver remembers which peer file it last played for a
//! catalog track (`src/lib/sources.js`, `soulstream.streamSources.v1`), and
//! that memory is what lets a track play again from the stream cache with no
//! search at all. It lived in the WebView's localStorage, which meant it
//! existed only while a WebView did: Android Auto starting the app cold —
//! no Activity, no page — could see the cache files sitting in
//! `stream-cache/` and had no way to tell which playlist row any of them
//! belonged to. Every streamed row was "needs the app", even the ones whose
//! bytes were all there.
//!
//! This is the same memory, mirrored to `<data_dir>/stream-sources.json` by
//! the page every time it remembers a source, and read back here by the
//! headless browse tree and the cold player. The page stays the writer of
//! record for its own store; this file is a second copy that the Rust side
//! owns, and the two are keyed identically so a row found by either agrees
//! with the other.
//!
//! Also the home of `keep`: a source fetched because its playlist is kept
//! offline is marked, and `playback::prune_cache` leaves that file alone
//! however old it gets. Without it the retention window (thirty days
//! unplayed, two gigabytes) would quietly undo an offline playlist.
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
/// One remembered peer file. `size` is the peer-stated size, which is also
/// how a byte-complete cache file is recognised.
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct StreamSource {
pub username: String,
pub path: String,
pub size: u64,
#[serde(default)]
pub duration: Option<f64>,
/// Pinned by an offline playlist; the cache pruner skips it.
#[serde(default)]
pub keep: bool,
}
/// What the page sends when it remembers (or re-sends) a source.
#[derive(Debug, Clone, Deserialize)]
pub struct RememberArg {
pub track_id: Option<String>,
pub artist: Option<String>,
pub title: Option<String>,
pub username: String,
pub path: String,
pub size: u64,
#[serde(default)]
pub duration: Option<f64>,
/// `None` leaves an existing pin as it was; `Some` sets it.
#[serde(default)]
pub keep: Option<bool>,
}
/// More than the page keeps (400): this file also holds the offline pins,
/// and a pinned source must never be the one an unpinned one pushes out.
const CAP: usize = 1200;
pub fn file(data_dir: &Path) -> PathBuf {
data_dir.join("stream-sources.json")
}
/// The lookup keys for a track, the page's own rule (`streamSourceKeys`):
/// the catalog id when there is one, and the lowercased `artist|title`.
pub fn keys(track_id: Option<&str>, artist: Option<&str>, title: Option<&str>) -> Vec<String> {
let mut out = Vec::with_capacity(2);
if let Some(t) = track_id.map(str::trim).filter(|t| !t.is_empty()) {
out.push(format!("t:{t}"));
}
if let (Some(a), Some(t)) = (artist, title) {
if !a.is_empty() && !t.is_empty() {
out.push(format!("n:{a}|{t}").to_lowercase());
}
}
out
}
/// Insertion-ordered so the cap can drop the oldest memory first — a
/// BTreeMap would forget which was old.
pub fn load(data_dir: &Path) -> Vec<(String, StreamSource)> {
std::fs::read_to_string(file(data_dir))
.ok()
.and_then(|s| serde_json::from_str::<Vec<(String, StreamSource)>>(&s).ok())
.unwrap_or_default()
}
fn save(data_dir: &Path, entries: &[(String, StreamSource)]) {
let path = file(data_dir);
if let Ok(json) = serde_json::to_string(entries) {
// Written beside and renamed over: a cold browse may be reading the
// file at this exact moment, and it must see the old whole file or
// the new whole file, never half of one.
let tmp = path.with_extension("json.tmp");
if std::fs::write(&tmp, json).is_ok() {
let _ = std::fs::rename(&tmp, &path);
}
}
}
/// Remember these sources. Every key of an entry maps to the same record;
/// an existing key is moved to the end (newest), and `keep` is carried
/// forward from the old record unless the argument sets it.
pub fn remember(data_dir: &Path, args: &[RememberArg]) {
if args.is_empty() {
return;
}
let mut entries = load(data_dir);
for a in args {
if a.username.is_empty() || a.path.is_empty() || a.size == 0 {
continue;
}
for key in keys(a.track_id.as_deref(), a.artist.as_deref(), a.title.as_deref()) {
let old_keep = entries
.iter()
.position(|(k, _)| *k == key)
.map(|i| entries.remove(i).1.keep)
.unwrap_or(false);
entries.push((
key,
StreamSource {
username: a.username.clone(),
path: a.path.clone(),
size: a.size,
duration: a.duration,
keep: a.keep.unwrap_or(old_keep),
},
));
}
}
if entries.len() > CAP {
// Drop the oldest UNPINNED memories first; a pin outlives the cap.
let over = entries.len() - CAP;
let mut dropped = 0;
entries.retain(|(_, s)| {
if dropped < over && !s.keep {
dropped += 1;
false
} else {
true
}
});
}
save(data_dir, &entries);
}
/// Pin or unpin the source remembered for a track.
pub fn set_keep(data_dir: &Path, track_id: Option<&str>, artist: Option<&str>, title: Option<&str>, keep: bool) {
let wanted = keys(track_id, artist, title);
if wanted.is_empty() {
return;
}
let mut entries = load(data_dir);
let mut changed = false;
for (k, s) in entries.iter_mut() {
if wanted.contains(k) && s.keep != keep {
s.keep = keep;
changed = true;
}
}
if changed {
save(data_dir, &entries);
}
}
/// The remembered source for a track, catalog id first.
pub fn lookup(data_dir: &Path, track_id: Option<&str>, artist: Option<&str>, title: Option<&str>) -> Option<StreamSource> {
let wanted = keys(track_id, artist, title);
if wanted.is_empty() {
return None;
}
let entries = load(data_dir);
for key in &wanted {
if let Some((_, s)) = entries.iter().find(|(k, _)| k == key) {
return Some(s.clone());
}
}
None
}
/// The cache file this source would land in, if its bytes are all there.
/// `None` for a missing file or a mid-transfer prefix — a prefix is not
/// the track, and a car that started one would stop mid-song.
pub fn cached_file(data_dir: &Path, src: &StreamSource) -> Option<PathBuf> {
let file = crate::playback::cache_file_in(&crate::playback::cache_dir_in(data_dir), &src.username, &src.path);
match std::fs::metadata(&file) {
Ok(md) if src.size > 0 && md.len() == src.size => Some(file),
_ => None,
}
}
/// Every cache file a pinned source names — the pruner's exemption list.
pub fn kept_cache_files(data_dir: &Path) -> HashSet<PathBuf> {
let dir = crate::playback::cache_dir_in(data_dir);
load(data_dir)
.into_iter()
.filter(|(_, s)| s.keep)
.map(|(_, s)| crate::playback::cache_file_in(&dir, &s.username, &s.path))
.collect()
}
/// Where this instance keeps its data — the same directory the config and
/// the stream cache live in, which on Android is also the `dataDir` the
/// media service hands to the cold browse.
fn data_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
crate::paths::app_data(app)
}
/// The page remembering a source (one), or re-sending its whole store at
/// boot so memories made before this file existed reach the cold side.
#[tauri::command]
pub fn stream_source_remember(app: tauri::AppHandle, entries: Vec<RememberArg>) -> Result<(), String> {
let dir = data_dir(&app)?;
tauri::async_runtime::spawn_blocking(move || remember(&dir, &entries));
Ok(())
}
/// Pin or unpin a track's remembered source for the cache pruner.
#[tauri::command]
pub fn stream_source_keep(
app: tauri::AppHandle,
track_id: Option<String>,
artist: Option<String>,
title: Option<String>,
keep: bool,
) -> Result<(), String> {
let dir = data_dir(&app)?;
tauri::async_runtime::spawn_blocking(move || {
set_keep(&dir, track_id.as_deref(), artist.as_deref(), title.as_deref(), keep)
});
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp() -> PathBuf {
let d = std::env::temp_dir().join(format!("ss-stream-sources-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn keys_follow_the_page() {
assert_eq!(
keys(Some("42"), Some("Hermeth"), Some("Once Again")),
vec!["t:42".to_string(), "n:hermeth|once again".to_string()]
);
assert_eq!(keys(None, Some("A"), None), Vec::<String>::new());
}
#[test]
fn remember_then_lookup_by_either_key_and_keep_survives() {
let d = tmp();
remember(
&d,
&[RememberArg {
track_id: Some("7".into()),
artist: Some("Hermeth".into()),
title: Some("Strictly Acid".into()),
username: "peer".into(),
path: "a\\b.flac".into(),
size: 10,
duration: Some(280.0),
keep: None,
}],
);
assert_eq!(lookup(&d, Some("7"), None, None).unwrap().path, "a\\b.flac");
assert_eq!(lookup(&d, None, Some("hermeth"), Some("STRICTLY ACID")).unwrap().size, 10);
set_keep(&d, Some("7"), None, None, true);
// a re-remember without `keep` carries the pin forward
remember(
&d,
&[RememberArg {
track_id: Some("7".into()),
artist: None,
title: None,
username: "peer2".into(),
path: "c\\d.flac".into(),
size: 11,
duration: None,
keep: None,
}],
);
let s = lookup(&d, Some("7"), None, None).unwrap();
assert_eq!(s.username, "peer2");
assert!(s.keep);
assert_eq!(kept_cache_files(&d).len(), 1);
}
#[test]
fn cached_file_wants_the_whole_track() {
let d = tmp();
let src = StreamSource { username: "u".into(), path: "x\\y.mp3".into(), size: 3, duration: None, keep: false };
assert!(cached_file(&d, &src).is_none());
let f = crate::playback::cache_file_in(&crate::playback::cache_dir_in(&d), "u", "x\\y.mp3");
std::fs::create_dir_all(f.parent().unwrap()).unwrap();
std::fs::write(&f, b"ab").unwrap();
assert!(cached_file(&d, &src).is_none(), "a prefix is not the track");
std::fs::write(&f, b"abc").unwrap();
assert_eq!(cached_file(&d, &src), Some(f));
}
}