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

+21
View File
@@ -448,6 +448,27 @@ export const preloadCachedStream = (username, path, size, duration) =>
invoke("preload_cached_stream", { username, path, size, duration });
export const streamWaveform = (username, path, size, buckets) =>
invoke("stream_waveform", { username, path, size, buckets });
/** The page's memory of a streamed track's peer file, mirrored to the Rust
* side (`stream_sources.rs`) so a process with no page — the media service
* a car starts — can tell which cache files are whole playlist rows. One
* entry when a source is remembered, the whole store once at boot. */
export const streamSourceRemember = (entries) => invoke("stream_source_remember", { entries });
/** Pin (or unpin) a track's remembered source for the cache pruner. */
export const streamSourceKeep = (trackId, artist, title, keep) =>
invoke("stream_source_keep", { trackId: trackId != null ? String(trackId) : null, artist, title, keep });
/** Name the queue for the resume point (`resume.rs`): what a cold PLAY
* would put back on. */
export const playbackNoteQueue = (items, idx, position, from) =>
invoke("playback_note_queue", { items, idx, position, from });
/** Where playback was left, as the Rust side holds it — null when nothing. */
export const playbackResumePoint = () => invoke("playback_resume_point");
/** The playlists kept offline, and the switch. */
export const playlistOfflineList = () => invoke("playlist_offline_list");
export const playlistOfflineSet = (id, keep) => invoke("playlist_offline_set", { id, keep });
/** Tell the media session that a row the car handed over did not play. */
export const remoteFailed = (message) => invoke("plugin:media-session|remote_failed", { failure: { message } });
/** DEBUG builds only: sever the server connection like a network drop would. */
export const debugDropConnection = () => invoke("debug_drop_connection");
export const streamCacheReady = (username, path, size) =>
invoke("stream_cache_ready", { username, path, size });
+16
View File
@@ -409,6 +409,9 @@ const enabled = (() => {
})();
/** Returns a Promise for mocked commands, or undefined to pass through. */
/** the playlists the mock keeps offline — the config's list, in memory */
const mockOffline = [];
export function mockInvoke(cmd, args) {
if (!enabled) return undefined;
/* the weather, BEFORE any dispatch, so a plugin: command and a catalog
@@ -831,6 +834,19 @@ function mockCatalog(cmd, args) {
record of having posted and cleared */
if (cmd === "plugin:transfer-notice|update") return Promise.resolve();
if (cmd === "plugin:transfer-notice|clear") return Promise.resolve();
/* the car's error report, the resume point, the stream-source mirror
and the offline switch: all fire-and-forget on the page, all answered
so the mock walk never sees a rejection where the phone sees none */
if (cmd === "plugin:media-session|remote_failed") return Promise.resolve();
if (cmd === "playback_note_queue") return Promise.resolve();
if (cmd === "stream_source_remember" || cmd === "stream_source_keep") return Promise.resolve();
if (cmd === "playlist_offline_list") return Promise.resolve(mockOffline.slice());
if (cmd === "playlist_offline_set") {
const i = mockOffline.indexOf(Number(args?.id));
if (args?.keep && i < 0) mockOffline.push(Number(args.id));
if (!args?.keep && i >= 0) mockOffline.splice(i, 1);
return Promise.resolve(mockOffline.slice());
}
if (cmd === "plugin:share|pending_share") {
/* the harness shares by setting window.__ssMock.share and waking the page */
const t = (typeof window !== "undefined" && window.__ssMock?.share) || null;
+40
View File
@@ -116,6 +116,45 @@ function rememberStreamSource(item) {
map.set(key, record);
}
saveStreamSources();
/* and the Rust side's copy (stream_sources.rs), so a process with no
page — the media service a car starts — can tell that this cache file
is this playlist row. Fire and forget: a desktop build and the mock
both refuse it, and neither is a reason to forget locally. */
mirrorStreamSources([{ item, record }]);
}
function mirrorStreamSources(pairs) {
const entries = pairs.map(({ item, record }) => ({
track_id: item.trackId != null ? String(item.trackId) : null,
artist: item.artist ?? null,
title: item.title ?? null,
username: record.username,
path: record.path,
size: record.size,
duration: record.duration ?? null,
}));
if (entries.length) api.streamSourceRemember(entries).catch(() => {});
}
/** Once, at boot: push every memory this page holds to the Rust side. The
* store predates the mirror, so a phone that streamed for weeks before it
* existed has cache files the cold browse could not otherwise name. The
* page's keys are `t:<id>` and `n:<artist>|<title>`; the identity is
* recovered from the key, which is all the Rust side keys on too. */
function syncStreamSourcesToBackend() {
const map = loadStreamSources();
const seen = new Map(); // record path → { item, record }
for (const [key, record] of map) {
const k = `${record.username}|${record.path}`;
let e = seen.get(k);
if (!e) { e = { item: { trackId: null, artist: null, title: null }, record }; seen.set(k, e); }
if (key.startsWith("t:")) e.item.trackId = key.slice(2);
else if (key.startsWith("n:")) {
const bar = key.indexOf("|");
if (bar > 2) { e.item.artist = key.slice(2, bar); e.item.title = key.slice(bar + 1); }
}
}
mirrorStreamSources([...seen.values()]);
}
/** The remembered source for this track, but ONLY if its cache file is
@@ -842,6 +881,7 @@ export {
loadStreamSources,
saveStreamSources,
rememberStreamSource,
syncStreamSourcesToBackend,
cachedStreamSourceFor,
passesSourcePrefs,
asciiFold,
+7
View File
@@ -5324,6 +5324,13 @@ export function initEvents() {
void loadBuddies();
} else if (ev.kind === "disconnected") {
app.connection = "offline";
} else if (ev.kind === "reconnecting") {
// The backend is dialling the dropped session back on its own
// (commands.rs schedule_reconnect, with the server's throttle
// respected). "connecting" rather than "offline", so the login
// modal is not pushed at a session that is about to be back.
app.connection = "connecting";
app.loginError = "";
}
});
+81 -6
View File
@@ -5,10 +5,11 @@
player carries out. Everything real lives in the plugin
(crates/tauri-plugin-media-session); this is the wiring. */
import { invoke, addPluginListener } from "@tauri-apps/api/core";
import { player, hooks, toggle, next, prev, seekSecs } from "./player.svelte.js";
import { player, hooks, toggle, next, prev, seekSecs, playAlbum, playEntries, entriesFromResume } from "./player.svelte.js";
import { art } from "./art.svelte.js";
import { playFrom } from "./playlists.svelte.js";
import { listPlaylists, playlistItems } from "../lib/api.js";
import { lib, tracksOf } from "./lib.svelte.js";
import { listPlaylists, playlistItems, playbackResumePoint } from "../lib/api.js";
/* THE QUEUE WINDOW. Every update carries a bounded slice of the queue —
the current item and QUEUE_WINDOW either side. Since round forty-eight
@@ -149,6 +150,7 @@ export async function bindMedia() {
case "seek": if (typeof p.position === "number" && p.position >= 0) seekSecs(p.position); break;
case "stop": if (playing) toggle(); break;
case "playRemote": playRemote(p.playlist, p.item); break;
case "playMediaId": playMediaId(p.desc, p.startMs); break;
}
});
} catch {
@@ -165,10 +167,14 @@ export async function bindMedia() {
*/
async function playRemote(playlistId, itemId) {
if (!playlistId) return;
/* the car is told WHY when this does not start — until 2026-09-18 a
hand-off that failed here was silence on the head unit, and the
driver could not tell a slow resolver from a dead one */
const tell = (why) => { invoke("plugin:media-session|remote_failed", { failure: { message: why } }).catch(() => {}); };
try {
const pls = await listPlaylists();
const pl = (pls || []).find((p) => Number(p.id) === Number(playlistId));
if (!pl) return;
if (!pl) { tell("That playlist is gone."); return; }
const items = (await playlistItems(pl.id)) || [];
/* item 0 means "the playlist", anything else is the row that was
tapped and the rest follows it — which is what playFrom's third
@@ -176,13 +182,82 @@ async function playRemote(playlistId, itemId) {
const at = Number(itemId) ? items.find((i) => Number(i.id) === Number(itemId)) : null;
if (!playFrom(pl, items, at)) {
player.error = "Nothing in “" + pl.name + "” can play yet.";
tell(player.error);
return;
}
} catch {
/* a playlist that has gone, or a resolver that said no — the car has
already been told this row did not start. */
/* the resolver runs after playFrom returns; its verdict lands on
player.error a few seconds later. Watch for it once, so a "no
source" or "sign in" reaches the car instead of only the phone. */
const started = Date.now();
const watch = setInterval(() => {
if (player.status === "playing") { clearInterval(watch); return; }
if (player.error) { clearInterval(watch); tell(player.error); return; }
if (Date.now() - started > 45_000) clearInterval(watch);
}, 500);
} catch (e) {
tell(String(e?.message || e || "SoulStream could not play that"));
}
}
/**
* ANY row the car tapped while the app is up — the page owns the queue, so
* the tap is the page's whatever the row is. `desc` is the row as Rust
* spelled it for us (auto_browse.rs describe_json): a library record or one
* of its tracks plays the way tapping it in the app would; a playlist row
* takes the hand-off above; "resume" puts back whatever the resume point
* holds, at the position the car asked for or the saved one.
*/
async function playMediaId(descJson, startMs) {
let d = {};
try { d = JSON.parse(descJson || "{}") || {}; } catch { d = {}; }
const tell = (why) => { invoke("plugin:media-session|remote_failed", { failure: { message: why } }).catch(() => {}); };
try {
switch (d.kind) {
case "album":
case "track": {
const album = lib.albums.find((a) => Number(a.id) === Number(d.albumId));
if (!album) { tell("That record is not on this phone any more."); return; }
const tracks = (await tracksOf(album.id)) || [];
if (!tracks.length) { tell("That record has no tracks on this phone."); return; }
let at = 0;
if (d.kind === "track") {
const i = tracks.findIndex((t) => Number(t.id) === Number(d.trackId));
if (i >= 0) at = i;
}
await playAlbum(album, tracks, at);
return;
}
case "playlist":
await playRemote(d.playlistId, d.itemId);
return;
case "resume":
await resumeFromPoint(startMs);
return;
default:
tell("SoulStream could not play that.");
}
} catch (e) {
tell(String(e?.message || e || "SoulStream could not play that"));
}
}
/** "Continue" with the app up: the bar's own track if there is one, else
* the resume point the Rust side holds, rebuilt as this page's queue. */
async function resumeFromPoint(startMs) {
const tell = (why) => { invoke("plugin:media-session|remote_failed", { failure: { message: why } }).catch(() => {}); };
if (player.track) {
if (!(player.status === "playing" || player.status === "buffering")) toggle();
return;
}
let rp = null;
try { rp = await playbackResumePoint(); } catch { rp = null; }
if (!rp || !Array.isArray(rp.items) || !rp.items.length) { tell("Nothing to continue yet."); return; }
const entries = entriesFromResume(rp.items);
const idx = Math.min(Math.max(0, rp.idx || 0), entries.length - 1);
const at = typeof startMs === "number" && startMs >= 0 ? startMs / 1000 : (rp.position || 0);
await playEntries(entries, idx, rp.from || "", at > 1 ? at : null);
}
/* the card interpolates its progress from the last update; a top-up
every half minute keeps a long track honest */
setInterval(() => { if (player.track) syncMedia(); }, 30_000);
+109 -5
View File
@@ -376,7 +376,7 @@ function advanceGaplessly(i) {
}
/* ── the queue ───────────────────────────────────────────────────────── */
export function playEntries(entries, at = 0, from = "") {
export function playEntries(entries, at = 0, from = "", startSecs = null) {
original = null;
if (player.shuffle && entries.length > 1) {
/* a predetermined order: the chosen track first, the rest shuffled */
@@ -389,8 +389,9 @@ export function playEntries(entries, at = 0, from = "") {
player.qi = at;
player.from = from;
/* the REACTIVE entry, not the raw one: a path-less first entry gets its
stream and path from the resolver, and the bar must see them land */
return startEntry(player.queue[at]);
stream and path from the resolver, and the bar must see them land.
`startSecs` is where a resumed session picks up; null is the top. */
return startEntry(player.queue[at], startSecs);
}
export function playAlbum(album, tracks, at = 0) {
return playEntries(tracks.map((t) => ({ t, album })), at, album.title);
@@ -650,9 +651,13 @@ function packEntry(e) {
album: a ? { id: a.id ?? null, title: a.title, artist: a.artist, year: a.year ?? null, art_path: a.art_path ?? null, artist_art_path: a.artist_art_path ?? null } : null,
};
}
/* set once restoreSession has READ the saved session, so a write that lands
before it (a visibility change under a launch with the screen off — seen
2026-09-18) cannot remove what it is about to restore */
let sessionBooted = false;
export function writeSession() {
try {
if (!player.track) { localStorage.removeItem(SESSION_KEY); return; }
if (!player.track) { if (sessionBooted) localStorage.removeItem(SESSION_KEY); return; }
localStorage.setItem(SESSION_KEY, JSON.stringify({
v: 1,
queue: player.queue.map(packEntry),
@@ -665,6 +670,68 @@ export function writeSession() {
original: original ? original.map(packEntry) : null,
}));
} catch { /* quota, or storage off: the session is a convenience */ }
noteResume();
}
/* ── the resume point ────────────────────────────────────────────────
The same session, in the Rust side's shape (resume.rs), for everything
that can ask "what was playing?" with no page alive to answer: Android's
own resume card, a head unit's PLAY into a cold app, Android Auto's
autoplay on connection. A bounded window around the current track — the
whole queue is not a thing to write every ten seconds — and each entry
named by what plays it cold: a file on the phone, or the peer file whose
bytes may be in the stream cache. The engine's pump keeps the position;
this names the queue. Fire and forget: the desktop and the mock refuse it
and neither is a reason to fail the session write. */
const RESUME_WINDOW = 25;
function resumeItem(e) {
const t = e?.t || {};
const a = e?.album || {};
const local = !!t.path && !t.stream;
return {
id: t.id != null ? String(t.id) : null,
title: t.title || "",
artist: t.artist || a.artist || "",
album: a.title || "",
duration: t.duration_secs ?? null,
art: a.art_path || null,
path: local ? t.path : null,
stream: t.stream?.username && t.path ? { username: t.stream.username, path: t.path, size: t.stream.size || 0 } : null,
};
}
/** The resume shape back into queue entries. A cached stream comes back as
* a plain file, which is what it is on this phone; a streamed entry whose
* cache is gone keeps its peer identity so the resolver can find it. */
export function entriesFromResume(items) {
return (items || []).map((it) => {
const local = !!it.path;
return {
t: {
id: it.id ?? it.path ?? it.title,
title: it.title || "",
artist: it.artist || "",
path: local ? it.path : (it.stream?.path || null),
duration_secs: it.duration ?? null,
stream: !local && it.stream?.username ? { username: it.stream.username, size: it.stream.size || 0 } : null,
catalog: null,
alts: [],
},
album: { id: null, catalogId: null, title: it.album || "", artist: it.artist || "", art_path: it.art || null },
};
});
}
function noteResume() {
/* NEVER cleared from here. A page with nothing on its bar is not proof
that there is nothing to continue — it is the state of every boot
before the session is restored, and a visibility change in that
window (the screen going off under a launch) wrote the resume point
away on 2026-09-18. A stale resume point costs a wrong "Continue"; a
wiped one costs the feature. */
if (!player.track || player.qi < 0) return;
const lo = Math.max(0, player.qi - RESUME_WINDOW);
const win = player.queue.slice(lo, player.qi + RESUME_WINDOW + 1);
api.playbackNoteQueue(win.map(resumeItem), player.qi - lo, player.position || 0, player.from || "").catch(() => {});
}
/** Put back what the last launch left behind. The audio thread is the
@@ -673,9 +740,42 @@ export function writeSession() {
export async function restoreSession() {
let saved = null;
try { saved = JSON.parse(localStorage.getItem(SESSION_KEY) || "null"); } catch { saved = null; }
if (!saved || saved.v !== 1 || !Array.isArray(saved.queue) || !saved.queue[saved.qi]?.t?.path) return false;
sessionBooted = true;
let transport = null;
try { transport = await api.playerState(); } catch { transport = null; }
/* A CAR was driving the engine when this page came up (a cold start
from Android Auto, then the app opened over it): the backend hands
that queue over exactly once, and it beats whatever this page's own
storage remembers — restoring an older session over live audio puts
the wrong title on the bar and the wrong record on the ground. The
entries arrive in the resume shape (resume.rs) and are turned back
into queue entries here; a cached stream comes back as a plain file,
which is what it is on this phone. */
const hand = transport?.handover;
if (hand && Array.isArray(hand.items) && hand.items[hand.idx]) {
const entries = entriesFromResume(hand.items);
const g = ++gen;
player.queue = entries;
player.qi = hand.idx;
player.from = hand.from || "";
original = null;
const e = entries[hand.idx];
player.track = e.t;
player.album = e.album;
const live = !!transport && transport.status !== "idle";
player.status = live ? transport.status : "paused";
player.position = live ? (transport.position ?? 0) : (hand.position ?? 0);
player.duration = live && transport.duration != null ? transport.duration : (e.t.duration_secs ?? null);
player.needsResource = !live;
player.phase = "ready";
void loadArt(e.album?.art_path, true);
if (e.t.path) loadWave(g, e.t.path);
if (live) armPreload();
writeSession();
mediaSync();
return true;
}
if (!saved || saved.v !== 1 || !Array.isArray(saved.queue) || !saved.queue[saved.qi]?.t?.path) return false;
const g = ++gen;
player.queue = saved.queue;
player.qi = saved.qi;
@@ -696,6 +796,10 @@ export async function restoreSession() {
loadWave(g, e.t.path);
if (live) armPreload();
mediaSync();
/* the resume point too: a restored-but-paused session is exactly what a
car connecting after a restart should be able to continue, and until
something played or changed nothing had written it */
noteResume();
return true;
}
+14 -4
View File
@@ -18,7 +18,7 @@ import {
} from "../lib/sources.js";
import { player, hooks, playEntries, playAlbum } from "./player.svelte.js";
import { lib, tracksOf } from "./lib.svelte.js";
import { slsk } from "./slsk.svelte.js";
import { slsk, awaitOnline } from "./slsk.svelte.js";
import { yearOf } from "./catalog.svelte.js";
import { downloadsAllowed } from "./network.svelte.js";
@@ -28,6 +28,16 @@ void refreshSourcePrefs();
const online = () => slsk.connection === "online";
const OFFLINE = "Sign in on Community to play from the network.";
/** Online now — or, while the backend is dialling a dropped session back
* (or the saved account is still signing in at boot), online within a
* moment. A car tapping a streamed row the second the phone thaws must
* not be refused "signed out" by a login that lands three seconds later;
* signed out for real, this answers at once. */
async function ensureOnline() {
if (online()) return true;
if (slsk.connection !== "connecting") return false;
return awaitOnline(20_000);
}
/* ── shapes ────────────────────────────────────────────────────────── */
/** The record a streamed entry belongs to, as the player's `album`. No
@@ -119,7 +129,7 @@ export async function streamSong(artist, title, o = {}) {
await playEntries([streamEntry(title, artist, cached, o, album), ...lazyEntries(o.rest, artist, o, album)], 0, from);
return;
}
if (!online()) { fail(OFFLINE); return; }
if (!(await ensureOnline())) { fail(OFFLINE); return; }
placeholder(title, artist, album);
const { responses, artistIncluded } = await resolveSources(
`${artist} — ${title}`,
@@ -148,7 +158,7 @@ export async function streamCatalogAlbum(album, at = 0) {
const tracks = await tracksOf(local.id).catch(() => []);
if (tracks.length) { await playAlbum(local, tracks, Math.min(at, tracks.length - 1)); return; }
}
if (!online()) { fail(OFFLINE); return; }
if (!(await ensureOnline())) { fail(OFFLINE); return; }
const meta = albumMeta({ albumId: album.id ?? null, album: album.title, cover: album.cover, year: yearOf(album.release_date) }, album.artist);
placeholder(album.title, album.artist, meta);
const catalogTracks = album.id
@@ -265,7 +275,7 @@ async function resolveEntry(e, quiet = false) {
e.t.duration_secs = cached.duration ?? e.t.duration_secs;
return true;
}
if (!online()) return false;
if (!(await ensureOnline())) return false;
const { responses, artistIncluded } = await resolveSources(
`${artist} — ${title}`,
[`${artist} ${simplifyTitle(title)}`, simplifyTitle(title)],
+26
View File
@@ -34,8 +34,26 @@ export const slsk = $state({
* proved at 1.2 s and the shipped 12 s is a judgement, not a measurement. */
roomsWaitMs: 12000,
roomsLate: false,
/** the backend's automatic redial after a drop: { attempt, inSecs },
* null while online or while nobody is dialling */
redial: null,
});
/** Resolve true as soon as the hub is online, or false after `ms`. Polled
* rather than effected: this is asked from plain async code (the resolver)
* and a quarter second of latency on a twenty-second wait is nothing. The
* point is a car tapping a streamed row the moment the phone comes back
* from a pocket — the backend is already redialling, and refusing the row
* "signed out" three seconds before the login lands is the wrong answer. */
export async function awaitOnline(ms) {
const until = Date.now() + Math.max(0, ms);
while (Date.now() < until) {
if (slsk.connection === "online") return true;
await new Promise((r) => setTimeout(r, 250));
}
return slsk.connection === "online";
}
const FEED_MAX = 300;
function trim(arr) {
if (arr.length > FEED_MAX) arr.splice(0, arr.length - FEED_MAX);
@@ -159,10 +177,18 @@ export async function bindSlsk() {
slsk.connection = "online";
slsk.username = ev.username || slsk.savedUser;
slsk.loginError = "";
slsk.redial = null;
void loadBuddies();
refreshRooms();
} else if (ev?.kind === "disconnected") {
slsk.connection = "offline";
} else if (ev?.kind === "reconnecting") {
/* the backend is dialling the dropped session back on its own
(commands.rs schedule_reconnect): the page shows "connecting",
not "offline", and the resolver waits for it rather than
refusing the next track outright */
slsk.connection = "connecting";
slsk.redial = { attempt: ev.attempt || 1, inSecs: ev.in_secs || 0 };
}
});
on("soulseek:room-list", (rooms) => {