// Round thirty-five: a playlist, out of the phone. The desktop has had // Export since playlists v2 and the phone has never had a door onto it, // for one honest reason — `tauri-plugin-dialog` DOES open Android's // create-document sheet, but it answers with a `content://` URI and // `playlist_export` writes with `std::fs::write`, which cannot take one. // So: a `create_document` in the storage plugin whose Kotlin writes the // bytes through the ContentResolver, the export restructured so the file // is built by `playlist_formats` ABOVE the platform fork (the phone must // never rebuild a format — two exports that drift are worse than one that // is missing), and the desktop's five profiles on the playlist's own // sheet, in the desktop's order and its own words. `create_document` is a // plugin function called from RUST, not a command: the file never passes // through the WebView, so build.rs, the permissions and the capability are // all untouched. 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').replace(/\s+$/, ''); 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); }; /* One whole function goes, doc comment and all — too long to spell out twice, so it is cut between two anchors `once` has vouched for. */ const cut = (from, to, b) => { const i = once(from); const j = once(to); if (j < i) throw new Error('end before start in ' + p + ': ' + from.slice(0, 60)); s = s.slice(0, i) + b + s.slice(j + to.length); }; const NL = String.fromCharCode(10); /* Several of the anchors below are re-emitted (the sleep branch, the two walk-array ends), so a second run would double-insert where `once` cannot see it coming and only throw further down, leaving half a tree. This round creates no new file, so the guard is what it adds to the one crate nothing else in the repo touches. */ if (fs.readFileSync(root + 'crates/tauri-plugin-storage/src/lib.rs', 'utf8').includes('create_document')) { throw new Error('tauri-plugin-storage already has create_document — round 35 has already run against ' + root); } // ── the plugin: a document written where the user points ────────────── /* Not a command, so `invoke_handler`, build.rs's COMMANDS, permissions/ and src-tauri/capabilities are all DELIBERATELY untouched: the ACL gates `invoke`, and nothing here is invoked from the WebView. */ load('crates/tauri-plugin-storage/src/lib.rs'); rep(`pub fn init() -> TauriPlugin {`, ins('ins-r35-storage-rs.txt') + NL + NL + `pub fn init() -> TauriPlugin {`); save(); // ── the Kotlin half. UNBUILT: nothing in this session can compile it — // it is written against pickImage/imagePicked next door and against // TransferNoticePlugin's @InvokeArg/parseArgs pair, and no further. load('crates/tauri-plugin-storage/android/src/main/java/com/soulstream/storage/StoragePlugin.kt'); rep(`import android.provider.Settings`, `import android.provider.OpenableColumns import android.provider.Settings`); rep(`import app.tauri.annotation.TauriPlugin`, `import app.tauri.annotation.InvokeArg import app.tauri.annotation.TauriPlugin`); /* before the class KDoc, never between a KDoc and what it documents */ rep(`/** * The phone's storage for the library:`, ins('ins-r35-kt-args.txt') + NL + NL + `/** * The phone's storage for the library:`); /* a blank line ahead of it: the file has none before pickImage's KDoc, and two members running together is the one thing it does not do elsewhere */ rep(` /** A photo from the phone: the document picker limited to images, the`, NL + ins('ins-r35-kt-command.txt') + NL + NL + ` /** A photo from the phone: the document picker limited to images, the`); save(); // ── the export itself: one command, restructured around the fork ────── load('src-tauri/src/playlists.rs'); cut(`/// Export a playlist through a save dialog. \`format\` ∈ m3u8 | xspf | csv |`, ` Ok(Some(dest))${NL}}`, ins('ins-r35-export-rs.txt')); save(); // ── the wrapper's comment. No new wrapper: JS never calls the plugin ── load('src/lib/api.js'); rep(`// Export via a save dialog; format ∈ m3u8 | xspf | csv | json. Resolves to // the written path, or null when the dialog was cancelled.`, `// Export through whichever save door the platform has; format ∈ m3u8 | // xspf | pls | csv | json. Resolves to where the file went — the written // path on the desktop, the document's name on the phone (Android's // create-document sheet answers a content:// URI, not a path) — or null // when that sheet was dismissed.`); save(); // ── the store: the desktop's five, and the one call ─────────────────── load('src/mobile/playlists.svelte.js'); rep(`import { note } from "./notice.svelte.js";`, `import { note } from "./notice.svelte.js"; import { basename } from "./util.js";`); rep(`/* ── reorder: a row up, down or to the top; the complete id order goes`, ins('ins-r35-store.txt') + NL + NL + `/* ── reorder: a row up, down or to the top; the complete id order goes`); save(); // ── the door: the playlist's own sheet, and the sheet it opens ──────── load('src/mobile/HalfSheet.svelte'); rep(`coverFromPhoto, coverClear } from "./playlists.svelte.js";`, `coverFromPhoto, coverClear, EXPORTS, exportPlaylist } from "./playlists.svelte.js";`); /* unguarded by `kind`, so the shelf card's sheet carries it too — the desktop has Export on BOTH menus (Playlists.svelte and Playlist.svelte) */ rep(`
{@render row("trash", "Delete playlist", "", () => confirmDelete(p))}`, `
{@render row("export", "Export…", p.track_count + " track" + (p.track_count === 1 ? "" : "s") + ", as a playlist file", () => openHalf("plexport", { playlist: p }))}
{@render row("trash", "Delete playlist", "", () => confirmDelete(p))}`); rep(` {:else if kind === "sleep"}`, ins('ins-r35-sheet.txt') + NL + ` {:else if kind === "sleep"}`); save(); // ── the mock: where the file went, and nothing else ─────────────────── load('src/lib/mockLibrary.js'); rep(` if (cmd === "list_playlists") return Promise.resolve(PL.map((p) => ({ ...p })));`, ` if (cmd === "list_playlists") return Promise.resolve(PL.map((p) => ({ ...p }))); ` + ins('ins-r35-mock.txt')); save(); // ── the walks. The mock's two anchors are the ARRAY ENDS, so this block // lands after round thirty-four's fresh install put a provisioned // device back — which is exactly the device it wants. load('tests/mobile/smoke.mjs'); rep(`]; const MOCK_EXPECT = {`, ins('ins-r35-mock-steps.txt') + NL + `]; const MOCK_EXPECT = {`); rep(`}; /* the phone walk: a record by name, played for real */`, ins('ins-r35-mock-expect.txt') + NL + `}; /* the phone walk: a record by name, played for real */`); /* the phone leg rides the "Walk test" playlist the walk itself makes and deletes two steps later; nothing is written to Kyle's storage */ rep(` { do: \`document.querySelectorAll('.gbar .iconbtn')[1].click()\`, name: "playlist-more", wait: 800 },`, ins('ins-r35-phone-steps.txt') + NL + ` { do: \`document.querySelectorAll('.gbar .iconbtn')[1].click()\`, name: "playlist-more", wait: 800 },`); rep(` end: (i) => /^(Good |Still)/.test(i.title),`, ins('ins-r35-phone-expect.txt') + NL + ` end: (i) => /^(Good |Still)/.test(i.title),`); save(); // ── the doc ─────────────────────────────────────────────────────────── load('ANDROID.md'); rep(`- \`pick_folder\` answers "nothing picked" (desktop-only dialog); \`save_file\` works;`, `- \`pick_folder\` answers "nothing picked" (desktop-only dialog); \`save_file\` opens Android's create-document sheet, but answers a \`content://\` URI that \`std::fs\` cannot open — which is why the playlist export goes through the storage plugin's \`create_document\` instead;`); rep(`- **The share sheet** (\`crates/tauri-plugin-share\`, \`share.svelte.js\`):`, ins('ins-r35-doc-export.txt') + NL + `- **The share sheet** (\`crates/tauri-plugin-share\`, \`share.svelte.js\`):`); rep(`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.`, `Not on the phone yet: the playlist-file door — it wants 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(`it answered), then back to the provisioned device, asserts what each step should show`, `it answered), then back to the provisioned device, and after that the playlist export door three ways (a file written where the sheet pointed, a dismissed sheet that says nothing at all, and a refusal read back as a sentence), asserts what each step should show`); /* nine named weather steps become twelve: the dismissed sheet (`answer`), the refusal (`fail`) and the one that reads the weather back off (`clear`) — the same three roles `weather-clear` was counted in */ rep(`Nine mock steps drive them`, `Twelve mock steps drive them`); /* the last line of that bullet — "beside the `pick_folder` and `pick_image` it already has" — is left BYTE-IDENTICAL so a sibling round adding `pick_file` still finds its anchor. It is now incomplete (create_document is there too); incomplete beats breaking the chain. */ rep(`- **Waiting on Rust, not on a picker.** Playlist EXPORT: \`tauri-plugin-dialog\` does open Android's create-document sheet from Rust, but it answers with a \`content://\` URI and \`playlist_export\` writes with \`std::fs::write\`, which cannot take one. It needs a ContentResolver write, or the bytes handed to an outbound share intent — and \`crates/tauri-plugin-share\` is inbound-only today. Playlist-FILE import wants a \`pick_file\` in the storage plugin,`, `- **Waiting on a picker.** The storage plugin can WRITE a document now (\`create_document\`, which the playlist export goes through) but it cannot read one. Playlist-FILE import wants a \`pick_file\` in the storage plugin,`); save(); console.log('round 35 wired');