android: the phone, on the real core — the mockup shell, playback, the media card, the catalog, the resolver, Save, the dev flavour
The same Tauri 2 project builds for Android: mobile.html as a third Vite
entry (src/mobile/, the mockup on real data), tauri.android.conf.json, the
mobile stubs (updater, lyric alignment, vocal separation), rustls reqwest
on Android, the AAudio buffer, and a hand-driven detour build
(src-tauri/android/{env,build,deploy,dev}.sh) while Developer Mode is off.
ANDROID.md is the recipe.
Playback: the media-session plugin crate (lock-screen card, MediaStyle
notification, audio focus, headset buttons, a foreground service that
promotes itself first — Android 14 kills the app otherwise), session
restore across reload and restart, solid chrome after measuring the
backdrop-filter lag.
The catalog: Search is the desktop's top bar (the top-result rule, artist
cards), the artist page and the record page are the desktop's, Home is the
desktop's landing page, Gaps per followed shelf. Playing goes through the
desktop's own source rules, moved verbatim from state.svelte.js into
src/lib/sources.js so both apps share one resolver; the phone readies the
next three tracks and crosses boundaries gapless. Save fetches a record or
a track into the library by the download rules; Transfers shows the
burst. The raw Soulseek search stays, offered on request, a peer's copy
streaming with the lane rising as the bytes arrive. Community is the
Soulseek side (rooms, chat, buddies); the private layer waits.
The opener and storage plugins (social links, the picked library folder,
all-files access), the volume normalization switch, and the harness:
tests/mobile/smoke.mjs walks the mock (a fake network behind
src/lib/events.js) and the real phone over CDP, perf.mjs measures frames.
This commit is contained in:
1 parent
972dd103cb
commit
bb828ff2dc
102 files changed
+12258
-830
No files matched your search
@@ -0,0 +1,695 @@
|
||||
/* 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 } from "./art.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: "",
|
||||
});
|
||||
|
||||
/** 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 || 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);
|
||||
} 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;
|
||||
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;
|
||||
const key = e.album?.art_path;
|
||||
if (key && art[key] === undefined) {
|
||||
api.streamEmbeddedArt(username, path).then((u) => { art[key] = u || ""; }, () => { art[key] = ""; });
|
||||
}
|
||||
}
|
||||
if (g === gen && !done) streamTimer = setTimeout(tick, 2500);
|
||||
};
|
||||
streamTimer = setTimeout(tick, 900);
|
||||
}
|
||||
|
||||
/** 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 = "") {
|
||||
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;
|
||||
return startEntry(entries[at]);
|
||||
}
|
||||
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]);
|
||||
}
|
||||
/** 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();
|
||||
}
|
||||
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);
|
||||
try {
|
||||
await 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);
|
||||
});
|
||||
} catch { /* not a phone */ }
|
||||
return await 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);
|
||||
}
|
||||
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 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,
|
||||
};
|
||||
}
|
||||
export function writeSession() {
|
||||
try {
|
||||
if (!player.track) { 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 */ }
|
||||
}
|
||||
|
||||
/** 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; }
|
||||
if (!saved || saved.v !== 1 || !Array.isArray(saved.queue) || !saved.queue[saved.qi]?.t?.path) return false;
|
||||
let transport = null;
|
||||
try { transport = await api.playerState(); } catch { transport = null; }
|
||||
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();
|
||||
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 + " | ||||