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.
170 lines
11 KiB
JavaScript
170 lines
11 KiB
JavaScript
// Round five: the catalog record page's own sheet (play, save, add all to a
|
|
// playlist, the artist), a raw file's sheet (play from here, save this
|
|
// file, browse), room users → the peer sheet, and Search's "did you mean"
|
|
// from the library's own names. 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); };
|
|
|
|
// ── HalfSheet ──
|
|
load('src/mobile/HalfSheet.svelte');
|
|
rep(` import { streamSong, saveSong } from "./resolve.svelte.js";`,
|
|
` import { streamSong, saveSong, streamCatalogAlbum, saveCatalogAlbum } from "./resolve.svelte.js";
|
|
import { catalog, yearOf } from "./catalog.svelte.js";`);
|
|
rep(` import { fmtOf, albumFmt, fmtBytes, basename, dirname, longDur, mmss } from "./util.js";`,
|
|
` import { fmtOf, albumFmt, fmtBytes, basename, dirname, longDur, mmss, titleFromName } from "./util.js";`);
|
|
rep(` if (t.n === "playlist") return "playlist-more";
|
|
return "home-more";`, ` if (t.n === "playlist") return "playlist-more";
|
|
if (t.n === "catalogAlbum") return "catalogAlbum-more";
|
|
return "home-more";`);
|
|
rep(` const trackArg = $derived(kind === "track" ? shown?.arg : null);`,
|
|
` const trackArg = $derived(kind === "track" ? shown?.arg : null);
|
|
/* the catalog record page's sheet reads the page the store holds */
|
|
const catPage = $derived(kind === "catalogAlbum-more" ? (catalog.albums[top().c.id]?.page ?? null) : null);`);
|
|
rep(`oncontextmenu={(e) => { e.preventDefault(); (buddy ? removeBuddy : addBuddy)(u.username).catch(() => {}); }}>`,
|
|
`oncontextmenu={(e) => { e.preventDefault(); openHalf("peer", { username: u.username }); }}>`);
|
|
beforeLine(` {:else if kind === "librarysort"}`, ins('ins-halfsheet-cat-file.txt'));
|
|
save();
|
|
|
|
// ── Search: a raw file's sheet; did you mean ──
|
|
load('src/mobile/screens/Search.svelte');
|
|
rep(` <button class="peer-file" class:playing={player.track?.path === f.path} onclick={() => playPeer(g, i)}>`,
|
|
` <button class="peer-file" class:playing={player.track?.path === f.path} onclick={() => playPeer(g, i)} oncontextmenu={(e) => { e.preventDefault(); openHalf("peerfile", { group: g, index: i }); }}>`);
|
|
rep(` const catEmpty = $derived(!!cat && !allArtists.length && !allAlbums.length && !allTracks.length);`,
|
|
` const catEmpty = $derived(!!cat && !allArtists.length && !allAlbums.length && !allTracks.length);
|
|
/* a spelling correction, when one is obvious — the desktop's rule: the
|
|
only trustworthy vocabulary offline is the names this phone knows
|
|
(the library, the searches that worked), a word is corrected only
|
|
when a real name is within two edits, nothing is guessed otherwise */
|
|
const vocabulary = $derived.by(() => {
|
|
const words = new Set();
|
|
const add = (text) => { for (const w of String(text ?? "").toLowerCase().split(/[^a-z0-9']+/)) if (w.length >= 3) words.add(w); };
|
|
for (const a of lib.albums) { add(a.artist); add(a.title); }
|
|
for (const r of recents) add(r);
|
|
return [...words];
|
|
});
|
|
const didYouMean = $derived.by(() => {
|
|
if (!catEmpty || catBusy) return null;
|
|
const typed = query.split(/\\s+/);
|
|
if (!typed.length || !vocabulary.length) return null;
|
|
let corrected = false;
|
|
const fixed = typed.map((word) => {
|
|
const lower = word.toLowerCase();
|
|
if (lower.length < 3 || vocabulary.includes(lower)) return word;
|
|
let best = null;
|
|
let bestDist = 3;
|
|
for (const c of vocabulary) { const d = editDistance(lower, c, bestDist); if (d < bestDist) { bestDist = d; best = c; } }
|
|
if (!best) return word;
|
|
corrected = true;
|
|
return best;
|
|
});
|
|
return corrected ? fixed.join(" ") : null;
|
|
});
|
|
/** Levenshtein, abandoned as soon as every cell exceeds the limit. */
|
|
function editDistance(a, b, limit) {
|
|
if (Math.abs(a.length - b.length) >= limit) return limit;
|
|
const prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
for (let i = 1; i <= a.length; i++) {
|
|
let diag = prev[0];
|
|
prev[0] = i;
|
|
let row = i;
|
|
for (let j = 1; j <= b.length; j++) {
|
|
const tmp = prev[j];
|
|
prev[j] = Math.min(prev[j] + 1, prev[j - 1] + 1, diag + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
diag = tmp;
|
|
row = Math.min(row, prev[j]);
|
|
}
|
|
if (row >= limit) return limit;
|
|
}
|
|
return prev[b.length];
|
|
}`);
|
|
rep(` {#if catEmpty && !catBusy}<div class="sec-note">Nothing on the catalog for “{query}”.</div>{/if}`,
|
|
` {#if catEmpty && !catBusy}<div class="sec-note">Nothing on the catalog for “{query}”.{#if didYouMean} Did you mean <button class="dym" onclick={() => setQ(didYouMean)}>{didYouMean}</button>?{/if}</div>{/if}`);
|
|
save();
|
|
|
|
// ── Browse: a file's sheet ──
|
|
load('src/mobile/screens/Browse.svelte');
|
|
rep(` <button class="peer-file" class:playing={player.track?.path === f.path} disabled={!audio(f)} onclick={() => play(d, f)}>`,
|
|
` <button class="peer-file" class:playing={player.track?.path === f.path} disabled={!audio(f)} onclick={() => play(d, f)} oncontextmenu={(e) => { e.preventDefault(); fileSheet(d, f); }}>`);
|
|
rep(` function toggle(name) {`, ` /* a file's sheet: the folder as the sheet's group shape */
|
|
function fileSheet(d, f) {
|
|
const files = d.files.filter(audio);
|
|
openHalf("peerfile", { group: { username, folder: d.name, folderName: dirName(d), files, slot: true, speed: 0, queue: 0 }, index: Math.max(0, files.indexOf(f)) });
|
|
}
|
|
function toggle(name) {`);
|
|
save();
|
|
|
|
// ── the walks ──
|
|
load('tests/mobile/smoke.mjs');
|
|
rep(` recents: document.querySelectorAll('.recent-chips .chip').length,`,
|
|
` recents: document.querySelectorAll('.recent-chips .chip').length,
|
|
dym: (document.querySelector('.dym') || {}).textContent || '',
|
|
sq: (document.querySelector('.sfield input') || {}).value || '',`);
|
|
// did you mean: a library name within two edits
|
|
rep(` { do: \`(function () { var i = document.querySelector('.sfield input'); i.value = 'shpo'; i.dispatchEvent(new Event('input', { bubbles: true })); })()\`, name: "search", wait: 900 },`,
|
|
` { do: \`(function () { var i = document.querySelector('.sfield input'); i.value = 'shpo'; i.dispatchEvent(new Event('input', { bubbles: true })); })()\`, name: "search", wait: 900 },
|
|
/* a misspelt library name: the catalog has nothing, the library's own names correct it */
|
|
{ do: \`(function () { var i = document.querySelector('.sfield input'); i.value = 'shpongel'; i.dispatchEvent(new Event('input', { bubbles: true })); })()\`, name: "search-dym", wait: 900 },
|
|
{ do: \`__click('.dym', null)\`, name: "search-dym-pick", wait: 700 },`);
|
|
afterLine('"search-empty": (i) =>', ` "search-dym": (i) => i.dym === "shpongle",
|
|
"search-dym-pick": (i) => i.sq === "shpongle" && !i.dym,
|
|
`);
|
|
// the catalog record page's sheet, a raw file's sheet
|
|
rep(` { do: \`__click('.rail .release', 'Discovery')\`, name: "cat-album", wait: 1500 },`,
|
|
` { do: \`__click('.rail .release', 'Discovery')\`, name: "cat-album", wait: 1500 },
|
|
/* the record page's own sheet from the bar */
|
|
{ do: \`document.querySelectorAll('.gbar .iconbtn')[1].click()\`, name: "cat-album-sheet", wait: 700 },
|
|
{ do: \`document.querySelector('.scrim').click()\`, wait: 500 },`);
|
|
rep(` "cat-album": (i) => i.screen.includes("tr-push") && i.title.startsWith("Discovery") && i.eds >= 2 && i.lede && i.bars >= 10,`,
|
|
` "cat-album": (i) => i.screen.includes("tr-push") && i.title.startsWith("Discovery") && i.eds >= 2 && i.lede && i.bars >= 10,
|
|
"cat-album-sheet": (i) => i.half.includes("up") && /Save to library/.test(i.halfText) && /Add to playlist/.test(i.halfText) && /Go to artist/.test(i.halfText),`);
|
|
rep(` { do: \`document.querySelector('.peer-head .peer-menu').click()\`, name: "peer-sheet", wait: 700 },`,
|
|
` { do: \`document.querySelector('.peer-file').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))\`, name: "peer-file-sheet", wait: 700 },
|
|
{ do: \`document.querySelector('.scrim').click()\`, wait: 500 },
|
|
{ do: \`document.querySelector('.peer-head .peer-menu').click()\`, name: "peer-sheet", wait: 700 },`);
|
|
rep(` "peer-sheet": (i) => i.half.includes("up") && /Browse their files/.test(i.halfText) && /Play this folder/.test(i.halfText),`,
|
|
` "peer-file-sheet": (i) => i.half.includes("up") && /Save this file/.test(i.halfText) && /Play from here/.test(i.halfText),
|
|
"peer-sheet": (i) => i.half.includes("up") && /Browse their files/.test(i.halfText) && /Play this folder/.test(i.halfText),`);
|
|
save();
|
|
|
|
// ── CSS ──
|
|
load('src/mobile/shell.css');
|
|
s = s.replace(/\s*$/, NL) + `
|
|
/* Search: the spelling suggestion, inline in the note */
|
|
.sec-note .dym { color: var(--accent-hover); font-weight: 750; padding: 0 2px; }
|
|
`;
|
|
save();
|
|
|
|
// ── the doc ──
|
|
load('ANDROID.md');
|
|
rep(`Two more long presses: a catalog song in the search results opens the
|
|
catalog track's sheet (play, save, add to a playlist, the record, the
|
|
artist), and a row of the queue face opens the track's sheet with Play now,
|
|
Move to next and Remove from queue on top.`,
|
|
`More long presses: a catalog song in the search results opens the catalog
|
|
track's sheet (play, save, add to a playlist, the record, the artist); a
|
|
row of the queue face opens the track's sheet with Play now, Move to next
|
|
and Remove from queue on top; a raw result's file (and a file on the Browse
|
|
screen) opens its own — play from here, save this one file, browse; a name
|
|
in a room's list opens the peer sheet. The catalog record page's bar ⋯ is
|
|
the record's sheet: play, save, add every track to a playlist as streams,
|
|
the artist. Search corrects an obvious misspelling from the names this
|
|
phone knows (the library, the searches that worked) — the desktop's rule,
|
|
a word only when a real name is within two edits.`);
|
|
save();
|
|
console.log('round five wired');
|