Without a drag: a playlist row's sheet moves it up, down or to the top and persists the whole order in one call (playlist_set_order, as the desktop's drop does); a queue row's sheet moves it up or down, never past the track playing. Also src-tauri/android/ship.sh — one round end to end: the mock walk, the embedded build installed, the phone walk, the commit, each step gated on the one before.
732 lines
30 KiB
JavaScript
732 lines
30 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 } 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 ?? 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;
|
|
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;
|
|
/* 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]);
|
|
}
|
|
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, never past the current track. */
|
|
export function moveInQueue(i, dir) {
|
|
const j = 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();
|
|
}
|
|
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);
|
|
/* 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 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 + " |