The storage plugin gains pick_image — the document picker limited to images, the picked content copied into the app's cache so the Rust side gets a plain path. playlist_cover_set(id, path) is the desktop pick's install step split out and shared (squared, the master cover, the row's revision moved). A playlist's sheet offers Choose cover photo and Remove custom cover; the mock walk exercises both.
364 lines
16 KiB
JavaScript
364 lines
16 KiB
JavaScript
/* 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();
|
|
}
|
|
|
|
/* ── the cover: a photo from the phone, or the mosaic again ── */
|
|
export async function coverFromPhoto(p) {
|
|
let r;
|
|
try { r = await api.pickImage(); } catch (e) { if (/cancelled/.test(String(e))) return false; throw e; }
|
|
if (!r?.path) return false;
|
|
await api.playlistCoverSet(p.id, r.path);
|
|
await refreshPlaylists();
|
|
note(`Cover set for ${p.name}`);
|
|
return true;
|
|
}
|
|
export async function coverClear(p) {
|
|
await api.playlistCoverClear(p.id);
|
|
await refreshPlaylists();
|
|
note(`Back to the mosaic for ${p.name}`);
|
|
}
|
|
|
|
/* ── reorder: a row up, down or to the top; the complete id order goes
|
|
in one call, as the desktop's drop does ── */
|
|
export async function moveItem(p, items, item, where) {
|
|
const ids = items.map((i) => i.id);
|
|
const from = ids.indexOf(item.id);
|
|
if (from < 0) return false;
|
|
const to = where === "top" ? 0 : where === "up" ? from - 1 : from + 1;
|
|
if (to < 0 || to >= ids.length || to === from) return false;
|
|
ids.splice(from, 1);
|
|
ids.splice(to, 0, item.id);
|
|
await api.playlistSetOrder(p.id, ids);
|
|
await refreshPlaylists();
|
|
return true;
|
|
}
|
|
|
|
/* ── 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;
|
|
}
|