Files
SoulStream/handoff/rounds/patch-round15.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

234 lines
12 KiB
JavaScript

// 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<R: Runtime>(tauri::plugin::PluginHandle<R>);`,
`#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct PickedImage {
pub path: Option<String>,
}
#[cfg(mobile)]
pub struct Storage<R: Runtime>(tauri::plugin::PluginHandle<R>);`);
rep(`pub fn init<R: Runtime>() -> TauriPlugin<R> {`,
`/// 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<R: Runtime>(app: tauri::AppHandle<R>) -> Result<PickedImage, String> {
#[cfg(mobile)]
{
use tauri::Manager;
let storage = app.state::<Storage<R>>();
storage
.0
.run_mobile_plugin::<PickedImage>("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<R: Runtime>() -> TauriPlugin<R> {`);
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<Vec<u8>, 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<String, String> {
let dir = covers_dir(app)?;
let target = dir.join(format!("{id}.jpg"));
let jpeg = tauri::async_runtime::spawn_blocking(move || -> Result<Vec<u8>, 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<String, String> {
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)))}
<div class="sheet-hair"></div>
{@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');