android auto: the car plays what is here, resumes, and is told the rest — soulseek dials back, and the storm is over
Four things the car could not do, all from a process with no Activity.
A streamed playlist row plays cold when its bytes are already here. The
page's memory of which peer file a catalog track resolved to lived only in
localStorage; it is mirrored now to `<data>/stream-sources.json`
(stream_sources.rs) — every remembered source, and the whole store once at
boot — and auto_browse.rs looks a row up there, computes its cache file
with the same hash playback::cache_path uses, and calls it playable when
the file is byte-complete. GhettoTechie reads "8 of 40 here" cold and a
tapped row plays from the cache with its catalog cover and colour.
The car resumes, the way every other player does. Where playback was is in
`<data>/resume.json` (resume.rs): the page names the queue whenever it
writes its session, the engine's pump keeps the position. media3's
MediaButtonReceiver is declared, so a PLAY key into a dead app — a
Bluetooth head unit, the Quick Settings card, Auto's autoplay on
connection — starts the service and lands in onPlaybackResumption, which
answers with the `ss:resume` row; the RECENT root serves the same row, and
the Recent tab lists it first. A force-stopped app came back PLAYING where
it left off from one `cmd media_session dispatch play`.
The car is told. A tap that cannot play — a streamed row with no cache copy
while the app is cold, a hand-off the resolver refused — used to be
silence. MediaHub.fail puts the reason in the playback state (STATE_ERROR,
which Auto draws as a card) and sends a SessionError carrying "Open
SoulStream"; the next track withdraws it. The page reports its own verdict
through the new `remote_failed` command.
Every tap while the app is UP is the page's. A library row tapped in the
car used to start on the engine underneath the page's own queue — the bar
naming one track while another played. `AutoNative.describe` spells the
row for the page and `playMediaId` plays it as the page's queue; the plugin
disarms the hand-off in onDestroy, and with the page gone the card's
transport goes to the engine. When the app opens over a car session, the
headless queue is handed to the page once (`playback::init` takes it before
clearing) and restoreSession builds the bar from it.
Every playlist row is playable: Auto draws a non-playable, non-browsable
item as a tile and BROWSES it on tap ("No items"). Rows that need the app
say so and fail on tap with the card; rows whose file is here wear the
host's own downloaded mark. The Recent tab's album subtitle counted played
tracks; it counts the album's now. The page never clears the resume point
and only drops its own session once it has booted — a visibility change
under a launch with the screen off wiped both.
The Soulseek session dials itself back after a drop. A server connection
dies for reasons that are nobody's — the phone changes networks, Android
freezes a backgrounded process past the five-minute keepalive — and until
now every one of those ended as `Event::Disconnected`, the page stamped
offline, and every streamed track after it refused with "sign in on
Community"; in a car nobody opens the login card. `schedule_reconnect`
redials with the stored credentials at 5, 15, 45, 120, then 300 s with
jitter, under the server's rapid-login throttle. Every session carries a
generation: an explicit login or disconnect bumps it, a redial loop stands
down when the user acts, and a drop reported by an OLDER session's pump
(login_inner shuts the old client down and its reader reports after the new
session is up) no longer stamps the fresh session offline — that race was
there all along. The page shows "connecting" on `reconnecting`, the
resolver waits up to twenty seconds for a dial in progress, and debug
builds expose `debug_drop_connection`. Verified by the debug command (drop →
redial 1 in 5 s → online at 7 s) and by a real drop when the head unit
connected: redial 1 failed on DNS, redial 2 at +15 s logged in.
The notification storm was media3 restarting the service into
onStartCommand. The morning's logcat had the card re-posted six times a
second for over a minute ("Package enqueue rate is 6.6 … Shedding"), then
"Too many PendingIntent created for uid" (media3 mints a fresh one for the
custom button on every rebuild), then twenty refused foreground starts in
two hundred milliseconds. media3 starts this service ITSELF on every
foreground notification update — MediaNotificationManager.startForeground
calls startForegroundService(startSelfIntent), a bare component intent with
nothing but a uid extra — and onStartCommand promoted on every intent it
saw, which re-posted the card, which made media3 start the service again.
It ran for as long as the service was foreground: while a track played,
and for the ten-minute tail after a pause (the capture stops at pause +
10:00 exactly). onStartCommand now promotes only for an intent that carries
an action or a data uri. A 250 ms coalescer on card rebuilds
(MediaHub.refreshSoon) logs the rate; it stayed silent through the storm,
which is what pointed at the restart. A full minute of playback after the
fix: zero shedding lines, zero PendingIntent complaints, zero refused
starts.
Verified on the Desktop Head Unit and the Pixel: the tree, the Continue
row, cold cached playback, the error card and recovery from it, the warm
hand-off, the media-key resume, the redial; mock walk 255/255, phone walk
142/142.
This commit is contained in:
1 parent
8f46f9c663
commit
17942fc04b
28 files changed
+2600
-396
No files matched your search
@@ -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 });
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = "";
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user