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

184 lines
11 KiB
JavaScript

// Round forty-six: the mirror grows a queue window. The Kotlin side of the
// media mirror knows about exactly one track, and media3's
// SimpleBasePlayer.getState() — the thing an Android Auto session is built
// on — wants a playlist and an index into it. So the window lands now, a
// round before the migration that needs it: NowPlaying (Rust) and
// NowPlayingArgs (Kotlin) grow a bounded `queue` of at most twenty-one
// entries (the current item and ten either side, each with id, title,
// artist, album, duration and an art key) and a `queueIndex` saying where
// the current one sits IN that window; src/mobile/media.svelte.js fills
// them from player.queue and player.qi; and MediaService ignores every bit
// of it, which is the whole point — the card is byte for byte the card it
// was, and the walk's existing media-session steps are the assertion that
// says so. What is NEW to assert is the thing no screen shows, so the
// plugin logs one line per update and the walk holds that line against
// __ss.card — the mirror media.svelte.js now keeps of what it last sent,
// the shape xfernote has kept of the shade since it was written — field for
// field, through a next and a previous. That check arms at queue index one
// rather than at wherever the walk left off (a row either side, a track at
// zero, and a window whose own index IS the queue index), and it wants each
// of its three readings fresh at BOTH ends — the push counter up and a new
// line in the log — because a prev() that lands back where it started reads
// exactly like a stale line otherwise.
//
// NOTE FOR WHOEVER BUILDS THIS: Rust AND Kotlin changed, so the dev flavour
// cannot carry it. It needs the full detour —
// `bash src-tauri/android/deploy.sh` — not `dev.sh --no-build`.
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;
/* `save` STAGES rather than writes, and the staged files all go down
together at the bottom of this file. Seven `rep`s in, that is not a
nicety: rounds forty-four and forty-five land before this one and both of
them touch ANDROID.md, so the anchor most likely to have moved under this
patch is in the LAST file it edits — and a throw down there, with the
writes done as they went, would leave the Rust, the Kotlin, the keep
rules, the phone and the walk applied and the doc not. Staged, a missing
or ambiguous anchor anywhere throws with the tree exactly as it was. */
const staged = new Map();
const load = (file) => { p = file; s = staged.has(file) ? staged.get(file) : fs.readFileSync(root + file, 'utf8'); };
const save = () => { staged.set(p, s); };
const flush = () => { for (const [f, t] of staged) fs.writeFileSync(root + f, t); };
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);
/* Every write below is a `rep` into an existing file — seven files, no new
ones — so a second run would throw part way through. Refuse before a byte
is touched. `mediacard` is what this round adds to the phone's media
wiring and nothing else in the repo makes it. (The staging above is the
other half of the same promise: that guard catches a second RUN, staging
catches an anchor a sibling round moved.) */
if (/\bmediacard\b/.test(fs.readFileSync(root + 'src/mobile/media.svelte.js', 'utf8'))) {
throw new Error('src/mobile/media.svelte.js already exports mediacard — round 46 has already run against ' + root);
}
// ── the Rust struct ───────────────────────────────────────────────────
/* QueueItem goes beside `yes()`, after NowPlaying rather than before it, so
the file still opens on the thing the card shows. */
load('crates/tauri-plugin-media-session/src/lib.rs');
/* the last two fields of NowPlaying and its closing brace — NOT the `art_key`
line above them, which reads the same as QueueItem's */
rep(` #[serde(default = "yes")]
pub has_prev: bool,
}`, ins('ins-r46-rust-fields.txt'));
rep(`fn yes() -> bool {
true
}`, ins('ins-r46-rust-item.txt'));
save();
// ── the Kotlin args ───────────────────────────────────────────────────
/* `queue` is nullable on purpose: a non-null Kotlin property whose setter
Jackson hands a null throws, and tauri's own mapper has
FAIL_ON_UNKNOWN_PROPERTIES disabled — so everything about this bridge
fails QUIETLY, in both directions, which is why the walk reads the window
back rather than trusting it. */
load('crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/MediaSessionPlugin.kt');
rep(` var hasNext: Boolean = true
var hasPrev: Boolean = true
}`, ins('ins-r46-kotlin-args.txt'));
/* the one line the walk reads the window out of. MediaService is NOT
touched by this round — the card must not be able to tell. */
rep(` val args = invoke.parseArgs(NowPlayingArgs::class.java)
ensureNotificationPermission()`, ins('ins-r46-kotlin-log.txt'));
save();
// ── the plugin's keep rules ───────────────────────────────────────────
/* A nested @InvokeArg class is a class R8 can rename like any other, and
round 43's rules name each one by hand. Without this line the release
build would fill NowPlayingArgs and leave every queue entry null — and,
this being Jackson with FAIL_ON_UNKNOWN_PROPERTIES off, say nothing. */
load('crates/tauri-plugin-media-session/android/consumer-rules.pro');
rep(`# field. Rename any of it and the app still builds, installs and plays; the
# lock-screen card simply never appears.`,
`# field, and QueueItemArgs — one per entry of the queue window it carries —
# the same way. Rename any of it and the app still builds, installs and
# plays; the lock-screen card simply never appears.`);
rep(`-keep class com.soulstream.mediasession.NowPlayingArgs { *; }`,
`-keep class com.soulstream.mediasession.NowPlayingArgs { *; }` + NL +
`-keep class com.soulstream.mediasession.QueueItemArgs { *; }`);
save();
// ── the phone fills it ────────────────────────────────────────────────
load('src/mobile/media.svelte.js');
/* the head: the cap, the entry shape, and the mirror the walk reads */
rep(`let lastArtKey = null;`, ins('ins-r46-js-head.txt'));
/* the two fields, onto the state object the card is built from */
rep(` hasNext: player.qi + 1 < player.queue.length || player.repeat > 0,
hasPrev: true,
};`, ins('ins-r46-js-fill.txt'));
/* the mirror, written before the call goes out */
rep(` await invoke("plugin:media-session|update", { state }).catch(() => {});`,
ins('ins-r46-js-mirror.txt'));
/* the clear path forgets the window too, or __ss.card would keep describing
a queue the card no longer has */
rep(` lastArtKey = null;
await invoke("plugin:media-session|clear").catch(() => {});`,
` lastArtKey = null;
mediacard.window = 0;
mediacard.index = -1;
mediacard.art = 0;
mediacard.at = "";
await invoke("plugin:media-session|clear").catch(() => {});`);
save();
// ── the probe handle ──────────────────────────────────────────────────
/* beside xfn, which is the same thing for the shade */
load('src/mobile/Mobile.svelte');
rep(`import { bindMedia, syncMedia } from "./media.svelte.js";`,
`import { bindMedia, syncMedia, mediacard } from "./media.svelte.js";`);
rep(`xfn: xfernote,`, `xfn: xfernote, card: mediacard,`);
save();
// ── the walk ──────────────────────────────────────────────────────────
/* The existing media-session steps are not touched: that they still pass
unchanged IS the assertion that the card did not move. What is added is
one check, in the phone-only tail, of the thing no screen shows. */
load('tests/mobile/smoke.mjs');
/* after mediaSessionState(), whose own tail is unique — the two-line
`return { owner: "", state: "" }` inside it is not */
rep(` return { owner: "com.soulstream.app", state: state[1] || "", position: state[2] ? +state[2] / 1000 : null };
} catch { return { owner: "", state: "" }; }
}`, ins('ins-r46-smoke-probe.txt'));
/* The block goes between media-keys and background — after the last check
that needs the transport exactly where the restore left it, and before
the one that only needs playback to be live, which it still is. Anchored
on the media-keys REPORT line and nothing below it: the background block
under this has already been rewritten once while this round was being
written (it grew a `crossed` clause for a track boundary), so no line of
it is a safe anchor. */
const keysLine = ` console.log((keysOk ? " ok " : " FAIL ") + "media-keys".padEnd(16) + " " + JSON.stringify({ paused: keyPaused?.status, playing: keyPlaying?.status }));`;
rep(keysLine, keysLine + NL + NL + ins('ins-r46-smoke-tail.txt'));
save();
// ── the doc ───────────────────────────────────────────────────────────
load('ANDROID.md');
/* the last sentence of the card section's tauri-build trap, which is the
end of that section. NOT the `## What a screen reader says` heading below
it — a sibling adding a section of its own would reach for the heading. */
rep(`after adding a plugin, touch \`src-tauri/build.rs\` too, so the app's build
script re-runs and rewrites the wiring.`,
`after adding a plugin, touch \`src-tauri/build.rs\` too, so the app's build
script re-runs and rewrites the wiring.` + NL + NL + ins('ins-r46-doc-window.txt'));
/* the phone tail's own count, in "What each walk actually proves". This
round adds an eighth thing to it and no named STEP to either list, so the
step counts in that section are left exactly alone. */
rep(`seven things the mock cannot do at all`, `eight things the mock cannot do at all`);
rep(`background with the position still advancing, and SoulStream's own mixer
underruns.`,
`background with the position still advancing, SoulStream's own mixer
underruns, and the queue window that rides along with every card update and
that nothing on the card reads.`);
save();
/* every anchor found, every replacement made — now the seven files go down */
flush();
console.log('round 46 wired');