android: playlists, the import doors, the share sheet, You → Sources — and three bugs the walks found
The desktop's two playlist pages on the phone: the shelf (pinned first, mosaic covers, badges, New playlist, Import) and the page (kind, Pinned chip, description, the meta line's warnings, Play / Shuffle / more, a filter, rows tagged unresolved / set audio / file missing). Sheets for a card, the page's overflow, a row, pick-a-playlist, a prompt and a confirm; Add to playlist on every track and record sheet; a removal undoable for seven seconds from the neutral toast (notice.svelte.js); stream rows play through the resolver as path-less entries, a set slice starts and ends at its cues; Add to library / Download all tracks with the desktop's bulk rules; nav.replace for a review that became the thing it made. The import doors that need no file — a link (YouTube · Spotify · Apple Music · TIDAL · Deezer), a pasted tracklist, a YouTube DJ set — with the review screen and the desktop's catalog verification pass. A share plugin crate (crates/tauri-plugin-share): Share → SoulStream from any app keeps the text in a ShareActivity and the WebView hands it to the import when it comes back to the front. You → Sources: the source-quality window and the priority per mode. Found by the walks and fixed: set_settings takes camelCase arguments, so the phone's normalization and library-folder saves never landed; Cover.svelte's non-eager wrapper has no layout box, so its intersection observer never fired and row thumbnails never loaded (the Artists tab too); a history push while a history.go was still in flight left the nav mirror one entry ahead and the page blank after a confirmed delete — the mirror now applies once per microtask and never pushes during a pending traversal. The harness names unmocked commands, carries console warnings' frames (the smoke bundle is built unminified), probes the history mirror on every step, mocks app_status / play_local / waveform / lyrics_get / player_state and applies settings patches, runs adb steps on the phone (the share intent), and walks playlists, the imports, the share and the settings on both the mock and the phone. ANDROID.md documents all of it.
This commit is contained in:
1 parent
bb828ff2dc
commit
6e39dc43f3
38 files changed
+2383
-88
No files matched your search
+253
-7
@@ -84,6 +84,199 @@ function tracksFor(albumId) {
|
||||
}));
|
||||
}
|
||||
|
||||
/* ── 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: null, 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));
|
||||
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.id === 1 || p.cover_path) ? svgArt(p.id === 1 ? 330 : 280, p.id === 1 ? "L" : "M") : null); }
|
||||
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));
|
||||
}
|
||||
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";
|
||||
@@ -95,6 +288,7 @@ const enabled = (() => {
|
||||
/** 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([]);
|
||||
@@ -105,7 +299,7 @@ export function mockInvoke(cmd, args) {
|
||||
const num = m ? +m[1] : 0;
|
||||
return Promise.resolve(svgArt((num * 47) % 360, String.fromCharCode(65 + (num % 26))));
|
||||
}
|
||||
if (cmd === "list_playlists") return Promise.resolve([]);
|
||||
{ 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") {
|
||||
@@ -120,7 +314,9 @@ export function mockInvoke(cmd, args) {
|
||||
? rows.filter((r) => { const hay = (r.artist + r.title + r.album).toLowerCase(); return words.every((w) => hay.includes(w)); }).slice(0, 40)
|
||||
: []);
|
||||
}
|
||||
return mockCatalog(cmd, args) ?? mockNet(cmd, args);
|
||||
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 ───────────────────────────────────────────────────
|
||||
@@ -270,22 +466,40 @@ function mockCatalog(cmd, args) {
|
||||
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({ download_dir: "C:\\mock\\Library" });
|
||||
if (cmd === "set_settings") 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: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 cacheReady = new Set(); // paths a preload has fully fetched
|
||||
const mockSettings = {
|
||||
download_dir: "C:\\mock\\Library",
|
||||
volume_normalization: true,
|
||||
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
|
||||
@@ -328,17 +542,38 @@ function mockNet(cmd, args) {
|
||||
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 Promise.resolve(null);
|
||||
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);
|
||||
if (done) { clearInterval(xfer.timer); cacheReady.add(xfer.path); }
|
||||
}, 400);
|
||||
let secs = 0;
|
||||
later(1200, () => {
|
||||
@@ -353,7 +588,18 @@ function mockNet(cmd, args) {
|
||||
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(xfer ? Math.round(xfer.received) : 0);
|
||||
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();
|
||||
|
||||
Reference in new issue
Block a user