The phone stood in for the desktop's drag with Move up / Move down / Move to the top on a row's sheet, which is fine but is not what a finger is for. Press and hold a row for 320ms now and it lifts, the rows below make room, and letting go lands it — on the playlist page and on the player sheet's queue, through one action in src/mobile/reorder.js. The Move rows stay: they are the accessible path round 28 named to a screen reader, and they are the only way to reorder while the filter is on. Release a press without having moved it and the row's sheet opens exactly as a long press always opened it, which is what keeps the queue's sheet reachable at all — a queue row has no ⋯, so `oncontextmenu` is its only door to Play now, Move to next and Remove from queue. Most of the work is in not fighting the page. Nothing carries `touch-action: none`, because a flick that starts on a row is how the list is scrolled and `touch-action` is latched at touchstart anyway; the scroll is refused instead by a non-passive `touchmove` listener added at the lift, which works precisely because the finger has been still for 320ms and the browser has not started one. A press that moves more than 8px before then is a scroll and the gesture lets go of it. Click and contextmenu guards sit on the container in the capture phase while a lift is live, or a released drag would fire the row's own click and play the track. Rows are measured once at the lift in the scroller's content space, so the edge pull — within 64px of either edge — leaves the snapshot true, and each displaced row moves by the exact difference between its top and its neighbour's rather than by one assumed row height, because the queue face puts album separators between rows. No new mutation and no new command: `moveItem` and `moveInQueue` each take a numeric target now as well as "up" / "down" / "top", so there is still exactly one call to `playlist_set_order` carrying the whole id order, and the existing queue guards were already the right guards. The drop is optimistic and snaps back with the error toast if the backend says no. Two refusals cover the middle of a gesture — the playlist drops nothing if a refetch has replaced its rows, and the queue drops nothing if `player.qi` has moved — and dragging is off entirely while the playlist's filter has anything in it, because a filtered list's visible order is not the stored order. Both walks drive it with real pointer events and assert the order out of the store: a drag, a drag back, a refusal under the filter, a press that still opens the sheet, and a drop the backend is armed to reject.
747 lines
31 KiB
JavaScript
747 lines
31 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: "",
|
|
/* 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;
|
|
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 — 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();
|
|
}
|
|
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,
|
|
};
|
|
}
|
|
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 + "\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();
|
|
}
|