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:
1 parent
24c6857847
commit
c737809325
9 files changed
+215
-47
No files matched your search
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user