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
@@ -497,6 +497,9 @@ export const rescanLibrary = () => invoke("rescan_library");
|
||||
export const pickLibraryFolder = () => invoke("plugin:storage|pick_folder");
|
||||
export const allFilesAccess = () => invoke("plugin:storage|all_files_access");
|
||||
export const requestAllFilesAccess = () => invoke("plugin:storage|request_all_files_access");
|
||||
// The phone's share plugin (crates/tauri-plugin-share): what another app
|
||||
// shared to SoulStream, { text } — read once.
|
||||
export const pendingShare = () => invoke("plugin:share|pending_share");
|
||||
// Download a found album copy into (Cache)\_audition\<rel>\ for the version
|
||||
// comparison. Called track 1 first, then the rest — `startIndex` keeps the
|
||||
// second batch's indices continuing, since index 0 means "track 1".
|
||||
|
||||
+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();
|
||||
|
||||
+199
-1
@@ -9,8 +9,10 @@
|
||||
import { nav, closeHalf, push, top, patch, go, back, openHalf, openSheet } from "./nav.svelte.js";
|
||||
import { player, playNext, enqueue, setSleep, sleepLabel } from "./player.svelte.js";
|
||||
import { streamSong, saveSong } from "./resolve.svelte.js";
|
||||
import { pls, bulk, bulkable, runBulk, coverFor, queueable, entryOf, playFrom, shufflePlay, togglePin, renamePlaylist, describePlaylist, duplicatePlaylist, deletePlaylist, removeItems, addEntries, createPlaylist } from "./playlists.svelte.js";
|
||||
import { DOORS, start as startImport } from "./importer.svelte.js";
|
||||
import { slsk, refreshRooms, disconnect, openThread, addBuddy, removeBuddy, leaveRoom } from "./slsk.svelte.js";
|
||||
import { albumById, albumsBy, tracksOf, loadLibrary } from "./lib.svelte.js";
|
||||
import { lib, albumById, albumsBy, tracksOf, loadLibrary } from "./lib.svelte.js";
|
||||
import { fmtOf, albumFmt, fmtBytes, basename, dirname, longDur } from "./util.js";
|
||||
|
||||
const SORTS = [["added", "Recently added"], ["az", "A–Z"], ["artist", "By artist"], ["year", "By year"]];
|
||||
@@ -26,6 +28,7 @@
|
||||
const t = top();
|
||||
if (t.n === "album") return "album-more";
|
||||
if (t.n === "library") return "library-more";
|
||||
if (t.n === "playlist") return "playlist-more";
|
||||
return "home-more";
|
||||
});
|
||||
|
||||
@@ -51,6 +54,94 @@
|
||||
const onThisAlbum = $derived((id) => top().n === "album" && top().c.id === id);
|
||||
const after = $derived(Math.max(0, player.queue.length - player.qi - 1));
|
||||
|
||||
/* ── playlists: the shelf card's sheet, the page's overflow (the bar's
|
||||
More resolves to it), a row's sheet, the pick-a-playlist sheet, and
|
||||
the prompt / confirm sheets they open ── */
|
||||
const plArg = $derived.by(() => {
|
||||
if (kind === "playlist") {
|
||||
const id = shown?.arg?.playlist?.id;
|
||||
if (id == null) return null;
|
||||
return { playlist: lib.playlists.find((p) => p.id === id) || shown.arg.playlist, items: null };
|
||||
}
|
||||
if (kind !== "playlist-more") return null;
|
||||
const id = shown?.arg?.playlist?.id ?? top().c.id;
|
||||
const p = lib.playlists.find((x) => x.id === id);
|
||||
if (!p) return null;
|
||||
return { playlist: p, items: pls.current?.id === p.id ? pls.current.items : (shown?.arg?.items || []) };
|
||||
});
|
||||
const oops = (e) => { player.error = String(e); };
|
||||
async function playPl(p, shuffle = false) {
|
||||
try {
|
||||
const items = (await api.playlistItems(p.id)) || [];
|
||||
if (!(shuffle ? shufflePlay(p, items) : playFrom(p, items))) player.error = "Nothing in “" + p.name + "” can play yet.";
|
||||
} catch (e) { oops(e); }
|
||||
}
|
||||
function confirmDelete(p) {
|
||||
openHalf("confirm", {
|
||||
title: "Delete “" + p.name + "”?",
|
||||
body: (p.track_count ? "Its " + p.track_count + " row" + (p.track_count === 1 ? "" : "s") + " go with it. " : "") + "The files stay in your library.",
|
||||
ok: "Delete", danger: true,
|
||||
onconfirm: async () => {
|
||||
try {
|
||||
if (top().n === "playlist" && top().c.id === p.id) back();
|
||||
await deletePlaylist(p);
|
||||
} catch (e) { oops(e); }
|
||||
},
|
||||
});
|
||||
}
|
||||
/* the prompts take the playlist as a VALUE: a {@const} of the sheet's
|
||||
block is gone by the time the prompt submits (the sheet switched
|
||||
kinds), and reading it then is a stale derived */
|
||||
function renamePrompt(p) {
|
||||
openHalf("prompt", { title: "Rename playlist", value: p.name, placeholder: "Name", ok: "Rename", onsubmit: (v) => renamePlaylist(p, v).catch(oops) });
|
||||
}
|
||||
function describePrompt(p) {
|
||||
openHalf("prompt", { title: "Description", value: p.description || "", placeholder: "What this playlist is", ok: "Save", multiline: true, allowEmpty: true, onsubmit: (v) => describePlaylist(p, v).catch(oops) });
|
||||
}
|
||||
function newPlaylistThenAdd(entries) {
|
||||
openHalf("prompt", {
|
||||
title: "New playlist", placeholder: "Name", value: "New Playlist " + (lib.playlists.length + 1), ok: "Create",
|
||||
onsubmit: async (name) => {
|
||||
try {
|
||||
const id = await createPlaylist(name);
|
||||
await addEntries(lib.playlists.find((x) => x.id === id) || { id, name }, entries);
|
||||
} catch (e) { oops(e); }
|
||||
},
|
||||
});
|
||||
}
|
||||
/* the import doors: a prompt for the link or the text, then the review
|
||||
screen while the backend reads and resolves */
|
||||
function importPrompt(door) {
|
||||
const d = DOORS.find((x) => x[0] === door);
|
||||
openHalf("prompt", {
|
||||
title: d[2].replace(/…$/, ""),
|
||||
ok: "Import",
|
||||
multiline: door === "text",
|
||||
placeholder: door === "text" ? "00:00 Bicep - Glue\n05:30 Overmono - So U Kno\nFour Tet - Baby\n…or pasted track links"
|
||||
: door === "youtube" ? "https://www.youtube.com/watch?v=…"
|
||||
: "https://open.spotify.com/playlist/…",
|
||||
onsubmit: (v) => { push("import", {}); void startImport(door, v); },
|
||||
});
|
||||
}
|
||||
/* the prompt's field, seeded when the prompt opens */
|
||||
let promptVal = $state("");
|
||||
$effect(() => {
|
||||
const h = nav.half;
|
||||
if (h?.kind === "prompt") promptVal = h.arg?.value ?? "";
|
||||
});
|
||||
function submitPrompt() {
|
||||
const a = shown?.arg;
|
||||
if (!a) return;
|
||||
const v = promptVal.trim();
|
||||
if (!v && !a.allowEmpty) return;
|
||||
closeHalf();
|
||||
a.onsubmit?.(v);
|
||||
}
|
||||
function focusLater(node) {
|
||||
const t = setTimeout(() => node.focus(), 280);
|
||||
return { destroy: () => clearTimeout(t) };
|
||||
}
|
||||
|
||||
/* a catalog track (no file on this phone): the resolver plays it, Save
|
||||
fetches it — the desktop's row buttons, on the sheet */
|
||||
const isCatalog = (t) => !!t && !t.path && !!t.catalog;
|
||||
@@ -100,6 +191,7 @@
|
||||
{#if isCatalog(track)}
|
||||
{@render row("play", "Play", "streams from the best source", () => playCatalog(track, album))}
|
||||
{@render row(saveNote.startsWith("Saving") ? "check" : "download", saveNote || "Save to library", saveNote ? "" : "the best copy by your download rules", () => { if (!saveNote) saveTrack(track, album); })}
|
||||
{@render row("playlist", "Add to playlist", "", () => openHalf("pickPlaylist", { entries: [{ t: track, album }] }))}
|
||||
<div class="sheet-hair"></div>
|
||||
{#if album?.catalogId != null}
|
||||
{@render row("disc", "Go to record", album.title, act(() => push("catalogAlbum", { id: album.catalogId, artist: album.artist, title: album.title })))}
|
||||
@@ -110,6 +202,7 @@
|
||||
{:else}
|
||||
{@render row("play", "Play next", "", act(() => playNext([{ t: track, album }])))}
|
||||
{@render row("list-plus", "Add to queue", after ? after + " after this" : "", act(() => enqueue([{ t: track, album }])))}
|
||||
{@render row("playlist", "Add to playlist", "", () => openHalf("pickPlaylist", { entries: [{ t: track, album }] }))}
|
||||
<div class="sheet-hair"></div>
|
||||
{#if album?.id != null && !onThisAlbum(album.id)}
|
||||
{@render row("disc", "Go to album", album.title, act(() => push("album", { id: album.id })))}
|
||||
@@ -137,6 +230,9 @@
|
||||
<div class="sheet-rows">
|
||||
{@render row("play", "Play next", "", act(() => playNext(tracks.map((t) => ({ t, album })))))}
|
||||
{@render row("list-plus", "Add to queue", tracks.length + " tracks", act(() => enqueue(tracks.map((t) => ({ t, album })))))}
|
||||
{#if tracks.length}
|
||||
{@render row("playlist", "Add to playlist", tracks.length + " tracks", () => openHalf("pickPlaylist", { entries: tracks.map((t) => ({ t, album })) }))}
|
||||
{/if}
|
||||
<div class="sheet-hair"></div>
|
||||
{@render row("users", "Go to artist", album.artist, act(() => push("artist", { name: album.artist })))}
|
||||
</div>
|
||||
@@ -145,6 +241,108 @@
|
||||
<div class="kv"><span class="k">Length</span><span class="v">{longDur(tracks.reduce((n, t) => n + (t.duration_secs || 0), 0))}</span></div>
|
||||
{#if tracks[0]?.path}<div class="kv"><span class="k">Folder</span><span class="v mono dim">{dirname(tracks[0].path)}</span></div>{/if}
|
||||
</div>
|
||||
{:else if (kind === "playlist" || kind === "playlist-more") && plArg}
|
||||
{@const p = plArg.playlist}
|
||||
{@const rows = plArg.items || []}
|
||||
{@const unres = rows.filter((i) => i.kind === "unresolved")}
|
||||
<div class="sheet-head">
|
||||
<span class="art">{#if coverFor(p)}<img src={coverFor(p)} alt="" />{:else}<Icon name="playlist" size={22} />{/if}</span>
|
||||
<span class="b"><span class="t">{p.name}</span><span class="m">{p.kind === "setlist" ? "Setlist" : "Playlist"} · {p.track_count} track{p.track_count === 1 ? "" : "s"}{p.duration_secs ? " · " + longDur(p.duration_secs) : ""}{p.pinned ? " · pinned" : ""}</span></span>
|
||||
</div>
|
||||
<div class="sheet-rows">
|
||||
{#if kind === "playlist"}
|
||||
{@render row("play", "Play", "", act(() => playPl(p)))}
|
||||
{@render row("shuffle", "Shuffle", "", act(() => playPl(p, true)))}
|
||||
<div class="sheet-hair"></div>
|
||||
{/if}
|
||||
{@render row("pin", p.pinned ? "Unpin" : "Pin", p.pinned ? "" : "keeps it at the top of the shelf", act(() => togglePin(p).catch(oops)))}
|
||||
{@render row("pencil", "Rename", "", () => renamePrompt(p))}
|
||||
{#if kind === "playlist-more"}
|
||||
{@render row("file-text", p.description ? "Edit description" : "Add a description", p.description || "", () => describePrompt(p))}
|
||||
{/if}
|
||||
{@render row("layers", "Duplicate", "a copy, “" + p.name + " copy”", act(() => duplicatePlaylist(p).then((id) => push("playlist", { id })).catch(oops)))}
|
||||
{#if kind === "playlist-more" && unres.length}
|
||||
{@render row("x", "Remove unresolved entries", unres.length + " that never matched a track", act(() => removeItems(p, rows, unres).catch(oops)))}
|
||||
{/if}
|
||||
{#if kind === "playlist-more" && bulkable(rows) && !bulk.active}
|
||||
<div class="sheet-hair"></div>
|
||||
{@render row("library", "Add to library", "files every already-streamed track — nothing is downloaded", act(() => { void runBulk(p, rows, "cached"); }))}
|
||||
{@render row("download", "Download all tracks", bulkable(rows) + " not in the library yet, by your download rules", act(() => { void runBulk(p, rows, "download"); }))}
|
||||
{/if}
|
||||
<div class="sheet-hair"></div>
|
||||
{@render row("trash", "Delete playlist", "", () => confirmDelete(p))}
|
||||
</div>
|
||||
{:else if kind === "pltrack" && shown?.arg?.item}
|
||||
{@const p = shown.arg.playlist}
|
||||
{@const rows = shown.arg.items || []}
|
||||
{@const item = shown.arg.item}
|
||||
<div class="sheet-head">
|
||||
<span class="art">{#if item.path && item.art_path}<Cover path={item.art_path} eager size={22} />{:else if item.art_url}<img src={item.art_url} alt="" />{:else}<Icon name="music" size={22} />{/if}</span>
|
||||
<span class="b"><span class="t">{item.title}</span><span class="m">{item.artist}{item.album ? " · " + item.album : ""}{item.kind === "unresolved" ? " · unresolved" : item.missing ? " · file missing" : item.kind === "stream" ? " · streams from the network" : ""}</span></span>
|
||||
</div>
|
||||
<div class="sheet-rows">
|
||||
{#if queueable(item)}
|
||||
{@render row("play", "Play from here", "the rest of the playlist follows", act(() => playFrom(p, rows, item)))}
|
||||
{@render row("skip-forward", "Play next", "", act(() => playNext([entryOf(item, p)])))}
|
||||
{@render row("list-plus", "Add to queue", after ? after + " after this" : "", act(() => enqueue([entryOf(item, p)])))}
|
||||
{#if !item.path}
|
||||
{@render row(saveNote.startsWith("Saving") ? "check" : "download", saveNote || "Save to library", saveNote ? "" : "the best copy by your download rules", () => { if (!saveNote) saveTrack({ title: item.title, artist: item.artist }, null); })}
|
||||
{/if}
|
||||
{:else if item.kind === "unresolved"}
|
||||
<div class="sec-note" style="padding: 2px var(--pad) 10px">This row never matched a track{item.source_line ? " — it came in as “" + item.source_line + "”" : ""}. Repair it on the desktop, or remove it.</div>
|
||||
{:else if item.missing}
|
||||
<div class="sec-note" style="padding: 2px var(--pad) 10px">The file is no longer where the library last saw it.</div>
|
||||
{/if}
|
||||
{#if item.artist}
|
||||
{@render row("users", "Go to artist", item.artist, act(() => push("artist", { name: item.artist })))}
|
||||
{/if}
|
||||
<div class="sheet-hair"></div>
|
||||
{@render row("x", "Remove from playlist", "undo for a few seconds after", act(() => removeItems(p, rows, [item]).catch(oops)))}
|
||||
</div>
|
||||
{:else if kind === "pickPlaylist" && shown?.arg?.entries}
|
||||
{@const entries = shown.arg.entries}
|
||||
<div class="sheet-title">Add to playlist <span class="t-sub">· {entries.length === 1 ? entries[0].t.title : entries.length + " tracks"}</span></div>
|
||||
<div class="sheet-rows">
|
||||
{@render row("plus", "New playlist", "", () => newPlaylistThenAdd(entries))}
|
||||
<div class="sheet-hair"></div>
|
||||
{#each lib.playlists as p (p.id)}
|
||||
<button class="row" onclick={act(() => addEntries(p, entries).catch(oops))}>
|
||||
<span class="art">{#if coverFor(p)}<img src={coverFor(p)} alt="" />{:else}<Icon name="playlist" size={18} />{/if}</span>
|
||||
<span class="b"><span class="t">{p.name}</span><span class="m">{p.track_count} track{p.track_count === 1 ? "" : "s"}{p.pinned ? " · pinned" : ""}</span></span>
|
||||
</button>
|
||||
{/each}
|
||||
{#if !lib.playlists.length}<div class="sec-note" style="padding-bottom: 12px">No playlists yet — make one above.</div>{/if}
|
||||
</div>
|
||||
{:else if kind === "prompt" && shown?.arg}
|
||||
{@const a = shown.arg}
|
||||
<div class="sheet-title">{a.title}</div>
|
||||
<div class="prompt">
|
||||
{#if a.multiline}
|
||||
<textarea class="field area" bind:value={promptVal} placeholder={a.placeholder || ""} rows="3" use:focusLater></textarea>
|
||||
{:else}
|
||||
<input class="field" bind:value={promptVal} placeholder={a.placeholder || ""} autocomplete="off" spellcheck="false" enterkeyhint="done" onkeydown={(e) => { if (e.key === "Enter") submitPrompt(); }} use:focusLater />
|
||||
{/if}
|
||||
<div class="prompt-acts">
|
||||
<button class="btn btn-ghost" onclick={closeHalf}>Cancel</button>
|
||||
<button class="btn btn-fill" disabled={!a.allowEmpty && !promptVal.trim()} onclick={submitPrompt}>{a.ok || "OK"}</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if kind === "confirm" && shown?.arg}
|
||||
{@const a = shown.arg}
|
||||
<div class="sheet-title">{a.title}</div>
|
||||
{#if a.body}<div class="sec-note" style="padding-bottom: 12px">{a.body}</div>{/if}
|
||||
<div class="prompt-acts" style="padding: 0 var(--pad) 16px">
|
||||
<button class="btn btn-ghost" onclick={closeHalf}>Cancel</button>
|
||||
<button class="btn btn-fill" class:danger={a.danger} onclick={() => { const f = a.onconfirm; closeHalf(); f?.(); }}>{a.ok || "OK"}</button>
|
||||
</div>
|
||||
{:else if kind === "import"}
|
||||
<div class="sheet-title">Import a playlist</div>
|
||||
<div class="sheet-rows">
|
||||
{#each DOORS as [door, icon, t, m] (door)}
|
||||
{@render row(icon, t, m, () => importPrompt(door))}
|
||||
{/each}
|
||||
</div>
|
||||
<div class="sec-note" style="padding-bottom: 14px">A playlist file or a folder of audio files imports on the desktop, where the files are.</div>
|
||||
{:else if kind === "librarysort"}
|
||||
<div class="sheet-title">Sort & view</div>
|
||||
<div class="sheet-sec">Order</div>
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
import Artist from "./screens/Artist.svelte";
|
||||
import CatalogAlbum from "./screens/CatalogAlbum.svelte";
|
||||
import Playlist from "./screens/Playlist.svelte";
|
||||
import Import from "./screens/Import.svelte";
|
||||
import Community from "./screens/Community.svelte";
|
||||
import Room from "./screens/Room.svelte";
|
||||
import Thread from "./screens/Thread.svelte";
|
||||
@@ -40,6 +41,10 @@
|
||||
import { slsk, bindSlsk, connectStored } from "./slsk.svelte.js";
|
||||
import { net, bindNet } from "./net.svelte.js";
|
||||
import { resolver, streamSong, streamCatalogAlbum } from "./resolve.svelte.js";
|
||||
import { notice, clearNote } from "./notice.svelte.js";
|
||||
import * as pls from "./playlists.svelte.js";
|
||||
import { importer, bindImporter } from "./importer.svelte.js";
|
||||
import { bindShare } from "./share.svelte.js";
|
||||
import * as sources from "../lib/sources.js";
|
||||
import { greeting, drag } from "./util.js";
|
||||
|
||||
@@ -62,6 +67,7 @@
|
||||
case "thread": return { title: e.c.username, kicker: "Chat", flush: true };
|
||||
case "you": return { title: "You", kicker: "You" };
|
||||
case "transfers": return { title: "Transfers", kicker: "Downloads" };
|
||||
case "import": return { title: "Import", kicker: importer.draft?.source_name || "Playlist" };
|
||||
case "album": {
|
||||
const a = albumById(e.c.id);
|
||||
return { title: a?.title ?? "", kicker: a?.artist ?? "", amb: a?.art_path ?? null, flush: true };
|
||||
@@ -79,7 +85,7 @@
|
||||
}
|
||||
case "playlist": {
|
||||
const p = lib.playlists.find((x) => x.id === e.c.id);
|
||||
return { title: p?.name ?? "Playlist", kicker: "Playlist", flush: true };
|
||||
return { title: p?.name ?? "Playlist", kicker: p?.kind === "setlist" ? "Setlist" : "Playlist", flush: true };
|
||||
}
|
||||
default: return { title: e.n, kicker: "" };
|
||||
}
|
||||
@@ -174,9 +180,11 @@
|
||||
watchLibrary();
|
||||
bindTransfers();
|
||||
bindHome();
|
||||
bindImporter();
|
||||
bindShare();
|
||||
/* the probe handle — the desktop has __soulstream for the same reason:
|
||||
tests/mobile/smoke.mjs drives the real app through it */
|
||||
window.__ss = { nav, top, go, push, back, openSheet, closeSheet, openHalf, closeHalf, player, lib, art, slsk, net, resolver, streamSong, streamCatalogAlbum, sources, api, transfers, next, prev, toggle, seekSecs, playQueueAt, patch };
|
||||
window.__ss = { nav, top, go, push, back, openSheet, closeSheet, openHalf, closeHalf, player, lib, art, slsk, net, resolver, streamSong, streamCatalogAlbum, sources, api, transfers, pls, notice, importer, next, prev, toggle, seekSecs, playQueueAt, patch };
|
||||
return () => { if (unlisten) unlisten(); };
|
||||
});
|
||||
</script>
|
||||
@@ -214,6 +222,8 @@
|
||||
<CatalogAlbum id={entry.c.id} artist={entry.c.artist} title={entry.c.title} {screenEl} />
|
||||
{:else if entry.n === "playlist"}
|
||||
<Playlist id={entry.c.id} />
|
||||
{:else if entry.n === "import"}
|
||||
<Import />
|
||||
{:else if entry.n === "community"}
|
||||
<Community {entry} {screenEl} />
|
||||
{:else if entry.n === "room"}
|
||||
@@ -279,4 +289,10 @@
|
||||
{#if player.error || lib.error}
|
||||
<div class="toast" onclick={() => { player.error = ""; lib.error = ""; }} role="status">{player.error || lib.error}</div>
|
||||
{/if}
|
||||
{#if notice.text}
|
||||
<div class="toast notice" role="status">
|
||||
<span class="nt">{notice.text}</span>
|
||||
{#if notice.action}<button class="na" onclick={() => { const f = notice.action.fn; clearNote(); f?.(); }}>{notice.action.label}</button>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,75 @@
|
||||
<script>
|
||||
/* Library → Playlists: the desktop's Playlists shelf — the stat line,
|
||||
Pinned then All, the cover or the mosaic on every card, New playlist.
|
||||
A long press on a card is its sheet (Pin, Rename, Duplicate, Delete);
|
||||
the import doors stay on the desktop for now. */
|
||||
import Icon from "../components/Icon.svelte";
|
||||
import Release from "./Release.svelte";
|
||||
import { lib } from "./lib.svelte.js";
|
||||
import { push, openHalf } from "./nav.svelte.js";
|
||||
import { player } from "./player.svelte.js";
|
||||
import { coverFor, createPlaylist, shelfTotals } from "./playlists.svelte.js";
|
||||
import { longDur } from "./util.js";
|
||||
|
||||
let { screenEl = null } = $props();
|
||||
|
||||
const pinned = $derived(lib.playlists.filter((p) => p.pinned));
|
||||
const rest = $derived(lib.playlists.filter((p) => !p.pinned));
|
||||
const totals = $derived(shelfTotals(lib.playlists));
|
||||
|
||||
let creating = false;
|
||||
function newPlaylist() {
|
||||
openHalf("prompt", {
|
||||
title: "New playlist",
|
||||
placeholder: "Name",
|
||||
value: `New Playlist ${lib.playlists.length + 1}`,
|
||||
ok: "Create",
|
||||
onsubmit: async (name) => {
|
||||
if (creating) return;
|
||||
creating = true;
|
||||
try {
|
||||
const id = await createPlaylist(name);
|
||||
push("playlist", { id }, screenEl);
|
||||
} catch (e) {
|
||||
player.error = String(e);
|
||||
} finally { creating = false; }
|
||||
},
|
||||
});
|
||||
}
|
||||
const sub = (p) => `${p.track_count} track${p.track_count === 1 ? "" : "s"}${p.duration_secs ? " · " + longDur(p.duration_secs) : ""}`;
|
||||
const badge = (p) => (p.kind === "setlist" ? "SETLIST" : p.unresolved_count > 0 ? p.unresolved_count + " UNRESOLVED" : null);
|
||||
</script>
|
||||
|
||||
<div class="plstats">
|
||||
{#if lib.playlists.length}
|
||||
<span class="t-sub">{lib.playlists.length} playlist{lib.playlists.length === 1 ? "" : "s"} · {totals.tracks.toLocaleString()} track{totals.tracks === 1 ? "" : "s"}{totals.secs ? " · " + longDur(totals.secs) : ""}{pinned.length ? " · " + pinned.length + " pinned" : ""}</span>
|
||||
{:else}
|
||||
<span class="t-sub">No playlists yet</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="plbtns">
|
||||
<button class="btn btn-ghost newpl" onclick={() => openHalf("import")}><Icon name="download" size={15} strokeWidth={3} />Import</button>
|
||||
<button class="btn btn-ghost newpl" onclick={newPlaylist}><Icon name="plus" size={15} strokeWidth={3} />New playlist</button>
|
||||
</div>
|
||||
|
||||
{#if !lib.playlists.length}
|
||||
<div class="stub"><b>No playlists on this phone yet.</b>Make one here, or add a track to a new one from its sheet. Playlists are part of the library folder, so the ones the desktop keeps come over with it.</div>
|
||||
{/if}
|
||||
|
||||
{#snippet card(p)}
|
||||
<Release title={p.name} sub={sub(p)} url={coverFor(p)} badge={badge(p)} mark={p.pinned ? "pin" : null} icon="playlist"
|
||||
onclick={() => push("playlist", { id: p.id }, screenEl)} onmore={() => openHalf("playlist", { playlist: p })} />
|
||||
{/snippet}
|
||||
|
||||
{#if pinned.length}
|
||||
<div class="sec plsec pinned"><h2><Icon name="pin" size={12} strokeWidth={3} />Pinned</h2></div>
|
||||
<div class="grid2 plgrid">
|
||||
{#each pinned as p (p.id)}{@render card(p)}{/each}
|
||||
</div>
|
||||
{#if rest.length}<div class="sec plsec"><h2>All playlists</h2></div>{/if}
|
||||
{/if}
|
||||
{#if rest.length}
|
||||
<div class="grid2 plgrid">
|
||||
{#each rest as p (p.id)}{@render card(p)}{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
/* `path` is a library cover (through the thumbnail cache); `url` a
|
||||
catalog cover, shown straight from the network */
|
||||
let { title, sub = "", path = null, url = null, badge = null, w2 = false, onclick, onmore = null } = $props();
|
||||
let { title, sub = "", path = null, url = null, badge = null, mark = null, icon = "disc", w2 = false, onclick, onmore = null } = $props();
|
||||
const src = $derived(url || (path ? art[path] : ""));
|
||||
function hold(e) {
|
||||
if (!onmore) return;
|
||||
@@ -18,7 +18,8 @@
|
||||
<button class="release" class:w2 {onclick} oncontextmenu={hold} use:visible={path}>
|
||||
<span class="cover-wrap">
|
||||
{#if badge}<span class="ed-badge">{badge}</span>{/if}
|
||||
{#if src}<img src={src} alt="" loading="lazy" />{:else}<span class="ph"><Icon name="disc" size={26} /></span>{/if}
|
||||
{#if mark}<span class="mark"><Icon name={mark} size={11} strokeWidth={3} /></span>{/if}
|
||||
{#if src}<img src={src} alt="" loading="lazy" />{:else}<span class="ph"><Icon name={icon} size={26} /></span>{/if}
|
||||
</span>
|
||||
<span class="title">{title}</span>
|
||||
<span class="sub">{sub}</span>
|
||||
|
||||
@@ -84,7 +84,11 @@ export function visible(node, path) {
|
||||
},
|
||||
{ rootMargin: "320px" },
|
||||
);
|
||||
io.observe(node);
|
||||
/* a display:contents wrapper (Cover.svelte in a row) has no box of
|
||||
its own — observe the nearest element that has one */
|
||||
let box = node;
|
||||
while (box && box !== document.body && !box.getClientRects().length) box = box.parentElement;
|
||||
io.observe(box || node);
|
||||
};
|
||||
arm(path);
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/* Importing a playlist on the phone — the desktop's PlaylistImportModal's
|
||||
rules, on the doors that need no tool and no file: a link (YouTube ·
|
||||
Spotify · Apple Music · TIDAL · Deezer), a pasted tracklist (plain lines,
|
||||
a timestamped setlist, or pasted track links), a YouTube DJ set's posted
|
||||
tracklist. The backend stages a draft (parsing and resolution against
|
||||
the library); nothing is written until commit. While the review is up
|
||||
the stream rows are verified against the catalog — the desktop's
|
||||
verifyStreams, verbatim: the ISRC first, then the hardened name walk —
|
||||
so a committed row plays as a catalog track with artwork. */
|
||||
import * as api from "../lib/api.js";
|
||||
import { listen } from "../lib/events.js";
|
||||
import { findCatalogTrack } from "../lib/catalogMatch.js";
|
||||
import { refreshPlaylists } from "./lib.svelte.js";
|
||||
|
||||
export const importer = $state({
|
||||
door: null, // "link" | "text" | "youtube"
|
||||
source: "", // what was pasted
|
||||
busy: false,
|
||||
error: "",
|
||||
progress: null, // { done, total, waiting } while pasted links resolve
|
||||
draft: null, // the backend's ImportDraft
|
||||
name: "",
|
||||
includeUnresolved: true,
|
||||
checks: {}, // ordinal → "checking" | { album, cover, anchored } | "none"
|
||||
committing: false,
|
||||
});
|
||||
|
||||
export const FORMAT_LABEL = {
|
||||
m3u: "M3U", pls: "PLS", xspf: "XSPF", csv: "CSV", soulstream: "SoulStream backup",
|
||||
text: "Pasted text", folder: "Folder", youtube: "YouTube", "youtube-playlist": "YouTube playlist",
|
||||
spotify: "Spotify", "apple-music": "Apple Music", tidal: "TIDAL", deezer: "Deezer",
|
||||
};
|
||||
/** [door, icon, title, hint] — the desktop's Import menu, minus the file
|
||||
* and folder doors (the files are on the desktop). */
|
||||
export const DOORS = [
|
||||
["link", "link", "Playlist from a link…", "YouTube · Spotify · Apple Music · TIDAL · Deezer"],
|
||||
["text", "pencil", "Paste a tracklist…", "plain lines, a timestamped setlist, or track links"],
|
||||
["youtube", "brand-youtube", "YouTube DJ set…", "tracks from chapters, description or comments"],
|
||||
];
|
||||
|
||||
let gen = 0;
|
||||
/** Open a door: the backend reads and resolves, the review shows the draft. */
|
||||
export async function start(door, source) {
|
||||
const g = ++gen;
|
||||
Object.assign(importer, { door, source, busy: true, error: "", progress: null, draft: null, name: "", includeUnresolved: true, checks: {}, committing: false });
|
||||
try {
|
||||
const draft = door === "link" ? await api.playlistImportLink(source)
|
||||
: door === "youtube" ? await api.playlistImportYoutube(source)
|
||||
: await api.playlistImportText(source);
|
||||
if (g !== gen) return;
|
||||
importer.draft = draft;
|
||||
importer.name = draft?.name ?? "";
|
||||
importer.busy = false;
|
||||
if (draft?.entries?.length) queueMicrotask(() => void verifyStreams(g));
|
||||
} catch (e) {
|
||||
if (g === gen) { importer.error = String(e); importer.busy = false; }
|
||||
}
|
||||
}
|
||||
/** The rows commit will send: streams are first-class; the switch only
|
||||
* gates the TRUE unknowns. */
|
||||
export function kept() {
|
||||
const w = importer.draft;
|
||||
if (!w) return [];
|
||||
return importer.includeUnresolved ? w.entries : w.entries.filter((e) => e.resolution !== "unresolved");
|
||||
}
|
||||
export function checkedFound() {
|
||||
return Object.values(importer.checks).filter((v) => v && v !== "none" && v !== "checking").length;
|
||||
}
|
||||
/** Make the playlist. Returns { id, name }, or null (the error is on the store). */
|
||||
export async function commit() {
|
||||
const w = importer.draft;
|
||||
if (!w || importer.committing) return null;
|
||||
const finalName = (importer.name || w.name || "Imported playlist").trim();
|
||||
importer.committing = true;
|
||||
importer.error = "";
|
||||
try {
|
||||
const rows = kept().map((e) => $state.snapshot(e));
|
||||
const id = await api.playlistImportCommit(finalName, w.description ?? null, w.kind, rows, w.cover_url ?? null);
|
||||
++gen; // the verification walk stops here
|
||||
await refreshPlaylists();
|
||||
return { id, name: finalName };
|
||||
} catch (e) {
|
||||
importer.error = String(e);
|
||||
return null;
|
||||
} finally { importer.committing = false; }
|
||||
}
|
||||
export function cancel() {
|
||||
++gen;
|
||||
Object.assign(importer, { busy: false, draft: null, error: "", progress: null, checks: {} });
|
||||
}
|
||||
|
||||
let bound = false;
|
||||
export async function bindImporter() {
|
||||
if (bound) return;
|
||||
bound = true;
|
||||
try {
|
||||
/* the pasted-links door looks each link up, a page each */
|
||||
await listen("playlist:link-progress", (ev) => { if (importer.busy) importer.progress = ev.payload || null; });
|
||||
} catch { /* not a phone */ }
|
||||
}
|
||||
|
||||
async function verifyStreams(g) {
|
||||
const w = importer.draft;
|
||||
if (!w) return;
|
||||
for (const e of w.entries) {
|
||||
if (e.resolution !== "stream") continue;
|
||||
if (g !== gen) return;
|
||||
importer.checks[e.ordinal] = "checking";
|
||||
try {
|
||||
/* anchor-exact first: a row that carries the recording's ISRC
|
||||
beats any name matcher; a miss falls through to the walk */
|
||||
let hit = null;
|
||||
let anchored = false;
|
||||
if (e.isrc) {
|
||||
hit = await api.catalogTrackByIsrc(e.isrc);
|
||||
if (g !== gen) return;
|
||||
anchored = !!hit;
|
||||
}
|
||||
if (!hit) {
|
||||
hit = await findCatalogTrack(
|
||||
(query) => api.catalogSearchTracks(query),
|
||||
{ title: e.title, artist: e.artist, duration_secs: e.duration_secs ?? null },
|
||||
(artist, title) => api.catalogArtistTrackCandidates(artist, title),
|
||||
);
|
||||
if (g !== gen) return;
|
||||
}
|
||||
if (hit) {
|
||||
/* the catalog identity rides into the committed row */
|
||||
e.track_id = Number(hit.id);
|
||||
e.art_url = hit.cover ?? null;
|
||||
if (!e.album) e.album = hit.album ?? null;
|
||||
if (!e.duration_secs) e.duration_secs = hit.duration_secs ?? null;
|
||||
importer.checks[e.ordinal] = { album: hit.album ?? "", cover: hit.cover ?? null, anchored };
|
||||
} else {
|
||||
importer.checks[e.ordinal] = "none";
|
||||
}
|
||||
} catch {
|
||||
if (g === gen) importer.checks[e.ordinal] = "none";
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,8 @@ export async function watchLibrary() {
|
||||
await refreshRecents();
|
||||
}, 600);
|
||||
});
|
||||
/* a playlist mutation (the desktop's commands emit it) re-lists them */
|
||||
await listen("soulseek:playlists-changed", () => { void refreshPlaylists(); });
|
||||
} catch { /* not a phone */ }
|
||||
}
|
||||
|
||||
@@ -121,3 +123,7 @@ export async function loadLibrary() {
|
||||
try { lib.playlists = (await api.listPlaylists()) || []; } catch { lib.playlists = []; }
|
||||
lib.loaded = true;
|
||||
}
|
||||
|
||||
export async function refreshPlaylists() {
|
||||
try { lib.playlists = (await api.listPlaylists()) || []; } catch { /* keep what we have */ }
|
||||
}
|
||||
@@ -58,9 +58,22 @@ function backables() {
|
||||
+ (nav.stacks[nav.tab].length - 1) + (nav.tab !== "home" ? 1 : 0);
|
||||
}
|
||||
|
||||
/* make the history depth equal the number of layers the back gesture can peel */
|
||||
/* make the history depth equal the number of layers the back gesture can
|
||||
peel. Applied once per microtask: a sheet closed and a screen popped in
|
||||
the same handler (a confirmed delete) is ONE traversal, go(-2) — two
|
||||
go(-1)s in a tick lose one in Chrome, and the mirror drifts until a
|
||||
later go(-k) walks off the first entry and the page is gone. */
|
||||
let syncQueued = false;
|
||||
function sync() {
|
||||
if (!booted) return;
|
||||
if (!booted || syncQueued) return;
|
||||
syncQueued = true;
|
||||
queueMicrotask(() => { syncQueued = false; applySync(); });
|
||||
}
|
||||
function applySync() {
|
||||
/* a traversal (history.go) completes in a LATER task; a pushState before
|
||||
then lands on the entry being left and the traversal strands it as
|
||||
forward history. Wait: the popstate that completes it re-syncs. */
|
||||
if (suppress > 0) return;
|
||||
const want = backables();
|
||||
while (depth < want) {
|
||||
depth++;
|
||||
@@ -78,7 +91,7 @@ export function bootNav() {
|
||||
booted = true;
|
||||
try { history.replaceState({ d: 0 }, ""); } catch { /* ignore */ }
|
||||
window.addEventListener("popstate", () => {
|
||||
if (suppress > 0) { suppress--; return; }
|
||||
if (suppress > 0) { suppress--; if (suppress === 0) sync(); return; }
|
||||
depth = Math.max(0, depth - 1);
|
||||
if (nav.sheet) nav.sheet = false;
|
||||
else if (nav.half) nav.half = null;
|
||||
@@ -143,6 +156,17 @@ export function patch(c) {
|
||||
Object.assign(top().c, c);
|
||||
}
|
||||
|
||||
/** Swap the top screen for another — a review that became the thing it
|
||||
* made. Plays the push transition; the history depth is unchanged. */
|
||||
export function replace(n, c = {}) {
|
||||
nav.half = null;
|
||||
const s = nav.stacks[nav.tab];
|
||||
s[s.length - 1] = { n, c, scroll: 0 };
|
||||
nav.tr = "push";
|
||||
nav.seq++;
|
||||
sync();
|
||||
}
|
||||
|
||||
export function openSheet(fromEl = null) {
|
||||
if (nav.sheet) return;
|
||||
let rect = null;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/* A neutral toast with one optional action — the desktop's follow-toast
|
||||
pill, the one Undo rides on. The red .toast stays for errors
|
||||
(player.error / lib.error); this one states a fact and goes away. */
|
||||
export const notice = $state({ text: "", action: null, seq: 0 });
|
||||
|
||||
let timer = 0;
|
||||
/** `action` is { label, fn } or null. */
|
||||
export function note(text, action = null, ms = 3200) {
|
||||
clearTimeout(timer);
|
||||
notice.text = text;
|
||||
notice.action = action;
|
||||
notice.seq++;
|
||||
timer = setTimeout(clearNote, ms);
|
||||
}
|
||||
export function clearNote() {
|
||||
clearTimeout(timer);
|
||||
notice.text = "";
|
||||
notice.action = null;
|
||||
}
|
||||
@@ -121,7 +121,7 @@ async function startEntry(e, startSecs = null) {
|
||||
armedPath = null;
|
||||
player.track = e.t;
|
||||
player.album = e.album;
|
||||
player.position = startSecs || 0;
|
||||
player.position = startSecs ?? e.t.startSecs ?? 0;
|
||||
player.duration = e.t.duration_secs ?? null;
|
||||
player.status = "buffering";
|
||||
player.needsResource = false;
|
||||
@@ -162,7 +162,7 @@ async function startEntry(e, startSecs = null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.playLocal(e.t.path, e.t.duration_secs ?? null, startSecs);
|
||||
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;
|
||||
@@ -183,6 +183,7 @@ async function startEntry(e, startSecs = null) {
|
||||
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;
|
||||
@@ -383,7 +384,9 @@ export function playEntries(entries, at = 0, from = "") {
|
||||
player.queue = entries;
|
||||
player.qi = at;
|
||||
player.from = from;
|
||||
return startEntry(entries[at]);
|
||||
/* 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);
|
||||
@@ -577,6 +580,11 @@ export async function bindEvents() {
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
/* Playlists on the phone: the desktop's Playlists shelf and Playlist page
|
||||
(src/views/Playlists.svelte, Playlist.svelte), their rules kept — pinned
|
||||
first; the cover or the composed mosaic, fetched once per revision; Play
|
||||
queues the rows that CAN play (a present local file, or a stream row the
|
||||
resolver finds on reach — hooks.resolveEntry, the same path a record's
|
||||
later tracks take); a set-recording slice carries its bounds; a removal
|
||||
is undoable for seven seconds; every mutation re-lists through
|
||||
soulseek:playlists-changed, which the desktop's commands emit. */
|
||||
import * as api from "../lib/api.js";
|
||||
import { lib, refreshPlaylists } from "./lib.svelte.js";
|
||||
import { player, playEntries } from "./player.svelte.js";
|
||||
import { streamSong } from "./resolve.svelte.js";
|
||||
import { resolveSources, songCandidates, simplifyTitle, cachedStreamSourceFor, normText } from "../lib/sources.js";
|
||||
import { slsk } from "./slsk.svelte.js";
|
||||
import { note } from "./notice.svelte.js";
|
||||
|
||||
export const pls = $state({
|
||||
/** "id:revision" → data URL, or null once the backend said there is none */
|
||||
covers: {},
|
||||
/** the playlist page that is open: { id, items } — the bar's sheet reads it */
|
||||
current: null,
|
||||
/** the last removal: { playlistId, rows: [{ item, position }], label } */
|
||||
undo: null,
|
||||
});
|
||||
|
||||
/* ── covers ──────────────────────────────────────────────────────────── */
|
||||
const coverKey = (p) => p.id + ":" + p.revision;
|
||||
const asking = new Set();
|
||||
/** The custom cover or the four-cover mosaic, keyed by revision so an
|
||||
* edit re-composes it and a re-render never refetches. */
|
||||
export function coverFor(p) {
|
||||
if (!p) return null;
|
||||
const k = coverKey(p);
|
||||
const have = pls.covers[k];
|
||||
if (have !== undefined) return have;
|
||||
if (!asking.has(k)) {
|
||||
asking.add(k);
|
||||
api.playlistCover(p.id, 360)
|
||||
.then((u) => { pls.covers[k] = u || null; }, () => { pls.covers[k] = null; })
|
||||
.finally(() => asking.delete(k));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ── rows ────────────────────────────────────────────────────────────── */
|
||||
export const isSetlist = (p) => p?.kind === "setlist";
|
||||
/** Rows Play/Shuffle can queue: present local files plus stream rows. */
|
||||
export const queueable = (item) => !!(item.path && !item.missing) || item.kind === "stream";
|
||||
export const unresolvedCount = (items) => items.filter((i) => i.kind === "unresolved").length;
|
||||
export const missingCount = (items) => items.filter((i) => !!i.missing).length;
|
||||
export const totalSecs = (items) => items.reduce((n, i) => n + (i.duration_secs ?? 0), 0);
|
||||
|
||||
/* Where each set-recording slice ends: the NEXT stamped row in the full
|
||||
(unfiltered) order bounds it; the last slice runs to the file's end. */
|
||||
function segmentEnds(items) {
|
||||
const ends = new Map();
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const it = items[i];
|
||||
if (it.kind !== "segment" || it.start_ms == null) continue;
|
||||
const next = items.slice(i + 1).find((n) => n.start_ms != null);
|
||||
ends.set(it.id, next ? next.start_ms : null);
|
||||
}
|
||||
return ends;
|
||||
}
|
||||
|
||||
/** The record a row belongs to, as the player's `album`: the library's
|
||||
* own when the row names one it holds, else a shell — a stream row's
|
||||
* cover is a catalog URL, which the art cache routes through the proxy. */
|
||||
function albumOfItem(item, p) {
|
||||
const k = String(item.artist || "").toLowerCase();
|
||||
const own = item.album ? lib.albums.find((a) => a.title === item.album && a.artist.toLowerCase() === k) : null;
|
||||
if (own) return own;
|
||||
return {
|
||||
id: null, catalogId: null,
|
||||
title: item.album || p?.name || null,
|
||||
artist: item.artist,
|
||||
art_path: item.path ? (item.art_path ?? null) : (item.art_url ?? null),
|
||||
};
|
||||
}
|
||||
|
||||
/** A row as the player's entry. A local file plays as itself; a stream row
|
||||
* is a path-less entry with its catalog identity, resolved on reach; a set
|
||||
* slice carries where in the recording it starts and ends. */
|
||||
export function entryOf(item, p, ends = null) {
|
||||
const local = !!item.path && !item.missing;
|
||||
const tid = item.track_id != null && item.track_id !== "" ? Number(item.track_id) : NaN;
|
||||
const endMs = ends ? ends.get(item.id) : undefined;
|
||||
const segment = item.kind === "segment" && local && item.start_ms != null
|
||||
? { start: item.start_ms / 1000, end: endMs != null ? endMs / 1000 : null }
|
||||
: null;
|
||||
return {
|
||||
t: {
|
||||
id: "pl" + item.id,
|
||||
title: item.title,
|
||||
artist: item.artist,
|
||||
path: local ? item.path : null,
|
||||
duration_secs: item.duration_secs ?? (segment && segment.end != null ? Math.round(segment.end - segment.start) : null),
|
||||
stream: null,
|
||||
catalog: local ? null : { trackId: Number.isFinite(tid) ? tid : null, albumId: null, expectSecs: item.duration_secs ?? null },
|
||||
alts: [],
|
||||
startSecs: segment ? segment.start : null,
|
||||
endSecs: segment ? segment.end : null,
|
||||
},
|
||||
album: albumOfItem(item, p),
|
||||
plItemId: item.id,
|
||||
};
|
||||
}
|
||||
|
||||
/** Play the playlist from a row (or its top), as ONE queue in order. */
|
||||
export function playFrom(p, items, item = null) {
|
||||
const rows = items.filter(queueable);
|
||||
if (!rows.length) return false;
|
||||
let at = 0;
|
||||
if (item) {
|
||||
const i = rows.findIndex((r) => r.id === item.id);
|
||||
if (i >= 0) at = i;
|
||||
}
|
||||
const ends = segmentEnds(items);
|
||||
void playEntries(rows.map((r) => entryOf(r, p, ends)), at, p?.name ?? "Playlist");
|
||||
return true;
|
||||
}
|
||||
/** Shuffle on, a random row first — playEntries lays the rest out shuffled. */
|
||||
export function shufflePlay(p, items) {
|
||||
const rows = items.filter(queueable);
|
||||
if (!rows.length) return false;
|
||||
player.shuffle = true;
|
||||
return playFrom(p, items, rows[Math.floor(Math.random() * rows.length)]);
|
||||
}
|
||||
/** A tap on a row, the desktop's rowClick: a queueable row plays within
|
||||
* the playlist; a weak row with a name still streams one-off; a bare
|
||||
* placeholder ("ID") has nothing to search for — false, and the caller
|
||||
* offers the sheet. */
|
||||
export function rowClick(p, items, item) {
|
||||
if (queueable(item)) return playFrom(p, items, item);
|
||||
if (item.title && item.artist && item.title.toLowerCase() !== "id") {
|
||||
const tid = Number(item.track_id);
|
||||
streamSong(item.artist, item.title, {
|
||||
cover: item.art_url ?? undefined,
|
||||
trackId: Number.isFinite(tid) ? tid : undefined,
|
||||
album: item.album ?? undefined,
|
||||
expectSecs: item.duration_secs ?? undefined,
|
||||
}).catch((e) => { player.error = String(e); });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* ── the shelf ───────────────────────────────────────────────────────── */
|
||||
export function shelfTotals(list) {
|
||||
let tracks = 0;
|
||||
let secs = 0;
|
||||
for (const p of list) {
|
||||
tracks += p.track_count ?? 0;
|
||||
secs += p.duration_secs ?? 0;
|
||||
}
|
||||
return { tracks, secs };
|
||||
}
|
||||
|
||||
/* ── mutations: each re-lists, so every screen showing the playlist follows ── */
|
||||
export async function createPlaylist(name) {
|
||||
const id = await api.createPlaylist(name);
|
||||
await refreshPlaylists();
|
||||
return id;
|
||||
}
|
||||
export async function renamePlaylist(p, name) {
|
||||
await api.renamePlaylist(p.id, name);
|
||||
await refreshPlaylists();
|
||||
}
|
||||
export async function describePlaylist(p, text) {
|
||||
await api.setPlaylistDescription(p.id, text || null);
|
||||
await refreshPlaylists();
|
||||
}
|
||||
export async function togglePin(p) {
|
||||
await api.setPlaylistPinned(p.id, !p.pinned);
|
||||
await refreshPlaylists();
|
||||
}
|
||||
export async function duplicatePlaylist(p) {
|
||||
const id = await api.duplicatePlaylist(p.id, `${p.name} copy`);
|
||||
await refreshPlaylists();
|
||||
return id;
|
||||
}
|
||||
export async function deletePlaylist(p) {
|
||||
await api.deletePlaylist(p.id);
|
||||
await refreshPlaylists();
|
||||
}
|
||||
|
||||
/* ── removal, undoable ───────────────────────────────────────────────── */
|
||||
let undoTimer = 0;
|
||||
function stashUndo(playlistId, rows, label) {
|
||||
clearTimeout(undoTimer);
|
||||
/* the stash pins the playlist it came from: navigating away and
|
||||
tapping Undo must never restore rows into a DIFFERENT playlist */
|
||||
pls.undo = { playlistId, rows, label };
|
||||
undoTimer = setTimeout(() => { pls.undo = null; }, 7000);
|
||||
note(label, { label: "Undo", fn: applyUndo }, 7000);
|
||||
}
|
||||
/** Remove `rows` (items of `items`, the page's full order) from `p`. */
|
||||
export async function removeItems(p, items, rows) {
|
||||
if (!rows.length) return;
|
||||
const stash = rows.map((item) => ({ item, position: items.findIndex((i) => i.id === item.id) }));
|
||||
await api.playlistRemoveItems(p.id, rows.map((r) => r.id));
|
||||
const allUnresolved = rows.every((r) => r.kind === "unresolved");
|
||||
stashUndo(p.id, stash, rows.length === 1 && !allUnresolved
|
||||
? `Removed “${rows[0].title}”`
|
||||
: `Removed ${rows.length} ${allUnresolved ? "unresolved " : ""}${rows.length === 1 ? "entry" : "entries"}`);
|
||||
await refreshPlaylists();
|
||||
}
|
||||
export async function applyUndo() {
|
||||
const stash = pls.undo;
|
||||
pls.undo = null;
|
||||
clearTimeout(undoTimer);
|
||||
if (!stash) return;
|
||||
/* ascending original positions, so each insert-at lands true */
|
||||
for (const { item, position } of [...stash.rows].sort((a, b) => a.position - b.position)) {
|
||||
await api.playlistAddItems(stash.playlistId, [{
|
||||
title: item.title, artist: item.artist, album: item.album,
|
||||
duration_secs: item.duration_secs, path: item.path, art_path: item.art_path,
|
||||
start_ms: item.start_ms, source_line: item.source_line, note: item.note,
|
||||
track_id: item.track_id, art_url: item.art_url,
|
||||
}], position);
|
||||
}
|
||||
await refreshPlaylists();
|
||||
}
|
||||
|
||||
/* ── "Add to playlist" from the sheets ───────────────────────────────── */
|
||||
const isUrl = (s) => /^https?:/i.test(String(s || ""));
|
||||
/** Queue entries ({ t, album }) → the insert rows: a file on the phone is
|
||||
* a local row; a streamed or catalog track keeps its identity and cover
|
||||
* URL and becomes a stream row. */
|
||||
export function rowsOfEntries(entries) {
|
||||
return entries.map(({ t, album }) => {
|
||||
const local = !!t.path && !t.stream;
|
||||
const cover = t.catalog?.cover ?? (isUrl(album?.art_path) ? album.art_path : null);
|
||||
return {
|
||||
title: t.title,
|
||||
artist: t.artist || album?.artist || "",
|
||||
album: album?.title ?? null,
|
||||
duration_secs: t.duration_secs ?? null,
|
||||
path: local ? t.path : null,
|
||||
art_path: local && album?.art_path && !isUrl(album.art_path) ? album.art_path : null,
|
||||
track_id: t.catalog?.trackId != null ? String(t.catalog.trackId) : null,
|
||||
art_url: local ? null : cover,
|
||||
};
|
||||
});
|
||||
}
|
||||
export async function addEntries(p, entries) {
|
||||
const rows = rowsOfEntries(entries);
|
||||
if (!rows.length) return 0;
|
||||
await api.playlistAddItems(p.id, rows);
|
||||
await refreshPlaylists();
|
||||
note(`Added ${rows.length === 1 ? "“" + rows[0].title + "”" : rows.length + " tracks"} to ${p.name}`);
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
/* ── the bulk actions: the desktop's runPlaylistBulk, its rules kept ──
|
||||
"cached" files every already-streamed track into the library, nothing
|
||||
downloaded — only a byte-complete cache counts; "download" finds a
|
||||
source for every stream row not in the library yet (the remembered one
|
||||
first, else the ladder) and hands it to the download pipeline. Deduped:
|
||||
the same track twice must not download twice. */
|
||||
export const bulk = $state({
|
||||
active: false,
|
||||
mode: null, // "cached" | "download"
|
||||
playlistId: null,
|
||||
done: 0,
|
||||
total: 0,
|
||||
organized: 0, // filed into the library at once (was fully cached)
|
||||
queued: 0, // handed to the download pipeline
|
||||
missed: 0, // no complete cache ("cached") / no source ("download")
|
||||
summary: null, // the last run's one line, until dismissed
|
||||
});
|
||||
let bulkCancel = false;
|
||||
export function cancelBulk() { bulkCancel = true; }
|
||||
export function dismissBulk() { bulk.summary = null; }
|
||||
export const bulkable = (items) => items.filter((i) => i.kind === "stream" && i.artist && i.title).length;
|
||||
export async function runBulk(p, items, mode) {
|
||||
if (bulk.active) return false;
|
||||
const seen = new Set();
|
||||
const want = items.filter((r) => {
|
||||
if (r.kind !== "stream" || !r.artist || !r.title) return false;
|
||||
const key = `${normText(r.artist)}|${normText(r.title)}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
bulkCancel = false;
|
||||
Object.assign(bulk, { active: true, mode, playlistId: p.id, done: 0, total: want.length, organized: 0, queued: 0, missed: 0, summary: null });
|
||||
try {
|
||||
for (const row of want) {
|
||||
if (bulkCancel) break;
|
||||
try {
|
||||
const tid = Number(row.track_id);
|
||||
const known = await cachedStreamSourceFor(Number.isFinite(tid) ? tid : null, row.artist, row.title);
|
||||
let src = known ? { username: known.username, path: known.path, size: known.size } : null;
|
||||
if (mode === "cached") {
|
||||
/* a prefix is not the track, and this mode promised "no downloading" */
|
||||
const len = src ? await api.streamCacheLen(src.username, src.path) : null;
|
||||
if (!src || !src.size || len !== src.size) { bulk.missed += 1; continue; }
|
||||
} else if (!src) {
|
||||
if (slsk.connection !== "online") { bulk.missed += 1; continue; }
|
||||
const { responses, artistIncluded } = await resolveSources(
|
||||
`${row.artist} — ${row.title}`,
|
||||
[`${row.artist} ${simplifyTitle(row.title)}`, simplifyTitle(row.title)],
|
||||
(rs) => songCandidates(rs, row.title, false, row.album ?? null, row.duration_secs ?? null, row.artist).length,
|
||||
row.artist,
|
||||
null,
|
||||
);
|
||||
const best = songCandidates(responses, row.title, false, row.album ?? null, row.duration_secs ?? null, row.artist, !artistIncluded)[0];
|
||||
if (!best) { bulk.missed += 1; continue; }
|
||||
src = { username: best.username, path: best.file.path, size: best.file.size };
|
||||
}
|
||||
const [organized, queued] = await api.saveToLibrary([src]);
|
||||
bulk.organized += organized;
|
||||
bulk.queued += queued;
|
||||
if (organized === 0 && queued === 0) bulk.missed += 1;
|
||||
} catch {
|
||||
bulk.missed += 1;
|
||||
} finally {
|
||||
bulk.done += 1;
|
||||
}
|
||||
}
|
||||
const parts = [];
|
||||
if (bulk.organized) parts.push(`${bulk.organized} saved to library`);
|
||||
if (bulk.queued) parts.push(`${bulk.queued} downloading`);
|
||||
if (bulk.missed) parts.push(`${bulk.missed} ${mode === "cached" ? "not fully streamed yet" : "without a source"}`);
|
||||
if (bulkCancel) parts.push("stopped early");
|
||||
bulk.summary = parts.length ? parts.join(" · ") : "Nothing to do";
|
||||
} finally {
|
||||
bulk.active = false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<script>
|
||||
/* The import review — the desktop modal's review, on a screen: what
|
||||
the door read (the format, the source), the counts, the warnings,
|
||||
every row with its verdict (in library / matched in the catalog /
|
||||
stream / set audio / unresolved), the name, the keep-unresolved
|
||||
switch, and Create. Creating swaps this screen for the playlist. */
|
||||
import Icon from "../../components/Icon.svelte";
|
||||
import { importer, FORMAT_LABEL, kept, checkedFound, commit, cancel } from "../importer.svelte.js";
|
||||
import { openHalf, back, replace } from "../nav.svelte.js";
|
||||
import { mmss } from "../util.js";
|
||||
|
||||
const w = $derived(importer.draft);
|
||||
const rows = $derived(kept());
|
||||
const n = $derived(rows.length);
|
||||
const found = $derived(checkedFound());
|
||||
|
||||
function stamp(ms) {
|
||||
const s = Math.floor((ms || 0) / 1000);
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const sec = s % 60;
|
||||
return (h ? h + ":" + String(m).padStart(2, "0") : String(m)) + ":" + String(sec).padStart(2, "0");
|
||||
}
|
||||
/* [tag class, label] — the desktop's evidence column */
|
||||
function verdict(e) {
|
||||
if (e.resolution === "local") return ["ok", e.matched_by === "path" || e.matched_by === "file" ? "file" : e.matched_by === "relinked" ? "relinked" : "tags"];
|
||||
if (e.resolution === "stream") {
|
||||
const c = importer.checks[e.ordinal];
|
||||
if (c === "checking") return ["st dim", "matching…"];
|
||||
if (c && c !== "none") return ["st", "catalog"];
|
||||
if (c === "none") return ["un", "not in catalog"];
|
||||
return ["st dim", "stream"];
|
||||
}
|
||||
if (e.resolution === "segment") return ["st dim", "set audio"];
|
||||
return ["un", "unresolved"];
|
||||
}
|
||||
function rename() {
|
||||
openHalf("prompt", { title: "Playlist name", value: importer.name || w?.name || "Imported playlist", placeholder: "Name", ok: "Save", onsubmit: (v) => { importer.name = v; } });
|
||||
}
|
||||
async function create() {
|
||||
const r = await commit();
|
||||
if (r) replace("playlist", { id: r.id });
|
||||
}
|
||||
function leave() { cancel(); back(); }
|
||||
const waitLine = $derived(importer.door === "link" ? "Reading the link…" : importer.door === "youtube" ? "Reading the set's tracklist…" : "Reading the tracklist…");
|
||||
</script>
|
||||
|
||||
{#if importer.busy}
|
||||
<div class="imp-wait">
|
||||
<span class="ring"></span>
|
||||
<b>{waitLine}</b>
|
||||
{#if importer.progress}
|
||||
<span>{importer.progress.waiting ? `Rate-limited — carrying on from ${importer.progress.done} of ${importer.progress.total}…` : `Reading track ${importer.progress.done} of ${importer.progress.total}…`}</span>
|
||||
{:else}
|
||||
<span>Parsing and matching against your library.</span>
|
||||
{/if}
|
||||
<button class="btn btn-ghost" onclick={leave}>Cancel</button>
|
||||
</div>
|
||||
{:else if importer.error && !w}
|
||||
<div class="stub"><b>That didn't import.</b>{importer.error}</div>
|
||||
<div class="imp-foot"><button class="btn btn-ghost wide" onclick={leave}>Back</button></div>
|
||||
{:else if w}
|
||||
<div class="imp-head">
|
||||
<div class="stats">
|
||||
<span class="s">{FORMAT_LABEL[w.format] ?? w.format}</span>
|
||||
{#if w.kind === "setlist"}<span class="s">Setlist</span>{/if}
|
||||
</div>
|
||||
<div class="imp-src">{w.source_name}</div>
|
||||
<div class="imp-counts"><b class="ok">{w.resolved_count}</b> in library{#if w.stream_count > 0}{" · "}<b class="st">{found}</b>/{w.stream_count} in catalog{/if}{#if w.unresolved_count > 0}{" · "}<b class="un">{w.unresolved_count}</b> unknown{/if}</div>
|
||||
{#if w.warnings?.length}
|
||||
<div class="imp-warn">{#each w.warnings as x, i (i)}<div>{x}</div>{/each}</div>
|
||||
{/if}
|
||||
{#if w.format === "spotify" && w.entries.length >= 100}
|
||||
<div class="imp-warn">Spotify shows the first 100 here. For the rest, paste the playlist's track links as a tracklist.</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="tracks imp-rows">
|
||||
{#each w.entries as e (e.ordinal)}
|
||||
{@const [cls, label] = verdict(e)}
|
||||
<div class="trk" class:unresolved={e.resolution === "unresolved"} class:excluded={!importer.includeUnresolved && e.resolution === "unresolved"}>
|
||||
<span class="n" class:stamped={w.kind === "setlist"}>{w.kind === "setlist" ? stamp(e.start_ms) : e.ordinal + 1}</span>
|
||||
<span class="b"><span class="t">{e.title}</span><span class="m">{e.artist}{e.album ? " — " + e.album : ""}</span></span>
|
||||
<span class="tag {cls}">{label}</span>
|
||||
<span class="d">{e.duration_secs != null ? mmss(e.duration_secs) : ""}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="group imp-form">
|
||||
<button class="row" onclick={rename}>
|
||||
<span class="lead"><Icon name="pencil" size={20} /></span>
|
||||
<span class="b"><span class="t">{importer.name || w.name || "Imported playlist"}</span><span class="m">the playlist's name</span></span>
|
||||
<span class="r"><Icon name="chevron-right" size={17} /></span>
|
||||
</button>
|
||||
{#if w.unresolved_count > 0}
|
||||
<button class="row" onclick={() => (importer.includeUnresolved = !importer.includeUnresolved)}>
|
||||
<span class="lead"><Icon name="alert" size={20} /></span>
|
||||
<span class="b"><span class="t">Keep unresolved entries</span><span class="m">as placeholders, to repair on the desktop</span></span>
|
||||
<span class="sw" class:on={importer.includeUnresolved}><span></span></span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if importer.error}<div class="sec-note" style="color: #ffb4b4; padding-top: 10px">{importer.error}</div>{/if}
|
||||
<div class="imp-foot">
|
||||
<button class="btn btn-ghost" onclick={leave}>Cancel</button>
|
||||
<button class="btn btn-fill" disabled={importer.committing || !n} onclick={create}>{importer.committing ? "Creating…" : `Create playlist (${n} ${n === 1 ? "track" : "tracks"})`}</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="stub"><b>Nothing to review.</b>Open a door from the Playlists shelf.</div>
|
||||
<div class="imp-foot"><button class="btn btn-ghost wide" onclick={back}>Back</button></div>
|
||||
{/if}
|
||||
|
||||
<div class="endpad"></div>
|
||||
@@ -6,6 +6,7 @@
|
||||
import Release from "../Release.svelte";
|
||||
import Cover from "../Cover.svelte";
|
||||
import Tracks from "./Tracks.svelte";
|
||||
import PlaylistShelf from "../PlaylistShelf.svelte";
|
||||
import * as api from "../../lib/api.js";
|
||||
import { lib, artists, isFollowed, followedEntry } from "../lib.svelte.js";
|
||||
import { push, go, patch, openHalf } from "../nav.svelte.js";
|
||||
@@ -136,19 +137,7 @@
|
||||
{:else if tab === "tracks"}
|
||||
<Tracks {entry} />
|
||||
{:else if tab === "playlists"}
|
||||
{#if !lib.playlists.length}
|
||||
<div class="stub"><b>No playlists on this phone yet.</b>Playlists are part of the library folder, so the ones the desktop keeps come over with it.</div>
|
||||
{/if}
|
||||
{#each lib.playlists as p (p.id)}
|
||||
<button class="row" onclick={() => push("playlist", { id: p.id }, screenEl)}>
|
||||
<span class="art"><Icon name="playlist" size={20} /></span>
|
||||
<span class="b">
|
||||
<span class="t">{p.name}</span>
|
||||
<span class="m">{p.description ? p.description + " · " : ""}{p.track_count} track{p.track_count === 1 ? "" : "s"}</span>
|
||||
</span>
|
||||
<span class="r"><Icon name="chevron-right" size={17} /></span>
|
||||
</button>
|
||||
{/each}
|
||||
<PlaylistShelf {screenEl} />
|
||||
{:else if tab === "gaps"}
|
||||
{#if !shelves.length}
|
||||
<div class="stub"><b>Follow the artists you collect.</b>The records they released that this library does not have show up here, shelf by shelf — the desktop's discography lookup, only ever for an artist you followed, so a name is never guessed onto a namesake.</div>
|
||||
|
||||
@@ -1,70 +1,137 @@
|
||||
<script>
|
||||
/* A playlist, in the record page's shape. Rows without a local file
|
||||
(streams, unresolved imports) keep their place and say so. */
|
||||
/* A playlist — the desktop's Playlist.svelte at phone scale, in the
|
||||
record page's shape: the cover or the mosaic, the kind and a Pinned
|
||||
chip, the name, the description, the meta line with its warnings,
|
||||
Play / Shuffle / more, a filter, and the rows. A local file plays as
|
||||
itself; a stream row finds its copy on its turn (the resolver); an
|
||||
unresolved row says so, and its sheet is the way to deal with it.
|
||||
The rows are re-read whenever the playlist's revision moves — a
|
||||
removal, an undo, an add from a sheet. */
|
||||
import Icon from "../../components/Icon.svelte";
|
||||
import Cover from "../Cover.svelte";
|
||||
import * as api from "../../lib/api.js";
|
||||
import { lib } from "../lib.svelte.js";
|
||||
import { player, playEntries } from "../player.svelte.js";
|
||||
import { player } from "../player.svelte.js";
|
||||
import { openHalf } from "../nav.svelte.js";
|
||||
import { mmss, longDur, shuffled } from "../util.js";
|
||||
import { pls, bulk, cancelBulk, dismissBulk, coverFor, isSetlist, queueable, unresolvedCount, missingCount, totalSecs, playFrom, shufflePlay, rowClick } from "../playlists.svelte.js";
|
||||
import { mmss, longDur } from "../util.js";
|
||||
|
||||
let { id } = $props();
|
||||
|
||||
const pl = $derived(lib.playlists.find((p) => p.id === id) || null);
|
||||
let items = $state([]);
|
||||
let cover = $state("");
|
||||
let loaded = $state(false);
|
||||
let gen = 0;
|
||||
$effect(() => {
|
||||
const p = pl;
|
||||
if (!p) return;
|
||||
api.playlistItems(p.id).then((r) => { items = r || []; }, (e) => { player.error = String(e); });
|
||||
api.playlistCover(p.id, 360).then((u) => { cover = u || ""; }, () => {});
|
||||
void p.revision;
|
||||
const g = ++gen;
|
||||
api.playlistItems(p.id).then(
|
||||
(r) => { if (g === gen) { items = r || []; loaded = true; } },
|
||||
(e) => { if (g === gen) { loaded = true; player.error = String(e); } },
|
||||
);
|
||||
});
|
||||
/* the bar's sheet (playlist-more) reads the open page's rows here */
|
||||
$effect(() => {
|
||||
const p = pl;
|
||||
if (!p) return;
|
||||
pls.current = { id: p.id, items };
|
||||
return () => { if (pls.current?.id === p.id) pls.current = null; };
|
||||
});
|
||||
const total = $derived(items.reduce((n, i) => n + (i.duration_secs || 0), 0));
|
||||
const playable = $derived(items.filter((i) => i.path).length);
|
||||
|
||||
function entryOf(x) {
|
||||
return {
|
||||
t: { id: "pl" + x.id, title: x.title, artist: x.artist, path: x.path, duration_secs: x.duration_secs ?? null },
|
||||
album: { id: null, title: x.album || pl.name, artist: x.artist, art_path: x.art_path || null },
|
||||
};
|
||||
}
|
||||
function playFrom(i) {
|
||||
const es = items.slice(i).filter((x) => x.path).map(entryOf);
|
||||
if (es.length) playEntries(es, 0, pl.name);
|
||||
}
|
||||
function shuffle() {
|
||||
const es = shuffled(items.filter((x) => x.path).map(entryOf));
|
||||
if (es.length) { player.shuffle = true; playEntries(es, 0, pl.name); }
|
||||
const cover = $derived(pl ? coverFor(pl) : null);
|
||||
let q = $state("");
|
||||
const norm = (s) => String(s ?? "").toLowerCase();
|
||||
const visible = $derived.by(() => {
|
||||
const k = norm(q.trim());
|
||||
if (!k) return items;
|
||||
return items.filter((i) => norm(i.title).includes(k) || norm(i.artist).includes(k) || norm(i.album).includes(k));
|
||||
});
|
||||
/* setlist cue times show only when EVERY row carries one — the
|
||||
import's own "coherent or not at all" rule, gated on the full list */
|
||||
const stamped = $derived(items.length > 0 && items.every((i) => i.start_ms != null));
|
||||
const unresolved = $derived(unresolvedCount(items));
|
||||
const missing = $derived(missingCount(items));
|
||||
const total = $derived(totalSecs(items));
|
||||
const playable = $derived(items.filter(queueable).length);
|
||||
const nowItemId = $derived(player.queue[player.qi]?.plItemId ?? null);
|
||||
|
||||
function stamp(ms) {
|
||||
const s = Math.floor((ms || 0) / 1000);
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const sec = s % 60;
|
||||
return (h ? h + ":" + String(m).padStart(2, "0") : String(m)) + ":" + String(sec).padStart(2, "0");
|
||||
}
|
||||
function sheet(item) { openHalf("pltrack", { playlist: pl, items, item }); }
|
||||
function tap(item) { if (!rowClick(pl, items, item)) sheet(item); }
|
||||
</script>
|
||||
|
||||
{#if pl}
|
||||
<div class="rec-head">
|
||||
<div class="rec-cover">{#if cover}<img src={cover} alt="" />{:else}<Icon name="playlist" size={48} />{/if}</div>
|
||||
<div class="rec-head pl">
|
||||
<div class="rec-cover">{#if cover}<img src={cover} alt="" />{:else}<Icon name="playlist" size={56} />{/if}</div>
|
||||
<div class="rec-meta">
|
||||
<div class="kind">{isSetlist(pl) ? "Setlist" : "Playlist"}{#if pl.pinned}<span class="pin-chip"><Icon name="pin" size={10} strokeWidth={3} />Pinned</span>{/if}</div>
|
||||
<div class="rec-title">{pl.name}</div>
|
||||
<div class="rec-line">{items.length} track{items.length === 1 ? "" : "s"}{total ? ` · ${longDur(total)}` : ""}{playable < items.length ? ` · ${playable} on this phone` : ""}</div>
|
||||
{#if pl.description}<div class="rec-line" style="margin-top: 8px; color: var(--text-secondary)">{pl.description}</div>{/if}
|
||||
{#if pl.description}<div class="rec-desc">{pl.description}</div>{/if}
|
||||
<div class="rec-line">{items.length} track{items.length === 1 ? "" : "s"}{total ? ` · ${longDur(total)}` : ""}{#if unresolved}{" · "}<span class="warn">{unresolved} unresolved</span>{/if}{#if missing}{" · "}<span class="warn">{missing} missing</span>{/if}</div>
|
||||
<div class="rec-acts">
|
||||
<button class="iconbtn muted" onclick={shuffle} aria-label="Shuffle"><Icon name="shuffle" size={22} /></button>
|
||||
<button class="disc lg" onclick={() => playFrom(0)} aria-label="Play"><Icon name="play" size={26} /></button>
|
||||
<button class="iconbtn muted" onclick={() => shufflePlay(pl, items)} disabled={!playable} aria-label="Shuffle"><Icon name="shuffle" size={22} /></button>
|
||||
<button class="disc lg" onclick={() => playFrom(pl, items)} disabled={!playable} aria-label="Play"><Icon name="play" size={26} /></button>
|
||||
<button class="iconbtn muted" onclick={() => openHalf("playlist-more", { playlist: pl, items })} aria-label="More"><Icon name="more" size={22} /></button>
|
||||
</div>
|
||||
{#if bulk.playlistId === pl.id && (bulk.active || bulk.summary)}
|
||||
<div class="bulkline">
|
||||
{#if bulk.active}
|
||||
<span class="ring"></span>
|
||||
<span>{bulk.mode === "cached" ? "Adding streamed tracks to the library" : "Downloading tracks"} — {bulk.done}/{bulk.total}{bulk.organized ? ` · ${bulk.organized} saved` : ""}{bulk.queued ? ` · ${bulk.queued} downloading` : ""}{bulk.missed ? ` · ${bulk.missed} missed` : ""}</span>
|
||||
<button class="bulkx" onclick={cancelBulk} aria-label="Stop"><Icon name="x" size={14} /></button>
|
||||
{:else}
|
||||
<Icon name="check" size={14} />
|
||||
<span>{bulk.summary}</span>
|
||||
<button class="bulkx" onclick={dismissBulk} aria-label="Dismiss"><Icon name="x" size={14} /></button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="tracks">
|
||||
{#each items as x, i (x.id)}
|
||||
<button class="trk" class:playing={x.path && player.track?.path === x.path} onclick={() => x.path && playFrom(i)} oncontextmenu={(e) => { e.preventDefault(); if (x.path) openHalf("track", entryOf(x)); }} style:opacity={x.path ? 1 : 0.5}>
|
||||
<span class="n">{i + 1}</span>
|
||||
<span class="b">
|
||||
<span class="t">{x.title}</span>
|
||||
<span class="m">{x.artist}{x.album ? " · " + x.album : ""}{x.path ? "" : " · not on this phone"}</span>
|
||||
</span>
|
||||
<span class="d">{mmss(x.duration_secs)}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if loaded && !items.length}
|
||||
<div class="stub"><b>This playlist is empty.</b>Add tracks from a record's or a track's sheet — long-press one and choose Add to playlist.</div>
|
||||
{:else}
|
||||
{#if items.length}
|
||||
<div class="sfield plfilter">
|
||||
<Icon name="search" size={18} />
|
||||
<input value={q} oninput={(e) => (q = e.currentTarget.value)} type="search" placeholder="Search in playlist" autocomplete="off" autocapitalize="off" spellcheck="false" aria-label="Search in playlist" />
|
||||
{#if q}<button class="iconbtn sm" onclick={() => (q = "")} aria-label="Clear"><Icon name="x" size={18} /></button>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if loaded && items.length && !visible.length}
|
||||
<div class="stub"><b>Nothing here matches “{q}”.</b></div>
|
||||
{/if}
|
||||
<div class="tracks pl-rows">
|
||||
{#each visible as item, i (item.id)}
|
||||
{@const now = nowItemId != null && nowItemId === item.id}
|
||||
<button class="trk" class:playing={now} class:unresolved={item.kind === "unresolved"} class:missing={!!item.missing} onclick={() => tap(item)} oncontextmenu={(e) => { e.preventDefault(); sheet(item); }}>
|
||||
<span class="n" class:stamped={isSetlist(pl) && stamped}>{#if isSetlist(pl) && stamped}<span class="stamp">{stamp(item.start_ms)}</span>{:else}{i + 1}{/if}</span>
|
||||
<span class="thumb">
|
||||
{#if item.path && item.art_path}<Cover path={item.art_path} size={14} />
|
||||
{:else if item.art_url}<img src={item.art_url} alt="" loading="lazy" />
|
||||
{:else}<Icon name="music" size={14} />{/if}
|
||||
</span>
|
||||
<span class="b">
|
||||
<span class="t">{item.title}{#if item.kind === "unresolved"} <span class="tag un">unresolved</span>{:else if item.kind === "segment" && !item.missing} <span class="tag seg">set audio</span>{:else if item.missing} <span class="tag miss">file missing</span>{/if}</span>
|
||||
{#if item.artist || item.album}<span class="m">{item.artist}{item.artist && item.album ? " — " : ""}{item.album ?? ""}</span>{/if}
|
||||
</span>
|
||||
<span class="d">{item.duration_secs != null ? mmss(item.duration_secs) : "–:––"}</span>
|
||||
<span class="r" role="button" tabindex="0" aria-label="More" onkeydown={() => {}} onclick={(e) => { e.stopPropagation(); sheet(item); }}><Icon name="more" size={17} /></span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="stub" style="margin-top: calc(var(--bar-h) + 10px)"><b>That playlist is gone.</b></div>
|
||||
<div class="stub" style="margin-top: calc(var(--bar-h) + 10px)"><b>Playlist not found.</b>It may have been deleted.</div>
|
||||
{/if}
|
||||
|
||||
<div class="endpad"></div>
|
||||
@@ -8,6 +8,7 @@
|
||||
import { transfers, active } from "../transfers.svelte.js";
|
||||
import { player } from "../player.svelte.js";
|
||||
import { settings, setSetting } from "../prefs.svelte.js";
|
||||
import { refreshSourcePrefs } from "../../lib/sources.js";
|
||||
|
||||
let cfg = $state(null);
|
||||
let scanning = $state(false);
|
||||
@@ -17,7 +18,7 @@
|
||||
const normalization = $derived(cfg ? cfg.volume_normalization !== false : true);
|
||||
async function toggleNormalization() {
|
||||
const next = !normalization;
|
||||
try { await api.setSettings({ volume_normalization: next }); cfg = await api.getSettings(); } catch (e) { player.error = String(e); }
|
||||
try { await api.setSettings({ volumeNormalization: next }); cfg = await api.getSettings(); } catch (e) { player.error = String(e); }
|
||||
}
|
||||
/* the library folder: picked on the phone, set as the app's download
|
||||
dir (the folder IS the library), rescanned. A folder in shared storage
|
||||
@@ -39,7 +40,7 @@
|
||||
try {
|
||||
const r = await api.pickLibraryFolder();
|
||||
if (!r?.path) { player.error = "That folder has no path the phone can scan — pick one on this phone's storage."; return; }
|
||||
await api.setSettings({ download_dir: r.path });
|
||||
await api.setSettings({ downloadDir: r.path });
|
||||
cfg = await api.getSettings();
|
||||
await rescan();
|
||||
} catch (e) {
|
||||
@@ -53,6 +54,24 @@
|
||||
scanning = false;
|
||||
}
|
||||
|
||||
/* the desktop's Source quality settings (Settings → Sources): a window
|
||||
per mode and the priority inside it; the resolver re-reads them */
|
||||
const MODES = [["stream", "Streaming"], ["download", "Downloads"]];
|
||||
const MAX_Q = [["any", "No limit"], ["lossy_only", "Lossy only"]];
|
||||
const MIN_Q = [["any", "Any quality"], ["kbps192", "192 kbps and up"], ["kbps256", "256 kbps and up"], ["kbps320", "320 kbps and up"], ["lossless", "Lossless only"]];
|
||||
const PRI = [["quality", "Best quality"], ["speed", "Fastest transfer"]];
|
||||
const PREF_DEFAULTS = { max: "any", min: "any", priority: "quality" };
|
||||
const prefsOf = (mode) => ({ ...PREF_DEFAULTS, ...((mode === "stream" ? cfg?.stream_source_prefs : cfg?.download_source_prefs) ?? {}) });
|
||||
async function setPref(mode, field, value) {
|
||||
if (prefsOf(mode)[field] === value) return;
|
||||
const next = { ...prefsOf(mode), [field]: value };
|
||||
try {
|
||||
await api.setSettings(mode === "stream" ? { streamSourcePrefs: next } : { downloadSourcePrefs: next });
|
||||
cfg = await api.getSettings();
|
||||
await refreshSourcePrefs();
|
||||
} catch (e) { player.error = String(e); }
|
||||
}
|
||||
|
||||
const ROWS = [
|
||||
["ambience", "image", "Ambience", "the artwork in play, blurred, under every screen"],
|
||||
["accent", "sparkle", "Accent from artwork", "the app takes the record's colour"],
|
||||
@@ -110,6 +129,32 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sheet-sec">Sources</div>
|
||||
<div class="sec-note">Independent for streaming and downloads. The ceiling and floor bound which peer files qualify; priority decides whether the resolver takes the best format or the fastest transfer among them.</div>
|
||||
{#each MODES as [key, label] (key)}
|
||||
<div class="group prefs" data-mode={key}>
|
||||
<div class="pref-head">{label}</div>
|
||||
<div class="pref">
|
||||
<div class="pref-l"><span class="t">Maximum</span><span class="m">lossy only avoids huge lossless transfers</span></div>
|
||||
<div class="chips">
|
||||
{#each MAX_Q as [v, l] (v)}<button class="chip" class:on={prefsOf(key).max === v} class:quiet={prefsOf(key).max !== v} onclick={() => setPref(key, "max", v)}>{l}</button>{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="pref">
|
||||
<div class="pref-l"><span class="t">Minimum</span><span class="m">files below the floor are skipped; a lossy file must report its bitrate</span></div>
|
||||
<div class="chips">
|
||||
{#each MIN_Q as [v, l] (v)}<button class="chip" class:on={prefsOf(key).min === v} class:quiet={prefsOf(key).min !== v} onclick={() => setPref(key, "min", v)}>{l}</button>{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="pref">
|
||||
<div class="pref-l"><span class="t">Priority</span><span class="m">among the files inside that window</span></div>
|
||||
<div class="chips">
|
||||
{#each PRI as [v, l] (v)}<button class="chip" class:on={prefsOf(key).priority === v} class:quiet={prefsOf(key).priority !== v} onclick={() => setPref(key, "priority", v)}>{l}</button>{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<div class="sheet-sec">This phone</div>
|
||||
<div class="group">
|
||||
<div class="kv"><span class="k">SoulStream</span><span class="v">{lib.version || "—"}</span></div>
|
||||
@@ -117,8 +162,8 @@
|
||||
</div>
|
||||
|
||||
<div class="stub">
|
||||
<b>Playback, storage, the network and the account come next.</b>
|
||||
They are the desktop's settings pages, drawn with the phone's components; the library folder you pick and Import land with them.
|
||||
<b>The network and the account come next.</b>
|
||||
They are the desktop's settings pages, drawn with the phone's components.
|
||||
</div>
|
||||
|
||||
<div class="endpad"></div>
|
||||
@@ -0,0 +1,32 @@
|
||||
/* A share from another app (the share sheet → the share plugin's
|
||||
ShareActivity) lands in the playlist import: a playlist link opens the
|
||||
link door, anything else is read as a pasted tracklist. Asked for at
|
||||
boot and whenever the app comes back to the front — that is when the
|
||||
hand-off has happened. */
|
||||
import * as api from "../lib/api.js";
|
||||
import { go, patch, push } from "./nav.svelte.js";
|
||||
import { start as startImport } from "./importer.svelte.js";
|
||||
|
||||
const URL_RE = /https?:\/\/[^\s<>"']+/i;
|
||||
let checking = false;
|
||||
export async function checkShare() {
|
||||
if (checking) return;
|
||||
checking = true;
|
||||
try {
|
||||
const r = await api.pendingShare();
|
||||
const text = String(r?.text || "").trim();
|
||||
if (!text) return;
|
||||
const url = URL_RE.exec(text)?.[0] || null;
|
||||
go("library");
|
||||
patch({ tab: "playlists" });
|
||||
push("import", {});
|
||||
void startImport(url ? "link" : "text", url || text);
|
||||
} catch { /* not a phone, or the plugin is not here */ } finally { checking = false; }
|
||||
}
|
||||
let bound = false;
|
||||
export function bindShare() {
|
||||
if (bound) return;
|
||||
bound = true;
|
||||
void checkShare();
|
||||
document.addEventListener("visibilitychange", () => { if (!document.hidden) void checkShare(); });
|
||||
}
|
||||
@@ -1258,3 +1258,80 @@ html[data-motion="off"] *, html[data-motion="off"] *::before, html[data-motion="
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── playlists: the desktop's shelf and page, at phone scale ─────────── */
|
||||
.plstats { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 0 var(--pad) 12px; }
|
||||
.plstats .t-sub { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.newpl { display: inline-flex; align-items: center; gap: 6px; height: 34px; padding: 0 12px 0 10px; font-size: 13px; font-weight: 750; flex: none; }
|
||||
.plsec { padding-top: 6px; }
|
||||
.plsec h2 { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 800; letter-spacing: 0.8px; text-transform: uppercase; color: var(--text-muted); }
|
||||
.plgrid { padding-bottom: 8px; }
|
||||
/* the pinned mark on a card, the desktop's pinned-mark */
|
||||
.cover-wrap .mark { position: absolute; top: 7px; left: 7px; z-index: 2; width: 22px; height: 22px; border-radius: 50%; background: rgba(0, 0, 0, 0.55); color: #fff; display: grid; place-items: center; }
|
||||
/* the page: the record page's shape with a smaller cover (a mosaic reads at any size) */
|
||||
.rec-head.pl .rec-cover { width: 56%; max-width: 260px; }
|
||||
.rec-meta .kind .pin-chip { display: inline-flex; align-items: center; gap: 3px; margin-left: 8px; padding: 2px 7px; border-radius: 999px; background: color-mix(in srgb, var(--accent) 22%, transparent); color: var(--accent-hover); letter-spacing: 0.4px; vertical-align: 1px; }
|
||||
.rec-desc { margin-top: 8px; font-size: 13.5px; line-height: 1.45; color: var(--text-secondary); padding: 0 8px; }
|
||||
.rec-line .warn { color: #f5a524; font-weight: 650; }
|
||||
.sfield.plfilter { margin-top: 10px; }
|
||||
.pl-rows .trk .thumb { width: 38px; height: 38px; border-radius: 6px; overflow: hidden; flex: none; background: rgba(255, 255, 255, 0.06); display: grid; place-items: center; color: var(--text-muted); }
|
||||
.pl-rows .trk .thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.pl-rows .trk .n.stamped { width: auto; min-width: 22px; font-size: 11.5px; }
|
||||
.pl-rows .trk.unresolved .t, .pl-rows .trk.missing .t { color: var(--text-secondary); }
|
||||
.pl-rows .trk .t .tag { margin-left: 6px; vertical-align: 1px; }
|
||||
.tag.un { background: rgba(232, 163, 61, 0.16); color: #e8a33d; }
|
||||
.tag.miss { background: rgba(241, 94, 108, 0.16); color: #f15e6c; }
|
||||
.tag.seg { background: color-mix(in srgb, var(--accent) 22%, transparent); color: var(--accent-hover); }
|
||||
/* the neutral toast: a fact with one action (Undo); the error toast keeps its red */
|
||||
.toast.notice { display: flex; align-items: center; gap: 12px; background: #1f1f26; color: #fff; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.6), inset 0 0 0 1px rgba(255, 255, 255, 0.1); }
|
||||
.toast.notice .nt { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.toast.notice .na { flex: none; color: var(--accent-hover); font-weight: 800; font-size: 13px; padding: 4px 8px; margin: -4px -8px -4px 0; border-radius: 6px; }
|
||||
.toast.notice .na:active { background: rgba(255, 255, 255, 0.1); }
|
||||
/* the prompt and confirm sheets */
|
||||
.prompt { padding: 4px var(--pad) 16px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.prompt .field.area { height: auto; min-height: 84px; padding: 12px 14px; border-radius: 14px; resize: none; line-height: 1.4; font-family: inherit; }
|
||||
.prompt-acts { display: flex; gap: 8px; }
|
||||
.prompt-acts .btn { flex: 1; }
|
||||
.btn.btn-fill.danger { background: #f15e6c; color: #fff; }
|
||||
.btn.btn-fill.danger:active { background: #d94a58; }
|
||||
|
||||
/* ── the import doors and the review ─────────────────────────────────── */
|
||||
.plbtns { display: flex; gap: 8px; padding: 0 var(--pad) 12px; }
|
||||
.plbtns .newpl { flex: 1; justify-content: center; }
|
||||
.imp-head { padding: 4px var(--pad) 10px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.imp-src { font-size: 14.5px; font-weight: 700; }
|
||||
.imp-counts { font-size: 13px; color: var(--text-muted); }
|
||||
.imp-counts b { font-weight: 800; }
|
||||
.imp-counts .ok { color: #3ddc84; }
|
||||
.imp-counts .st { color: var(--accent-hover); }
|
||||
.imp-counts .un { color: #e8a33d; }
|
||||
.imp-warn { font-size: 12.5px; line-height: 1.5; color: #e8a33d; padding: 8px 12px; border-radius: 10px; background: rgba(232, 163, 61, 0.1); }
|
||||
.imp-rows .trk { min-height: 44px; }
|
||||
.imp-rows .trk .tag { flex: none; }
|
||||
.imp-rows .trk.excluded { opacity: 0.4; }
|
||||
.tag.ok { background: rgba(61, 220, 132, 0.16); color: #3ddc84; }
|
||||
.tag.st { background: color-mix(in srgb, var(--accent) 22%, transparent); color: var(--accent-hover); }
|
||||
.tag.dim { opacity: 0.7; }
|
||||
.imp-form { margin: 10px var(--pad) 0; }
|
||||
.imp-foot { display: flex; gap: 8px; padding: 14px var(--pad) 4px; }
|
||||
.imp-foot .btn { flex: 1; }
|
||||
.imp-wait { margin: 40px var(--pad) 0; padding: 26px 18px; border-radius: var(--r-card); background: var(--surface); box-shadow: var(--ring); display: flex; flex-direction: column; align-items: center; gap: 12px; text-align: center; font-size: 13px; color: var(--text-secondary); }
|
||||
.imp-wait b { color: #fff; font-size: 15px; }
|
||||
.imp-wait .ring { width: 26px; height: 26px; border-radius: 50%; border: 3px solid rgba(255, 255, 255, 0.15); border-top-color: var(--accent); animation: imp-spin 0.9s linear infinite; }
|
||||
@keyframes imp-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ── You → Sources: a window per mode, the priority inside it ─────────── */
|
||||
.pref-head { padding: 12px 14px 2px; font-size: 11px; font-weight: 800; letter-spacing: 0.8px; text-transform: uppercase; color: var(--text-muted); }
|
||||
.pref { padding: 8px 0 12px; }
|
||||
.pref + .pref { box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.055); }
|
||||
.pref-l { padding: 0 14px 8px; }
|
||||
.pref-l .t { display: block; font-size: 14.5px; font-weight: 650; }
|
||||
.pref-l .m { display: block; font-size: 12px; color: var(--text-muted); margin-top: 1px; }
|
||||
.pref .chips { padding: 0 14px; }
|
||||
|
||||
/* ── a playlist's bulk run: the desktop's bulkline, under the actions ── */
|
||||
.bulkline { display: inline-flex; align-items: center; gap: 8px; margin: 2px auto 4px; padding: 7px 8px 7px 12px; border-radius: 999px; background: rgba(255, 255, 255, 0.08); font-size: 12.5px; color: var(--text-secondary); max-width: 100%; text-align: left; }
|
||||
.bulkline > span:not(.ring) { min-width: 0; }
|
||||
.bulkline .ring { width: 14px; height: 14px; flex: none; border-radius: 50%; border: 2px solid rgba(255, 255, 255, 0.15); border-top-color: var(--accent); animation: imp-spin 0.9s linear infinite; }
|
||||
.bulkline .bulkx { display: grid; place-items: center; width: 24px; height: 24px; flex: none; border-radius: 50%; color: var(--text-muted); }
|
||||
.bulkline .bulkx:active { background: rgba(255, 255, 255, 0.12); }
|
||||
Reference in new issue
Block a user