Files
SoulStream/handoff/rounds/patch-round24.cjs
T
KeelrLeek 9f0601027c android: the handoff — the pending rounds, the pipeline, and what is unverified
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.
2026-09-14 07:57:41 -05:00

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');