android: a folder of audio files, and a paste of track links

The paste door now reads itself the way the desktop's does: one track link
in the paste and every line is looked up on its own public page
(playlist_import_track_links), which is the way past Spotify's 100-track
public ceiling — the wait screen counts them as they arrive. A fourth door
imports a folder of audio files off this phone, picked with the storage
plugin's own document-tree picker; playlist_import_folder grew a path
argument for it, because on Android it had no dialog to open and always
answered nothing. A YouTube playlist link needed no door at all — the link
one already dispatches it.
This commit is contained in:
KeelrLeek committed 2026-09-13 23:32:00 -05:00
1 parent 24c6857847
commit c737809325
9 files changed
+215 -47

No files matched your search

+14 -3
View File
@@ -13,7 +13,7 @@
import { streamSong, saveSong, streamCatalogAlbum, saveCatalogAlbum } from "./resolve.svelte.js";
import { catalog, yearOf } from "./catalog.svelte.js";
import { pls, bulk, bulkable, runBulk, coverFor, queueable, entryOf, playFrom, shufflePlay, togglePin, renamePlaylist, describePlaylist, duplicatePlaylist, deletePlaylist, removeItems, addEntries, createPlaylist, moveItem, coverFromPhoto, coverClear } from "./playlists.svelte.js";
import { DOORS, start as startImport } from "./importer.svelte.js";
import { DOORS, start as startImport, pickImportFolder, startFolder } from "./importer.svelte.js";
import { slsk, refreshRooms, disconnect, openThread, addBuddy, removeBuddy, leaveRoom } from "./slsk.svelte.js";
import { lib, albumById, albumsBy, tracksOf, loadLibrary, refreshPlaylists, refreshRecents } from "./lib.svelte.js";
import { lyr, nudge, searchCandidates, closeCandidates, chooseCandidate, forgetLyrics, pasteLyrics, setAutoSync, openTapSync, tapProgress, reduceVocals, backToOriginal } from "./lyrics.svelte.js";
@@ -146,6 +146,17 @@
onsubmit: (v) => { push("import", {}); void startImport(door, v); },
});
}
/* the folder door picks FIRST: the review only opens for a folder that
was chosen, and a tree with no path on this phone cannot be walked */
async function folderDoor() {
closeHalf();
try {
const path = await pickImportFolder();
if (!path) return;
push("import", {});
void startFolder(path);
} catch (e) { oops(e); }
}
/* find a local match — the desktop's repair panel, on a sheet: the
library searched by the row's name, a pick relinks the row (an
unknown placeholder adopts the track's identity; the original line
@@ -494,10 +505,10 @@
<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))}
{@render row(icon, t, m, () => (door === "folder" ? folderDoor() : 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>
<div class="sec-note" style="padding-bottom: 14px">A playlist file — M3U, XSPF, PLS or CSV — imports on the desktop; the phone has no file picker for one yet.</div>
{:else if kind === "plmatch" && shown?.arg?.item}
{@const p = shown.arg.playlist}
{@const item = shown.arg.item}
+2 -2
View File
@@ -47,7 +47,7 @@
import { resolver, streamSong, streamCatalogAlbum } from "./resolve.svelte.js";
import { notice, clearNote } from "./notice.svelte.js";
import * as pls from "./playlists.svelte.js";
import { importer, bindImporter } from "./importer.svelte.js";
import { importer, bindImporter, startFolder } from "./importer.svelte.js";
import { bindShare } from "./share.svelte.js";
import { downloadsAllowed, cellular } from "./network.svelte.js";
import { contacts, loadContacts, display } from "./contacts.svelte.js";
@@ -197,7 +197,7 @@
setTimeout(() => { api.libraryArtPrewarm([360]).catch(() => {}); }, 8000);
/* the probe handle — the desktop has __soulstream for the same reason:
tests/mobile/smoke.mjs drives the real app through it */
window.__ss = { nav, top, go, push, back, openSheet, closeSheet, openHalf, closeHalf, player, lib, art, slsk, net, resolver, streamSong, streamCatalogAlbum, sources, api, transfers, pls, notice, importer, wifi: { downloadsAllowed, cellular }, next, prev, toggle, seekSecs, playQueueAt, patch, contacts, loadContacts };
window.__ss = { nav, top, go, push, back, openSheet, closeSheet, openHalf, closeHalf, player, lib, art, slsk, net, resolver, streamSong, streamCatalogAlbum, sources, api, transfers, pls, notice, importer, imp: { startFolder }, wifi: { downloadsAllowed, cellular }, next, prev, toggle, seekSecs, playQueueAt, patch, contacts, loadContacts };
return () => { if (unlisten) unlisten(); };
});
</script>
+54 -8
View File
@@ -1,8 +1,9 @@
/* 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
rules, on the doors that need no file picker: a link (YouTube · Spotify ·
Apple Music · TIDAL · Deezer), a pasted tracklist (plain lines, a
timestamped setlist, or track links — a paste holding one is read as
links), a YouTube DJ set's posted tracklist, and a folder of audio files
on this phone. 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 —
@@ -13,7 +14,7 @@ import { findCatalogTrack } from "../lib/catalogMatch.js";
import { refreshPlaylists } from "./lib.svelte.js";
export const importer = $state({
door: null, // "link" | "text" | "youtube"
door: null, // "link" | "text" | "youtube" | "folder"
source: "", // what was pasted
busy: false,
error: "",
@@ -23,29 +24,44 @@ export const importer = $state({
includeUnresolved: true,
checks: {}, // ordinal → "checking" | { album, cover, anchored } | "none"
committing: false,
links: false, // the paste held track links: each read off its own page
});
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",
links: "Pasted links",
};
/** [door, icon, title, hint] — the desktop's Import menu, minus the file
* and folder doors (the files are on the desktop). */
/* Mirrors the backend's detector closely enough to CHOOSE a door; the
backend decides for real. A paste holding even one track link takes the
link path, so a list copied out of Spotify never parses as track names. */
const TRACK_LINK = /(?:spotify:track:[A-Za-z0-9]{22})|(?:\/\/(?:open\.spotify\.com\/(?:[\w-]+\/)*track\/|(?:www\.)?deezer\.com\/(?:[\w-]+\/)*track\/|(?:[\w-]+\.)?tidal\.com\/(?:[\w-]+\/)*track\/|music\.apple\.com\/[^\s]*(?:\/song\/|[?&]i=)))/i;
/** [door, icon, title, hint] — the desktop's Import menu, minus the
* playlist-file door (that one still wants a file picker). A YouTube
* playlist link goes through "link": the backend dispatches it. */
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"],
["folder", "folder-open", "Folder of audio files…", "tag or natural order"],
];
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 });
/* the paste reads itself: one track link in it and every line is
looked up on its own public page — the desktop's rule, and the way
past Spotify's 100-track public ceiling */
const links = door === "text" && TRACK_LINK.test(source || "");
Object.assign(importer, { door, source, busy: true, error: "", progress: null, draft: null, name: "", includeUnresolved: true, checks: {}, committing: false, links });
try {
const draft = door === "link" ? await api.playlistImportLink(source)
: door === "youtube" ? await api.playlistImportYoutube(source)
: links ? await api.playlistImportTrackLinks(source, null)
: await api.playlistImportText(source);
if (g !== gen) return;
importer.draft = draft;
@@ -56,6 +72,36 @@ export async function start(door, source) {
if (g === gen) { importer.error = String(e); importer.busy = false; }
}
}
/** The folder door picks FIRST — the phone's own document-tree picker — so
* the review only opens for a folder that was actually chosen. The tree has
* to be on this phone's storage: the backend walks a path, and a cloud
* provider's tree has none. Returns null when the picker was dismissed. */
export async function pickImportFolder() {
let picked;
try { picked = await api.pickLibraryFolder(); }
catch (e) { if (/cancelled/.test(String(e))) return null; throw e; }
if (!picked?.path) throw new Error("That folder has no path the phone can read — pick one on this phone's storage.");
return picked.path;
}
/** Stage a folder of audio files. The backend walks the folder and its disc
* subfolders, orders by the files' own track numbers when every one carries
* a distinct one and by natural filename order otherwise, and matches each
* file against the library by path. Every row comes back local, so there is
* nothing for the catalog pass to verify. */
export async function startFolder(path) {
const g = ++gen;
Object.assign(importer, { door: "folder", source: path, busy: true, error: "", progress: null, draft: null, name: "", includeUnresolved: true, checks: {}, committing: false, links: false });
try {
const draft = await api.playlistImportFolder(path);
if (g !== gen) return;
importer.busy = false;
if (!draft) { importer.error = "Nothing came back from that folder."; return; }
importer.draft = draft;
importer.name = draft.name ?? "";
} 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() {
+5 -1
View File
@@ -42,7 +42,11 @@
if (r) replace("playlist", { id: r.id });
}
function leave() { cancel(); back(); }
const waitLine = $derived(importer.door === "link" ? "Reading the link…" : importer.door === "youtube" ? "Reading the set's tracklist…" : "Reading the tracklist…");
const waitLine = $derived(importer.door === "link" ? "Reading the link…"
: importer.door === "youtube" ? "Reading the set's tracklist…"
: importer.door === "folder" ? "Reading the folder…"
: importer.links ? "Reading the pasted links…"
: "Reading the tracklist…");
</script>
{#if importer.busy}