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.
184 lines
11 KiB
JavaScript
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');
|