// Round fifteen: a playlist's cover from a photo on the phone. The storage // plugin gains pick_image (the document picker limited to images, the // content copied into the app's cache); Rust gains playlist_cover_set(id, // path) — the desktop's pick's install step, split out and shared; the // playlist sheets gain Choose cover photo and Remove custom cover. const fs = require('fs'); const 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 storage plugin: pick_image ── load('crates/tauri-plugin-storage/android/src/main/java/com/soulstream/storage/StoragePlugin.kt'); { const end = s.lastIndexOf('}'); s = s.slice(0, end).replace(/\s*$/, NL) + ins('ins-storage-pickimage.txt').replace(/^\n/, '') + '}' + NL; } save(); load('crates/tauri-plugin-storage/build.rs'); rep(`const COMMANDS: &[&str] = &["pick_folder", "all_files_access", "request_all_files_access"];`, `const COMMANDS: &[&str] = &["pick_folder", "all_files_access", "request_all_files_access", "pick_image"];`); save(); load('crates/tauri-plugin-storage/permissions/default.toml'); rep(`permissions = ["allow-pick-folder", "allow-all-files-access", "allow-request-all-files-access"]`, `permissions = ["allow-pick-folder", "allow-all-files-access", "allow-request-all-files-access", "allow-pick-image"]`); save(); load('crates/tauri-plugin-storage/src/lib.rs'); rep(`#[cfg(mobile)] pub struct Storage(tauri::plugin::PluginHandle);`, `#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct PickedImage { pub path: Option, } #[cfg(mobile)] pub struct Storage(tauri::plugin::PluginHandle);`); rep(`pub fn init() -> TauriPlugin {`, `/// A photo from the phone: the document picker limited to images, the /// picked content copied into the app's cache — a plain path for the Rust /// side (a playlist's cover, for one). #[tauri::command] async fn pick_image(app: tauri::AppHandle) -> Result { #[cfg(mobile)] { use tauri::Manager; let storage = app.state::>(); storage .0 .run_mobile_plugin::("pickImage", ()) .map_err(|e| e.to_string()) } #[cfg(not(mobile))] { let _ = app; Err("the image picker here is the desktop's dialog".into()) } } pub fn init() -> TauriPlugin {`); rep(` .invoke_handler(tauri::generate_handler![pick_folder, all_files_access, request_all_files_access])`, ` .invoke_handler(tauri::generate_handler![pick_folder, all_files_access, request_all_files_access, pick_image])`); save(); // ── Rust: the pick's install step shared with a given path ── load('src-tauri/src/playlists.rs'); { const a = ` let dir = covers_dir(&app)?; let target = dir.join(format!("{id}.jpg")); let jpeg = tauri::async_runtime::spawn_blocking(move || -> Result, String> { let bytes = std::fs::read(&source).map_err(|e| e.to_string())?;`; const b = ` changed(&app); Ok(Some(data_url_jpeg(&jpeg))) }`; const i = once(a); const j = s.indexOf(b, i); if (j < 0) throw new Error('cover pick tail'); const tail = s.slice(i, j + b.length); s = s.slice(0, i) + ` install_cover(&app, &lib, id, source).await.map(Some) } /// A picked image — the desktop's dialog, or a photo the phone copied into /// its cache — squared, written as the playlist's master cover, recorded. async fn install_cover( app: &tauri::AppHandle, lib: &LibraryState, id: i64, source: String, ) -> Result { let dir = covers_dir(app)?; let target = dir.join(format!("{id}.jpg")); let jpeg = tauri::async_runtime::spawn_blocking(move || -> Result, String> { let bytes = std::fs::read(&source).map_err(|e| e.to_string())?; if bytes.len() > 64 * 1024 * 1024 { return Err("Image is unreasonably large".into()); } let img = image::load_from_memory(&bytes).map_err(|e| format!("Not a usable image: {e}"))?; Ok(square_jpeg(&img, COVER_MASTER_EDGE)) }) .await .map_err(|e| e.to_string())??; let temp = dir.join(format!("{id}.importing")); std::fs::write(&temp, &jpeg).map_err(|e| e.to_string())?; std::fs::rename(&temp, &target).map_err(|e| e.to_string())?; let l = lib.library.clone(); let stored = target.to_string_lossy().into_owned(); tauri::async_runtime::spawn_blocking(move || { l.set_playlist_cover(id, Some(&stored), now_secs()) }) .await .map_err(|e| e.to_string())? .map_err(|e| e.to_string())?; changed(app); Ok(data_url_jpeg(&jpeg)) } /// The phone's door: a photo already on disk (the storage plugin copied it /// into the app's cache) becomes the playlist's cover. #[tauri::command] pub async fn playlist_cover_set( app: tauri::AppHandle, lib: State<'_, LibraryState>, id: i64, path: String, ) -> Result { install_cover(&app, &lib, id, path).await }` + s.slice(j + b.length); void tail; } save(); load('src-tauri/src/lib.rs'); rep(` playlists::playlist_cover_pick,`, ` playlists::playlist_cover_pick, playlists::playlist_cover_set,`); save(); // ── api ── load('src/lib/api.js'); rep(`export const playlistCoverClear = (id) => invoke("playlist_cover_clear", { id });`, `export const playlistCoverClear = (id) => invoke("playlist_cover_clear", { id }); export const playlistCoverSet = (id, path) => invoke("playlist_cover_set", { id, path });`); rep(`export const pendingShare = () => invoke("plugin:share|pending_share");`, `export const pendingShare = () => invoke("plugin:share|pending_share"); // A photo from the phone (the storage plugin): { path } in the app's cache. export const pickImage = () => invoke("plugin:storage|pick_image");`); save(); // ── the store and the sheets ── load('src/mobile/playlists.svelte.js'); rep(`/* ── reorder: a row up, down or to the top;`, `/* ── the cover: a photo from the phone, or the mosaic again ── */ export async function coverFromPhoto(p) { let r; try { r = await api.pickImage(); } catch (e) { if (/cancelled/.test(String(e))) return false; throw e; } if (!r?.path) return false; await api.playlistCoverSet(p.id, r.path); await refreshPlaylists(); note(\`Cover set for \${p.name}\`); return true; } export async function coverClear(p) { await api.playlistCoverClear(p.id); await refreshPlaylists(); note(\`Back to the mosaic for \${p.name}\`); } /* ── reorder: a row up, down or to the top;`); save(); load('src/mobile/HalfSheet.svelte'); rep(`removeItems, addEntries, createPlaylist, moveItem } from "./playlists.svelte.js";`, `removeItems, addEntries, createPlaylist, moveItem, coverFromPhoto, coverClear } from "./playlists.svelte.js";`); rep(` {@render row("layers", "Duplicate", "a copy, “" + p.name + " copy”", act(() => duplicatePlaylist(p).then((id) => push("playlist", { id })).catch(oops)))}`, ` {@render row("layers", "Duplicate", "a copy, “" + p.name + " copy”", act(() => duplicatePlaylist(p).then((id) => push("playlist", { id })).catch(oops)))}
{@render row("image", "Choose cover photo…", "a picture from this phone", act(() => coverFromPhoto(p).catch(oops)))} {#if p.cover_path} {@render row("x", "Remove custom cover", "the mosaic comes back", act(() => coverClear(p).catch(oops)))} {/if}`); save(); // ── mocks ── load('src/lib/mockLibrary.js'); rep(` { id: 1, name: "Late night drive", track_count: 0, description: "Slow, wide, mostly synths.", kind: "manual", pinned: true, cover_path: null,`, ` { id: 1, name: "Late night drive", track_count: 0, description: "Slow, wide, mostly synths.", kind: "manual", pinned: true, cover_path: "mock://cover1",`); rep(` if (cmd === "playlist_cover") { const p = plOf(args); return Promise.resolve(p && (p.id === 1 || p.cover_path) ? svgArt(p.id === 1 ? 330 : 280, p.id === 1 ? "L" : "M") : null); }`, ` if (cmd === "playlist_cover") { const p = plOf(args); return Promise.resolve(p && p.cover_path ? svgArt(p.id === 1 ? 330 : 280, p.id === 1 ? "L" : "M") : null); } if (cmd === "playlist_cover_set") { const p = plOf(args); if (!p) return Promise.reject(new Error("no such playlist")); p.cover_path = String(args?.path || "mock://photo"); plTouch(p); return Promise.resolve(svgArt(300, "P")); } if (cmd === "playlist_cover_clear") { const p = plOf(args); if (p) p.cover_path = null; plTouch(p); return Promise.resolve(); }`); rep(` if (cmd === "plugin:storage|request_all_files_access") return Promise.resolve();`, ` if (cmd === "plugin:storage|request_all_files_access") return Promise.resolve(); if (cmd === "plugin:storage|pick_image") return Promise.resolve({ path: "C:\\\\mock\\\\photo.jpg" });`); save(); // ── the walk (mock only: the phone's picker is the system's) ── load('tests/mobile/smoke.mjs'); rep(` plfirst: (document.querySelector('.pl-rows .trk .t') || {}).textContent || '',`, ` plfirst: (document.querySelector('.pl-rows .trk .t') || {}).textContent || '', plcover: !!document.querySelector('.rec-head.pl .rec-cover img'),`); rep(` { do: \`__click('.halfsheet .row', 'Unpin')\`, name: "playlist-unpinned", wait: 900 },`, ` { do: \`__click('.halfsheet .row', 'Unpin')\`, name: "playlist-unpinned", wait: 900 }, /* the cover: the custom one removed (the mosaic), then a photo chosen */ { do: \`document.querySelectorAll('.gbar .iconbtn')[1].click()\`, wait: 600 }, { do: \`__click('.halfsheet .row', 'Remove custom cover')\`, name: "cover-removed", wait: 1000 }, { do: \`document.querySelectorAll('.gbar .iconbtn')[1].click()\`, wait: 600 }, { do: \`__click('.halfsheet .row', 'Choose cover photo')\`, name: "cover-set", wait: 1000 },`); rep(` "playlist-unpinned": (i) => !i.half.includes("up") && !/Pinned/.test(i.plkind),`, ` "playlist-unpinned": (i) => !i.half.includes("up") && !/Pinned/.test(i.plkind), "cover-removed": (i) => !i.plcover && /mosaic/.test(i.notice), "cover-set": (i) => i.plcover && /Cover set/.test(i.notice),`); save(); // ── the doc ── load('ANDROID.md'); rep(`- **Add to playlist** is on every track and record sheet:`, `- **The cover**: Choose cover photo on a playlist's sheet opens the phone's image picker (the storage plugin's \`pick_image\`: the picked content is copied into the app's cache, and \`playlist_cover_set\` installs it the way the desktop's dialog pick does — squared, the master cover, the row's revision moved); Remove custom cover brings the mosaic back. - **Add to playlist** is on every track and record sheet:`); rep(`the listener door and the lyric alignment models (a desktop runtime), playlist export and the cover picker, drag reorder,`, `the listener door and the lyric alignment models (a desktop runtime), playlist export, drag reorder (Move up / down / to the top stand in),`); save(); console.log('round fifteen wired');