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

+29 -18
View File
@@ -349,14 +349,23 @@ describe / pin / duplicate / delete, `playlist_add_items`,
desktop's commands already emit; the page re-reads its rows when the desktop's commands already emit; the page re-reads its rows when the
playlist's revision moves. playlist's revision moves.
- **Importing** (`importer.svelte.js`, `screens/Import.svelte`): the - **Importing** (`importer.svelte.js`, `screens/Import.svelte`): the
shelf's Import opens the doors that need no file — a link (YouTube · shelf's Import opens four doors — a link (YouTube · Spotify · Apple
Spotify · Apple Music · TIDAL · Deezer), a pasted tracklist (plain lines, Music · TIDAL · Deezer; a YouTube playlist link rides this one too, the
a timestamped setlist, or track links), a YouTube DJ set's posted backend dispatches it by host), a pasted tracklist (plain lines, a
tracklist. The backend stages the draft over plain HTTP (the same timestamped setlist, or track links), a YouTube DJ set's posted
commands as the desktop, no yt-dlp involved), the review screen shows tracklist, and a folder of audio files on this phone. A paste holding
every row's verdict, the stream rows are verified against the catalog even one track link takes the link path (`playlist_import_track_links`,
while it is up (the desktop's pass, verbatim), and Create swaps the the desktop's own detector verbatim): each line is read off its own
review for the playlist it made (`nav.replace`). public page — the way past Spotify's 100-track public ceiling — and the
wait screen counts them as they come. The folder door picks with the
storage plugin's document-tree picker and hands the resolved path to
`playlist_import_folder`, which grew a `path` argument for it: on the
phone that command had no dialog to open and always answered nothing.
The backend stages the draft over plain HTTP (the same commands as the
desktop, no yt-dlp involved), the review screen shows every row's
verdict, the stream rows are verified against the catalog while it is up
(the desktop's pass, verbatim), and Create swaps the review for the
playlist it made (`nav.replace`).
- **The share sheet** (`crates/tauri-plugin-share`, `share.svelte.js`): - **The share sheet** (`crates/tauri-plugin-share`, `share.svelte.js`):
Share → SoulStream from any app. The plugin's `ShareActivity` (an Share → SoulStream from any app. The plugin's `ShareActivity` (an
ACTION_SEND text/plain target, merged into the app's manifest from the ACTION_SEND text/plain target, merged into the app's manifest from the
@@ -367,9 +376,9 @@ describe / pin / duplicate / delete, `playlist_add_items`,
anything else the pasted-tracklist door. The phone walk sends the intent anything else the pasted-tracklist door. The phone walk sends the intent
itself (`adb shell am start -a android.intent.action.SEND …`). itself (`adb shell am start -a android.intent.action.SEND …`).
Not on the phone yet: the file and folder import doors (the files are on Not on the phone yet: the playlist-file door and export — both want a file
the desktop), the listener door, drag reorder, the cover picker, export, picker the storage plugin has still to grow — the listener door (it needs
Share on SoulStream. yt-dlp, a desktop tool), drag reorder, and Share on SoulStream.
## Settings that reach the config ## Settings that reach the config
@@ -569,8 +578,9 @@ a catalog record, its Save and the Transfers burst → Gaps → the playlist
shelf and a page with every row kind (a local row, the stream row through shelf and a page with every row kind (a local row, the stream row through
the resolver, the filter, a removal and its undo, unpin, rename, a new the resolver, the filter, a removal and its undo, unpin, rename, a new
playlist, Add to playlist from a record's sheet, delete) → the import doors playlist, Add to playlist from a record's sheet, delete) → the import doors
(a pasted tracklist, a link with its catalog verification, a share from (a pasted tracklist, a link with its catalog verification, pasted track
another app) → the playlist repair (an unknown row relinked from the links read a page each, a folder of audio files off the phone, a share
from another app) → the playlist repair (an unknown row relinked from the
library) → the artwork viewer → a queue row's sheet → a catalog song's, library) → the artwork viewer → a queue row's sheet → a catalog song's,
a catalog card's and the record page's sheets → the now-playing Save → a catalog card's and the record page's sheets → the now-playing Save →
a peer's sheet, a raw file's sheet, a user's shares browsed and a folder a peer's sheet, a raw file's sheet, a user's shares browsed and a folder
@@ -616,11 +626,12 @@ paused) without the walk.
key, Who has it) — Freenet on the phone is a later experiment; the key, Who has it) — Freenet on the phone is a later experiment; the
network side is in. Rooms and chat are built and mock-walked; a live room network side is in. Rooms and chat are built and mock-walked; a live room
is Kyle's to join. is Kyle's to join.
- The desktop-only doors and tools: the file and folder playlist imports, - The desktop-only doors and tools: the playlist-file import and export (a
the listener door and the lyric alignment models (a desktop runtime), file picker the phone's storage plugin has still to grow), the listener
playlist export, drag reorder (Move up / down / to the top stand in), catalog track door and the lyric alignment models (a desktop runtime), drag reorder
previews, the full discography page, the artist-artwork upscaler, (Move up / down / to the top stand in), catalog track previews, the full
updates and the account section (sign-in lives on Community). discography page, the artist-artwork upscaler, updates and the account
section (sign-in lives on Community).
- Follows and the play history are per library folder, so the phone's own - Follows and the play history are per library folder, so the phone's own
folder starts empty of both; they arrive with a shared library. folder starts empty of both; they arrive with a shared library.
- The artwork viewer: the cover at full size, pinch to zoom, one finger - The artwork viewer: the cover at full size, pinch to zoom, one finger
+24 -13
View File
@@ -721,20 +721,31 @@ pub async fn playlist_import_probe(
pub async fn playlist_import_folder( pub async fn playlist_import_folder(
app: tauri::AppHandle, app: tauri::AppHandle,
lib: State<'_, LibraryState>, lib: State<'_, LibraryState>,
path: Option<String>,
) -> Result<Option<ImportDraft>, String> { ) -> Result<Option<ImportDraft>, String> {
let (tx, rx) = tokio::sync::oneshot::channel::<Option<String>>(); // The phone has no dialog here, so it picks the folder itself (the
#[cfg(desktop)] // storage plugin's document-tree picker) and hands the resolved path
{ // in; the desktop passes nothing and opens its own.
use tauri_plugin_dialog::DialogExt; let picked = match path {
app.dialog().file().pick_folder(move |path| { Some(p) if !p.trim().is_empty() => Some(p),
let _ = tx.send(path.map(|p| p.to_string())); _ => {
}); let (tx, rx) = tokio::sync::oneshot::channel::<Option<String>>();
} #[cfg(desktop)]
#[cfg(mobile)] {
{ use tauri_plugin_dialog::DialogExt;
let _ = tx.send(None); app.dialog().file().pick_folder(move |path| {
} let _ = tx.send(path.map(|p| p.to_string()));
let Some(root) = rx.await.unwrap_or(None) else { });
}
#[cfg(mobile)]
{
let _ = &app;
let _ = tx.send(None);
}
rx.await.unwrap_or(None)
}
};
let Some(root) = picked else {
return Ok(None); return Ok(None);
}; };
let root_path = PathBuf::from(&root); let root_path = PathBuf::from(&root);
+3 -1
View File
@@ -278,7 +278,9 @@ export const playlistCoverSet = (id, path) => invoke("playlist_cover_set", { id,
// Import: probe opens a file dialog and stages a reviewed draft; text stages // Import: probe opens a file dialog and stages a reviewed draft; text stages
// a pasted tracklist; commit writes the accepted draft in one transaction. // a pasted tracklist; commit writes the accepted draft in one transaction.
export const playlistImportProbe = () => invoke("playlist_import_probe"); export const playlistImportProbe = () => invoke("playlist_import_probe");
export const playlistImportFolder = () => invoke("playlist_import_folder"); // The desktop opens its own folder dialog (no path); the phone picks with
// the storage plugin and hands the resolved path in.
export const playlistImportFolder = (path = null) => invoke("playlist_import_folder", { path });
export const playlistImportText = (text) => invoke("playlist_import_text", { text }); export const playlistImportText = (text) => invoke("playlist_import_text", { text });
export const playlistImportYoutube = (url) => invoke("playlist_import_youtube", { url }); export const playlistImportYoutube = (url) => invoke("playlist_import_youtube", { url });
export const playlistImportYoutubePlaylist = (url) => export const playlistImportYoutubePlaylist = (url) =>
+39
View File
@@ -288,6 +288,45 @@ function mockImport(cmd, args) {
}, 180); }, 180);
}); });
} }
if (cmd === "playlist_import_track_links") {
const links = String(args?.text || "").split(/\r?\n/).map((l) => l.trim())
.filter((l) => /^https?:\/\//.test(l) || /^spotify:track:/.test(l));
if (!links.length) return Promise.reject(new Error("Nothing to import in that paste."));
/* one row per link, in the paste's order — the lines a real lookup
would have read off each track's own public page. A page each is
slow, and the pace here is slow on purpose: the walk's still takes
long enough to miss a wait card that closes inside a second. */
const named = ["Aphex Twin - Track 1", "Daft Punk - One More Time", "Bicep - Glue", "Four Tet - Track 2"];
const entries = impParse(links.map((_, i) => named[i % named.length]).join("\n"));
return new Promise((r) => {
let done = 0;
const tick = setInterval(() => {
done++;
mockEmit("playlist:link-progress", { done, total: links.length, waiting: false });
if (done >= links.length) {
clearInterval(tick);
setTimeout(() => r(impDraft(entries, args?.name || "Pasted track links", "links", args?.name ?? null, {
warnings: [entries.length + " track" + (entries.length === 1 ? "" : "s") + " read from " + links.length + " pasted link" + (links.length === 1 ? "" : "s") + "."],
})), 250);
}
}, 600);
});
}
if (cmd === "playlist_import_folder") {
const root = String(args?.path || "");
if (!root) return Promise.resolve(null); /* the desktop's dialog, dismissed */
const name = root.split(/[\\/]/).filter(Boolean).pop() || "Folder";
/* four files off a record the library already holds, as the backend's
walk hands them over: every row local, matched by path */
const own = albums[0];
const entries = tracksFor(own.id).slice(0, 4).map((t, i) => ({
ordinal: i, title: t.title, artist: own.artist, album: own.title,
duration_secs: t.duration_secs, start_ms: null, raw: t.path,
resolution: "local", matched_by: "path", path: t.path, art_path: own.art_path,
track_id: null, art_url: null, isrc: null,
}));
return new Promise((r) => setTimeout(() => r(impDraft(entries, name, "folder", name)), 500));
}
if (cmd === "playlist_import_commit") { if (cmd === "playlist_import_commit") {
const name = String(args?.name || "").trim(); const name = String(args?.name || "").trim();
if (!name) return Promise.reject(new Error("The playlist needs a name")); if (!name) return Promise.reject(new Error("The playlist needs a name"));
+14 -3
View File
@@ -13,7 +13,7 @@
import { streamSong, saveSong, streamCatalogAlbum, saveCatalogAlbum } from "./resolve.svelte.js"; import { streamSong, saveSong, streamCatalogAlbum, saveCatalogAlbum } from "./resolve.svelte.js";
import { catalog, yearOf } from "./catalog.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 { 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 { slsk, refreshRooms, disconnect, openThread, addBuddy, removeBuddy, leaveRoom } from "./slsk.svelte.js";
import { lib, albumById, albumsBy, tracksOf, loadLibrary, refreshPlaylists, refreshRecents } from "./lib.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"; 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); }, 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 /* 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 library searched by the row's name, a pick relinks the row (an
unknown placeholder adopts the track's identity; the original line 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-title">Import a playlist</div>
<div class="sheet-rows"> <div class="sheet-rows">
{#each DOORS as [door, icon, t, m] (door)} {#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} {/each}
</div> </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} {:else if kind === "plmatch" && shown?.arg?.item}
{@const p = shown.arg.playlist} {@const p = shown.arg.playlist}
{@const item = shown.arg.item} {@const item = shown.arg.item}
+2 -2
View File
@@ -47,7 +47,7 @@
import { resolver, streamSong, streamCatalogAlbum } from "./resolve.svelte.js"; import { resolver, streamSong, streamCatalogAlbum } from "./resolve.svelte.js";
import { notice, clearNote } from "./notice.svelte.js"; import { notice, clearNote } from "./notice.svelte.js";
import * as pls from "./playlists.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 { bindShare } from "./share.svelte.js";
import { downloadsAllowed, cellular } from "./network.svelte.js"; import { downloadsAllowed, cellular } from "./network.svelte.js";
import { contacts, loadContacts, display } from "./contacts.svelte.js"; import { contacts, loadContacts, display } from "./contacts.svelte.js";
@@ -197,7 +197,7 @@
setTimeout(() => { api.libraryArtPrewarm([360]).catch(() => {}); }, 8000); setTimeout(() => { api.libraryArtPrewarm([360]).catch(() => {}); }, 8000);
/* the probe handle — the desktop has __soulstream for the same reason: /* the probe handle — the desktop has __soulstream for the same reason:
tests/mobile/smoke.mjs drives the real app through it */ 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(); }; return () => { if (unlisten) unlisten(); };
}); });
</script> </script>
+54 -8
View File
@@ -1,8 +1,9 @@
/* Importing a playlist on the phone — the desktop's PlaylistImportModal's /* 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 · rules, on the doors that need no file picker: a link (YouTube · Spotify ·
Spotify · Apple Music · TIDAL · Deezer), a pasted tracklist (plain lines, Apple Music · TIDAL · Deezer), a pasted tracklist (plain lines, a
a timestamped setlist, or pasted track links), a YouTube DJ set's posted timestamped setlist, or track links — a paste holding one is read as
tracklist. The backend stages a draft (parsing and resolution against 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 library); nothing is written until commit. While the review is up
the stream rows are verified against the catalog — the desktop's the stream rows are verified against the catalog — the desktop's
verifyStreams, verbatim: the ISRC first, then the hardened name walk — 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"; import { refreshPlaylists } from "./lib.svelte.js";
export const importer = $state({ export const importer = $state({
door: null, // "link" | "text" | "youtube" door: null, // "link" | "text" | "youtube" | "folder"
source: "", // what was pasted source: "", // what was pasted
busy: false, busy: false,
error: "", error: "",
@@ -23,29 +24,44 @@ export const importer = $state({
includeUnresolved: true, includeUnresolved: true,
checks: {}, // ordinal → "checking" | { album, cover, anchored } | "none" checks: {}, // ordinal → "checking" | { album, cover, anchored } | "none"
committing: false, committing: false,
links: false, // the paste held track links: each read off its own page
}); });
export const FORMAT_LABEL = { export const FORMAT_LABEL = {
m3u: "M3U", pls: "PLS", xspf: "XSPF", csv: "CSV", soulstream: "SoulStream backup", m3u: "M3U", pls: "PLS", xspf: "XSPF", csv: "CSV", soulstream: "SoulStream backup",
text: "Pasted text", folder: "Folder", youtube: "YouTube", "youtube-playlist": "YouTube playlist", text: "Pasted text", folder: "Folder", youtube: "YouTube", "youtube-playlist": "YouTube playlist",
spotify: "Spotify", "apple-music": "Apple Music", tidal: "TIDAL", deezer: "Deezer", 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 = [ export const DOORS = [
["link", "link", "Playlist from a link…", "YouTube · Spotify · Apple Music · TIDAL · Deezer"], ["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"], ["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"], ["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; let gen = 0;
/** Open a door: the backend reads and resolves, the review shows the draft. */ /** Open a door: the backend reads and resolves, the review shows the draft. */
export async function start(door, source) { export async function start(door, source) {
const g = ++gen; 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 { try {
const draft = door === "link" ? await api.playlistImportLink(source) const draft = door === "link" ? await api.playlistImportLink(source)
: door === "youtube" ? await api.playlistImportYoutube(source) : door === "youtube" ? await api.playlistImportYoutube(source)
: links ? await api.playlistImportTrackLinks(source, null)
: await api.playlistImportText(source); : await api.playlistImportText(source);
if (g !== gen) return; if (g !== gen) return;
importer.draft = draft; importer.draft = draft;
@@ -56,6 +72,36 @@ export async function start(door, source) {
if (g === gen) { importer.error = String(e); importer.busy = false; } 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 /** The rows commit will send: streams are first-class; the switch only
* gates the TRUE unknowns. */ * gates the TRUE unknowns. */
export function kept() { export function kept() {
+5 -1
View File
@@ -42,7 +42,11 @@
if (r) replace("playlist", { id: r.id }); if (r) replace("playlist", { id: r.id });
} }
function leave() { cancel(); back(); } 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> </script>
{#if importer.busy} {#if importer.busy}
+45 -1
View File
@@ -125,6 +125,8 @@ const PROBE = `(function () {
impCounts: (document.querySelector('.imp-counts') || {}).textContent || '', impCounts: (document.querySelector('.imp-counts') || {}).textContent || '',
impFmt: (document.querySelector('.imp-head .stats .s') || {}).textContent || '', impFmt: (document.querySelector('.imp-head .stats .s') || {}).textContent || '',
impWait: !!document.querySelector('.imp-wait'), impWait: !!document.querySelector('.imp-wait'),
impWaitLine: (document.querySelector('.imp-wait b') || {}).textContent || '',
impWarn: [].map.call(document.querySelectorAll('.imp-warn div'), function (d) { return d.textContent.trim(); }).join('|'),
impBtn: (document.querySelector('.imp-foot .btn-fill') || {}).textContent || '', impBtn: (document.querySelector('.imp-foot .btn-fill') || {}).textContent || '',
keepOn: !!document.querySelector('.imp-form .sw.on'), keepOn: !!document.querySelector('.imp-form .sw.on'),
bulkline: (document.querySelector('.bulkline') || {}).textContent || '', bulkline: (document.querySelector('.bulkline') || {}).textContent || '',
@@ -454,6 +456,19 @@ const MOCK_STEPS = [
{ name: "import-link-review", wait: 3400 }, { name: "import-link-review", wait: 3400 },
{ do: `__click('.imp-foot .btn-fill', 'Create playlist')`, name: "import-link-created", wait: 1500 }, { do: `__click('.imp-foot .btn-fill', 'Create playlist')`, name: "import-link-created", wait: 1500 },
{ do: `history.back()`, name: "playlists-after-import", wait: 900 }, { do: `history.back()`, name: "playlists-after-import", wait: 900 },
/* pasted TRACK links: the paste door reads each one off its own page —
the wait line names the door, the warning counts the links */
{ do: `__click('.newpl', 'Import')`, wait: 700 },
{ do: `__click('.halfsheet .row', 'Paste a tracklist')`, wait: 700 },
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'https://open.spotify.com/track/aaaaaaaaaaaaaaaaaaaaaa\\nhttps://open.spotify.com/track/bbbbbbbbbbbbbbbbbbbbbb\\nhttps://open.spotify.com/track/cccccccccccccccccccccc'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Import')`, name: "import-links-wait", wait: 500 },
{ name: "import-links-review", wait: 2600 },
{ do: `__click('.imp-foot .btn-ghost', null)`, wait: 900 },
/* the folder door: the phone's own folder picker, then the backend's walk */
{ do: `__click('.newpl', 'Import')`, wait: 700 },
{ do: `__click('.halfsheet .row', 'Folder of audio files')`, name: "import-folder-review", wait: 2200 },
{ do: `__click('.imp-foot .btn-fill', 'Create playlist')`, name: "import-folder-created", wait: 1500 },
{ do: `history.back()`, wait: 900 },
/* a share from another app: the plugin hands the text over when the /* a share from another app: the plugin hands the text over when the
app comes to the front, and it lands in the import */ app comes to the front, and it lands in the import */
{ do: `__ssMock.share = 'Check this out https://music.apple.com/us/playlist/late/pl.u-mock'; document.dispatchEvent(new Event('visibilitychange'))`, name: "share-import", wait: 3600 }, { do: `__ssMock.share = 'Check this out https://music.apple.com/us/playlist/late/pl.u-mock'; document.dispatchEvent(new Event('visibilitychange'))`, name: "share-import", wait: 3600 },
@@ -648,7 +663,7 @@ const MOCK_EXPECT = {
"playlist-filled": (i) => /^New Playlist/.test(i.title) && i.plrows >= 5, "playlist-filled": (i) => /^New Playlist/.test(i.title) && i.plrows >= 5,
"playlist-delete-confirm": (i) => i.half.includes("up") && /Delete/.test(i.halfText), "playlist-delete-confirm": (i) => i.half.includes("up") && /Delete/.test(i.halfText),
"playlist-deleted": (i) => i.title.startsWith("Your library") && i.plcards === 3 && !/New Playlist/.test(i.plnames), "playlist-deleted": (i) => i.title.startsWith("Your library") && i.plcards === 3 && !/New Playlist/.test(i.plnames),
"import-sheet": (i) => i.half.includes("up") && /Playlist from a link/.test(i.halfText) && /Paste a tracklist/.test(i.halfText), "import-sheet": (i) => i.half.includes("up") && /Playlist from a link/.test(i.halfText) && /Paste a tracklist/.test(i.halfText) && /Folder of audio files/.test(i.halfText),
"import-paste-prompt": (i) => i.half.includes("up") && /Paste a tracklist/.test(i.halfText) && i.promptVal === "", "import-paste-prompt": (i) => i.half.includes("up") && /Paste a tracklist/.test(i.halfText) && i.promptVal === "",
"import-review": (i) => i.title.startsWith("Import") && i.imp === 3 && /1 in library/.test(i.impCounts) && /tags/.test(i.impTags) && /unresolved/.test(i.impTags) && /catalog/.test(i.impTags) && i.keepOn && /Create playlist \(3 tracks\)/.test(i.impBtn), "import-review": (i) => i.title.startsWith("Import") && i.imp === 3 && /1 in library/.test(i.impCounts) && /tags/.test(i.impTags) && /unresolved/.test(i.impTags) && /catalog/.test(i.impTags) && i.keepOn && /Create playlist \(3 tracks\)/.test(i.impBtn),
"import-keep-off": (i) => !i.keepOn && /Create playlist \(2 tracks\)/.test(i.impBtn), "import-keep-off": (i) => !i.keepOn && /Create playlist \(2 tracks\)/.test(i.impBtn),
@@ -658,6 +673,10 @@ const MOCK_EXPECT = {
"import-link-review": (i) => i.imp === 5 && /Spotify/.test(i.impFmt) && /catalog/.test(i.impTags) && !/matching/.test(i.impTags) && /Create playlist \(5 tracks\)/.test(i.impBtn), "import-link-review": (i) => i.imp === 5 && /Spotify/.test(i.impFmt) && /catalog/.test(i.impTags) && !/matching/.test(i.impTags) && /Create playlist \(5 tracks\)/.test(i.impBtn),
"import-link-created": (i) => i.title.startsWith("Late Night Mix") && i.plrows === 5, "import-link-created": (i) => i.title.startsWith("Late Night Mix") && i.plrows === 5,
"playlists-after-import": (i) => i.title.startsWith("Your library") && i.plcards === 5, "playlists-after-import": (i) => i.title.startsWith("Your library") && i.plcards === 5,
"import-links-wait": (i) => i.impWait && /pasted links/.test(i.impWaitLine),
"import-links-review": (i) => i.imp === 3 && /Pasted links/.test(i.impFmt) && /read from 3 pasted links/.test(i.impWarn) && /Create playlist \(3 tracks\)/.test(i.impBtn),
"import-folder-review": (i) => i.title.startsWith("Import") && /Folder/.test(i.impFmt) && i.imp === 4 && i.impTags === "file|file|file|file" && /4 in library/.test(i.impCounts) && /Create playlist \(4 tracks\)/.test(i.impBtn),
"import-folder-created": (i) => i.title.startsWith("Music") && i.plrows === 4,
"share-import": (i) => i.title.startsWith("Import") && /Apple Music/.test(i.impFmt) && i.imp === 5, "share-import": (i) => i.title.startsWith("Import") && /Apple Music/.test(i.impFmt) && i.imp === 5,
"share-cancel": (i) => i.title.startsWith("Your library"), "share-cancel": (i) => i.title.startsWith("Your library"),
"cat-album-row": (i) => i.playing.includes("Aerodynamic") && /streaming from/.test(i.live), "cat-album-row": (i) => i.playing.includes("Aerodynamic") && /streaming from/.test(i.live),
@@ -743,6 +762,23 @@ const PHONE_STEPS = [
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 800 }, { do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 800 },
{ do: `__click('.halfsheet .row', 'Delete playlist')`, wait: 700 }, { do: `__click('.halfsheet .row', 'Delete playlist')`, wait: 700 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Delete')`, name: "import-deleted", wait: 1500 }, { do: `__click('.halfsheet .prompt-acts .btn-fill', 'Delete')`, name: "import-deleted", wait: 1500 },
/* the pasted-links door on the real core: one track link, and the wait
line says which door read the paste; the review is left as it came */
{ do: `__click('.newpl', 'Import')`, wait: 700 },
{ do: `__click('.halfsheet .row', 'Paste a tracklist')`, wait: 700 },
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'https://open.spotify.com/track/0DiWJLRWCDgbxTYvvVjHOP'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Import')`, name: "import-links-wait", wait: 700 },
{ name: "import-links-review", wait: 9000 },
{ do: `__click('.imp-foot .btn-ghost', null)`, wait: 900 },
/* the folder door on the real core, one record's own folder — the
argument the command grew, walked for real; nothing is created */
{ do: `(async function () { var a = __ss.lib.albums[0]; var ts = await __ss.api.albumTracks(a.id); var f = ts[0].path; var cut = Math.max(f.lastIndexOf('/'), f.lastIndexOf(String.fromCharCode(92))); __ss.push('import', {}); __ss.imp.startFolder(f.slice(0, cut)); return f.slice(0, cut); })()`, name: "import-folder-real", wait: 7000 },
{ do: `__click('.imp-foot .btn-ghost', null)`, wait: 900 },
/* the picker itself is a system sheet the walk cannot tap: open it,
dismiss it with BACK, and check the cancel path leaves nothing */
{ do: `__click('.newpl', 'Import')`, wait: 700 },
{ do: `__click('.halfsheet .row', 'Folder of audio files')`, name: "import-folder-picker", wait: 2600 },
{ adb: ["shell", "input", "keyevent", "4"], name: "import-folder-cancelled", wait: 2000 },
/* the share sheet: a Spotify playlist link sent by "another app" (an /* the share sheet: a Spotify playlist link sent by "another app" (an
intent on the plugin's activity) lands in the import — the review is intent on the plugin's activity) lands in the import — the review is
left without creating anything */ left without creating anything */
@@ -826,6 +862,14 @@ const PHONE_EXPECT = {
"import-match-results": (i) => /Aerodynamic/.test(i.halfText), "import-match-results": (i) => /Aerodynamic/.test(i.halfText),
"import-matched": (i) => !i.half.includes("up") && !/unresolved/.test(i.pltags) && /Linked to/.test(i.notice) && !i.toast, "import-matched": (i) => !i.half.includes("up") && !/unresolved/.test(i.pltags) && /Linked to/.test(i.notice) && !i.toast,
"import-deleted": (i) => i.title.startsWith("Your library") && !/Imported playlist/.test(i.plnames) && !i.toast, "import-deleted": (i) => i.title.startsWith("Your library") && !/Imported playlist/.test(i.plnames) && !i.toast,
/* the real core can answer one track link faster than the probe lands,
so the wait card is allowed to be gone already — but then the review
it turned into has to be there. Either way the door did something. */
"import-links-wait": (i) => i.title.startsWith("Import") && (i.impWait ? /pasted links/.test(i.impWaitLine) : i.imp > 0 || i.impErr),
"import-links-review": (i) => i.title.startsWith("Import") && (i.imp > 0 || i.impErr),
"import-folder-real": (i) => i.title.startsWith("Import") && /Folder/.test(i.impFmt) && i.imp > 0,
"import-folder-picker": (i) => !i.half.includes("up") && !i.toast,
"import-folder-cancelled": (i) => i.title.startsWith("Your library") && !i.half.includes("up") && !i.toast,
"share-import": (i) => i.title.startsWith("Import") && (i.imp > 0 || i.impErr), "share-import": (i) => i.title.startsWith("Import") && (i.imp > 0 || i.impErr),
"share-cancel": (i) => i.title.startsWith("Your library"), "share-cancel": (i) => i.title.startsWith("Your library"),
artist: (i) => i.screen.includes("has-hero") && i.amb.includes("on:img") && i.tabs >= 1, artist: (i) => i.screen.includes("has-hero") && i.amb.includes("on:img") && i.tabs >= 1,