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:
KeelrLeek committed 2026-09-13 17:56:52 -05:00
1 parent bb828ff2dc
commit 6e39dc43f3
38 files changed
+2383 -88

No files matched your search

+142
View File
@@ -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));
}
}