// Round thirty-seven: dragged into place. The phone stood in for the // desktop's drag with Move up / Move down / Move to the top on a row's // sheet, which is the accessible path and stays — but a phone should drag // BETTER than a desktop does, not worse. So: one gesture module for both // lists, press and hold a row and it lifts, the rows below make room, let // go and it lands. The hard parts are all about the finger being the thing // that scrolls as well: a 320ms hold that does not fight the page's own // scroll (touch-action cannot be flipped mid-gesture, so a non-passive // touchmove refuses it instead), an edge pull measured in the scroller's // CONTENT space, a drop that maps to an index the mutations already // understand — moveItem and moveInQueue each grew a numeric target rather // than a second path to the backend — and a drag refused outright while // the filter is on, because a filtered list's visible order is not the // stored order. Release without moving and the row's sheet opens exactly // as it always did, which is what keeps the queue's sheet reachable at // all. No Rust, no Kotlin, no new command and no new mock command. 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').replace(/\s+$/, ''); 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 shell.css block is an APPEND with no anchor, so a second run would write it twice where `once` cannot see it coming and would only throw further down, leaving half a tree. Refuse one here, before anything is touched: the gesture module is the file this round creates and nothing else in the repo makes it. */ if (fs.existsSync(root + 'src/mobile/reorder.js')) { throw new Error('src/mobile/reorder.js already exists — round 37 has already run against ' + root); } // ── the gesture, once, for both lists ───────────────────────────────── fs.writeFileSync(root + 'src/mobile/reorder.js', ins('ins-r37-reorder.txt') + NL); // ── the mutations widen; they are not replaced ──────────────────────── load('src/mobile/playlists.svelte.js'); rep(` /** the last removal: { playlistId, rows: [{ item, position }], label } */`, ` /** true while a row is in hand on the playlist page: the page's own * re-read waits for the drop rather than replacing the rows under it */ dragging: false, /** the last removal: { playlistId, rows: [{ item, position }], label } */`); rep(`/* ── reorder: a row up, down or to the top; the complete id order goes in one call, as the desktop's drop does ── */`, `/* ── reorder: a row up, down, to the top, or to whatever index a drag dropped it at; the complete id order goes in one call either way, as the desktop's drop does ── */`); rep(` const to = where === "top" ? 0 : where === "up" ? from - 1 : from + 1;`, ` const to = typeof where === "number" ? Math.max(0, Math.min(ids.length - 1, where)) : where === "top" ? 0 : where === "up" ? from - 1 : from + 1;`); save(); load('src/mobile/player.svelte.js'); rep(`/** An upcoming row one place up or down, never past the current track. */`, `/** An upcoming row one place up or down — or to an absolute index, which * is what a drop on the queue face hands it — never past the current * track. The guards were already the right guards for either. */`); rep(` const j = i + (dir === "up" ? -1 : 1);`, ` const j = typeof dir === "number" ? dir : i + (dir === "up" ? -1 : 1);`); save(); // ── the playlist page ───────────────────────────────────────────────── load('src/mobile/screens/Playlist.svelte'); /* a sibling import rather than a rewritten one: the util.js line is the exact class of line two rounds of an earlier wave collided on */ rep(` import { mmss, longDur, stamp } from "../util.js";`, ` import { mmss, longDur, stamp } from "../util.js"; import { reorder } from "../reorder.js";`); rep(`rowClick } from "../playlists.svelte.js";`, `rowClick, moveItem } from "../playlists.svelte.js";`); rep(` function tap(item) { if (!rowClick(pl, items, item)) sheet(item); }`, ` function tap(item) { if (!rowClick(pl, items, item)) sheet(item); }` + NL + ins('ins-r37-pl-glue.txt')); rep(` void again;`, ` void again; /* a refetch that lands mid-drag would yank the rows out from under the gesture; the desktop defers one the same way (skippedWhileDrag). Reading the flag is what makes this effect re-run by itself the moment the drag has finished. */ if (pls.dragging) return;`); rep(` {#if q}{/if} {/if}`, ` {#if q}{/if} {#if q && items.length > 1}
Clear the search to drag rows into place.
{/if} {/if}`); rep(`
`, `
`); save(); // ── the queue face ──────────────────────────────────────────────────── load('src/mobile/Sheet.svelte'); rep(` import { mmss, drag } from "./util.js";`, ` import { mmss, drag } from "./util.js"; import { reorder } from "./reorder.js";`); rep(`cycleRepeat } from "./player.svelte.js";`, `cycleRepeat, moveInQueue } from "./player.svelte.js";`); rep(` function lineSeek(l) { seekSecs(Math.max(0, (l.time_ms + lyr.offset) / 1000)); }`, ` function lineSeek(l) { seekSecs(Math.max(0, (l.time_ms + lyr.offset) / 1000)); }` + NL + ins('ins-r37-sheet-glue.txt')); rep(`
`, `
`); save(); // ── the sheet teaches the gesture where someone is already looking for // reordering. No new chrome, no invented grip. ──────────────────────── load('src/mobile/HalfSheet.svelte'); rep(`{@render row("chevron-up", "Move up", "", act(() => moveInQueue(trackArg.queueIndex, "up")))}`, `{@render row("chevron-up", "Move up", "or press and hold a row to drag it", act(() => moveInQueue(trackArg.queueIndex, "up")))}`); rep(`{@render row("chevron-up", "Move up", "", act(() => moveItem(p, rows, item, "up").catch(oops)))}`, `{@render row("chevron-up", "Move up", "or press and hold a row to drag it", act(() => moveItem(p, rows, item, "up").catch(oops)))}`); save(); // ── the look: appended, so the guard above is what refuses a second run load('src/mobile/shell.css'); s = s.replace(/\s*$/, NL) + NL + ins('ins-r37-css.txt') + NL; save(); // ── the walks ───────────────────────────────────────────────────────── load('tests/mobile/smoke.mjs'); /* A row lifting under a finger buzzes. Chrome refuses navigator.vibrate until the frame has had a real tap, and every pointer event this walk sends is synthetic — so the lift logs an intervention notice at error level, which the console trap would otherwise count as a problem in a step that is working perfectly. It is a fact about the harness, not about the app, and it joins the reload's orphaned-callback notice on the same short list. */ rep(` cdp.on["Log.entryAdded"] = (p) => { if (p.entry.level === "error") problems.push("LOG " + p.entry.text + " " + (p.entry.url || "")); };`, ` cdp.on["Log.entryAdded"] = (p) => { if (p.entry.level !== "error") return; if (/navigator\\.vibrate/.test(p.entry.text || "")) return; problems.push("LOG " + p.entry.text + " " + (p.entry.url || "")); };`); rep(`window.__settle = function () {`, ins('ins-r37-helpers.txt') + NL + `window.__settle = function () {`); rep(` plthumbs: document.querySelectorAll('.pl-rows .thumb img').length,`, ` plthumbs: document.querySelectorAll('.pl-rows .thumb img').length,` + NL + ins('ins-r37-probe.txt')); rep(` { do: \`__click('.halfsheet .row', 'Move down')\`, name: "playlist-moved-down", wait: 900 },`, ` { do: \`__click('.halfsheet .row', 'Move down')\`, name: "playlist-moved-down", wait: 900 },` + NL + ins('ins-r37-mock-steps.txt')); rep(` { do: \`__click('.halfsheet .row', 'Move up')\`, name: "queue-moved-up", wait: 600 },`, ` { do: \`__click('.halfsheet .row', 'Move up')\`, name: "queue-moved-up", wait: 600 },` + NL + ins('ins-r37-mock-qsteps.txt')); rep(` "playlist-moved-down": (i) => i.plfirst.startsWith("Track 1"),`, ` "playlist-moved-down": (i) => i.plfirst.startsWith("Track 1"),` + NL + ins('ins-r37-mock-expect.txt')); rep(` { do: \`__click('.plgrid .release', 'Walk test')\`, name: "playlist-filled", wait: 1500 },`, ` { do: \`__click('.plgrid .release', 'Walk test')\`, name: "playlist-filled", wait: 1500 },` + NL + ins('ins-r37-phone-plsteps.txt')); rep(` { do: \`document.querySelectorAll('.fbtn')[2].click()\`, name: "queue", wait: 800 },`, ` { do: \`document.querySelectorAll('.fbtn')[2].click()\`, name: "queue", wait: 800 },` + NL + ins('ins-r37-phone-qsteps.txt')); rep(` "playlist-filled": (i) => i.title.startsWith("Walk test") && i.plrows >= 1,`, ` "playlist-filled": (i) => i.title.startsWith("Walk test") && i.plrows >= 1,` + NL + ins('ins-r37-phone-expect.txt')); save(); // ── the doc ─────────────────────────────────────────────────────────── load('ANDROID.md'); rep(` Remove from playlist; Move up, Move down and Move to the top reorder without a drag (\`playlist_set_order\`, the whole order in one call, as the desktop's drop does).`, ` Remove from playlist; Move up, Move down and Move to the top reorder from the sheet (\`playlist_set_order\`, the whole order in one call, as the desktop's drop does) — the accessible path, and the one that stays honest while the filter is on.`); rep(` A removal is undoable for seven seconds from the neutral toast (\`notice.svelte.js\` — the desktop's undo pill); the stash pins the playlist it came from.`, ` A removal is undoable for seven seconds from the neutral toast (\`notice.svelte.js\` — the desktop's undo pill); the stash pins the playlist it came from. ` + ins('ins-r37-doc-drag.txt')); rep(`yt-dlp, a desktop tool), drag reorder, and Share on SoulStream.`, `yt-dlp, a desktop tool), and Share on SoulStream.`); rep(`shelf and a page with every row kind (a local row, the stream row through the resolver, the filter, a removal and its undo, unpin, rename, a new playlist, Add to playlist from a record's sheet, delete) → the import doors`, `shelf and a page with every row kind (a local row, the stream row through the resolver, the filter, a removal and its undo, a row dragged into place and dragged back, a drag refused while the filter is on, a press released without moving still opening the row's sheet, a drop the backend rejects snapping back, unpin, rename, a new playlist, Add to playlist from a record's sheet, delete) → the import doors`); rep(`library) → the artwork viewer → a queue row's sheet → a catalog song's,`, `library) → the artwork viewer → a queue row's sheet and a queue row dragged into place → a catalog song's,`); /* the weather block's count is a running total across the rounds that add to it: round thirty-one wrote nine, round thirty-five took it to twelve with the export's three, and this round's two make fourteen */ rep(`Twelve mock steps drive them, and the phone walk carries`, `Fourteen mock steps drive them, and the phone walk carries`); rep(`- **Still desktop-only, smaller.** Drag reorder (Move up / down / to the top stand in), catalog track previews, the full discography page, and the fused-artist portrait verdict — that last one needs the identity merge the phone does not run, so porting it means porting the merge first.`, `- **Still desktop-only, smaller.** Catalog track previews, the full discography page, and the fused-artist portrait verdict — that last one needs the identity merge the phone does not run, so porting it means porting the merge first.`); save(); console.log('round 37 wired');