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

+199 -1
View File
@@ -9,8 +9,10 @@
import { nav, closeHalf, push, top, patch, go, back, openHalf, openSheet } from "./nav.svelte.js";
import { player, playNext, enqueue, setSleep, sleepLabel } from "./player.svelte.js";
import { streamSong, saveSong } from "./resolve.svelte.js";
import { pls, bulk, bulkable, runBulk, coverFor, queueable, entryOf, playFrom, shufflePlay, togglePin, renamePlaylist, describePlaylist, duplicatePlaylist, deletePlaylist, removeItems, addEntries, createPlaylist } from "./playlists.svelte.js";
import { DOORS, start as startImport } from "./importer.svelte.js";
import { slsk, refreshRooms, disconnect, openThread, addBuddy, removeBuddy, leaveRoom } from "./slsk.svelte.js";
import { albumById, albumsBy, tracksOf, loadLibrary } from "./lib.svelte.js";
import { lib, albumById, albumsBy, tracksOf, loadLibrary } from "./lib.svelte.js";
import { fmtOf, albumFmt, fmtBytes, basename, dirname, longDur } from "./util.js";
const SORTS = [["added", "Recently added"], ["az", "A–Z"], ["artist", "By artist"], ["year", "By year"]];
@@ -26,6 +28,7 @@
const t = top();
if (t.n === "album") return "album-more";
if (t.n === "library") return "library-more";
if (t.n === "playlist") return "playlist-more";
return "home-more";
});
@@ -51,6 +54,94 @@
const onThisAlbum = $derived((id) => top().n === "album" && top().c.id === id);
const after = $derived(Math.max(0, player.queue.length - player.qi - 1));
/* ── playlists: the shelf card's sheet, the page's overflow (the bar's
More resolves to it), a row's sheet, the pick-a-playlist sheet, and
the prompt / confirm sheets they open ── */
const plArg = $derived.by(() => {
if (kind === "playlist") {
const id = shown?.arg?.playlist?.id;
if (id == null) return null;
return { playlist: lib.playlists.find((p) => p.id === id) || shown.arg.playlist, items: null };
}
if (kind !== "playlist-more") return null;
const id = shown?.arg?.playlist?.id ?? top().c.id;
const p = lib.playlists.find((x) => x.id === id);
if (!p) return null;
return { playlist: p, items: pls.current?.id === p.id ? pls.current.items : (shown?.arg?.items || []) };
});
const oops = (e) => { player.error = String(e); };
async function playPl(p, shuffle = false) {
try {
const items = (await api.playlistItems(p.id)) || [];
if (!(shuffle ? shufflePlay(p, items) : playFrom(p, items))) player.error = "Nothing in “" + p.name + "” can play yet.";
} catch (e) { oops(e); }
}
function confirmDelete(p) {
openHalf("confirm", {
title: "Delete “" + p.name + "”?",
body: (p.track_count ? "Its " + p.track_count + " row" + (p.track_count === 1 ? "" : "s") + " go with it. " : "") + "The files stay in your library.",
ok: "Delete", danger: true,
onconfirm: async () => {
try {
if (top().n === "playlist" && top().c.id === p.id) back();
await deletePlaylist(p);
} catch (e) { oops(e); }
},
});
}
/* the prompts take the playlist as a VALUE: a {@const} of the sheet's
block is gone by the time the prompt submits (the sheet switched
kinds), and reading it then is a stale derived */
function renamePrompt(p) {
openHalf("prompt", { title: "Rename playlist", value: p.name, placeholder: "Name", ok: "Rename", onsubmit: (v) => renamePlaylist(p, v).catch(oops) });
}
function describePrompt(p) {
openHalf("prompt", { title: "Description", value: p.description || "", placeholder: "What this playlist is", ok: "Save", multiline: true, allowEmpty: true, onsubmit: (v) => describePlaylist(p, v).catch(oops) });
}
function newPlaylistThenAdd(entries) {
openHalf("prompt", {
title: "New playlist", placeholder: "Name", value: "New Playlist " + (lib.playlists.length + 1), ok: "Create",
onsubmit: async (name) => {
try {
const id = await createPlaylist(name);
await addEntries(lib.playlists.find((x) => x.id === id) || { id, name }, entries);
} catch (e) { oops(e); }
},
});
}
/* the import doors: a prompt for the link or the text, then the review
screen while the backend reads and resolves */
function importPrompt(door) {
const d = DOORS.find((x) => x[0] === door);
openHalf("prompt", {
title: d[2].replace(/…$/, ""),
ok: "Import",
multiline: door === "text",
placeholder: door === "text" ? "00:00 Bicep - Glue\n05:30 Overmono - So U Kno\nFour Tet - Baby\n…or pasted track links"
: door === "youtube" ? "https://www.youtube.com/watch?v=…"
: "https://open.spotify.com/playlist/…",
onsubmit: (v) => { push("import", {}); void startImport(door, v); },
});
}
/* the prompt's field, seeded when the prompt opens */
let promptVal = $state("");
$effect(() => {
const h = nav.half;
if (h?.kind === "prompt") promptVal = h.arg?.value ?? "";
});
function submitPrompt() {
const a = shown?.arg;
if (!a) return;
const v = promptVal.trim();
if (!v && !a.allowEmpty) return;
closeHalf();
a.onsubmit?.(v);
}
function focusLater(node) {
const t = setTimeout(() => node.focus(), 280);
return { destroy: () => clearTimeout(t) };
}
/* a catalog track (no file on this phone): the resolver plays it, Save
fetches it — the desktop's row buttons, on the sheet */
const isCatalog = (t) => !!t && !t.path && !!t.catalog;
@@ -100,6 +191,7 @@
{#if isCatalog(track)}
{@render row("play", "Play", "streams from the best source", () => playCatalog(track, album))}
{@render row(saveNote.startsWith("Saving") ? "check" : "download", saveNote || "Save to library", saveNote ? "" : "the best copy by your download rules", () => { if (!saveNote) saveTrack(track, album); })}
{@render row("playlist", "Add to playlist", "", () => openHalf("pickPlaylist", { entries: [{ t: track, album }] }))}
<div class="sheet-hair"></div>
{#if album?.catalogId != null}
{@render row("disc", "Go to record", album.title, act(() => push("catalogAlbum", { id: album.catalogId, artist: album.artist, title: album.title })))}
@@ -110,6 +202,7 @@
{:else}
{@render row("play", "Play next", "", act(() => playNext([{ t: track, album }])))}
{@render row("list-plus", "Add to queue", after ? after + " after this" : "", act(() => enqueue([{ t: track, album }])))}
{@render row("playlist", "Add to playlist", "", () => openHalf("pickPlaylist", { entries: [{ t: track, album }] }))}
<div class="sheet-hair"></div>
{#if album?.id != null && !onThisAlbum(album.id)}
{@render row("disc", "Go to album", album.title, act(() => push("album", { id: album.id })))}
@@ -137,6 +230,9 @@
<div class="sheet-rows">
{@render row("play", "Play next", "", act(() => playNext(tracks.map((t) => ({ t, album })))))}
{@render row("list-plus", "Add to queue", tracks.length + " tracks", act(() => enqueue(tracks.map((t) => ({ t, album })))))}
{#if tracks.length}
{@render row("playlist", "Add to playlist", tracks.length + " tracks", () => openHalf("pickPlaylist", { entries: tracks.map((t) => ({ t, album })) }))}
{/if}
<div class="sheet-hair"></div>
{@render row("users", "Go to artist", album.artist, act(() => push("artist", { name: album.artist })))}
</div>
@@ -145,6 +241,108 @@
<div class="kv"><span class="k">Length</span><span class="v">{longDur(tracks.reduce((n, t) => n + (t.duration_secs || 0), 0))}</span></div>
{#if tracks[0]?.path}<div class="kv"><span class="k">Folder</span><span class="v mono dim">{dirname(tracks[0].path)}</span></div>{/if}
</div>
{:else if (kind === "playlist" || kind === "playlist-more") && plArg}
{@const p = plArg.playlist}
{@const rows = plArg.items || []}
{@const unres = rows.filter((i) => i.kind === "unresolved")}
<div class="sheet-head">
<span class="art">{#if coverFor(p)}<img src={coverFor(p)} alt="" />{:else}<Icon name="playlist" size={22} />{/if}</span>
<span class="b"><span class="t">{p.name}</span><span class="m">{p.kind === "setlist" ? "Setlist" : "Playlist"} · {p.track_count} track{p.track_count === 1 ? "" : "s"}{p.duration_secs ? " · " + longDur(p.duration_secs) : ""}{p.pinned ? " · pinned" : ""}</span></span>
</div>
<div class="sheet-rows">
{#if kind === "playlist"}
{@render row("play", "Play", "", act(() => playPl(p)))}
{@render row("shuffle", "Shuffle", "", act(() => playPl(p, true)))}
<div class="sheet-hair"></div>
{/if}
{@render row("pin", p.pinned ? "Unpin" : "Pin", p.pinned ? "" : "keeps it at the top of the shelf", act(() => togglePin(p).catch(oops)))}
{@render row("pencil", "Rename", "", () => renamePrompt(p))}
{#if kind === "playlist-more"}
{@render row("file-text", p.description ? "Edit description" : "Add a description", p.description || "", () => describePrompt(p))}
{/if}
{@render row("layers", "Duplicate", "a copy, “" + p.name + " copy”", act(() => duplicatePlaylist(p).then((id) => push("playlist", { id })).catch(oops)))}
{#if kind === "playlist-more" && unres.length}
{@render row("x", "Remove unresolved entries", unres.length + " that never matched a track", act(() => removeItems(p, rows, unres).catch(oops)))}
{/if}
{#if kind === "playlist-more" && bulkable(rows) && !bulk.active}
<div class="sheet-hair"></div>
{@render row("library", "Add to library", "files every already-streamed track — nothing is downloaded", act(() => { void runBulk(p, rows, "cached"); }))}
{@render row("download", "Download all tracks", bulkable(rows) + " not in the library yet, by your download rules", act(() => { void runBulk(p, rows, "download"); }))}
{/if}
<div class="sheet-hair"></div>
{@render row("trash", "Delete playlist", "", () => confirmDelete(p))}
</div>
{:else if kind === "pltrack" && shown?.arg?.item}
{@const p = shown.arg.playlist}
{@const rows = shown.arg.items || []}
{@const item = shown.arg.item}
<div class="sheet-head">
<span class="art">{#if item.path && item.art_path}<Cover path={item.art_path} eager size={22} />{:else if item.art_url}<img src={item.art_url} alt="" />{:else}<Icon name="music" size={22} />{/if}</span>
<span class="b"><span class="t">{item.title}</span><span class="m">{item.artist}{item.album ? " · " + item.album : ""}{item.kind === "unresolved" ? " · unresolved" : item.missing ? " · file missing" : item.kind === "stream" ? " · streams from the network" : ""}</span></span>
</div>
<div class="sheet-rows">
{#if queueable(item)}
{@render row("play", "Play from here", "the rest of the playlist follows", act(() => playFrom(p, rows, item)))}
{@render row("skip-forward", "Play next", "", act(() => playNext([entryOf(item, p)])))}
{@render row("list-plus", "Add to queue", after ? after + " after this" : "", act(() => enqueue([entryOf(item, p)])))}
{#if !item.path}
{@render row(saveNote.startsWith("Saving") ? "check" : "download", saveNote || "Save to library", saveNote ? "" : "the best copy by your download rules", () => { if (!saveNote) saveTrack({ title: item.title, artist: item.artist }, null); })}
{/if}
{:else if item.kind === "unresolved"}
<div class="sec-note" style="padding: 2px var(--pad) 10px">This row never matched a track{item.source_line ? " — it came in as “" + item.source_line + "”" : ""}. Repair it on the desktop, or remove it.</div>
{:else if item.missing}
<div class="sec-note" style="padding: 2px var(--pad) 10px">The file is no longer where the library last saw it.</div>
{/if}
{#if item.artist}
{@render row("users", "Go to artist", item.artist, act(() => push("artist", { name: item.artist })))}
{/if}
<div class="sheet-hair"></div>
{@render row("x", "Remove from playlist", "undo for a few seconds after", act(() => removeItems(p, rows, [item]).catch(oops)))}
</div>
{:else if kind === "pickPlaylist" && shown?.arg?.entries}
{@const entries = shown.arg.entries}
<div class="sheet-title">Add to playlist <span class="t-sub">· {entries.length === 1 ? entries[0].t.title : entries.length + " tracks"}</span></div>
<div class="sheet-rows">
{@render row("plus", "New playlist", "", () => newPlaylistThenAdd(entries))}
<div class="sheet-hair"></div>
{#each lib.playlists as p (p.id)}
<button class="row" onclick={act(() => addEntries(p, entries).catch(oops))}>
<span class="art">{#if coverFor(p)}<img src={coverFor(p)} alt="" />{:else}<Icon name="playlist" size={18} />{/if}</span>
<span class="b"><span class="t">{p.name}</span><span class="m">{p.track_count} track{p.track_count === 1 ? "" : "s"}{p.pinned ? " · pinned" : ""}</span></span>
</button>
{/each}
{#if !lib.playlists.length}<div class="sec-note" style="padding-bottom: 12px">No playlists yet — make one above.</div>{/if}
</div>
{:else if kind === "prompt" && shown?.arg}
{@const a = shown.arg}
<div class="sheet-title">{a.title}</div>
<div class="prompt">
{#if a.multiline}
<textarea class="field area" bind:value={promptVal} placeholder={a.placeholder || ""} rows="3" use:focusLater></textarea>
{:else}
<input class="field" bind:value={promptVal} placeholder={a.placeholder || ""} autocomplete="off" spellcheck="false" enterkeyhint="done" onkeydown={(e) => { if (e.key === "Enter") submitPrompt(); }} use:focusLater />
{/if}
<div class="prompt-acts">
<button class="btn btn-ghost" onclick={closeHalf}>Cancel</button>
<button class="btn btn-fill" disabled={!a.allowEmpty && !promptVal.trim()} onclick={submitPrompt}>{a.ok || "OK"}</button>
</div>
</div>
{:else if kind === "confirm" && shown?.arg}
{@const a = shown.arg}
<div class="sheet-title">{a.title}</div>
{#if a.body}<div class="sec-note" style="padding-bottom: 12px">{a.body}</div>{/if}
<div class="prompt-acts" style="padding: 0 var(--pad) 16px">
<button class="btn btn-ghost" onclick={closeHalf}>Cancel</button>
<button class="btn btn-fill" class:danger={a.danger} onclick={() => { const f = a.onconfirm; closeHalf(); f?.(); }}>{a.ok || "OK"}</button>
</div>
{:else if kind === "import"}
<div class="sheet-title">Import a playlist</div>
<div class="sheet-rows">
{#each DOORS as [door, icon, t, m] (door)}
{@render row(icon, t, m, () => importPrompt(door))}
{/each}
</div>
<div class="sec-note" style="padding-bottom: 14px">A playlist file or a folder of audio files imports on the desktop, where the files are.</div>
{:else if kind === "librarysort"}
<div class="sheet-title">Sort &amp; view</div>
<div class="sheet-sec">Order</div>