Files
SoulStream/src/mobile/playlists.svelte.js
T
KeelrLeek b50577fc81 android: a playlist's cover from a photo on the phone
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.
2026-09-13 21:20:11 -05:00

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;
}