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.
This commit is contained in:
1 parent
867fd42972
commit
9f0601027c
400 files changed
+26401
-3
No files matched your search
@@ -0,0 +1,233 @@
|
||||
// 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');
|
||||
Reference in new issue
Block a user