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

243 lines
15 KiB
JavaScript

// Round ten: the lyrics tools (a store the sheet's face and a lyrics sheet
// share: nudge, choose another candidate, forget and look again), You →
// Clear play history, a record's Re-fetch the cover. Mocks, walks, doc.
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);
const afterLine = (anchor, text) => { const i = once(anchor); const nl = s.indexOf(NL, i); s = s.slice(0, nl + 1) + text + s.slice(nl + 1); };
const beforeLine = (anchor, text) => { const i = once(anchor); const ls = s.lastIndexOf(NL, i) + 1; s = s.slice(0, ls) + text + s.slice(ls); };
// ── the sheet's lyrics face reads the store ──
load('src/mobile/Sheet.svelte');
rep(` /* ── lyrics: the app's own pipeline, per track ───────────────────── */
let lyr = $state({ key: null, status: "none", lines: [], plain: "", offset: 0 });
$effect(() => {
if (face !== "lyrics" || !player.track) return;
const t = player.track;
const key = t.path;
if (lyr.key === key) return;
lyr = { key, status: "loading", lines: [], plain: "", offset: 0 };
api.lyricsGet({
title: t.title,
artist: t.artist || player.album?.artist || "",
album: player.album?.title ?? null,
durationSecs: t.duration_secs ?? null,
path: t.path,
}).then(
(p) => {
if (lyr.key !== key) return;
lyr = {
key,
status: p?.status || "none",
lines: p?.synced || [],
plain: p?.plain || "",
offset: (p?.offset_ms || 0) + (p?.auto_offset_ms || 0),
};
},
() => { if (lyr.key === key) lyr = { key, status: "none", lines: [], plain: "", offset: 0 }; },
);
});`,
` /* ── lyrics: the store (lyrics.svelte.js), asked for the playing track
whenever the face is up ───────────────────────────────────────── */
$effect(() => {
if (face !== "lyrics" || !player.track) return;
void player.track.path;
void loadLyrics();
});`);
rep(`import { nav, closeSheet, morphSource, openHalf, openViewer, push } from "./nav.svelte.js";`,
`import { nav, closeSheet, morphSource, openHalf, openViewer, push } from "./nav.svelte.js";
import { lyr, loadLyrics } from "./lyrics.svelte.js";`);
rep(` <div class="lyr">
{#if lyr.status === "loading"}`,
` <div class="lyr" oncontextmenu={(e) => { e.preventDefault(); openHalf("lyrics"); }} role="presentation">
{#if lyr.status === "loading"}`);
rep(` {:else if lyr.plain}
<div class="lyr-plain">{lyr.plain}</div>
<div class="lyr-note">Plain lyrics — nothing timed for this recording.</div>`,
` {:else if lyr.plain}
<div class="lyr-plain">{lyr.plain}</div>
<div class="lyr-note">Plain lyrics — nothing timed for this recording.</div>
<button class="lyr-tools" onclick={() => openHalf("lyrics")}>Wrong lyrics? ⋯</button>`);
rep(` {:else if lyr.status === "instrumental"}
<div class="lyr-note">Instrumental.</div>`,
` {:else if lyr.status === "instrumental"}
<div class="lyr-note">Instrumental.</div>
<button class="lyr-tools" onclick={() => openHalf("lyrics")}>Not so? ⋯</button>`);
save();
// the synced list gets its tools link after the lines: the {/each} that closes the lines
{
const a = ` {/each}
{:else if lyr.plain}`;
once(a);
s = s.replace(a, () => ` {/each}
<button class="lyr-tools" onclick={() => openHalf("lyrics")}>Out of time, or wrong? ⋯</button>
{:else if lyr.plain}`);
save();
}
// ── the lyrics sheet ──
load('src/mobile/HalfSheet.svelte');
rep(` import { lib, albumById, albumsBy, tracksOf, loadLibrary, refreshPlaylists } from "./lib.svelte.js";`,
` import { lib, albumById, albumsBy, tracksOf, loadLibrary, refreshPlaylists, refreshRecents } from "./lib.svelte.js";
import { lyr, nudge, searchCandidates, closeCandidates, chooseCandidate, forgetLyrics } from "./lyrics.svelte.js";
import { art, wantArt } from "./art.svelte.js";`);
beforeLine(` {:else if kind === "librarysort"}`, ins('ins-halfsheet-lyrics.txt'));
// the record's sheet: re-fetch the cover
rep(` {@render row("users", "Go to artist", album.artist, act(() => push("artist", { name: album.artist })))}
</div>
<div class="sheet-info">
<div class="kv"><span class="k">Format</span><span class="v">{albumFmt(tracks) || "—"}</span></div>`,
` {@render row("users", "Go to artist", album.artist, act(() => push("artist", { name: album.artist })))}
{#if album.id != null}
<div class="sheet-hair"></div>
{@render row("image", "Re-fetch the cover", "the canonical high-resolution cover, into the folder and the tags", act(() => refetchCover(album)))}
{/if}
</div>
<div class="sheet-info">
<div class="kv"><span class="k">Format</span><span class="v">{albumFmt(tracks) || "—"}</span></div>`);
rep(` /* the prompt's field, seeded when the prompt opens */`,
` /* the desktop's per-record cover refresh; the thumbnail is asked for again */
async function refetchCover(album) {
try {
await api.refreshAlbumArt(album.id);
if (album.art_path) { delete art[album.art_path]; wantArt(album.art_path); }
note("Cover re-fetched for “" + album.title + "”");
} catch (e) { oops(e); }
}
/* the prompt's field, seeded when the prompt opens */`);
save();
// ── You: clear play history ──
load('src/mobile/screens/You.svelte');
rep(` import { lib, loadLibrary } from "../lib.svelte.js";`, ` import { lib, loadLibrary, refreshRecents } from "../lib.svelte.js";`);
rep(` async function rescan() {`, ` function clearHistory() {
openHalf("confirm", {
title: "Clear the play history?",
body: "Jump back in and the play counts start over. The library and the playlists stay.",
ok: "Clear", danger: true,
onconfirm: async () => {
try { await api.clearPlayHistory(); await refreshRecents(); note("Play history cleared"); } catch (e) { player.error = String(e); }
},
});
}
async function rescan() {`);
rep(` <button class="row" onclick={() => push("transfers", {})}>`,
` <button class="row" onclick={clearHistory}>
<span class="lead"><Icon name="history" size={20} /></span>
<span class="b"><span class="t">Clear play history</span><span class="m">what Jump back in and the play counts are built from</span></span>
</button>
<button class="row" onclick={() => push("transfers", {})}>`);
save();
// ── mocks ──
load('src/lib/mockLibrary.js');
rep(` if (cmd === "lyrics_get") return Promise.resolve(null);`,
` if (cmd === "lyrics_get") return new Promise((r) => setTimeout(() => r(mockLyrics(args?.title)), 150));
if (cmd === "lyrics_search") return new Promise((r) => setTimeout(() => r([
{ id: 901, title: args?.title || "Track", artist: args?.artist || "?", album: "The Mock Sessions", synced: true, instrumental: false, durationSecs: 214 },
{ id: 902, title: (args?.title || "Track") + " (Live)", artist: args?.artist || "?", album: null, synced: false, instrumental: false, durationSecs: 233 },
]), 200));
if (cmd === "lyrics_select") return Promise.resolve({ ...mockLyrics(args?.title), source: "lrclib #" + args?.candidateId });
if (cmd === "lyrics_set_offset") return Promise.resolve();
if (cmd === "lyrics_clear") return Promise.resolve();
if (cmd === "clear_play_history") return Promise.resolve();
if (cmd === "refresh_album_art") return new Promise((r) => setTimeout(() => r("C:\\\\mock\\\\cover.jpg"), 200));`);
rep(`function mockPlaylists(cmd, args) {`, `/* every track has three timed lines; the sheet's tools have something to act on */
function mockLyrics(title) {
return {
status: "synced", source: "lrclib",
synced: [{ time_ms: 0, text: "Here is " + (title || "a song") }, { time_ms: 4000, text: "Every word in time" }, { time_ms: 9000, text: "And the last one, late" }],
plain: null, offset_ms: 0, auto_offset_ms: 0, auto_sync_off: false,
};
}
function mockPlaylists(cmd, args) {`);
save();
// ── the walks ──
load('tests/mobile/smoke.mjs');
rep(` viewer: !!document.querySelector('.viewer img'),`,
` viewer: !!document.querySelector('.viewer img'),
lyrLines: document.querySelectorAll('.lyr .ln').length,
sheetTitle: (document.querySelector('.halfsheet .sheet-title') || {}).textContent || '',`);
// mock: the lyrics tools on the lyrics face
rep(` { do: \`document.querySelectorAll('.fbtn')[1].click()\`, name: "lyrics", wait: 900 },
{ do: \`document.querySelectorAll('.fbtn')[0].click()\`, wait: 300 },`,
` { do: \`document.querySelectorAll('.fbtn')[1].click()\`, name: "lyrics", wait: 900 },
/* the lyrics tools: nudged half a second, another candidate chosen */
{ do: \`__click('.lyr-tools', null)\`, name: "lyrics-sheet", wait: 700 },
{ do: \`__click('.halfsheet .row', 'Lyrics later')\`, name: "lyrics-nudged", wait: 500 },
{ do: \`__click('.halfsheet .row', 'Search again')\`, name: "lyrics-candidates", wait: 900 },
{ do: \`__click('.halfsheet .row', 'Live')\`, name: "lyrics-chosen", wait: 700 },
{ do: \`document.querySelectorAll('.fbtn')[0].click()\`, wait: 300 },`);
afterLine(' album: (i) => i.screen.includes("tr-push") && !i.mini,', ` lyrics: (i) => i.sheet.includes("up") && i.lyrLines >= 3,
"lyrics-sheet": (i) => i.half.includes("up") && /Lyrics later/.test(i.halfText) && /Search again/.test(i.halfText),
"lyrics-nudged": (i) => /\\+0\\.5 s/.test(i.sheetTitle),
"lyrics-candidates": (i) => /Choose the right lyrics/.test(i.halfText) && /Live/.test(i.halfText),
"lyrics-chosen": (i) => !i.half.includes("up") && i.lyrLines >= 3,
`);
// mock: clear play history from You
rep(` { do: \`__click('.prefs[data-mode="albumart"] .chip', '1500 px')\`, wait: 400 },
/* recent searches on the empty page: the ones this walk used */`,
` { do: \`__click('.prefs[data-mode="albumart"] .chip', '1500 px')\`, wait: 400 },
{ do: \`__click('.screen .row', 'Clear play history')\`, name: "history-confirm", wait: 600 },
{ do: \`__click('.halfsheet .prompt-acts .btn-fill', 'Clear')\`, name: "history-cleared", wait: 800 },
/* recent searches on the empty page: the ones this walk used */`);
rep(` "you-artedge": (i) => i.artEdge === "1000 px",`, ` "you-artedge": (i) => i.artEdge === "1000 px",
"history-confirm": (i) => i.half.includes("up") && /Clear the play history/.test(i.halfText),
"history-cleared": (i) => !i.half.includes("up") && /history cleared/i.test(i.notice),`);
// mock: the record sheet's cover re-fetch (the walk's first sheet is a record's)
rep(` { do: \`document.querySelector('.grid2 .release').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))\`, name: "hold-sheet", wait: 700 },
{ do: \`document.querySelector('.scrim').click()\`, wait: 500 },
{ do: \`document.querySelectorAll('.nav button')[1].click()\`, name: "search-empty", wait: 700 },`,
` { do: \`document.querySelector('.grid2 .release').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))\`, name: "hold-sheet", wait: 700 },
{ do: \`__click('.halfsheet .row', 'Re-fetch the cover')\`, name: "cover-refetched", wait: 800 },
{ do: \`document.querySelectorAll('.nav button')[1].click()\`, name: "search-empty", wait: 700 },`);
rep(` "hold-sheet": (i) => i.half.includes("up"),
"search-empty"`, ` "hold-sheet": (i) => i.half.includes("up") && /Re-fetch the cover/.test(i.halfText),
"cover-refetched": (i) => !i.half.includes("up") && /Cover re-fetched/.test(i.notice),
"search-empty"`);
// phone: the lyrics sheet, a nudge and its undo (nothing chosen, nothing forgotten)
rep(` { do: \`document.querySelectorAll('.fbtn')[1].click()\`, name: "lyrics", wait: 3500 },`,
` { do: \`document.querySelectorAll('.fbtn')[1].click()\`, name: "lyrics", wait: 3500 },
{ do: \`(function () { var b = document.querySelector('.lyr-tools'); if (b) b.click(); else __ss.openHalf('lyrics'); })()\`, name: "lyrics-sheet", wait: 800 },
{ do: \`(function () { var r = [].slice.call(document.querySelectorAll('.halfsheet .row')).find(function (x) { return /Lyrics later/.test(x.textContent); }); if (r) r.click(); })()\`, name: "lyrics-nudged", wait: 700 },
{ do: \`(function () { var r = [].slice.call(document.querySelectorAll('.halfsheet .row')).find(function (x) { return /Lyrics earlier/.test(x.textContent); }); if (r) r.click(); })()\`, wait: 700 },
{ do: \`document.querySelector('.scrim').click()\`, wait: 500 },`);
afterLine(' "album-playing": (i) => i.mini && !i.toast,', ` "lyrics-sheet": (i) => i.half.includes("up") && /Search again/.test(i.halfText),
"lyrics-nudged": (i) => i.half.includes("up") && !i.toast,
`);
save();
// ── CSS ──
load('src/mobile/shell.css');
s = s.replace(/\s*$/, NL) + `
/* the lyrics face's way to its tools */
.lyr-tools { display: block; margin: 18px auto 8px; padding: 7px 14px; border-radius: 999px; background: rgba(255, 255, 255, 0.08); color: var(--text-secondary); font-size: 12.5px; font-weight: 700; }
.lyr-tools:active { background: rgba(255, 255, 255, 0.14); }
`;
save();
// ── the doc ──
load('ANDROID.md');
rep(`sheet's ⋯ on a streamed track`, `sheet's lyrics face has its tools on a long press or the link under the
words (\`lyrics.svelte.js\`, the desktop's): the offset nudged half a
second at a time and kept for the track, another LRCLIB candidate chosen
when the words are wrong, the lookup forgotten and done again. A record's
sheet re-fetches its cover (the desktop's per-record refresh); You →
Library clears the play history behind a confirm. The now-playing
sheet's ⋯ on a streamed track`);
save();
console.log('round ten wired');