"Needs the app" is gone. It named what the cold service could not do — the resolver and the Soulseek session live in the page, and a car that starts the app cold has no page — and Kyle's answer to the label was the right one: a tap should find a copy and play it, in the background, and a playlist should keep going through rows that are not here exactly as the page keeps going. headless_slsk.rs is a Soulseek session for a process with no page: the stored account, one search (artist + simplified title, the server's excluded phrases stripped), copies ranked by rules close to the page's songCandidates — the title in the filename, streamable formats, a duration within eight per cent, lossless over 320 over the rest, the artist in the path, a free upload slot before anything else — and a download into the same stream cache the page uses. Its own tokio runtime: a car-started process has no Tauri one. headless.rs is the page's queue in Rust. resolve_playable queues EVERY row of a playlist in order, a row not here with its remembered peer file and no path. The engine starts a row from its file when it is whole, from the fetch already filling its cache file when one is, and otherwise finds it on the network right then (the remembered peer first, then the search), playing the bytes as they land; a peer that sends nothing for fifteen seconds is dropped for the next copy, twice, and when a row has no copy anywhere the queue moves on — only the last row's failure reaches the card. The next three rows are fetched ahead of time, four seconds after a start so nothing competes with the track that is starting, and when the immediate next is whole before the current one ends it is appended to the sink and the boundary is gapless — the page's preloadLocal and preloadCachedStream in one. Fetches are keyed by cache file, not index, so a shuffle cannot start the same file from the same peer twice. The card shows the title at once with a spinner (STATE_BUFFERING through headlessState), then the track; every source is remembered in the mirror, so the row is "here" next time. One session per process: when the app opens over it, login_inner ADOPTS the client and reads its events through subscribe() instead of logging in again — two logins from one address in quick succession is what the server throttles — and the fetches in flight become the page's own preloads, so the row the car was fetching ahead lands gaplessly under the page too. Covers, always, like the desktop. Thirty of GhettoTechie's forty rows have no catalog cover, and the car drew the app icon over them. In the cold process the cover embedded in the file is extracted — the desktop's own tagger::extract_art, keyed by the audio path under <library>/.soulstream/stream-art/by-file/, a miss remembered as an empty file — for browse rows, the card and the Continue row, and a row fetched cold gets the same once 200 kB of it have landed. On the phone, coverart.svelte.js is the desktop's playlist backfill as a background walk over every playlist: the file before the catalog (the row's own cached copy, persisted as art_path), then the ONE strict ladder the import review uses (findCatalogTrack, persisted as art_url + track_id) — never a loose name lookup. After boot, on every playlist change, every six hours, the open page's rows first, a miss remembered for a day and only when the catalog answered. The player does the same for the track that is playing (ensureStreamArt: embedded, then catalog), makes the URL the entry's art key and hands it to the walk to persist. The page's own hole is closed too: a streamed row with no cover got no art key at all, and where it had one the extractor's FILE PATH was stored where a data URL belongs; such rows get a "stream:" key and the file is read as a thumbnail, and a stream row's art_path now draws on the phone. A car that starts the app cold has no page to walk for it, so the cold process asks the catalog itself. cover_hunt.rs is catalogMatch.js in Rust — the same Deezer field queries through the metadata crate, a match only when the artist agrees, the base title agrees, the version qualifiers are compatible and a known duration does not contradict; the two are meant to stay in step — fed by every browse and every queue built from a playlist. A blank stream row is answered from what was asked before (stream-art/catalog.json, a miss for a day and only when the catalog answered), else queued for one thread that looks rows up one at a time behind the client's own Deezer pacing, writes the answer on the row (art_url and the track id, only ever filling a NULL; the library is WAL and the page's writer is another connection), and re-lists the playlist on the car. The track that is playing gets the same, through an entry that moves only the card's art and never its position. The cold Soulseek search is the page's two-rung ladder as well: artist plus the simplified title, then the title alone with the artist required in the peer's path; the hunt's last rung is the page's last rung too, the artist's whole shelf flattened to tracks, for the records the search index buries — spent only on the track that is playing, bounded to forty seconds, and the cover memory carries the matcher's version so a "no" from an older matcher is no answer once a rung is added. Two things the head unit found in the seam. media3's default onSubscribe answers "not supported", so nobody was ever subscribed and the re-list notice reached nobody; LibraryTree accepts subscriptions now, and covers land on the car's list within seconds of being found. And the Soulseek client spawns a download's transfer task with tokio::spawn, which needs a runtime on the calling thread — the engine's resolver thread is not a Tauri command, the spawn panicked, the thread died without a word, and the card sat on BUFFERING; every client call from an engine thread runs under the cold runtime's enter() now. The phone's artist page never ran the MusicBrainz pass. It built its discography from Deezer's list alone, so the releases Deezer's artist listing omits — EPs, and whole albums — never appeared, nor the authoritative release types or the guest-appearance and co-tenant arbitration. The desktop's identity merge is a plain module now (lib/artistIdentity.js, moved out of state.svelte.js verbatim, the desktop importing it back), and the phone runs it after the page is on screen — one implementation for both surfaces. What the restarted head unit found, all in the seam between a car session and the page — and one bug older than this branch. The pump's calls into Kotlin never worked. "auto advanced to" has not appeared in a single log while cold queues advanced through whole albums under a card stuck on track one, and the new end-of-queue notice did not fire either. FindClass on the self-attached pump thread resolves through the system class loader, which cannot see com.soulstream.mediasession .AutoNative; the lookup failed and the exception was cleared, silently, every time. The class is taken from the object Kotlin hands every JNI entry — on a Java thread, through the app's own loader — and kept as a global reference; every pump call goes through call_auto_native. Autoplay on connection worked unprompted and resumed Starboy at 3:50 of 3:50: the page writes the position one last time when a track runs out, so a resume point can sit at the end. resume_queue treats a position within two seconds of the duration as "the next item from the top", keeps every entry (one not here is found on reach, like any other), and the headless engine reports end-of-queue to Kotlin, which turns the card to paused. A seek on the page reached the resume point only at the pump's next five-second flush; seekSecs writes the session at once. Opening the app over a cold car session handed the page the right queue and the page called it a paused session that needed re-sourcing: the transport snapshot it reads is built only from events after the page subscribes, and an engine already playing sends no Playing. The bar said paused, the card said PAUSED, the audio kept going. playback::init seeds the snapshot from the engine's own progress when it adopts a running engine, so the page follows what is sounding. preview::init opened two more output streams at every launch for the desktop's hover previews, which the phone never calls — three live AAudio tracks on our uid with one sounding. It is gated off on Android.
892 lines
38 KiB
JavaScript
892 lines
38 KiB
JavaScript
/* The player, on the phone: one queue, the audio thread behind the same
|
|
IPC the desktop uses, and the lane's story.
|
|
|
|
THE QUEUE IS A LIST. Entries are { t: Track, album: Album } — the album
|
|
is carried because the mini bar, the ground and the accent all read the
|
|
artwork off it, and a queue can leave the record it started from.
|
|
|
|
GAPLESS, the desktop's way: when a track starts, the next one is decoded
|
|
now and appended behind it in the same sink (`preload_local`); the engine
|
|
then reports the boundary as `track-ended { gapless: true }` and the
|
|
phone only moves its index. If the queue changed under an armed preload,
|
|
the boundary is re-sourced properly instead — a gap, never a wrong track.
|
|
|
|
SHUFFLE is a predetermined order: the rest of the queue is shuffled once,
|
|
so the track the sheet promises next is the one the engine has armed.
|
|
The unshuffled queue is kept so shuffle off restores the order.
|
|
|
|
THE SESSION survives a relaunch (localStorage): the queue, the position,
|
|
the modes. A reload finds the audio thread still playing and follows it;
|
|
a restart restores the track paused with `needsResource`, and play
|
|
re-sources it from where it was — the desktop's rule, kept.
|
|
|
|
THE LANE'S STORY (src/lib/waveshape.js's constants): on a track change the
|
|
outgoing shape is HELD for ARRIVE_HOLD_MS; a decode that lands inside the
|
|
hold morphs shape → shape (ARRIVING, one motion over ARRIVE_MS); one that
|
|
takes longer drops to the LOADING swell first. */
|
|
import * as api from "../lib/api.js";
|
|
import { listen } from "../lib/events.js";
|
|
import { ARRIVE_HOLD_MS, ARRIVE_MS } from "../lib/waveshape.js";
|
|
import { loadArt, art, isStreamKey } from "./art.svelte.js";
|
|
import { catalogCoverFor, learnCover } from "./coverart.svelte.js";
|
|
import { lib, tracksOf } from "./lib.svelte.js";
|
|
import { shuffled } from "./util.js";
|
|
|
|
export const player = $state({
|
|
status: "idle", // idle | buffering | playing | paused
|
|
position: 0,
|
|
duration: null,
|
|
queue: [],
|
|
qi: -1,
|
|
from: "", // what the queue was built from, for the sheet's kicker
|
|
track: null,
|
|
album: null,
|
|
peaks: [],
|
|
heldPeaks: [],
|
|
phase: "ready", // hold | loading | rising | arriving | ready
|
|
/** a stream: how much of the track the peaks describe */
|
|
coverage: 1,
|
|
/** the transfer behind a streamed track */
|
|
stream: { received: 0, total: 0, done: false, failed: null },
|
|
/** how the last boundary was crossed: "gapless" (the engine had the next
|
|
* appended) or "sourced" (the next was started from scratch) */
|
|
advance: "",
|
|
shuffle: false,
|
|
repeat: 0, // 0 off · 1 all · 2 one
|
|
/* restored from the last session with nothing loaded behind it: play
|
|
has to re-source the track rather than resume */
|
|
needsResource: false,
|
|
/* the sleep timer: when it pauses, as a timestamp; 0 = off */
|
|
sleepAt: 0,
|
|
error: "",
|
|
/* the toast's one action, tied to the message it belongs to:
|
|
{ for: <the exact error>, fn } — so a later, different error can never
|
|
inherit the last one's Try again */
|
|
retry: null,
|
|
});
|
|
|
|
/** Things the shell plugs in: `recentsDirty` runs when a play has been
|
|
* written and the recents rail should re-read; `mediaSync` mirrors the
|
|
* bar to the phone's media session (media.svelte.js). */
|
|
export const hooks = { recentsDirty: null, mediaSync: null, resolveEntry: null };
|
|
function mediaSync() {
|
|
if (typeof hooks.mediaSync === "function") hooks.mediaSync();
|
|
}
|
|
|
|
let gen = 0;
|
|
let holdTimer = null;
|
|
let arriveTimer = null;
|
|
let original = null; // the unshuffled queue while shuffle is on
|
|
let armedPath = null; // the file the engine has appended for the next boundary
|
|
|
|
export function current() {
|
|
return player.queue[player.qi] ?? null;
|
|
}
|
|
|
|
function setErr(e) {
|
|
player.error = String(e);
|
|
}
|
|
|
|
/* ── the lane's story ────────────────────────────────────────────────── */
|
|
function beginStory() {
|
|
player.heldPeaks = player.peaks;
|
|
player.peaks = [];
|
|
player.phase = "hold";
|
|
clearTimeout(holdTimer);
|
|
clearTimeout(arriveTimer);
|
|
const g = gen;
|
|
holdTimer = setTimeout(() => {
|
|
if (g === gen && player.phase === "hold") player.phase = "loading";
|
|
}, ARRIVE_HOLD_MS);
|
|
}
|
|
function landPeaks(g, peaks) {
|
|
if (g !== gen) return;
|
|
clearTimeout(holdTimer);
|
|
player.peaks = peaks;
|
|
player.phase = "arriving";
|
|
arriveTimer = setTimeout(() => { if (g === gen) player.phase = "ready"; }, ARRIVE_MS + 80);
|
|
}
|
|
async function loadWave(g, path) {
|
|
try {
|
|
const w = await api.waveform(path, 900);
|
|
landPeaks(g, Array.isArray(w) ? w : []);
|
|
} catch {
|
|
if (g === gen) player.phase = "ready";
|
|
}
|
|
}
|
|
|
|
/* ── starting a track ────────────────────────────────────────────────── */
|
|
async function startEntry(e, startSecs = null) {
|
|
if (!e) return;
|
|
const g = ++gen;
|
|
clearTimeout(watchdog);
|
|
++preloadGen;
|
|
clearTimeout(preloadPoll);
|
|
listenFlush();
|
|
armedPath = null;
|
|
player.track = e.t;
|
|
player.album = e.album;
|
|
player.position = startSecs ?? e.t.startSecs ?? 0;
|
|
player.duration = e.t.duration_secs ?? null;
|
|
player.status = "buffering";
|
|
player.needsResource = false;
|
|
player.error = "";
|
|
beginStory();
|
|
player.coverage = 1;
|
|
player.stream = { received: 0, total: 0, done: false, failed: null };
|
|
void loadArt(e.album?.art_path, true);
|
|
writeSession();
|
|
mediaSync();
|
|
if (e.t.catalog && !e.t.path) {
|
|
/* a record's later track: it finds its own copy now (resolve.svelte.js) */
|
|
let ok = false;
|
|
try { ok = await hooks.resolveEntry?.(e); } catch { ok = false; }
|
|
if (g !== gen) return;
|
|
if (!ok) {
|
|
setErr("No source for “" + e.t.title + "”");
|
|
ended(false);
|
|
return;
|
|
}
|
|
player.duration = e.t.duration_secs ?? null;
|
|
writeSession();
|
|
mediaSync();
|
|
}
|
|
if (e.t.stream) {
|
|
/* a peer's copy: the engine plays it as it arrives; the lane tells
|
|
the story from the transfer's own bytes (streamStory) */
|
|
try {
|
|
await api.playStream(e.t.stream.username, e.t.path, e.t.stream.size, e.t.duration_secs ?? null);
|
|
} catch (err) {
|
|
if (g === gen) { setErr(err); player.status = "idle"; player.phase = "ready"; }
|
|
return;
|
|
}
|
|
if (g !== gen) return;
|
|
streamStory(g, e);
|
|
armWatchdog(g, e);
|
|
armPreload();
|
|
return;
|
|
}
|
|
try {
|
|
await api.playLocal(e.t.path, e.t.duration_secs ?? null, startSecs ?? e.t.startSecs ?? null);
|
|
} catch (err) {
|
|
if (g === gen) { setErr(err); player.status = "idle"; player.phase = "ready"; }
|
|
return;
|
|
}
|
|
if (g !== gen) return;
|
|
armPreload();
|
|
loadWave(g, e.t.path);
|
|
}
|
|
|
|
/* ── a stream's lane ─────────────────────────────────────────────────────
|
|
The partial waveform is asked for every few seconds while the bytes
|
|
arrive: the backend folds what it has and says how much of the track that
|
|
covers, and the lane RISES to that frontier. The last request, once the
|
|
transfer is done, is the whole shape — the arrival. The cover comes out
|
|
of the file's own tags once enough of it is here. */
|
|
/* A peer that has not sent a byte in time is not about to: the entry's
|
|
runner-ups (`t.alts`, the resolver's next candidates) take over one by
|
|
one — 12s for the first peer (an indirect handshake honestly takes ~10),
|
|
8s for the rest — and when they are spent the queue moves on. */
|
|
let watchdog = null;
|
|
let endFired = 0; /* the generation whose set-slice end bound has fired */
|
|
function armWatchdog(g, e) {
|
|
clearTimeout(watchdog);
|
|
const first = !e.t.tried;
|
|
watchdog = setTimeout(() => {
|
|
if (g !== gen) return;
|
|
if (player.status === "playing" || player.stream.received > 0) return;
|
|
const n = e.t.alts?.shift();
|
|
if (n) {
|
|
e.t.tried = (e.t.tried || 0) + 1;
|
|
e.t.path = n.path;
|
|
e.t.stream = { username: n.username, size: n.size };
|
|
if (n.duration) e.t.duration_secs = n.duration;
|
|
startEntry(e);
|
|
return;
|
|
}
|
|
setErr("Sources for “" + e.t.title + "” aren't responding");
|
|
ended(false);
|
|
}, first ? 12000 : 8000);
|
|
}
|
|
|
|
let streamTimer = null;
|
|
function streamStory(g, e) {
|
|
clearTimeout(streamTimer);
|
|
const { username, size } = e.t.stream;
|
|
const path = e.t.path;
|
|
let artTried = false;
|
|
const tick = async () => {
|
|
if (g !== gen) return;
|
|
const done = player.stream.done;
|
|
try {
|
|
const slice = await api.streamWaveform(username, path, size, 900);
|
|
if (g !== gen) return;
|
|
const peaks = slice?.peaks || [];
|
|
const coverage = Math.max(0, Math.min(1, slice?.coverage ?? 0));
|
|
if (peaks.length) {
|
|
clearTimeout(holdTimer);
|
|
player.peaks = peaks;
|
|
player.coverage = coverage;
|
|
if (done || coverage >= 0.999) {
|
|
player.phase = "arriving";
|
|
clearTimeout(arriveTimer);
|
|
arriveTimer = setTimeout(() => { if (g === gen) player.phase = "ready"; }, ARRIVE_MS + 80);
|
|
} else if (player.phase !== "rising") {
|
|
player.phase = "rising";
|
|
}
|
|
}
|
|
} catch { /* nothing to fold yet */ }
|
|
if (!artTried && (player.stream.received > 200_000 || done)) {
|
|
artTried = true;
|
|
void ensureStreamArt(g, e, username, path);
|
|
}
|
|
if (g === gen && !done) streamTimer = setTimeout(tick, 2500);
|
|
};
|
|
streamTimer = setTimeout(tick, 900);
|
|
}
|
|
|
|
/** A streamed track showing no artwork: find it some, in order of certainty
|
|
* — the desktop's ensureStreamArt. The bytes the peer is sending ARE the
|
|
* recording, so a cover embedded in them belongs to it: no matching, no
|
|
* doubt, no network. Only a file that carries none asks the catalog, and
|
|
* strictly (coverart.svelte.js: the one ladder, one memory of the answer;
|
|
* a loose lookup once put one record's cover on seven unrelated songs).
|
|
* A hit becomes the entry's art key — what the bar, the sheet, the media
|
|
* card and the resume point all read — and the walk persists it on the
|
|
* playlist row. */
|
|
async function ensureStreamArt(g, e, username, path) {
|
|
const key = e.album?.art_path;
|
|
if (!key || !isStreamKey(key)) return; // a cover already: a URL, or a file
|
|
if (art[key] === undefined) {
|
|
/* the extractor answers with the FILE it wrote, and the art store
|
|
holds data URLs — the thumbnail read turns one into the other,
|
|
the way a library cover is loaded */
|
|
let u = "";
|
|
try {
|
|
const p = await api.streamEmbeddedArt(username, path);
|
|
u = p ? (await api.albumArt(p, 360)) || "" : "";
|
|
} catch { u = ""; }
|
|
art[key] = u;
|
|
}
|
|
if (art[key]) return;
|
|
let found = null;
|
|
try {
|
|
found = await catalogCoverFor(e.t.artist, e.t.title, e.t.duration_secs ?? null);
|
|
} catch { return; /* offline: a later play asks again */ }
|
|
if (!found) return;
|
|
e.album.art_path = found.url;
|
|
learnCover(e.t.artist, e.t.title, found.url, found.trackId);
|
|
void loadArt(found.url, true);
|
|
if (g !== gen) return;
|
|
mediaSync();
|
|
writeSession();
|
|
}
|
|
|
|
/** The index the engine will play next, in this order, with these modes. */
|
|
function nextIndex() {
|
|
if (!player.queue.length) return -1;
|
|
if (player.repeat === 2) return player.qi;
|
|
if (player.qi + 1 < player.queue.length) return player.qi + 1;
|
|
return player.repeat === 1 ? 0 : -1;
|
|
}
|
|
|
|
/** Ready the next track now, whatever it is — the desktop's album fill and
|
|
* stream preload, folded into one place:
|
|
* - a library file: decoded and appended behind the current one;
|
|
* - a peer's copy: fetched into the cache ahead of time, and appended the
|
|
* moment it is byte-complete, so the boundary is gapless;
|
|
* - a record's later track that has no copy yet: found first, QUIETLY (the
|
|
* resolver's line never shows for a background search), then the above.
|
|
* Streams wait ALBUM_FILL_DELAY_MS after a start, so the search and the
|
|
* fetch never compete with the track that is starting. */
|
|
const ALBUM_FILL_DELAY_MS = 4000;
|
|
/** THREE ahead — the desktop's STREAM_PRELOAD_DEPTH. One track of headroom
|
|
* is not enough when a peer is slow or a search misses. */
|
|
const PRELOAD_DEPTH = 3;
|
|
let preloadGen = 0;
|
|
let preloadPoll = null;
|
|
/** The next `count` queue indices in play order: none past the end unless
|
|
* repeat-all wraps them; repeat-one has no next. The queue IS the play
|
|
* order on the phone (shuffle reorders it). */
|
|
function peekNextIndices(count = PRELOAD_DEPTH) {
|
|
const n = player.queue.length;
|
|
if (!n || player.repeat === 2) return [];
|
|
const out = [];
|
|
let i = player.qi;
|
|
for (let k = 0; k < count; k++) {
|
|
i += 1;
|
|
if (i >= n) { if (player.repeat !== 1) break; i = 0; }
|
|
if (i === player.qi) break;
|
|
out.push(i);
|
|
}
|
|
return out;
|
|
}
|
|
function armPreload() {
|
|
++preloadGen;
|
|
clearTimeout(preloadPoll);
|
|
armedPath = null;
|
|
const ahead = player.status === "idle" ? [] : peekNextIndices();
|
|
if (!ahead.length) return;
|
|
const first = player.queue[ahead[0]];
|
|
if (first?.t?.path && !first.t.stream && !first.t.catalog) {
|
|
/* a library file: decoded and appended now; the rest are files too or
|
|
get their fetch below */
|
|
const path = first.t.path;
|
|
armedPath = path;
|
|
api.preloadLocal(path, first.t.duration_secs ?? null).catch(() => {
|
|
if (armedPath === path) armedPath = null;
|
|
});
|
|
}
|
|
if (ahead.some((i) => player.queue[i]?.t?.stream || player.queue[i]?.t?.catalog)) armStreamPreload(ahead);
|
|
}
|
|
/** Ready the peers' copies among the next three: found QUIETLY where a
|
|
* record's later track has none yet (a background search never shows the
|
|
* resolver's line), fetched into the cache ahead of time — one after the
|
|
* other, so the search pacing holds — and the FIRST appended the moment it
|
|
* is byte-complete, so the boundary is gapless. Waits after a start so
|
|
* nothing competes with the track that is starting. */
|
|
function armStreamPreload(indices) {
|
|
const g = preloadGen;
|
|
preloadPoll = setTimeout(async () => {
|
|
for (const i of indices) {
|
|
if (g !== preloadGen) return;
|
|
const e = player.queue[i];
|
|
if (!e?.t) continue;
|
|
if (e.t.catalog && !e.t.path) {
|
|
let ok = false;
|
|
try { ok = await hooks.resolveEntry?.(e, true); } catch { ok = false; }
|
|
if (g !== preloadGen) return;
|
|
if (!ok) continue;
|
|
}
|
|
if (!e.t.stream) continue; // the library's, or nothing to fetch
|
|
try { await api.preloadStream(e.t.stream.username, e.t.path, e.t.stream.size); } catch { /* next one */ }
|
|
}
|
|
/* the immediate next, once it is whole */
|
|
const e = player.queue[indices[0]];
|
|
if (!e?.t?.stream || g !== preloadGen) return;
|
|
const { username, size } = e.t.stream;
|
|
const path = e.t.path;
|
|
const poll = async () => {
|
|
if (g !== preloadGen) return;
|
|
let ready = false;
|
|
try { ready = await api.streamCacheReady(username, path, size); } catch { ready = false; }
|
|
if (g !== preloadGen) return;
|
|
if (!ready) { preloadPoll = setTimeout(poll, 3000); return; }
|
|
try {
|
|
const ok = await api.preloadCachedStream(username, path, size, e.t.duration_secs ?? null);
|
|
if (ok !== false && g === preloadGen) armedPath = path;
|
|
} catch { /* a gap at the boundary, never the wrong track */ }
|
|
};
|
|
poll();
|
|
}, ALBUM_FILL_DELAY_MS);
|
|
}
|
|
|
|
/** The engine crossed into the appended track on its own. */
|
|
function advanceGaplessly(i) {
|
|
const e = player.queue[i];
|
|
const g = ++gen;
|
|
player.advance = "gapless";
|
|
listenFlush();
|
|
player.qi = i;
|
|
player.track = e.t;
|
|
player.album = e.album;
|
|
player.position = 0;
|
|
player.duration = e.t.duration_secs ?? null;
|
|
player.status = "playing";
|
|
player.needsResource = false;
|
|
beginStory();
|
|
void loadArt(e.album?.art_path, true);
|
|
armedPath = null;
|
|
armPreload();
|
|
if (e.t.stream) {
|
|
/* the appended copy was byte-complete: the lane gets the whole shape */
|
|
player.coverage = 1;
|
|
player.stream = { received: e.t.stream.size, total: e.t.stream.size, done: true, failed: null };
|
|
streamStory(g, e);
|
|
} else {
|
|
loadWave(g, e.t.path);
|
|
}
|
|
writeSession();
|
|
mediaSync();
|
|
}
|
|
|
|
/* ── the queue ───────────────────────────────────────────────────────── */
|
|
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 */
|
|
original = entries;
|
|
const first = entries[at];
|
|
entries = [first, ...shuffled(entries.filter((_, i) => i !== at))];
|
|
at = 0;
|
|
}
|
|
player.queue = entries;
|
|
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.
|
|
`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);
|
|
}
|
|
export function playAlbumShuffled(album, tracks) {
|
|
if (!tracks.length) return;
|
|
player.shuffle = true;
|
|
return playEntries(tracks.map((t) => ({ t, album })), Math.floor(Math.random() * tracks.length), album.title);
|
|
}
|
|
export function playQueueAt(i) {
|
|
if (i < 0 || i >= player.queue.length) return;
|
|
player.qi = i;
|
|
return startEntry(player.queue[i]);
|
|
}
|
|
/** Drop an upcoming row from the queue (the shuffle's original order too). */
|
|
export function removeFromQueue(i) {
|
|
if (i <= player.qi || i >= player.queue.length) return;
|
|
const [gone] = player.queue.splice(i, 1);
|
|
if (original && gone) {
|
|
const k = original.indexOf(gone);
|
|
if (k >= 0) original.splice(k, 1);
|
|
}
|
|
armPreload();
|
|
writeSession();
|
|
}
|
|
/** An upcoming row one place up or down — or to an absolute index, which
|
|
* is what a drop on the queue face hands it — never past the current
|
|
* track. The guards were already the right guards for either. */
|
|
export function moveInQueue(i, dir) {
|
|
const j = typeof dir === "number" ? dir : i + (dir === "up" ? -1 : 1);
|
|
if (i <= player.qi || j <= player.qi || i >= player.queue.length || j >= player.queue.length) return;
|
|
const [row] = player.queue.splice(i, 1);
|
|
player.queue.splice(j, 0, row);
|
|
armPreload();
|
|
writeSession();
|
|
}
|
|
/** Pull an upcoming row up to right after the current track. */
|
|
export function moveToNext(i) {
|
|
if (i <= player.qi + 1 || i >= player.queue.length) return;
|
|
const [row] = player.queue.splice(i, 1);
|
|
player.queue.splice(player.qi + 1, 0, row);
|
|
armPreload();
|
|
writeSession();
|
|
}
|
|
/** After the current track. */
|
|
export function playNext(entries) {
|
|
if (player.qi < 0) return playEntries(entries, 0, player.from);
|
|
player.queue.splice(player.qi + 1, 0, ...entries);
|
|
if (original) {
|
|
const cur = player.queue[player.qi];
|
|
const k = original.findIndex((x) => x.t.path === cur?.t?.path);
|
|
original.splice(k >= 0 ? k + 1 : original.length, 0, ...entries);
|
|
}
|
|
armPreload();
|
|
writeSession();
|
|
}
|
|
/** At the end of the queue. */
|
|
export function enqueue(entries) {
|
|
if (player.qi < 0) return playEntries(entries, 0, player.from);
|
|
player.queue.push(...entries);
|
|
if (original) original.push(...entries);
|
|
if (player.qi + 1 === player.queue.length - entries.length) armPreload();
|
|
writeSession();
|
|
}
|
|
|
|
/* ── a track from the index (Search, the Tracks tab) ─────────────────── */
|
|
/** The library record a TrackIndexRow belongs to, when the library has it. */
|
|
export function albumOfRow(row) {
|
|
const k = String(row.artist || "").toLowerCase();
|
|
return lib.albums.find((a) => a.title === row.album && a.artist.toLowerCase() === k)
|
|
|| (row.art_path ? lib.albums.find((a) => a.art_path === row.art_path) : null)
|
|
|| null;
|
|
}
|
|
export function entryFromRow(row, album = albumOfRow(row)) {
|
|
return {
|
|
t: { id: row.path, title: row.title, artist: row.artist, path: row.path, duration_secs: row.duration_secs ?? null },
|
|
album: album || { id: null, title: row.album, artist: row.artist, art_path: row.art_path ?? null },
|
|
};
|
|
}
|
|
/** The track, then the rest of its record. */
|
|
export async function playIndexRow(row) {
|
|
try {
|
|
const album = albumOfRow(row);
|
|
if (album) {
|
|
const tr = await tracksOf(album.id);
|
|
const i = tr.findIndex((t) => t.path === row.path);
|
|
if (i >= 0) return await playAlbum(album, tr, i);
|
|
}
|
|
await playEntries([entryFromRow(row, album)], 0, row.album);
|
|
} catch (e) { setErr(e); }
|
|
}
|
|
|
|
/* ── the sleep timer ─────────────────────────────────────────────────── */
|
|
let sleepTimer = null;
|
|
export function setSleep(minutes) {
|
|
clearTimeout(sleepTimer);
|
|
sleepTimer = null;
|
|
player.sleepAt = minutes > 0 ? Date.now() + minutes * 60_000 : 0;
|
|
if (minutes > 0) {
|
|
sleepTimer = setTimeout(() => {
|
|
player.sleepAt = 0;
|
|
sleepTimer = null;
|
|
if (player.status === "playing" || player.status === "buffering") api.playerControl("pause").catch(() => {});
|
|
}, minutes * 60_000);
|
|
}
|
|
}
|
|
export function sleepLabel() {
|
|
if (!player.sleepAt) return "off";
|
|
const m = Math.max(1, Math.round((player.sleepAt - Date.now()) / 60_000));
|
|
return m + " min left";
|
|
}
|
|
|
|
/* ── the transport ───────────────────────────────────────────────────── */
|
|
export function next() {
|
|
if (!player.queue.length) return;
|
|
if (player.repeat === 2) { seekSecs(0); return; }
|
|
const i = player.qi + 1 < player.queue.length ? player.qi + 1 : (player.repeat === 1 ? 0 : -1);
|
|
if (i < 0) {
|
|
/* the end of the queue: stop, keep the record on the bar */
|
|
api.playerControl("stop").catch(() => {});
|
|
player.status = "idle";
|
|
armedPath = null;
|
|
mediaSync();
|
|
return;
|
|
}
|
|
playQueueAt(i);
|
|
}
|
|
export function prev() {
|
|
/* the universal transport rule: restart first, go back on a second press */
|
|
if (player.position > 5 || player.qi <= 0) { seekSecs(0); return; }
|
|
playQueueAt(player.qi - 1);
|
|
}
|
|
export function toggle() {
|
|
if (player.status === "playing" || player.status === "buffering") {
|
|
api.playerControl("pause").catch(setErr);
|
|
} else if (player.status === "paused" && !player.needsResource) {
|
|
api.playerControl("resume").catch(setErr);
|
|
} else if (current()) {
|
|
/* nothing loaded behind the bar — a restored session, or a queue
|
|
that ran out: source it again, from where it was if it was somewhere */
|
|
const at = player.needsResource && player.position > 1 && player.duration
|
|
&& player.position < player.duration - 1 ? player.position : null;
|
|
startEntry(current(), at);
|
|
}
|
|
}
|
|
export function seekSecs(s) {
|
|
if (player.duration == null) return;
|
|
s = Math.max(0, Math.min(player.duration, s));
|
|
player.position = s;
|
|
if (player.needsResource) { startEntry(current(), s > 1 ? s : null); return; }
|
|
api.playerControl("seek", s).catch(setErr);
|
|
mediaSync();
|
|
/* the resume point too, now: the engine's own flush is every five
|
|
seconds, and a phone put down (or a car started) right after a scrub
|
|
would otherwise continue from before it (2026-09-18) */
|
|
writeSession();
|
|
}
|
|
export function seekFrac(f) {
|
|
if (player.duration) seekSecs(f * player.duration);
|
|
}
|
|
export function cycleRepeat() {
|
|
player.repeat = (player.repeat + 1) % 3;
|
|
armPreload();
|
|
writeSession();
|
|
}
|
|
export function toggleShuffle() {
|
|
if (!player.shuffle) {
|
|
original = player.queue.slice();
|
|
const rest = shuffled(player.queue.slice(player.qi + 1));
|
|
player.queue = player.queue.slice(0, player.qi + 1).concat(rest);
|
|
player.shuffle = true;
|
|
} else {
|
|
if (original) {
|
|
const cur = player.queue[player.qi];
|
|
const k = original.findIndex((x) => x.t.path === cur?.t?.path);
|
|
if (k >= 0) { player.queue = original; player.qi = k; }
|
|
original = null;
|
|
}
|
|
player.shuffle = false;
|
|
}
|
|
armPreload();
|
|
writeSession();
|
|
}
|
|
|
|
function ended(gapless) {
|
|
listenFlush();
|
|
const i = nextIndex();
|
|
const e = i >= 0 ? player.queue[i] : null;
|
|
if (gapless && e && armedPath && e.t.path === armedPath) { advanceGaplessly(i); return; }
|
|
if (!e) {
|
|
player.status = "idle";
|
|
if (player.duration != null) player.position = player.duration;
|
|
armedPath = null;
|
|
writeSession();
|
|
mediaSync();
|
|
return;
|
|
}
|
|
/* not armed, or armed with a track the queue no longer has next: source
|
|
the right one (a gap at worst, never the wrong track) */
|
|
player.advance = "sourced";
|
|
playQueueAt(i);
|
|
}
|
|
|
|
/** Subscribe to the audio thread. Returns the unlisten. */
|
|
export async function bindEvents() {
|
|
document.addEventListener("visibilitychange", () => { if (document.hidden) writeSession(); });
|
|
window.addEventListener("pagehide", writeSession);
|
|
setInterval(() => { if (player.status === "playing") writeSession(); }, 10_000);
|
|
/* Two subscriptions in one round, for the same reason as everywhere
|
|
else this round: `listen` is an invoke, so one behind the other is
|
|
two bridge round trips at launch. Both are in flight from here; they
|
|
are awaited together at the end, so neither can reject with nobody
|
|
listening. */
|
|
const streamSub = listen("soulseek:stream", (ev) => {
|
|
const p = ev.payload || {};
|
|
if (!current()?.t?.stream) return;
|
|
player.stream = { received: p.received || 0, total: p.total || 0, done: !!p.done, failed: p.failed || null };
|
|
if (p.failed) setErr("The stream from " + current().t.stream.username + " failed: " + p.failed);
|
|
});
|
|
const transportSub = listen("soulseek:player", (ev) => {
|
|
const p = ev.payload || {};
|
|
switch (p.kind) {
|
|
case "playing": player.status = "playing"; player.needsResource = false; mediaSync(); break;
|
|
case "paused": player.status = "paused"; mediaSync(); break;
|
|
case "buffering": player.status = "buffering"; break;
|
|
case "stopped": player.status = "idle"; mediaSync(); break;
|
|
case "position":
|
|
if (typeof p.secs === "number") {
|
|
player.position = p.secs;
|
|
listenTick(p.secs);
|
|
/* a set-recording slice (a setlist row) ends at the next cue */
|
|
if (player.track?.endSecs != null && p.secs >= player.track.endSecs && endFired !== gen) {
|
|
endFired = gen;
|
|
next();
|
|
}
|
|
}
|
|
if (p.duration != null) player.duration = p.duration;
|
|
break;
|
|
case "track-ended": ended(!!p.gapless); break;
|
|
case "error":
|
|
player.error = p.message || "playback error";
|
|
player.status = "idle";
|
|
break;
|
|
}
|
|
});
|
|
/* the stream listener is still allowed to have nothing behind it (a
|
|
desktop build, the mock without the bus); the transport's failure is
|
|
still the one the shell shows, and the unlisten handed back is still
|
|
the transport's — the one onMount stores and calls on teardown. */
|
|
const [, unlisten] = await Promise.all([streamSub.catch(() => { /* not a phone */ }), transportSub]);
|
|
return unlisten;
|
|
}
|
|
|
|
/* ── the session ─────────────────────────────────────────────────────── */
|
|
const SESSION_KEY = "ss.mobile.session.v1";
|
|
|
|
function packEntry(e) {
|
|
const a = e.album;
|
|
return {
|
|
t: e.t,
|
|
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) { if (sessionBooted) localStorage.removeItem(SESSION_KEY); return; }
|
|
localStorage.setItem(SESSION_KEY, JSON.stringify({
|
|
v: 1,
|
|
queue: player.queue.map(packEntry),
|
|
qi: player.qi,
|
|
from: player.from,
|
|
position: player.position,
|
|
duration: player.duration,
|
|
shuffle: player.shuffle,
|
|
repeat: player.repeat,
|
|
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,
|
|
/* a "stream:" key is this page's own name for a cover it fills from
|
|
the file's tag; the Rust side reads the tag itself when given none */
|
|
art: a.art_path && !String(a.art_path).startsWith("stream:") ? 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
|
|
* authority: a reload finds it playing and follows it; a restart finds it
|
|
* idle and the track comes back paused, to be re-sourced by play. */
|
|
export async function restoreSession() {
|
|
let saved = null;
|
|
try { saved = JSON.parse(localStorage.getItem(SESSION_KEY) || "null"); } catch { saved = null; }
|
|
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;
|
|
player.from = saved.from || "";
|
|
player.shuffle = !!saved.shuffle;
|
|
player.repeat = saved.repeat || 0;
|
|
original = Array.isArray(saved.original) ? saved.original : null;
|
|
const e = player.queue[player.qi];
|
|
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) : (saved.position ?? 0);
|
|
player.duration = live && transport.duration != null ? transport.duration : (saved.duration ?? e.t.duration_secs ?? null);
|
|
player.needsResource = !live;
|
|
player.phase = "ready";
|
|
void loadArt(e.album?.art_path, true);
|
|
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;
|
|
}
|
|
|
|
/* ── play history ────────────────────────────────────────────────────────
|
|
The desktop's rule (state.svelte.js): listened time is accumulated from
|
|
the position ticks, a row opens once a track has really accumulated a
|
|
second, top-ups land every 15s and at the track change, seeks are ignored
|
|
by the delta guard. The phone writes to the same table, so Jump back in
|
|
is one history across both. */
|
|
const LISTEN_FLUSH_SECS = 15;
|
|
const RECENTS_FLOOR_SECS = 45;
|
|
let session = null;
|
|
let lastPos = null;
|
|
|
|
function listenFlush(s = session) {
|
|
if (!s?.playIdP) return;
|
|
const secs = Math.floor(s.pending);
|
|
if (secs < 1) return;
|
|
s.pending -= secs;
|
|
s.playIdP.then((id) => api.addPlaySeconds(id, secs)).catch(() => {});
|
|
}
|
|
function scheduleRecents(ms) {
|
|
setTimeout(() => { if (typeof hooks.recentsDirty === "function") hooks.recentsDirty(); }, ms);
|
|
}
|
|
function listenTick(pos) {
|
|
const t = player.track;
|
|
if (!t) return;
|
|
const title = t.title;
|
|
const artist = (t.artist || player.album?.artist || "").trim();
|
|
if (!title || !artist) return;
|
|
const key = artist + "\u0000" + title;
|
|
if (session?.key !== key) {
|
|
listenFlush();
|
|
scheduleRecents(1500);
|
|
session = { key, pending: 0, playIdP: null };
|
|
lastPos = null;
|
|
}
|
|
const d = lastPos == null ? 0 : pos - lastPos;
|
|
lastPos = pos;
|
|
if (d <= 0 || d > 3) return;
|
|
session.pending += d;
|
|
if (!session.playIdP && session.pending >= 1) {
|
|
session.playIdP = api.recordPlay(artist, title, t.stream ? null : (t.path ?? null));
|
|
session.playIdP.catch(() => {});
|
|
session.playIdP.then(() => scheduleRecents(RECENTS_FLOOR_SECS * 1000 + 2000)).catch(() => {});
|
|
}
|
|
if (session.pending >= LISTEN_FLUSH_SECS) listenFlush();
|
|
}
|