// Round twenty-three: the rest of the lyrics tools — the per-track auto-sync // switch, the tap-to-sync room (a full-screen layer the back gesture peels: // the song plays, one tap per line, the taps are the timing), and the // desktop's vocal-reduced copy. Store, room, sheet rows, mocks, walks, doc. const fs = require('fs'); const root = process.env.SS_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 store: the switch, the reduced copy, the tap room ── load('src/mobile/lyrics.svelte.js'); rep(`import { player } from "./player.svelte.js";`, `import { player, current, playQueueAt } from "./player.svelte.js"; import { note } from "./notice.svelte.js"; import { openTiming, closeTiming } from "./nav.svelte.js";`); rep(` searching: false, });`, ` searching: false, autoSyncOff: false, // the user turned the automatic correction off here reducing: false, // a vocal-reduced copy is being made reduced: null, // { from, to } once one has been made for this track });`); /* Only a READ carries the entry's auto-sync flag: the Rust's payload_from — what lyrics_save_lines, lyrics_save_text and lyrics_select all return — leaves that field at its default, so applying one of those over the store would flip the switch back on while the backend still has it off. Reads pass `full`; writes hand over the document and nothing else. */ rep(`function apply(p) {`, `function apply(p, full = false) {`); rep(` lyr.autoOffset = p?.auto_offset_ms || 0;`, ` lyr.autoOffset = p?.auto_offset_ms || 0; if (full) lyr.autoSyncOff = !!p?.auto_sync_off;`); rep(` const p = await api.lyricsGet({ title: id.title, artist: id.artist, album: id.album, durationSecs: id.durationSecs, path: id.path, refresh: force ? true : null }); if (g !== gen) return; apply(p);`, ` const p = await api.lyricsGet({ title: id.title, artist: id.artist, album: id.album, durationSecs: id.durationSecs, path: id.path, refresh: force ? true : null }); if (g !== gen) return; apply(p, true);`); /* `reduced` is deliberately NOT reset here: the swap to the vocal-reduced copy changes the path, which IS this store's key, so a reset would forget the way back the moment it was taken. */ rep(` Object.assign(lyr, { key: id.key, status: "loading", source: "", lines: [], plain: "", offset: 0, userOffset: 0, autoOffset: 0, candidates: null, searching: false });`, ` Object.assign(lyr, { key: id.key, status: "loading", source: "", lines: [], plain: "", offset: 0, userOffset: 0, autoOffset: 0, autoSyncOff: false, candidates: null, searching: false });`); s = s.replace(/\s*$/, NL) + ins('ins-r23-lyrics-store.txt'); save(); // ── nav: one more back-able layer, peeled before the rest ── load('src/mobile/nav.svelte.js'); rep(` viewerKey: null,`, ` viewerKey: null, /* the tap-to-sync room — over everything, peeled first */ timing: false,`); rep(` return (nav.viewer ? 1 : 0) + (nav.sheet ? 1 : 0) + (nav.half ? 1 : 0)`, ` return (nav.timing ? 1 : 0) + (nav.viewer ? 1 : 0) + (nav.sheet ? 1 : 0) + (nav.half ? 1 : 0)`); rep(` depth = Math.max(0, depth - 1); if (nav.viewer) nav.viewer = false;`, ` depth = Math.max(0, depth - 1); if (nav.timing) nav.timing = false; else if (nav.viewer) nav.viewer = false;`); rep(`export function back() { if (nav.viewer) nav.viewer = false;`, `export function back() { if (nav.timing) nav.timing = false; else if (nav.viewer) nav.viewer = false;`); rep(`export function closeViewer() { if (!nav.viewer) return; nav.viewer = false; sync(); }`, `export function closeViewer() { if (!nav.viewer) return; nav.viewer = false; sync(); } export function openTiming() { if (nav.timing) return; nav.timing = true; sync(); } export function closeTiming() { if (!nav.timing) return; nav.timing = false; sync(); }`); save(); // ── the room itself ── fs.writeFileSync(root + 'src/mobile/SyncRoom.svelte', ins('ins-r23-syncroom.txt')); // ── the shell hangs it over everything ── load('src/mobile/Mobile.svelte'); rep(` import Viewer from "./Viewer.svelte";`, ` import Viewer from "./Viewer.svelte"; import SyncRoom from "./SyncRoom.svelte";`); rep(` `, ` `); save(); // ── the lyrics sheet: the switch, the way into the room, the copy ── load('src/mobile/HalfSheet.svelte'); rep(` import { lyr, nudge, searchCandidates, closeCandidates, chooseCandidate, forgetLyrics, pasteLyrics } from "./lyrics.svelte.js";`, ` import { lyr, nudge, searchCandidates, closeCandidates, chooseCandidate, forgetLyrics, pasteLyrics, setAutoSync, openTapSync, tapProgress, reduceVocals, backToOriginal } from "./lyrics.svelte.js";`); rep(` const onThisAlbum = $derived((id) => top().n === "album" && top().c.id === id);`, ` /* the lyrics sheet: a tap-sync set left mid-tap for the playing track, and whether a vocal-reduced copy is what is playing */ const tapLeft = $derived(kind === "lyrics" ? tapProgress() : null); const canReduce = $derived(!!player.track?.path && !player.track?.stream); const onReduced = $derived(!!lyr.reduced && player.track?.path === lyr.reduced.to); const onThisAlbum = $derived((id) => top().n === "album" && top().c.id === id);`); /* the sheet's first switch row — .row is a flex and .sw is flex: none, so it lays out the way You's switch rows do */ rep(`{#snippet row(icon, t, m, fn)} {/snippet}`, `{#snippet row(icon, t, m, fn)} {/snippet}` + NL + ins('ins-r23-halfsheet-swrow.txt').replace(/\s*$/, '')); /* no act(): the sheet stays up, so the switch is SEEN to flip */ rep(` {@render row("skip-forward", "Lyrics later", "half a second", () => nudge(500))}`, ` {@render row("skip-forward", "Lyrics later", "half a second", () => nudge(500))} {@render swrow("history", "Auto-sync this track", lyr.autoOffset ? \`correcting by \${lyr.autoOffset > 0 ? "+" : ""}\${(lyr.autoOffset / 1000).toFixed(1)} s\` : "let the app correct this track's timing", !lyr.autoSyncOff, () => { void setAutoSync(lyr.autoSyncOff); })}`); { const a = ` {@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); } }))}`; rep(a, a + NL + ins('ins-r23-halfsheet-rows.txt').replace(/\s*$/, '')); } save(); // ── the room's own styles ── load('src/mobile/shell.css'); s = s.replace(/\s*$/, NL) + ins('ins-r23-shell-css.txt'); save(); // ── mocks: the switch remembers, the taps come back timed, a copy is made ── load('src/lib/mockLibrary.js'); rep(`/* every track has three timed lines; the sheet's tools have something to act on */`, `/* the per-track auto-sync switch the lyrics sheet flips */ let autoSyncOff = false; /* every track has three timed lines; the sheet's tools have something to act on */`); rep(` plain: null, offset_ms: 0, auto_offset_ms: 0, auto_sync_off: false,`, ` plain: null, offset_ms: 0, auto_offset_ms: 0, auto_sync_off: autoSyncOff,`); rep(` if (cmd === "lyrics_clear") return Promise.resolve();`, ins('ins-r23-mock.txt') + ` if (cmd === "lyrics_clear") return Promise.resolve();`); save(); // ── the walks ── load('tests/mobile/smoke.mjs'); rep(` lyrLines: document.querySelectorAll('.lyr .ln').length,`, ` lyrLines: document.querySelectorAll('.lyr .ln').length,` + NL + ins('ins-r23-smoke-probe.txt').replace(/\s*$/, '')); { const a = " { do: `__click('.halfsheet .prompt-acts .btn-fill', 'Save')`, name: \"lyrics-pasted\", wait: 800 },"; rep(a, a + NL + ins('ins-r23-smoke-mock-steps.txt').replace(/\s*$/, '')); } { const a = ` "lyrics-pasted": (i) => !i.half.includes("up") && i.lyrLines === 2,`; rep(a, a + NL + ins('ins-r23-smoke-mock-expect.txt').replace(/\s*$/, '')); } { const a = " { 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 },"; rep(a, a + NL + ins('ins-r23-smoke-phone-steps.txt').replace(/\s*$/, '')); } { const a = ` "lyrics-nudged": (i) => i.half.includes("up") && !i.toast,`; rep(a, a + NL + ins('ins-r23-smoke-phone-expect.txt').replace(/\s*$/, '')); } save(); // ── the doc ── load('ANDROID.md'); rep(`or LRC with timestamps, kept for the track). One switch the desktop never needed:`, `or LRC with timestamps, kept for the track).` + NL + ins('ins-r23-android.txt').replace(/\s*$/, '') + NL + `One switch the desktop never needed:`); rep('`HalfSheet.svelte`, `Lane.svelte`, `Cover.svelte`, `Release.svelte` |', '`HalfSheet.svelte`, `SyncRoom.svelte`, `Lane.svelte`, `Cover.svelte`, `Release.svelte` |'); rep('the waveform lane, the two card pieces', 'the tap-to-sync room, the waveform lane, the two card pieces'); save(); console.log('round 23 wired');