Everything written this session that has not been through the device: rounds 43 to 48 (each patch adversarially verified, 43's transient-notice expectation fixed), a partial round 49, the pipeline scripts, the Android Auto design and the icon comparison mock. Also the two harness changes that were never A/B'd — adaptive waits, which may only shorten a declared wait and never lengthen it, and a ship that runs the mock walk and the deploy at the same time. The A/B and its 255-step baseline are in handoff/tools; main stays at what the phone has actually walked.
264 lines
14 KiB
JavaScript
264 lines
14 KiB
JavaScript
// Round twenty-four: the last two playlist doors that need no new picker —
|
|
// a paste of TRACK links (the desktop's own detector chooses the door) and
|
|
// a folder of audio files off this phone. playlist_import_folder grows an
|
|
// optional `path`, because on Android it had no dialog to open and always
|
|
// answered nothing. A YouTube playlist link needs no door of its own: the
|
|
// link door already dispatches it by host.
|
|
const fs = require('fs');
|
|
const root = process.env.SS_ROOT || 'F:/Coding/SoulStream/';
|
|
const scratch = 'C:/Users/Kyle/AppData/Local/Temp/claude/F--Coding-SoulStream/5488514a-11e8-482a-b0a0-20cb511ee110/scratchpad/';
|
|
const ins = (name) => fs.readFileSync(scratch + name, 'utf8');
|
|
let p, s;
|
|
const load = (file) => { p = file; s = fs.readFileSync(root + p, 'utf8'); };
|
|
const save = () => fs.writeFileSync(root + p, s);
|
|
const once = (a) => {
|
|
const i = s.indexOf(a);
|
|
if (i < 0) throw new Error('missing in ' + p + ': ' + a.slice(0, 90));
|
|
if (s.indexOf(a, i + 1) >= 0) throw new Error('ambiguous in ' + p + ': ' + a.slice(0, 90));
|
|
return i;
|
|
};
|
|
const rep = (a, b) => { once(a); s = s.replace(a, () => b); };
|
|
const NL = String.fromCharCode(10);
|
|
|
|
// ── the Rust: the folder command takes a path the phone already picked ──
|
|
load('src-tauri/src/playlists.rs');
|
|
rep(`pub async fn playlist_import_folder(
|
|
app: tauri::AppHandle,
|
|
lib: State<'_, LibraryState>,
|
|
) -> Result<Option<ImportDraft>, String> {
|
|
let (tx, rx) = tokio::sync::oneshot::channel::<Option<String>>();
|
|
#[cfg(desktop)]
|
|
{
|
|
use tauri_plugin_dialog::DialogExt;
|
|
app.dialog().file().pick_folder(move |path| {
|
|
let _ = tx.send(path.map(|p| p.to_string()));
|
|
});
|
|
}
|
|
#[cfg(mobile)]
|
|
{
|
|
let _ = tx.send(None);
|
|
}
|
|
let Some(root) = rx.await.unwrap_or(None) else {
|
|
return Ok(None);
|
|
};`,
|
|
`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)]
|
|
{
|
|
use tauri_plugin_dialog::DialogExt;
|
|
app.dialog().file().pick_folder(move |path| {
|
|
let _ = tx.send(path.map(|p| p.to_string()));
|
|
});
|
|
}
|
|
#[cfg(mobile)]
|
|
{
|
|
let _ = &app;
|
|
let _ = tx.send(None);
|
|
}
|
|
rx.await.unwrap_or(None)
|
|
}
|
|
};
|
|
let Some(root) = picked else {
|
|
return Ok(None);
|
|
};`);
|
|
save();
|
|
|
|
// ── the wrapper ──
|
|
load('src/lib/api.js');
|
|
rep(`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 });`);
|
|
save();
|
|
|
|
// ── the store: the links branch, the folder door ──
|
|
load('src/mobile/importer.svelte.js');
|
|
rep(` 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`,
|
|
` 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`);
|
|
rep(` door: null, // "link" | "text" | "youtube"`,
|
|
` door: null, // "link" | "text" | "youtube" | "folder"`);
|
|
rep(` committing: false,
|
|
});`, ` committing: false,
|
|
links: false, // the paste held track links: each read off its own page
|
|
});`);
|
|
rep(` spotify: "Spotify", "apple-music": "Apple Music", tidal: "TIDAL", deezer: "Deezer",
|
|
};`, ins('ins-r24-importer-regex.txt').replace(/\n$/, '') + NL);
|
|
rep(`/** [door, icon, title, hint] — the desktop's Import menu, minus the file
|
|
* and folder doors (the files are on the desktop). */
|
|
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"],
|
|
];`,
|
|
`/** [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"],
|
|
];`);
|
|
rep(` 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 });`);
|
|
rep(` const draft = door === "link" ? await api.playlistImportLink(source)
|
|
: door === "youtube" ? await api.playlistImportYoutube(source)
|
|
: await api.playlistImportText(source);`,
|
|
` const draft = door === "link" ? await api.playlistImportLink(source)
|
|
: door === "youtube" ? await api.playlistImportYoutube(source)
|
|
: links ? await api.playlistImportTrackLinks(source, null)
|
|
: await api.playlistImportText(source);`);
|
|
rep(`/** The rows commit will send:`, ins('ins-r24-importer-folder.txt') + `/** The rows commit will send:`);
|
|
save();
|
|
|
|
// ── the sheet: the folder row picks before the review opens ──
|
|
load('src/mobile/HalfSheet.svelte');
|
|
rep(` import { DOORS, start as startImport } from "./importer.svelte.js";`,
|
|
` import { DOORS, start as startImport, pickImportFolder, startFolder } from "./importer.svelte.js";`);
|
|
rep(` 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); }
|
|
}`);
|
|
rep(` {@render row(icon, t, m, () => importPrompt(door))}`,
|
|
` {@render row(icon, t, m, () => (door === "folder" ? folderDoor() : importPrompt(door)))}`);
|
|
rep(` <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>`);
|
|
save();
|
|
|
|
// ── the review: the wait line names the door that read the paste ──
|
|
load('src/mobile/screens/Import.svelte');
|
|
rep(` 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…");`);
|
|
save();
|
|
|
|
// ── the harness handle: the walk starts the folder door itself ──
|
|
load('src/mobile/Mobile.svelte');
|
|
rep(` import { importer, bindImporter } from "./importer.svelte.js";`,
|
|
` import { importer, bindImporter, startFolder } from "./importer.svelte.js";`);
|
|
rep(`importer, wifi: { downloadsAllowed, cellular }, next,`,
|
|
`importer, imp: { startFolder }, wifi: { downloadsAllowed, cellular }, next,`);
|
|
save();
|
|
|
|
// ── the mocks ──
|
|
load('src/lib/mockLibrary.js');
|
|
rep(` if (cmd === "playlist_import_commit") {`, ins('ins-r24-mock.txt') + ` if (cmd === "playlist_import_commit") {`);
|
|
save();
|
|
|
|
// ── the walks ──
|
|
load('tests/mobile/smoke.mjs');
|
|
rep(` 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('|'),`);
|
|
{
|
|
const a = ` /* a share from another app: the plugin hands the text over when the`;
|
|
rep(a, ins('ins-r24-smoke-mock.txt') + a);
|
|
}
|
|
rep(` "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),`);
|
|
{
|
|
const a = ` "playlists-after-import": (i) => i.title.startsWith("Your library") && i.plcards === 5,`;
|
|
rep(a, a + NL + ins('ins-r24-smoke-expect.txt').replace(/\n$/, ''));
|
|
}
|
|
{
|
|
const a = ` /* the share sheet: a Spotify playlist link sent by "another app" (an`;
|
|
rep(a, ins('ins-r24-smoke-phone.txt') + a);
|
|
}
|
|
{
|
|
const a = ` "share-import": (i) => i.title.startsWith("Import") && (i.imp > 0 || i.impErr),`;
|
|
rep(a, ins('ins-r24-smoke-phone-expect.txt') + a);
|
|
}
|
|
save();
|
|
|
|
// ── the doc ──
|
|
load('ANDROID.md');
|
|
rep(`- **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\`).`,
|
|
`- **Importing** (\`importer.svelte.js\`, \`screens/Import.svelte\`): the
|
|
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\`).`);
|
|
rep(`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.`);
|
|
rep(`- 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).`);
|
|
rep(`(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`);
|
|
save();
|
|
console.log('round twenty-four wired');
|