// 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');