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
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user