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
+203
-35
No files matched your search
+29
-18
@@ -349,14 +349,23 @@ describe / pin / duplicate / delete, `playlist_add_items`,
|
||||
desktop's commands already emit; the page re-reads its rows when the
|
||||
playlist's revision moves.
|
||||
- **Importing** (`importer.svelte.js`, `screens/Import.svelte`): the
|
||||
shelf's Import opens the doors that need no file — a link (YouTube ·
|
||||
Spotify · Apple Music · TIDAL · Deezer), a pasted tracklist (plain lines,
|
||||
a timestamped setlist, or track links), a YouTube DJ set's posted
|
||||
tracklist. 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`).
|
||||
shelf's Import opens four doors — a link (YouTube · Spotify · Apple
|
||||
Music · TIDAL · Deezer; a YouTube playlist link rides this one too, the
|
||||
backend dispatches it by host), a pasted tracklist (plain lines, a
|
||||
timestamped setlist, or track links), a YouTube DJ set's posted
|
||||
tracklist, and a folder of audio files on this phone. A paste holding
|
||||
even one track link takes the link path (`playlist_import_track_links`,
|
||||
the desktop's own detector verbatim): each line is read off its own
|
||||
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`):
|
||||
Share → SoulStream from any app. The plugin's `ShareActivity` (an
|
||||
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
|
||||
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
|
||||
the desktop), the listener door, drag reorder, the cover picker, export,
|
||||
Share on SoulStream.
|
||||
Not on the phone yet: the playlist-file door and export — both want a file
|
||||
picker the storage plugin has still to grow — the listener door (it needs
|
||||
yt-dlp, a desktop tool), drag reorder, and Share on SoulStream.
|
||||
|
||||
## 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
|
||||
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
|
||||
(a pasted tracklist, a link with its catalog verification, a share from
|
||||
another app) → the playlist repair (an unknown row relinked from the
|
||||
(a pasted tracklist, a link with its catalog verification, pasted track
|
||||
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,
|
||||
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
|
||||
@@ -616,11 +626,12 @@ paused) without the walk.
|
||||
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
|
||||
is Kyle's to join.
|
||||
- The desktop-only doors and tools: the file and folder playlist imports,
|
||||
the listener door and the lyric alignment models (a desktop runtime),
|
||||
playlist export, drag reorder (Move up / down / to the top stand in), catalog track
|
||||
previews, the full discography page, the artist-artwork upscaler,
|
||||
updates and the account section (sign-in lives on Community).
|
||||
- The desktop-only doors and tools: the playlist-file import and export (a
|
||||
file picker the phone's storage plugin has still to grow), the listener
|
||||
door and the lyric alignment models (a desktop runtime), drag reorder
|
||||
(Move up / down / to the top stand in), catalog track previews, the full
|
||||
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
|
||||
folder starts empty of both; they arrive with a shared library.
|
||||
- The artwork viewer: the cover at full size, pinch to zoom, one finger
|
||||
|
||||
@@ -721,7 +721,14 @@ pub async fn playlist_import_probe(
|
||||
pub async fn playlist_import_folder(
|
||||
app: tauri::AppHandle,
|
||||
lib: State<'_, LibraryState>,
|
||||
path: Option<String>,
|
||||
) -> Result<Option<ImportDraft>, String> {
|
||||
// The phone has no dialog here, so it picks the folder itself (the
|
||||
// storage plugin's document-tree picker) and hands the resolved path
|
||||
// in; the desktop passes nothing and opens its own.
|
||||
let picked = match path {
|
||||
Some(p) if !p.trim().is_empty() => Some(p),
|
||||
_ => {
|
||||
let (tx, rx) = tokio::sync::oneshot::channel::<Option<String>>();
|
||||
#[cfg(desktop)]
|
||||
{
|
||||
@@ -732,9 +739,13 @@ pub async fn playlist_import_folder(
|
||||
}
|
||||
#[cfg(mobile)]
|
||||
{
|
||||
let _ = &app;
|
||||
let _ = tx.send(None);
|
||||
}
|
||||
let Some(root) = rx.await.unwrap_or(None) else {
|
||||
rx.await.unwrap_or(None)
|
||||
}
|
||||
};
|
||||
let Some(root) = picked else {
|
||||
return Ok(None);
|
||||
};
|
||||
let root_path = PathBuf::from(&root);
|
||||
|
||||
+3
-1
@@ -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
|
||||
// a pasted tracklist; commit writes the accepted draft in one transaction.
|
||||
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 playlistImportYoutube = (url) => invoke("playlist_import_youtube", { url });
|
||||
export const playlistImportYoutubePlaylist = (url) =>
|
||||
|
||||
@@ -288,6 +288,45 @@ function mockImport(cmd, args) {
|
||||
}, 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") {
|
||||
const name = String(args?.name || "").trim();
|
||||
if (!name) return Promise.reject(new Error("The playlist needs a name"));
|
||||
|
||||
@@ -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}
|
||||
|
||||
+45
-1
@@ -125,6 +125,8 @@ const PROBE = `(function () {
|
||||
impCounts: (document.querySelector('.imp-counts') || {}).textContent || '',
|
||||
impFmt: (document.querySelector('.imp-head .stats .s') || {}).textContent || '',
|
||||
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 || '',
|
||||
keepOn: !!document.querySelector('.imp-form .sw.on'),
|
||||
bulkline: (document.querySelector('.bulkline') || {}).textContent || '',
|
||||
@@ -454,6 +456,19 @@ const MOCK_STEPS = [
|
||||
{ name: "import-link-review", wait: 3400 },
|
||||
{ do: `__click('.imp-foot .btn-fill', 'Create playlist')`, name: "import-link-created", wait: 1500 },
|
||||
{ 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
|
||||
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 },
|
||||
@@ -648,7 +663,7 @@ const MOCK_EXPECT = {
|
||||
"playlist-filled": (i) => /^New Playlist/.test(i.title) && i.plrows >= 5,
|
||||
"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),
|
||||
"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-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),
|
||||
@@ -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-created": (i) => i.title.startsWith("Late Night Mix") && i.plrows === 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-cancel": (i) => i.title.startsWith("Your library"),
|
||||
"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: `__click('.halfsheet .row', 'Delete playlist')`, wait: 700 },
|
||||
{ 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
|
||||
intent on the plugin's activity) lands in the import — the review is
|
||||
left without creating anything */
|
||||
@@ -826,6 +862,14 @@ const PHONE_EXPECT = {
|
||||
"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-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-cancel": (i) => i.title.startsWith("Your library"),
|
||||
artist: (i) => i.screen.includes("has-hero") && i.amb.includes("on:img") && i.tabs >= 1,
|
||||
|
||||
Reference in new issue
Block a user