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.
114 lines
7.0 KiB
JavaScript
114 lines
7.0 KiB
JavaScript
// Round eighteen: the hub's raw-search filters on the phone's network
|
|
// results (free slot only; FLAC or MP3 320+), and the lyrics door the
|
|
// desktop has for pasted words (plain text, or LRC with timestamps).
|
|
const fs = require('fs');
|
|
const root = process.env.SS_ROOT || 'F:/Coding/SoulStream/';
|
|
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); };
|
|
|
|
// ── Search: the filters ──
|
|
load('src/mobile/screens/Search.svelte');
|
|
rep(` function playPeer(g, i) {`, ` /* the hub's filters over the raw results: a free slot only, a format */
|
|
let netFree = $state(false);
|
|
let netFmt = $state("any");
|
|
const shownGroups = $derived(net.groups.filter((g) => {
|
|
if (netFree && !g.slot) return false;
|
|
if (netFmt === "flac") return g.topExt === "flac";
|
|
if (netFmt === "mp3320") return g.topExt !== "flac" && (g.maxBitrate ?? 0) >= 320;
|
|
return true;
|
|
}));
|
|
function playPeer(g, i) {`);
|
|
rep(` {#if net.error}<div class="sec-note">{net.error}</div>{/if}`,
|
|
` {#if net.error}<div class="sec-note">{net.error}</div>{/if}
|
|
{#if net.groups.length}
|
|
<div class="chips netfilters" style="padding-bottom: 10px">
|
|
<button class="chip" class:on={netFree} class:quiet={!netFree} onclick={() => (netFree = !netFree)}>Free slot</button>
|
|
{#each [["any", "Any format"], ["flac", "FLAC"], ["mp3320", "MP3 320+"]] as [v, l] (v)}
|
|
<button class="chip" class:on={netFmt === v} class:quiet={netFmt !== v} onclick={() => (netFmt = v)}>{l}</button>
|
|
{/each}
|
|
</div>
|
|
{#if shownGroups.length !== net.groups.length}<div class="sec-note">{shownGroups.length} of {net.groups.length} folders</div>{/if}
|
|
{/if}`);
|
|
rep(` {#each net.groups.slice(0, 40) as g (g.key)}`, ` {#each shownGroups.slice(0, 40) as g (g.key)}`);
|
|
save();
|
|
|
|
// ── lyrics: pasted words ──
|
|
load('src/mobile/lyrics.svelte.js');
|
|
s = s.replace(/\s*$/, String.fromCharCode(10)) + `
|
|
/** Pasted words: LRC with timestamps becomes synced, anything else plain. */
|
|
export async function pasteLyrics(text) {
|
|
const id = identity();
|
|
const t = String(text || "").trim();
|
|
if (!id || !t) return false;
|
|
try {
|
|
const p = await api.lyricsSaveText(id.title, id.artist, id.durationSecs, t);
|
|
if (p && p.status) apply(p); else await loadLyrics(true);
|
|
return true;
|
|
} catch (e) {
|
|
player.error = String(e);
|
|
return false;
|
|
}
|
|
}
|
|
`;
|
|
save();
|
|
load('src/mobile/HalfSheet.svelte');
|
|
rep(` import { lyr, nudge, searchCandidates, closeCandidates, chooseCandidate, forgetLyrics } from "./lyrics.svelte.js";`,
|
|
` import { lyr, nudge, searchCandidates, closeCandidates, chooseCandidate, forgetLyrics, pasteLyrics } from "./lyrics.svelte.js";`);
|
|
rep(` {@render row("search", "Wrong lyrics? Search again", "LRCLIB, by this track's name", () => { void searchCandidates(); })}`,
|
|
` {@render row("search", "Wrong lyrics? Search again", "LRCLIB, by this track's name", () => { void searchCandidates(); })}
|
|
{@render row("file-text", "Paste lyrics…", "plain text, or LRC with [mm:ss] timestamps", () => openHalf("prompt", { title: "Paste lyrics", placeholder: "[00:12.50] The first line…", ok: "Save", multiline: true, onsubmit: (v) => { void pasteLyrics(v); } }))}`);
|
|
save();
|
|
|
|
// ── mocks ──
|
|
load('src/lib/mockLibrary.js');
|
|
rep(` if (cmd === "lyrics_set_offset") return Promise.resolve();`,
|
|
` if (cmd === "lyrics_set_offset") return Promise.resolve();
|
|
if (cmd === "lyrics_save_text") {
|
|
const lines = String(args?.text || "").split(/\\r?\\n/).map((l) => l.trim()).filter(Boolean);
|
|
const timed = lines.map((l) => { const m = /^\\[(\\d+):(\\d\\d(?:\\.\\d+)?)\\]\\s*(.*)$/.exec(l); return m ? { time_ms: Math.round((+m[1] * 60 + +m[2]) * 1000), text: m[3] } : null; });
|
|
const synced = timed.every(Boolean);
|
|
return Promise.resolve({ status: synced ? "synced" : "plain", source: "pasted", synced: synced ? timed : [], plain: synced ? null : lines.join("\\n"), offset_ms: 0, auto_offset_ms: 0, auto_sync_off: false });
|
|
}`);
|
|
save();
|
|
|
|
// ── the walks ──
|
|
load('tests/mobile/smoke.mjs');
|
|
rep(` { do: \`__click('.halfsheet .row', 'Live')\`, name: "lyrics-chosen", wait: 700 },`,
|
|
` { do: \`__click('.halfsheet .row', 'Live')\`, name: "lyrics-chosen", wait: 700 },
|
|
/* pasted words, timestamped: synced lines */
|
|
{ do: \`__click('.lyr-tools', null)\`, wait: 600 },
|
|
{ do: \`__click('.halfsheet .row', 'Paste lyrics')\`, wait: 600 },
|
|
{ do: \`(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = '[00:01.00] Pasted line one\\\\n[00:05.00] Pasted line two'; i.dispatchEvent(new Event('input', { bubbles: true })); })()\`, wait: 300 },
|
|
{ do: \`__click('.halfsheet .prompt-acts .btn-fill', 'Save')\`, name: "lyrics-pasted", wait: 800 },`);
|
|
rep(` "lyrics-chosen": (i) => !i.half.includes("up") && i.lyrLines >= 3,`,
|
|
` "lyrics-chosen": (i) => !i.half.includes("up") && i.lyrLines >= 3,
|
|
"lyrics-pasted": (i) => !i.half.includes("up") && i.lyrLines === 2,`);
|
|
rep(` { do: \`document.querySelector('.peer-file').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))\`, name: "peer-file-sheet", wait: 700 },`,
|
|
` /* the hub's filters: FLAC only, then a free slot only */
|
|
{ do: \`__click('.netfilters .chip', 'FLAC')\`, name: "net-filter-flac", wait: 500 },
|
|
{ do: \`__click('.netfilters .chip', 'Free slot')\`, name: "net-filter-free", wait: 500 },
|
|
{ do: \`__click('.netfilters .chip', 'Free slot')\`, wait: 300 },
|
|
{ do: \`__click('.netfilters .chip', 'Any format')\`, wait: 400 },
|
|
{ do: \`document.querySelector('.peer-file').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))\`, name: "peer-file-sheet", wait: 700 },`);
|
|
rep(` "peer-file-sheet": (i) => i.half.includes("up") && /Save this file/.test(i.halfText) && /Play from here/.test(i.halfText),`,
|
|
` "net-filter-flac": (i) => i.peers === 3,
|
|
"net-filter-free": (i) => i.peers === 2,
|
|
"peer-file-sheet": (i) => i.half.includes("up") && /Save this file/.test(i.halfText) && /Play from here/.test(i.halfText),`);
|
|
save();
|
|
|
|
// ── the doc ──
|
|
load('ANDROID.md');
|
|
rep(`The nav wears two badges the desktop's chrome has:`, `The network results carry the hub's filters (a free slot only; FLAC or
|
|
MP3 320+), and the lyrics sheet has the desktop's paste door (plain text,
|
|
or LRC with timestamps, kept for the track). The nav wears two badges the desktop's chrome has:`);
|
|
save();
|
|
console.log('round eighteen wired');
|