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