The storage plugin gains pick_image — the document picker limited to images, the picked content copied into the app's cache so the Rust side gets a plain path. playlist_cover_set(id, path) is the desktop pick's install step split out and shared (squared, the master cover, the row's revision moved). A playlist's sheet offers Choose cover photo and Remove custom cover; the mock walk exercises both.
685 lines
43 KiB
JavaScript
685 lines
43 KiB
JavaScript
// Dev-only mock library data, for exercising the Library UI on a machine
|
||
// without a populated music library. Inert unless explicitly enabled by
|
||
// setting localStorage.mockLibrary = "1" in the webview (then reload);
|
||
// remove the key to go back to real backend data.
|
||
|
||
const ARTISTS = ["Aphex Twin","Boards of Canada","Caribou","Daft Punk","Eno Brian","Four Tet","Grimes","Holly Herndon","Jon Hopkins","Kraftwerk","LCD Soundsystem","Moderat","Nils Frahm","Oneohtrix","Portishead","Radiohead","Squarepusher","Tycho","Underworld","Vangelis","Wilco","Yello","Zero 7","Air","Bonobo","Burial","Clark","Deadmau5","Emancipator","Floating Points","Gold Panda","Helios","Kiasmos","Lorn","Moby","Nujabes","Ott","Plaid","Rival Consoles","Shpongle"];
|
||
|
||
// Titles vary by slot so the Library's classifier has something real to file:
|
||
// a live set, demos, a remix record and a collection appear per artist cycle,
|
||
// and a few rows have NO art_path so the generated artwork can be judged.
|
||
const TITLE_SHAPES = [
|
||
(a, j) => `Album ${j + 1} of ${a}`,
|
||
(a) => `Live at the Roundhouse`,
|
||
(a) => `Four-Track Demos`,
|
||
(a) => `Remixes 2001`,
|
||
(a) => `Singles Collection`,
|
||
];
|
||
const albums = [];
|
||
let aid = 1;
|
||
for (let i = 0; i < ARTISTS.length; i++) {
|
||
const n = 2 + (i % 4); // 2-5 albums per artist
|
||
for (let j = 0; j < n; j++) {
|
||
const shape = j < 2 ? TITLE_SHAPES[0] : TITLE_SHAPES[(i + j) % TITLE_SHAPES.length];
|
||
albums.push({
|
||
id: aid,
|
||
artist: ARTISTS[i],
|
||
title: shape(ARTISTS[i], j),
|
||
year: 1995 + ((i + j) % 30),
|
||
track_count: j === 2 && i % 5 === 0 ? 3 : 8 + ((i + j) % 7),
|
||
art_path: (i + j) % 4 === 3 ? null : `mock://art/${aid}`,
|
||
artist_art_path: `mock://artist/${i}`,
|
||
added_at: 1700000000 + i * 86400 * 9 + j * 86400 * 40,
|
||
path: `C:\\mock\\${i}\\${j}`,
|
||
});
|
||
aid++;
|
||
}
|
||
}
|
||
|
||
// Per-album file aggregates, the same shape library_album_stats returns.
|
||
// Deterministic variety: a third FLAC 16/44.1, a few 24/96, the rest MP3.
|
||
function statsFor(a) {
|
||
const kind = a.id % 7;
|
||
const flac = kind === 0 || kind === 3 || kind === 5;
|
||
const hires = kind === 5;
|
||
const perTrack = flac ? (hires ? 120e6 : 38e6) : 9e6;
|
||
return {
|
||
album_id: a.id,
|
||
secs: a.track_count * (170 + (a.id % 90)),
|
||
bytes: Math.round(a.track_count * perTrack),
|
||
avg_kbps: flac ? (hires ? 2400 : 900) + (a.id % 120) : 192 + (a.id % 4) * 32,
|
||
bit_depth: flac ? (hires ? 24 : 16) : null,
|
||
sample_rate: flac ? (hires ? 96000 : 44100) : null,
|
||
formats: [flac ? "flac" : "mp3"],
|
||
last_played_at: a.id % 3 === 0 ? null : 1723000000 - (a.id % 40) * 86400,
|
||
};
|
||
}
|
||
|
||
function svgArt(hue, label) {
|
||
const s =
|
||
`<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256">` +
|
||
`<rect width="256" height="256" fill="hsl(${hue},45%,35%)"/>` +
|
||
`<text x="128" y="140" font-size="90" text-anchor="middle" fill="rgba(255,255,255,.75)" font-family="sans-serif">${label}</text></svg>`;
|
||
return "data:image/svg+xml;base64," + btoa(s);
|
||
}
|
||
|
||
function tracksFor(albumId) {
|
||
const album = albums.find((a) => a.id === albumId);
|
||
const artist = album?.artist ?? "?";
|
||
const s = album ? statsFor(album) : null;
|
||
const ext = s?.formats[0] ?? "mp3";
|
||
const n = album?.track_count ?? 10;
|
||
return Array.from({ length: n }, (_, k) => ({
|
||
id: albumId * 100 + k,
|
||
album_id: albumId,
|
||
title: `Track ${k + 1}`,
|
||
artist,
|
||
track_no: k + 1,
|
||
duration_secs: 120 + ((albumId + k) % 9) * 23,
|
||
bitrate_kbps: s?.avg_kbps ?? 320,
|
||
bit_depth: s?.bit_depth ?? null,
|
||
sample_rate: s?.sample_rate ?? null,
|
||
size: Math.round((s?.bytes ?? 40e6) / n),
|
||
path: `C:\\mock\\t${albumId}-${k}.${ext}`,
|
||
}));
|
||
}
|
||
|
||
/* ── playlists: the desktop's shapes (library::Playlist rows with the
|
||
command layer's duration_secs / unresolved_count, PlaylistItemPayload
|
||
rows with `missing`). One pinned playlist holding every row kind, a
|
||
setlist with coherent cue times, and an empty one. Mutations bump the
|
||
revision and emit soulseek:playlists-changed, as the real commands do. */
|
||
let plSeq = 3;
|
||
let plItemSeq = 100;
|
||
const PL_T0 = 1_725_000_000;
|
||
const PL = [
|
||
{ id: 1, name: "Late night drive", track_count: 0, description: "Slow, wide, mostly synths.", kind: "manual", pinned: true, cover_path: "mock://cover1", created_at: PL_T0, updated_at: PL_T0, revision: 1, duration_secs: 0, unresolved_count: 0 },
|
||
{ id: 2, name: "Boiler Room 2019", track_count: 0, description: null, kind: "setlist", pinned: false, cover_path: null, created_at: PL_T0 + 10, updated_at: PL_T0 + 10, revision: 1, duration_secs: 0, unresolved_count: 0 },
|
||
{ id: 3, name: "Empty one", track_count: 0, description: null, kind: "manual", pinned: false, cover_path: null, created_at: PL_T0 + 20, updated_at: PL_T0 + 20, revision: 1, duration_secs: 0, unresolved_count: 0 },
|
||
];
|
||
function plLocalRow(albumId, k, extra = {}) {
|
||
const t = tracksFor(albumId)[k];
|
||
const a = albums.find((x) => x.id === albumId);
|
||
return { title: t.title, artist: a.artist, album: a.title, duration_secs: t.duration_secs, path: t.path, art_path: a.art_path, kind: "local", source_line: null, start_ms: null, note: null, track_id: null, art_url: null, missing: false, ...extra };
|
||
}
|
||
const PL_ITEMS = {
|
||
1: [
|
||
plLocalRow(1, 0), plLocalRow(2, 1), plLocalRow(5, 2, { missing: true }), plLocalRow(9, 3),
|
||
{ title: "One More Time", artist: "Daft Punk", album: "Discovery", duration_secs: 200, path: null, art_path: null, kind: "stream", source_line: "Daft Punk - One More Time", start_ms: null, note: null, track_id: "3135550", art_url: svgArt(200, "D"), missing: false },
|
||
{ title: "ID", artist: "ID", album: null, duration_secs: null, path: null, art_path: null, kind: "unresolved", source_line: "07 ID - ID", start_ms: null, note: null, track_id: null, art_url: null, missing: false },
|
||
],
|
||
2: [plLocalRow(3, 0, { start_ms: 0 }), plLocalRow(3, 1, { start_ms: 245_000 }), plLocalRow(3, 2, { start_ms: 1_130_000 })],
|
||
3: [],
|
||
};
|
||
for (const id of Object.keys(PL_ITEMS)) PL_ITEMS[id].forEach((r, i) => { r.id = ++plItemSeq; r.position = i; });
|
||
function plSync(p) {
|
||
const rows = PL_ITEMS[p.id] || [];
|
||
p.track_count = rows.length;
|
||
p.duration_secs = rows.reduce((n, r) => n + (r.duration_secs || 0), 0);
|
||
p.unresolved_count = rows.filter((r) => r.kind === "unresolved").length;
|
||
}
|
||
PL.forEach(plSync);
|
||
function plTouch(p) {
|
||
if (p) { p.revision++; p.updated_at = Math.floor(Date.now() / 1000); plSync(p); }
|
||
setTimeout(() => mockEmit("soulseek:playlists-changed", {}), 30);
|
||
}
|
||
const plOf = (args) => PL.find((p) => p.id === Number(args?.id ?? args?.playlistId));
|
||
/* every track has three timed lines; the sheet's tools have something to act on */
|
||
function mockLyrics(title) {
|
||
return {
|
||
status: "synced", source: "lrclib",
|
||
synced: [{ time_ms: 0, text: "Here is " + (title || "a song") }, { time_ms: 4000, text: "Every word in time" }, { time_ms: 9000, text: "And the last one, late" }],
|
||
plain: null, offset_ms: 0, auto_offset_ms: 0, auto_sync_off: false,
|
||
};
|
||
}
|
||
function mockPlaylists(cmd, args) {
|
||
if (cmd === "list_playlists") return Promise.resolve(PL.map((p) => ({ ...p })));
|
||
if (cmd === "playlist_items") return new Promise((r) => setTimeout(() => r((PL_ITEMS[Number(args?.id)] || []).map((x) => ({ ...x }))), 80));
|
||
if (cmd === "playlist_cover") { const p = plOf(args); return Promise.resolve(p && p.cover_path ? svgArt(p.id === 1 ? 330 : 280, p.id === 1 ? "L" : "M") : null); }
|
||
if (cmd === "playlist_cover_set") { const p = plOf(args); if (!p) return Promise.reject(new Error("no such playlist")); p.cover_path = String(args?.path || "mock://photo"); plTouch(p); return Promise.resolve(svgArt(300, "P")); }
|
||
if (cmd === "playlist_cover_clear") { const p = plOf(args); if (p) p.cover_path = null; plTouch(p); return Promise.resolve(); }
|
||
if (cmd === "create_playlist") {
|
||
const p = { id: ++plSeq, name: args?.name || "New Playlist", track_count: 0, description: null, kind: args?.kind || "manual", pinned: false, cover_path: null, created_at: Math.floor(Date.now() / 1000), updated_at: 0, revision: 0, duration_secs: 0, unresolved_count: 0 };
|
||
PL.push(p);
|
||
PL_ITEMS[p.id] = [];
|
||
plTouch(p);
|
||
return Promise.resolve(p.id);
|
||
}
|
||
if (cmd === "rename_playlist") { const p = plOf(args); if (p) p.name = args.name; plTouch(p); return Promise.resolve(); }
|
||
if (cmd === "set_playlist_description") { const p = plOf(args); if (p) p.description = args.description || null; plTouch(p); return Promise.resolve(); }
|
||
if (cmd === "set_playlist_pinned") { const p = plOf(args); if (p) p.pinned = !!args.pinned; plTouch(p); return Promise.resolve(); }
|
||
if (cmd === "duplicate_playlist") {
|
||
const p = plOf(args);
|
||
if (!p) return Promise.reject(new Error("duplicate_playlist: no such playlist"));
|
||
const c = { ...p, id: ++plSeq, name: args?.name || p.name + " copy", pinned: false, revision: 0 };
|
||
PL.push(c);
|
||
PL_ITEMS[c.id] = (PL_ITEMS[p.id] || []).map((x, i) => ({ ...x, id: ++plItemSeq, position: i }));
|
||
plTouch(c);
|
||
return Promise.resolve(c.id);
|
||
}
|
||
if (cmd === "delete_playlist") {
|
||
const i = PL.findIndex((p) => p.id === Number(args?.id));
|
||
if (i >= 0) { delete PL_ITEMS[PL[i].id]; PL.splice(i, 1); }
|
||
plTouch(null);
|
||
return Promise.resolve();
|
||
}
|
||
if (cmd === "playlist_remove_items") {
|
||
const p = plOf(args);
|
||
const ids = new Set((args?.itemIds || []).map(Number));
|
||
if (p) { PL_ITEMS[p.id] = (PL_ITEMS[p.id] || []).filter((x) => !ids.has(x.id)); PL_ITEMS[p.id].forEach((x, i) => { x.position = i; }); }
|
||
plTouch(p);
|
||
return Promise.resolve();
|
||
}
|
||
if (cmd === "playlist_add_items") {
|
||
const p = plOf(args);
|
||
if (!p) return Promise.reject(new Error("playlist_add_items: no such playlist"));
|
||
const rows = (args?.items || []).map((it) => ({
|
||
title: it.title, artist: it.artist || "", album: it.album ?? null, duration_secs: it.duration_secs ?? null,
|
||
path: it.path ?? null, art_path: it.art_path ?? null,
|
||
kind: it.path ? "local" : (it.title && it.artist ? "stream" : "unresolved"),
|
||
source_line: it.source_line ?? null, start_ms: it.start_ms ?? null, note: it.note ?? null,
|
||
track_id: it.track_id ?? null, art_url: it.art_url ?? null, missing: false, id: ++plItemSeq,
|
||
}));
|
||
const list = PL_ITEMS[p.id];
|
||
const at = args?.at == null ? list.length : Math.min(Number(args.at), list.length);
|
||
list.splice(at, 0, ...rows);
|
||
list.forEach((x, i) => { x.position = i; });
|
||
plTouch(p);
|
||
return Promise.resolve(rows.map((r) => r.id));
|
||
}
|
||
if (cmd === "playlist_set_order") {
|
||
const p = plOf(args);
|
||
if (!p) return Promise.reject(new Error("playlist_set_order: no such playlist"));
|
||
const by = new Map((PL_ITEMS[p.id] || []).map((x) => [x.id, x]));
|
||
const next = (args?.orderedIds || []).map(Number).map((id) => by.get(id)).filter(Boolean);
|
||
for (const x of PL_ITEMS[p.id] || []) if (!next.includes(x)) next.push(x);
|
||
PL_ITEMS[p.id] = next;
|
||
next.forEach((x, i) => { x.position = i; });
|
||
plTouch(p);
|
||
return Promise.resolve();
|
||
}
|
||
if (cmd === "playlist_relink_item") {
|
||
const p = plOf(args);
|
||
const it = p ? (PL_ITEMS[p.id] || []).find((x) => x.id === Number(args?.itemId)) : null;
|
||
if (!it) return Promise.reject(new Error("playlist_relink_item: no such row"));
|
||
if (args.path != null) { it.path = args.path; it.kind = "local"; it.missing = false; }
|
||
if (args.artPath != null) it.art_path = args.artPath;
|
||
if (args.durationSecs != null) it.duration_secs = args.durationSecs;
|
||
if (args.title != null) it.title = args.title;
|
||
if (args.artist != null) it.artist = args.artist;
|
||
if (args.album != null) it.album = args.album;
|
||
plTouch(p);
|
||
return Promise.resolve();
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
/* ── the import doors: a pasted tracklist, a link, a YouTube set — a draft
|
||
in the backend's shape (ImportDraft / DraftEntry, resolved against the
|
||
mock library), then commit makes the playlist ── */
|
||
function impEntry(ordinal, artist, title, raw, start_ms = null) {
|
||
const own = albums.find((a) => a.artist.toLowerCase() === artist.toLowerCase());
|
||
const tr = own ? tracksFor(own.id).find((t) => t.title.toLowerCase() === title.toLowerCase()) : null;
|
||
const weak = !artist || !title || title.toLowerCase() === "id";
|
||
return {
|
||
ordinal, title, artist, album: tr ? own.title : null,
|
||
duration_secs: tr ? tr.duration_secs : (weak ? null : 200 + ((ordinal * 37) % 180)),
|
||
start_ms, raw,
|
||
resolution: tr ? "local" : weak ? "unresolved" : "stream",
|
||
matched_by: tr ? "tags" : null,
|
||
path: tr ? tr.path : null, art_path: tr ? own.art_path : null,
|
||
track_id: null, art_url: null, isrc: null,
|
||
};
|
||
}
|
||
function impParse(text) {
|
||
const out = [];
|
||
for (const raw of String(text || "").split(/\r?\n/)) {
|
||
const line = raw.trim();
|
||
if (!line) continue;
|
||
const m = /^(?:(\d+):(\d\d)(?::(\d\d))?\s+)?(.*?)\s+[-–]\s+(.*)$/.exec(line);
|
||
const start_ms = m && m[1] != null ? ((m[3] != null ? (+m[1] * 3600 + +m[2] * 60 + +m[3]) : (+m[1] * 60 + +m[2])) * 1000) : null;
|
||
out.push(impEntry(out.length, m ? m[4].trim() : "", m ? m[5].trim() : line, raw, start_ms));
|
||
}
|
||
return out;
|
||
}
|
||
function impDraft(entries, source_name, format, name = null, extra = {}) {
|
||
return {
|
||
source_name, format, name, description: null,
|
||
kind: entries.some((e) => e.start_ms != null) ? "setlist" : "manual",
|
||
entries, warnings: [],
|
||
resolved_count: entries.filter((e) => e.resolution === "local").length,
|
||
stream_count: entries.filter((e) => e.resolution === "stream").length,
|
||
unresolved_count: entries.filter((e) => e.resolution === "unresolved").length,
|
||
cover_url: null,
|
||
...extra,
|
||
};
|
||
}
|
||
function mockImport(cmd, args) {
|
||
if (cmd === "playlist_import_text") {
|
||
const entries = impParse(args?.text);
|
||
if (!entries.length) return Promise.reject(new Error("Nothing in that text reads as a track."));
|
||
return new Promise((r) => setTimeout(() => r(impDraft(entries, "Pasted tracklist", "text")), 400));
|
||
}
|
||
if (cmd === "playlist_import_link" || cmd === "playlist_import_youtube") {
|
||
const url = String(args?.url || "");
|
||
if (!/^https?:\/\//.test(url)) return Promise.reject(new Error("That is not a link."));
|
||
const set = cmd === "playlist_import_youtube";
|
||
const format = set ? "youtube" : /spotify/.test(url) ? "spotify" : /apple/.test(url) ? "apple-music" : /tidal/.test(url) ? "tidal" : /deezer/.test(url) ? "deezer" : /youtu/.test(url) ? "youtube-playlist" : null;
|
||
if (!format) return Promise.reject(new Error("That link is not a playlist SoulStream can read."));
|
||
const lines = set
|
||
? ["00:00 Daft Punk - One More Time", "05:20 Daft Punk - Aerodynamic", "09:00 ID - ID", "12:10 Aphex Twin - Track 1"]
|
||
: ["Daft Punk - One More Time", "Daft Punk - Aerodynamic", "Daft Punk - Digital Love", "Aphex Twin - Track 1", "ID - ID"];
|
||
const entries = impParse(lines.join("\n"));
|
||
return new Promise((r) => {
|
||
let done = 0;
|
||
const total = entries.length;
|
||
const tick = setInterval(() => {
|
||
done++;
|
||
mockEmit("playlist:link-progress", { done, total, waiting: false });
|
||
if (done >= total) {
|
||
clearInterval(tick);
|
||
setTimeout(() => r(impDraft(entries,
|
||
set ? "Mock Channel — Boiler Room 2019" : "Mock Curator — Late Night Mix", format,
|
||
set ? "Boiler Room 2019 (live)" : "Late Night Mix",
|
||
{ cover_url: svgArt(280, "M"), warnings: set ? ["Tracklist from the video's chapters."] : [] })), 200);
|
||
}
|
||
}, 180);
|
||
});
|
||
}
|
||
if (cmd === "playlist_import_commit") {
|
||
const name = String(args?.name || "").trim();
|
||
if (!name) return Promise.reject(new Error("The playlist needs a name"));
|
||
const p = { id: ++plSeq, name, track_count: 0, description: args?.description ?? null, kind: args?.kind === "setlist" ? "setlist" : "manual", pinned: false, cover_path: args?.coverUrl ? "mock://cover" : null, created_at: Math.floor(Date.now() / 1000), updated_at: 0, revision: 0, duration_secs: 0, unresolved_count: 0 };
|
||
PL.push(p);
|
||
PL_ITEMS[p.id] = (args?.entries || []).map((e, i) => ({
|
||
id: ++plItemSeq, position: i, title: e.title, artist: e.artist, album: e.album ?? null, duration_secs: e.duration_secs ?? null,
|
||
path: e.path ?? null, art_path: e.art_path ?? null,
|
||
kind: e.path ? "local" : e.resolution === "unresolved" ? "unresolved" : "stream",
|
||
source_line: e.raw ?? null, start_ms: e.start_ms ?? null, note: null,
|
||
track_id: e.track_id != null ? String(e.track_id) : null, art_url: e.art_url ?? null, missing: false,
|
||
}));
|
||
plTouch(p);
|
||
return Promise.resolve(p.id);
|
||
}
|
||
if (cmd === "catalog_search_tracks") {
|
||
const words = String(args?.query || "").toLowerCase().replace(/\b(artist|track|album):/g, " ").replace(/["']/g, " ").split(/\s+/).filter(Boolean);
|
||
const hits = CAT_TRACKS.filter((t) => { const hay = (t.artist + " " + t.title + " " + t.album).toLowerCase(); return words.every((w) => hay.includes(w)); });
|
||
return new Promise((r) => setTimeout(() => r(hits.slice(0, args?.limit || 10)), 90));
|
||
}
|
||
if (cmd === "catalog_track_by_isrc") return Promise.resolve(null);
|
||
if (cmd === "catalog_artist_track_candidates") return Promise.resolve([]);
|
||
return undefined;
|
||
}
|
||
|
||
const enabled = (() => {
|
||
try {
|
||
return localStorage.getItem("mockLibrary") === "1";
|
||
} catch {
|
||
return false;
|
||
}
|
||
})();
|
||
|
||
/** Returns a Promise for mocked commands, or undefined to pass through. */
|
||
export function mockInvoke(cmd, args) {
|
||
if (!enabled) return undefined;
|
||
if (cmd === "app_status") return Promise.resolve({ version: "mock", connected_as: null });
|
||
if (cmd === "library_albums") return Promise.resolve(albums);
|
||
if (cmd === "library_album_stats") return Promise.resolve(albums.map(statsFor));
|
||
if (cmd === "recent_albums") return Promise.resolve([]);
|
||
if (cmd === "album_tracks")
|
||
return new Promise((r) => setTimeout(() => r(tracksFor(args.albumId)), 60));
|
||
if (cmd === "album_art") {
|
||
const m = /(\d+)$/.exec(args.path || "");
|
||
const num = m ? +m[1] : 0;
|
||
return Promise.resolve(svgArt((num * 47) % 360, String.fromCharCode(65 + (num % 26))));
|
||
}
|
||
{ const pl = mockPlaylists(cmd, args) ?? mockImport(cmd, args); if (pl) return pl; }
|
||
// The track index, as the library crate shapes it (TrackIndexRow) — the
|
||
// phone's Tracks tab and its library search read these.
|
||
if (cmd === "library_track_index" || cmd === "library_track_search") {
|
||
const rows = albums.flatMap((a) =>
|
||
tracksFor(a.id).map((t) => ({
|
||
title: t.title, artist: a.artist, album: a.title,
|
||
duration_secs: t.duration_secs, path: t.path, art_path: a.art_path,
|
||
})));
|
||
if (cmd === "library_track_index") return Promise.resolve(rows);
|
||
const words = String(args?.query || "").toLowerCase().split(/\s+/).filter(Boolean);
|
||
return Promise.resolve(words.length
|
||
? rows.filter((r) => { const hay = (r.artist + r.title + r.album).toLowerCase(); return words.every((w) => hay.includes(w)); }).slice(0, 40)
|
||
: []);
|
||
}
|
||
const r = mockCatalog(cmd, args) ?? mockNet(cmd, args);
|
||
if (r === undefined) console.warn("[mock] no mock for " + cmd + " — the real IPC is not here");
|
||
return r;
|
||
}
|
||
|
||
/* ── the fake network ───────────────────────────────────────────────────
|
||
A Soulseek behind the phone's screens: a stored account that signs in at
|
||
boot, a room list, rooms with a few lines in them, a search answered by
|
||
five peers, and a stream that arrives over twelve seconds with its
|
||
waveform growing — enough to walk Search, Community and the rising lane
|
||
in a plain browser. Events go out over the bus below; the harness can
|
||
fire its own through window.__ssMock.emit. */
|
||
const bus = new Map();
|
||
/** Returns a Promise<unlisten> for a mocked page, or undefined to pass through. */
|
||
export function mockListen(event, cb) {
|
||
if (!enabled) return undefined;
|
||
let set = bus.get(event);
|
||
if (!set) { set = new Set(); bus.set(event, set); }
|
||
set.add(cb);
|
||
return Promise.resolve(() => { set.delete(cb); });
|
||
}
|
||
export function mockEmit(event, payload) {
|
||
for (const cb of bus.get(event) || []) {
|
||
try { cb({ event, payload }); } catch (e) { console.error(e); }
|
||
}
|
||
}
|
||
const later = (ms, fn) => setTimeout(fn, ms);
|
||
if (enabled && typeof window !== "undefined") window.__ssMock = { emit: mockEmit };
|
||
|
||
const MOCK_USER = "mockuser";
|
||
const ROOMS = [["Lounge", 412], ["Electronic", 98], ["Vinyl Rips", 37], ["Jazz", 61], ["Lossless Only", 23], ...Array.from({ length: 90 }, (_, i) => ["Room " + (i + 1), 90 - i])];
|
||
const PEOPLE = ["ada", "bo", "cy", "dee"];
|
||
const LINES = ["anyone got the 2024 remaster?", "the vinyl rip is warmer imo", "that folder is complete btw", "brb"];
|
||
const PEERS = [
|
||
{ username: "hifi_hank", slot: true, speed: 2400, queue: 0, ext: "flac", depth: 24, rate: 96000 },
|
||
{ username: "dusty_crates", slot: true, speed: 900, queue: 0, ext: "flac", depth: 16, rate: 44100 },
|
||
{ username: "bo", slot: false, speed: 1500, queue: 3, ext: "flac", depth: 16, rate: 44100 },
|
||
{ username: "mp3_marge", slot: true, speed: 500, queue: 0, ext: "mp3", bitrate: 320 },
|
||
{ username: "slowpoke", slot: false, speed: 60, queue: 12, ext: "mp3", bitrate: 192 },
|
||
];
|
||
const TITLES = ["One More Time", "Aerodynamic", "Digital Love", "Harder, Better, Faster, Stronger", "Crescendolls", "Nightvision", "Superheroes", "High Life", "Something About Us", "Voyager", "Veridis Quo", "Short Circuit", "Face to Face", "Too Long"];
|
||
function peerResponse(token, peer, query) {
|
||
const artist = /daft/i.test(query) ? "Daft Punk" : (query.split(/\s+/)[0] || "Various");
|
||
const album = /discovery/i.test(query) ? "Discovery" : "Homework";
|
||
const folder = "@@shared\\Music\\" + artist + "\\" + artist + " - 2001 - " + album + (peer.ext === "flac" ? " [FLAC]" : " [MP3 " + peer.bitrate + "]");
|
||
const files = TITLES.map((t, i) => {
|
||
const name = String(i + 1).padStart(2, "0") + " - " + t + "." + peer.ext;
|
||
const duration = 200 + ((i * 37) % 180);
|
||
const size = peer.ext === "flac" ? Math.round(duration * (peer.depth === 24 ? 320000 : 110000)) : Math.round(duration * peer.bitrate * 125);
|
||
return { path: folder + "\\" + name, name, folder, size, extension: peer.ext, bitrate: peer.bitrate ?? null, duration, sample_rate: peer.rate ?? 44100, bit_depth: peer.depth ?? null, vbr: false };
|
||
});
|
||
return { token, username: peer.username, has_free_upload_slot: peer.slot, upload_speed: peer.speed, queue_length: peer.queue, files };
|
||
}
|
||
|
||
/* the catalog: one artist, two records, the songs of one of them */
|
||
const CAT_ARTIST = { id: 27, name: "Daft Punk", picture: svgArt(210, "DP"), album_count: 4, fans: 4200000 };
|
||
const CAT_NAMESAKE = { id: 28, name: "Daft Punk", picture: "https://cdn-images.dzcdn.net/images/artist/d41d8cd98f00b204e9800998ecf8427e/1000x1000-000000-80-0-0.jpg", album_count: 1, fans: 120 };
|
||
const CAT_ALBUMS = [
|
||
{ id: 302127, title: "Discovery", artist: "Daft Punk", cover: null, release_date: "2001-03-12", record_type: "album", track_count: 14, edition_group: 302127, is_soundtrack: false },
|
||
{ id: 302128, title: "Homework", artist: "Daft Punk", cover: null, release_date: "1997-01-20", record_type: "album", track_count: 16, edition_group: null, is_soundtrack: false },
|
||
];
|
||
const CAT_TRACKS = TITLES.map((t, i) => ({
|
||
id: 3135550 + i, title: t, artist: "Daft Punk", artist_id: 27, rank: 900000 - i * 1000,
|
||
album: "Discovery", album_id: 302127, cover: null, duration_secs: 200 + ((i * 37) % 180), preview: null, track_no: i + 1,
|
||
}));
|
||
const CAT_DISCO = [
|
||
...CAT_ALBUMS,
|
||
{ id: 302129, title: "Discovery (Deluxe Edition)", artist: "Daft Punk", cover: null, release_date: "2001-03-12", record_type: "album", track_count: 16, edition_group: 302127, is_soundtrack: false },
|
||
{ id: 302130, title: "Alive 1997", artist: "Daft Punk", cover: null, release_date: "2001-10-01", record_type: "album", track_count: 1, edition_group: null, is_soundtrack: false },
|
||
{ id: 302131, title: "One More Time", artist: "Daft Punk", cover: null, release_date: "2000-11-13", record_type: "single", track_count: 2, edition_group: null, is_soundtrack: false },
|
||
{ id: 302132, title: "Musique Vol. 1 1993–2005", artist: "Daft Punk", cover: null, release_date: "2006-04-03", record_type: "compile", track_count: 15, edition_group: null, is_soundtrack: false },
|
||
{ id: 302133, title: "TRON: Legacy (Original Motion Picture Soundtrack)", artist: "Daft Punk", cover: null, release_date: "2010-12-03", record_type: "album", track_count: 22, edition_group: null, is_soundtrack: true },
|
||
{ id: 302134, title: "Human After All", artist: "Daft Punk", cover: null, release_date: "2005-03-14", record_type: "album", track_count: 10, edition_group: null, is_soundtrack: false },
|
||
{ id: 302135, title: "Random Access Memories", artist: "Daft Punk", cover: null, release_date: "2013-05-17", record_type: "album", track_count: 13, edition_group: null, is_soundtrack: false },
|
||
];
|
||
/* the follows feed needs covers and dates near today */
|
||
const dayIso = (offset) => new Date(Date.now() + offset * 86400000).toISOString().slice(0, 10);
|
||
for (const a of CAT_DISCO) a.cover = svgArt((a.id * 31) % 360, a.title[0]);
|
||
CAT_DISCO.push(
|
||
{ id: 302136, title: "Infinity Repeating", artist: "Daft Punk", cover: svgArt(300, "I"), release_date: dayIso(-9), record_type: "single", track_count: 2, edition_group: null, is_soundtrack: false },
|
||
{ id: 302137, title: "Alive 2027", artist: "Daft Punk", cover: svgArt(120, "A"), release_date: dayIso(21), record_type: "album", track_count: 12, edition_group: null, is_soundtrack: false },
|
||
);
|
||
const NEWS = [
|
||
{ title: "Daft Punk announce Alive 2027 — a live record from the reunion shows", url: "https://example.com/news/1", source: "Pitchfork", date: Math.floor(Date.now() / 1000) - 3600 * 5 },
|
||
{ title: "Infinity Repeating gets a video", url: "https://example.com/news/2", source: "NME", date: Math.floor(Date.now() / 1000) - 86400 * 2 },
|
||
];
|
||
const RELATED = [["Justice", 411], ["Air", 412], ["Kavinsky", 413], ["Chemical Brothers", 414]].map(([name, id], i) => ({ id, name, picture: i % 2 ? null : svgArt(40 + id, name[0]), album_count: 3, fans: 900000 - id * 100 }));
|
||
let follows = [];
|
||
function mockCatalog(cmd, args) {
|
||
if (cmd === "catalog_artist") {
|
||
const id = Number(args?.artistId);
|
||
const a = id === CAT_ARTIST.id ? CAT_ARTIST : RELATED.find((r) => r.id === id);
|
||
if (!a) return Promise.reject(new Error("catalog_artist: unknown artist"));
|
||
const own = a === CAT_ARTIST;
|
||
return new Promise((r) => setTimeout(() => r({
|
||
artist: a, banner: null, banners: [], banner_custom: false, logos: [],
|
||
popular_tracks: own ? CAT_TRACKS.slice(0, 7).map((t, i) => ({ ...t, listeners: 2_400_000 - i * 310_000 })) : [],
|
||
albums: own ? CAT_DISCO : [{ id: 900 + id, title: a.name + " Live", artist: a.name, cover: null, release_date: "2015-01-01", record_type: "album", track_count: 9, edition_group: null, is_soundtrack: false }],
|
||
top_genre: "Electronic", socials: [],
|
||
}), 220));
|
||
}
|
||
if (cmd === "catalog_artist_socials") {
|
||
const id = Number(args?.artistId);
|
||
return new Promise((r) => setTimeout(() => r(id === CAT_ARTIST.id ? [{ kind: "website", url: "https://example.com" }, { kind: "wikipedia", url: "https://en.wikipedia.org/wiki/Daft_Punk" }] : []), 300));
|
||
}
|
||
if (cmd === "home_warm") return Promise.resolve();
|
||
if (cmd === "home_artist_news") return new Promise((r) => setTimeout(() => r(Object.fromEntries((args?.artists || []).map((n) => [n, /daft/i.test(n) ? NEWS : []]))), 150));
|
||
if (cmd === "catalog_artist_identity") return Promise.resolve({ artist_id: Number(args?.artistId), albums: [], socials: [], type_hints: [], owned_album_ids: [] });
|
||
if (cmd === "catalog_artist_profile") return Promise.resolve({ listeners: 3_100_000, plays: 210_000_000, tags: ["electronic", "house", "french house", "dance"] });
|
||
if (cmd === "catalog_artist_bio") return Promise.resolve("Daft Punk were a French electronic music duo formed in Paris in 1993 by Guy-Manuel de Homem-Christo and Thomas Bangalter. They achieved popularity in the late 1990s as part of the French house movement.");
|
||
if (cmd === "catalog_related") return Promise.resolve(RELATED);
|
||
if (cmd === "artist_play_stats") return Promise.resolve({ seconds: 7380, last_played_at: Math.floor(Date.now() / 1000) - 86400, top_title: "Track 1", top_count: 12 });
|
||
if (cmd === "followed_artists") return Promise.resolve(follows.slice());
|
||
if (cmd === "follow_artist") { follows.push({ artist_id: args?.artistId, name: args?.name, portrait: args?.portrait ?? null, followed_at: Date.now() / 1000 }); later(30, () => mockEmit("soulseek:follows-changed", {})); return Promise.resolve(); }
|
||
if (cmd === "unfollow_artist") { follows = follows.filter((f) => f.artist_id !== args?.artistId); later(30, () => mockEmit("soulseek:follows-changed", {})); return Promise.resolve(); }
|
||
if (cmd === "catalog_search") {
|
||
const q = String(args?.query || "").toLowerCase();
|
||
const hit = /daft|discovery|one more|punk/.test(q);
|
||
return new Promise((r) => setTimeout(() => r(hit
|
||
? { artists: [CAT_ARTIST, CAT_NAMESAKE], albums: CAT_ALBUMS, tracks: CAT_TRACKS.slice(0, 8) }
|
||
: { artists: [], albums: [], tracks: [] }), 120));
|
||
}
|
||
if (cmd === "catalog_album") {
|
||
const a = CAT_ALBUMS.find((x) => x.id === Number(args?.albumId));
|
||
if (!a) return Promise.reject(new Error("catalog_album: unknown album"));
|
||
return Promise.resolve({ album: a, tracks: a.id === 302127 ? CAT_TRACKS.map((t, i) => ({ ...t, listeners: 2_400_000 - i * 150_000 })) : [], album_plays: a.id === 302127 ? 310_000_000 : null, album_listeners: a.id === 302127 ? 4_100_000 : null, totals_summed: false });
|
||
}
|
||
if (cmd === "catalog_album_info") {
|
||
const t = String(args?.title || "");
|
||
return new Promise((r) => setTimeout(() => r(/discovery/i.test(t)
|
||
? { description: "Discovery is the second studio album by the French electronic music duo Daft Punk, released on 12 March 2001. It marks a shift from the Chicago house of their debut to a house style more heavily inspired by disco, post-disco, garage house and synth-pop. The album's singles included the worldwide hit One More Time.", genre: "Electronic", label: "Virgin", source: "wikipedia" }
|
||
: null), 150));
|
||
}
|
||
if (cmd === "download_file") {
|
||
const id = ++downloadSeq;
|
||
const total = args?.size || 20e6;
|
||
const name = String(args?.path || "").split("\\").pop();
|
||
const base = { id, username: args?.username, path: args?.path, name, queue_place: null, local_path: null, error: null };
|
||
later(200, () => mockEmit("soulseek:download", { ...base, state: "connecting", received: 0, total }));
|
||
let got = 0;
|
||
const step = setInterval(() => {
|
||
got = Math.min(total, got + total / 8);
|
||
mockEmit("soulseek:download", { ...base, state: got >= total ? "complete" : "transferring", received: Math.round(got), total, local_path: got >= total ? "C:\\mock\\library\\" + name : null });
|
||
if (got >= total) { clearInterval(step); mockEmit("soulseek:library-changed", {}); }
|
||
}, 400 + (id % 3) * 120);
|
||
return Promise.resolve(id);
|
||
}
|
||
if (cmd === "cancel_download") return Promise.resolve();
|
||
if (cmd === "rescan_library") return Promise.resolve();
|
||
if (cmd === "library_art_prewarm") return Promise.resolve();
|
||
if (cmd === "refresh_all_album_art") return new Promise((r) => setTimeout(() => r(140), 300));
|
||
if (cmd === "catalog_art") return Promise.resolve(svgArt(200, "D"));
|
||
if (cmd === "stream_cache_ready") return Promise.resolve(cacheReady.has(args?.path) || (xfer && xfer.path === args?.path && xfer.received >= xfer.total));
|
||
if (cmd === "preload_stream") {
|
||
const path = args?.path;
|
||
cacheSizes.set(path, args?.size || 0);
|
||
if (!cacheReady.has(path) && !preloading.has(path)) { preloading.add(path); later(4000, () => { preloading.delete(path); cacheReady.add(path); }); }
|
||
return Promise.resolve();
|
||
}
|
||
if (cmd === "preload_cached_stream") return Promise.resolve(cacheReady.has(args?.path));
|
||
if (cmd === "excluded_search_phrases") return Promise.resolve([]);
|
||
if (cmd === "get_settings") return Promise.resolve({ ...mockSettings, stream_source_prefs: { ...mockSettings.stream_source_prefs }, download_source_prefs: { ...mockSettings.download_source_prefs } });
|
||
if (cmd === "set_settings") {
|
||
/* the command's arguments are camelCase; the config's fields are snake_case */
|
||
for (const [k, v] of Object.entries(args || {})) mockSettings[k.replace(/[A-Z]/g, (m) => "_" + m.toLowerCase())] = v;
|
||
return Promise.resolve();
|
||
}
|
||
if (cmd === "plugin:storage|pick_folder") return Promise.resolve({ uri: "content://mock/tree/primary%3AMusic", path: "C:\\mock\\Music" });
|
||
if (cmd === "plugin:storage|all_files_access") return Promise.resolve({ granted: true });
|
||
if (cmd === "plugin:storage|request_all_files_access") return Promise.resolve();
|
||
if (cmd === "plugin:storage|pick_image") return Promise.resolve({ path: "C:\\mock\\photo.jpg" });
|
||
if (cmd === "plugin:share|pending_share") {
|
||
/* the harness shares by setting window.__ssMock.share and waking the page */
|
||
const t = (typeof window !== "undefined" && window.__ssMock?.share) || null;
|
||
if (t && window.__ssMock) window.__ssMock.share = null;
|
||
return Promise.resolve({ text: t });
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
let token = 0;
|
||
let downloadSeq = 0;
|
||
const mockSettings = {
|
||
download_dir: "C:\\mock\\Library",
|
||
volume_normalization: true,
|
||
library_layout: "{artist}\\{album}",
|
||
shared_folders: [],
|
||
upload_slots: 2,
|
||
share_files: 1234,
|
||
word_timing: false,
|
||
replace_embedded_art: true,
|
||
embed_art_max_edge: 1500,
|
||
write_folder_cover: true,
|
||
stream_keep_forever: false,
|
||
stream_retention_days: 30,
|
||
stream_cache_max_mb: 2048,
|
||
stream_source_prefs: { max: "any", min: "any", priority: "quality" },
|
||
download_source_prefs: { max: "any", min: "any", priority: "quality" },
|
||
};
|
||
const cacheReady = new Set(); // paths a preload or a finished stream has fully fetched
|
||
const cacheSizes = new Map(); // path → the file's size, for stream_cache_len
|
||
const preloading = new Set();
|
||
let xfer = null; // the one fake transfer: { received, total, timer }
|
||
let posTimer = null; // the fake engine's position ticks
|
||
function mockNet(cmd, args) {
|
||
if (cmd === "get_credentials") return Promise.resolve({ username: MOCK_USER, has_password: true });
|
||
if (cmd === "connect_stored" || cmd === "connect_and_login") {
|
||
later(400, () => mockEmit("soulseek:state", { kind: "connected", username: MOCK_USER }));
|
||
return Promise.resolve();
|
||
}
|
||
if (cmd === "disconnect") { later(50, () => mockEmit("soulseek:state", { kind: "disconnected" })); return Promise.resolve(); }
|
||
if (cmd === "forget_credentials") return Promise.resolve();
|
||
if (cmd === "buddy_list") return Promise.resolve(["ada", "cy"]);
|
||
if (cmd === "buddy_add") return Promise.resolve(["ada", "cy", args?.username].filter(Boolean));
|
||
if (cmd === "buddy_remove") return Promise.resolve(["ada", "cy"].filter((u) => u !== args?.username));
|
||
if (cmd === "slsk_room_list") {
|
||
later(200, () => mockEmit("soulseek:room-list", ROOMS.map(([name, users]) => ({ name, users }))));
|
||
return Promise.resolve();
|
||
}
|
||
if (cmd === "slsk_join_room") {
|
||
const room = args?.room;
|
||
later(250, () => mockEmit("soulseek:room-joined", { room, users: [...PEOPLE, MOCK_USER].map((username) => ({ username })) }));
|
||
LINES.forEach((message, i) => later(400 + i * 350, () => mockEmit("soulseek:room-message", { room, username: PEOPLE[i % PEOPLE.length], message, ts: Math.floor(Date.now() / 1000) })));
|
||
return Promise.resolve();
|
||
}
|
||
if (cmd === "slsk_leave_room") { later(50, () => mockEmit("soulseek:room-left", { room: args?.room })); return Promise.resolve(); }
|
||
if (cmd === "slsk_say_room") {
|
||
later(120, () => mockEmit("soulseek:room-message", { room: args?.room, username: MOCK_USER, message: args?.message, ts: Math.floor(Date.now() / 1000) }));
|
||
return Promise.resolve();
|
||
}
|
||
if (cmd === "slsk_message_user") {
|
||
later(900, () => mockEmit("soulseek:private-message", { username: args?.username, message: "sure, sending", timestamp: Math.floor(Date.now() / 1000) }));
|
||
return Promise.resolve();
|
||
}
|
||
if (cmd === "slsk_browse_user") {
|
||
/* a share: two records and a folder of scans, in the browse DTO's shape */
|
||
const dir = (album, ext, bitrate) => {
|
||
const folder = "@@shared\\Music\\Daft Punk\\Daft Punk - 2001 - " + album + (ext === "flac" ? " [FLAC]" : " [MP3 " + bitrate + "]");
|
||
return {
|
||
name: folder,
|
||
files: TITLES.slice(0, album === "Discovery" ? 14 : 8).map((t, i) => {
|
||
const name = String(i + 1).padStart(2, "0") + " - " + t + "." + ext;
|
||
const duration = 200 + ((i * 37) % 180);
|
||
return { name, path: folder + "\\" + name, size: ext === "flac" ? duration * 110000 : duration * bitrate * 125, ext, bitrate: ext === "flac" ? null : bitrate, duration };
|
||
}),
|
||
};
|
||
};
|
||
return new Promise((r) => setTimeout(() => r([
|
||
dir("Discovery", "flac"),
|
||
dir("Homework", "mp3", 320),
|
||
{ name: "@@shared\\Music\\Scans", files: [{ name: "front.jpg", path: "@@shared\\Music\\Scans\\front.jpg", size: 1200000, ext: "jpg", bitrate: null, duration: null }] },
|
||
]), 600));
|
||
}
|
||
if (cmd === "start_search") {
|
||
const t = ++token;
|
||
const q = String(args?.query || "");
|
||
later(300, () => mockEmit("soulseek:search-results", [peerResponse(t, PEERS[1], q)]));
|
||
later(700, () => mockEmit("soulseek:search-results", [peerResponse(t, PEERS[0], q), peerResponse(t, PEERS[3], q)]));
|
||
later(1300, () => mockEmit("soulseek:search-results", [peerResponse(t, PEERS[2], q), peerResponse(t, PEERS[4], q)]));
|
||
return Promise.resolve(t);
|
||
}
|
||
if (cmd === "stop_search") return Promise.resolve();
|
||
if (cmd === "player_state") return Promise.resolve({ status: "idle", position: 0 });
|
||
if (cmd === "preload_local") return Promise.resolve();
|
||
if (cmd === "waveform") {
|
||
const n = args?.buckets || 900;
|
||
return new Promise((r) => setTimeout(() => r(Array.from({ length: n }, (_, i) => 0.2 + 0.75 * Math.abs(Math.sin(i * 0.23) * Math.cos(i * 0.017)))), 120));
|
||
}
|
||
if (cmd === "lyrics_get") return new Promise((r) => setTimeout(() => r(mockLyrics(args?.title)), 150));
|
||
if (cmd === "lyrics_search") return new Promise((r) => setTimeout(() => r([
|
||
{ id: 901, title: args?.title || "Track", artist: args?.artist || "?", album: "The Mock Sessions", synced: true, instrumental: false, durationSecs: 214 },
|
||
{ id: 902, title: (args?.title || "Track") + " (Live)", artist: args?.artist || "?", album: null, synced: false, instrumental: false, durationSecs: 233 },
|
||
]), 200));
|
||
if (cmd === "lyrics_select") return Promise.resolve({ ...mockLyrics(args?.title), source: "lrclib #" + args?.candidateId });
|
||
if (cmd === "lyrics_set_offset") return Promise.resolve();
|
||
if (cmd === "lyrics_clear") return Promise.resolve();
|
||
if (cmd === "clear_play_history") return Promise.resolve();
|
||
if (cmd === "refresh_album_art") return new Promise((r) => setTimeout(() => r("C:\\mock\\cover.jpg"), 200));
|
||
if (cmd === "play_local") {
|
||
/* a file: the engine reports playing after a beat, then the position
|
||
from where it was asked to start */
|
||
if (xfer) { clearInterval(xfer.timer); xfer = null; }
|
||
clearInterval(posTimer);
|
||
const duration = args?.duration || 240;
|
||
let secs = Number(args?.startSecs) || 0;
|
||
later(300, () => {
|
||
mockEmit("soulseek:player", { kind: "playing" });
|
||
posTimer = setInterval(() => { secs += 0.5; mockEmit("soulseek:player", { kind: "position", secs, duration }); }, 500);
|
||
});
|
||
return Promise.resolve();
|
||
}
|
||
if (cmd === "play_stream") {
|
||
if (xfer) clearInterval(xfer.timer);
|
||
clearInterval(posTimer);
|
||
const total = args?.size || 30e6;
|
||
const duration = args?.duration || 240;
|
||
xfer = { received: 0, total, timer: null, path: args?.path };
|
||
cacheSizes.set(args?.path, total);
|
||
xfer.timer = setInterval(() => {
|
||
xfer.received = Math.min(total, xfer.received + total / 30);
|
||
const done = xfer.received >= total;
|
||
mockEmit("soulseek:stream", { received: Math.round(xfer.received), total, done, failed: null });
|
||
if (done) { clearInterval(xfer.timer); cacheReady.add(xfer.path); }
|
||
}, 400);
|
||
let secs = 0;
|
||
later(1200, () => {
|
||
mockEmit("soulseek:player", { kind: "playing" });
|
||
posTimer = setInterval(() => { secs += 0.5; mockEmit("soulseek:player", { kind: "position", secs, duration }); }, 500);
|
||
});
|
||
return Promise.resolve();
|
||
}
|
||
if (cmd === "stream_waveform") {
|
||
const coverage = xfer ? xfer.received / xfer.total : 0;
|
||
const n = Math.round((args?.buckets || 900) * coverage);
|
||
const peaks = Array.from({ length: n }, (_, i) => 0.25 + 0.7 * Math.abs(Math.sin(i * 0.37) * Math.cos(i * 0.011)));
|
||
return Promise.resolve({ coverage, peaks });
|
||
}
|
||
if (cmd === "stream_cache_len") return Promise.resolve(cacheReady.has(args?.path) ? (cacheSizes.get(args.path) || 0) : (xfer && xfer.path === args?.path ? Math.round(xfer.received) : 0));
|
||
if (cmd === "save_to_library") {
|
||
/* a byte-complete cache files at once; anything else goes to the download pipeline */
|
||
let organized = 0, queued = 0;
|
||
for (const f of args?.files || []) {
|
||
if (cacheReady.has(f.path)) { organized++; continue; }
|
||
queued++;
|
||
mockInvoke("download_file", { username: f.username, path: f.path, size: f.size });
|
||
}
|
||
if (organized) later(30, () => mockEmit("soulseek:library-changed", {}));
|
||
return Promise.resolve([organized, queued]);
|
||
}
|
||
if (cmd === "stream_embedded_art") return Promise.resolve(xfer && xfer.received > 200000 ? svgArt(200, "D") : null);
|
||
if (cmd === "record_play") return Promise.resolve(1);
|
||
if (cmd === "add_play_seconds") return Promise.resolve();
|
||
return undefined;
|
||
}
|