// Round forty-eight: the media card, on media3.
//
// The card on Kyle's lock screen has been a hand-built MediaStyle
// notification over a MediaSessionCompat, with a MediaButtonReceiver for the
// headset and audio focus managed by hand — all of it on `androidx.media`,
// which was deprecated in 1.8.0 on 2026-05-06. Android Auto needs a media3
// session to browse, so the choice was to add new code to a deprecated
// library or to move the card first. This moves the card, and adds no Auto:
// `MediaService` becomes a `MediaSessionService`, a new `SoulStreamPlayer`
// is the `SimpleBasePlayer` media3 asks for when the engine is somewhere
// else (rodio, in Rust, behind a WebView round trip), and media3 posts and
// owns the notification from here on.
//
// It cannot be split. A midpoint with both stacks live puts two cards in the
// shade and two records in `dumpsys media_session`, and two components fight
// over every headset button — which is why the one new assertion in the walk
// is `one-card`, the only check that can tell a landed migration from a
// doubled one.
//
// Three things the research design got wrong, corrected here and argued in
// the files themselves: media3 1.10.1 and not 1.11.0 (whose POM drags in
// kotlin-stdlib 2.2.10 against this project's Kotlin 1.9.25); the audio-focus
// and becoming-noisy blocks stay, because `handleSetAudioAttributes` is
// @ForOverride and throws by default and media3 does no focus work for a
// custom player; and the service is BOUND, never started, because
// `MediaSessionService.onStartCommand` returns START_STICKY without promoting
// for any intent that is not media3's own — a `startForegroundService` would
// arm the ten-second kill timer and satisfy nothing, which is the 2026-09-13
// bug with a new coat on.
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);
const PLUGIN = 'crates/tauri-plugin-media-session/';
const KOTLIN = PLUGIN + 'android/src/main/java/com/soulstream/mediasession/';
/* ── the re-run guard, before a byte moves ────────────────────────────
This round `rep`s across six files and rewrites a seventh. A second pass
would throw on the first anchor it had already consumed and leave a tree
with the new build.gradle.kts and no MediaService, or the reverse — an app
with no media session at all. SoulStreamPlayer.kt is what this round
creates and nothing else in the repo makes. */
if (fs.existsSync(root + KOTLIN + 'SoulStreamPlayer.kt')) {
throw new Error(KOTLIN + 'SoulStreamPlayer.kt already exists — round 48 has already run against ' + root);
}
/* ── every anchor, and every insert, before the first write ───────────
The round-44 lesson: check it ALL here, so the round is all or nothing. */
// build.gradle.kts
const A_GRADLE = ' implementation("androidx.core:core-ktx:1.13.1")' + NL +
' // MediaSessionCompat, the MediaStyle notification, MediaButtonReceiver' + NL +
' implementation("androidx.media:media:1.7.0")';
// AndroidManifest.xml — the service, and the receiver that goes with it
const A_SERVICE = ' android:exported="false"' + NL +
' android:foregroundServiceType="mediaPlayback">' + NL +
' ' + NL +
' ' + NL +
' ' + NL +
' ';
const A_RECEIVER = ' ' + NL + NL +
' ' + NL +
' ' + NL +
' ' + NL +
' ' + NL +
' ' + NL +
' ';
// consumer-rules.pro
const A_RULES = "# MediaService and androidx's MediaButtonReceiver are declared in this" + NL +
"# module's AndroidManifest.xml, so AGP's own merged-manifest keep rules hold" + NL +
'# them by name. Not repeated here: one place decides each of them.';
// MediaSessionPlugin.kt
const A_KT_STALE = ' * NOTHING READS IT: MediaService ignores both of these on purpose, so' + NL +
" * the card is byte for byte the card it was. media3's" + NL +
' * SimpleBasePlayer.getState() wants a playlist and an index into it,' + NL +
' * and this is that data arriving a round before the migration does.';
const A_KT_INDEX = ' var queueIndex: Int? = null';
/* the one line on the DEVICE that says what crossed the bridge. Round
forty-eight adds two fields to that bridge and neither is on any screen,
so neither is readable at all until this line carries them. */
const A_KT_LOG = ' /* The window is on no screen anywhere — MediaService ignores it and' + NL +
' the card cannot show it — so logcat is the only place on the' + NL +
' device it can be read back, and tests/mobile/smoke.mjs reads it' + NL +
' from exactly here. One line per update, and an update is a track' + NL +
' change, a transport change or the thirty-second top-up, never a' + NL +
' position tick. `at=` goes LAST because a title may hold anything,' + NL +
' spaces and `=` included. */' + NL +
' val window = args.queue ?: emptyList()' + NL +
' android.util.Log.i(' + NL +
' "SoulStream",' + NL +
' "media window n=" + window.size + " i=" + (args.queueIndex ?: -1) +' + NL +
' " art=" + window.count { !it.artKey.isNullOrEmpty() } +' + NL +
' " at=" + (window.getOrNull(args.queueIndex ?: -1)?.title ?: ""),' + NL +
' )';
// lib.rs
const A_RS_STALE = ' /// NOTHING READS IT YET, and that is the point — `MediaService` ignores' + NL +
' /// both of these fields, so the card is exactly the card it was. media3\'s' + NL +
' /// `SimpleBasePlayer.getState()` wants a playlist and an index into it,' + NL +
' /// and when that migration comes the data it needs should have been' + NL +
' /// running on the phone for a while rather than have been written the' + NL +
' /// same day.';
const A_RS_INDEX = ' pub queue_index: Option,';
// media.svelte.js
const A_JS_INDEX = ' state.queueIndex = at >= 0 && at < win.length ? at : null;';
/* the file's own opening paragraph, which still said nothing reads it */
const A_JS_STALE = " the current item and QUEUE_WINDOW either side — because media3's" + NL +
' SimpleBasePlayer.getState() wants a playlist and an index into it, and' + NL +
' the migration to it should find this data already running rather than' + NL +
' written the same day. Nothing reads it yet: MediaService ignores both' + NL +
' fields and the card is exactly the card it was.';
// tests/mobile/smoke.mjs
const A_SLEEP = ' await sleep(13000);';
const A_SESSFN = "/* The system's view of the app's media session: who owns it and what the" + NL +
' * playback state says. From `dumpsys media_session`. */' + NL +
'function mediaSessionState() {';
const A_SESSDUMP = ' const dump = adb("shell", "dumpsys", "media_session").toString();' + NL +
' /* sessions are listed as blocks; find ours by package, then the first' + NL +
' playback state after it */';
const A_SESSEARLY = ' if (at < 0) return { owner: "", state: "" };';
const A_SESSRET = ' return { owner: "com.soulstream.app", state: state[1] || "", position: state[2] ? +state[2] / 1000 : null };';
const A_SESSCATCH = ' } catch { return { owner: "", state: "" }; }';
const A_CARDLOG = ' console.log((cardOk ? " ok " : " FAIL ") + "media-card".padEnd(16) + " " + JSON.stringify(cardSeen));';
/* the two halves of the logcat readback: the line mediaWindow() parses, and
the predicate queue-window holds it against */
const A_WINPARSE = ' const m = (lines[lines.length - 1] || "").match(/media window n=(' + '\\' + 'd+) i=(-?' + '\\' + 'd+) art=(' + '\\' + 'd+) at=(.*)$/);' + NL +
' if (!m) return { n: -1, i: -1, art: -1, at: "", lines: lines.length };' + NL +
' return { n: +m[1], i: +m[2], art: +m[3], at: m[4].trim(), lines: lines.length };';
const A_WINERR = ' } catch (e) { return { n: -1, i: -1, art: -1, at: "ERR " + e.message, lines: 0 }; }';
const A_WINOK = ' && wins[1][2].lines > wins[0][2].lines && wins[2][2].lines > wins[1][2].lines;';
/* two comments that said "nothing reads the window". Something does now, and
a wrong reason on a check is worse than no reason at all. */
const A_WINFN = ' * update — the current item and ten either side — and left MediaService' + NL +
' * ignoring all of it, so there is nothing on any screen to read it back' + NL +
' * from: logcat is the only place on the device it exists, one line per' + NL +
' * update, and the LAST line is the state now.';
const A_WINWHY = ' its id, title, artist, album, duration and art key, plus where' + NL +
' the current one sits among them — and left MediaService' + NL +
" ignoring every bit of it, because media3's" + NL +
' SimpleBasePlayer.getState() will want a playlist and an index,' + NL +
' and that data should be a round old before anything depends on' + NL +
' it. Which means every check above this one is blind to it: a';
// ANDROID.md
const A_DOC_ROW = '| `crates/tauri-plugin-media-session/` | a Tauri plugin with a Kotlin foreground service (`MediaService`) that owns the `MediaSession`, the MediaStyle notification, audio focus and the becoming-noisy pause. The audio never passes through it; it is what keeps the process alive in the background and what the lock screen shows |';
const A_DOC_BG = 'The service is started with `startForegroundService`, so it promotes itself' + NL +
'FIRST in `onStartCommand` — with the card there is, or a plain one — and' + NL +
'only then mirrors the state, stepping back (`STOP_FOREGROUND_DETACH`) when' + NL +
'the track is paused or stopping when the bar is empty. Android 14 kills the' + NL +
'whole app about ten seconds after a foreground start that never called' + NL +
'`startForeground()`, and every restart restores a PAUSED track, which is' + NL +
'exactly that case: on 2026-09-13 the first real Save died with the process' + NL +
'that way, four tracks in. The check: force-stop, relaunch with a paused' + NL +
'track, wait 25 s, the pid survives.';
const A_DOC_PLUGIN = '`crates/tauri-plugin-media-session` is a Tauri mobile plugin: `build.rs`' + NL +
'hands its `android/` Gradle module to tauri-build (which writes it into' + NL +
'`gen/android/tauri.settings.gradle`), `src/lib.rs` registers the Kotlin' + NL +
'class and exposes `update` / `clear`, and the Kotlin side is two files —' + NL +
'the plugin (arguments in, the `action` event out) and `MediaService`, a' + NL +
'`mediaPlayback` foreground service holding a `MediaSessionCompat` and a' + NL +
'MediaStyle notification. Playing → `startForeground`; paused → the service' + NL +
'steps back and the card stays, dismissable. Android 13+ asks for' + NL +
'`POST_NOTIFICATIONS` the first time something plays. Audio focus is' + NL +
'requested while playing (loss pauses, a transient loss pauses and resumes),' + NL +
'and headphones coming out pause. The app\'s capability grants' + NL +
'`media-session:default`; the plugin is registered on every platform so the' + NL +
'permission resolves everywhere, and on the desktop its commands answer' + NL +
'"not here" (the desktop has `smtc.rs`).';
const A_DOC_ICON = 'The card\'s large icon falls back to the app\'s own icon. `MediaHub.art` is' + NL +
'cleared the moment `artKey` changes and only refilled once the frontend has' + NL +
'decoded a cover into bytes, and a track with no cover never refills it at' + NL +
'all — so without a fallback every track change has a window where the lock' + NL +
'screen shows a grey hole. `MediaHub.appIcon()` decodes the icon ONCE and' + NL +
'keeps it (`refresh()` runs on every position tick, so a per-update decode' + NL +
'would be a real cost), through the PackageManager rather than a resource id' + NL +
'because a library module\'s own R has no `ic_launcher`.';
const A_DOC_ART = 'The same bitmap serves' + NL + '`METADATA_KEY_ALBUM_ART`.';
const A_DOC_PROVES = 'Then, after the step loop, eight things the mock cannot do at all: a' + NL +
'force-stop and the paused restore that follows it (waited out past the' + NL +
'thirteen-second FGS-kill window), the position restored, playback resumed, the' + NL +
'system media session and its ownership, media keys, ten seconds in the' + NL +
'background with the position still advancing, SoulStream\'s own mixer' + NL +
'underruns, and the queue window that rides along with every card update and' + NL +
'that nothing on the card reads.';
const A_DOC_QUEUE = 'Every update also carries a **queue window** that nothing reads. `NowPlaying`' + NL +
'(Rust) and `NowPlayingArgs` (Kotlin) hold a `queue` of at most twenty-one' + NL +
'entries — the current item and ten either side, each with its id, title,' + NL +
'artist, album, duration and art key — plus `queueIndex`, where the current one' + NL +
'sits *within that window* rather than within the whole queue.' + NL +
'`src/mobile/media.svelte.js` fills both from `player.queue` and `player.qi`;' + NL +
'`MediaService` ignores them completely, so the card is byte for byte the card' + NL +
'it was. The reason is media3: `SimpleBasePlayer.getState()` wants a playlist' + NL +
'and an index into it, and the data that migration needs should have been' + NL +
'running on the phone for a while before anything depends on it.';
const A_DOC_SCREENREADER = '## What a screen reader says';
const A_DOC_LOGLINE = '`MediaSessionPlugin.update` logs one `media window n=… i=… art=… at=…` line' + NL +
"per update under the tag `SoulStream`, and the phone walk's `queue-window`" + NL +
'check holds that against `__ss.card` — the mirror `media.svelte.js` keeps of' + NL +
"what it last sent, beside xfernote's — field for field, through a next and a" + NL +
'previous. Field for field is deliberate: a key spelled snake_case where the' + NL +
'bridge wants camelCase arrives as nothing at all on both hops, with no error' + NL +
'anywhere, which is why the art-key count is compared and not just the size and' + NL +
'the index.';
const A_DOC_TAIL = ' the share sheet, which no walk reaches. Work down the smoke list in "The' + NL +
' release build" before trusting a release APK on a phone.';
for (const [file, anchors] of [
[PLUGIN + 'android/build.gradle.kts', [A_GRADLE]],
[PLUGIN + 'android/src/main/AndroidManifest.xml', [A_SERVICE, A_RECEIVER]],
[PLUGIN + 'android/consumer-rules.pro', [A_RULES]],
[KOTLIN + 'MediaSessionPlugin.kt', [A_KT_STALE, A_KT_INDEX, A_KT_LOG]],
[PLUGIN + 'src/lib.rs', [A_RS_STALE, A_RS_INDEX]],
['src/mobile/media.svelte.js', [A_JS_STALE, A_JS_INDEX]],
['tests/mobile/smoke.mjs', [A_SLEEP, A_SESSFN, A_SESSDUMP, A_SESSEARLY, A_SESSRET,
A_SESSCATCH, A_CARDLOG, A_WINPARSE, A_WINERR, A_WINOK, A_WINFN, A_WINWHY]],
/* the two opening paragraphs are replaced as ONE span, so it is the span
that has to be checked — two separate hits would not prove they are
still adjacent */
['ANDROID.md', [A_DOC_ROW, A_DOC_BG + NL + NL + A_DOC_PLUGIN, A_DOC_ICON, A_DOC_ART,
A_DOC_PROVES, A_DOC_QUEUE, A_DOC_LOGLINE, A_DOC_SCREENREADER, A_DOC_TAIL]],
]) {
load(file);
for (const a of anchors) once(a);
}
/* the file this round rewrites whole, and the directory the two new Kotlin
files land in: named here so a tree pointed at the wrong place fails now
rather than after four writes */
for (const f of [KOTLIN + 'MediaService.kt', PLUGIN + 'android/src/main/res/drawable/ic_soulstream_note.xml']) {
if (!fs.existsSync(root + f)) throw new Error('not the tree this round expects: missing ' + f);
}
/* and every insert, because a typo in a filename should not land half a
migration either */
for (const f of ['ins-r48-gradle.txt', 'ins-r48-player.txt', 'ins-r48-service.txt',
'ins-r48-walk-helper.txt', 'ins-r48-walk-checks.txt', 'ins-r48-doc-bg.txt',
'ins-r48-doc-card-proves.txt', 'ins-r48-doc-proves.txt', 'ins-r48-doc-queue.txt',
'ins-r48-doc-auto.txt']) {
if (!fs.existsSync(scratch + f)) throw new Error('missing insert: ' + scratch + f);
}
// ── the Gradle module: media3 in, androidx.media out of OUR compile path ──
load(PLUGIN + 'android/build.gradle.kts');
rep(A_GRADLE, ins('ins-r48-gradle.txt'));
save();
// ── the manifest: media3's own service filter, and no receiver at all ────
load(PLUGIN + 'android/src/main/AndroidManifest.xml');
rep(A_SERVICE,
' android:exported="true"' + NL +
' android:foregroundServiceType="mediaPlayback">' + NL +
' ' + NL +
' ' + NL +
' ' + NL +
' ');
rep(A_RECEIVER, ' ' + NL + ' ');
save();
// ── the channel's name, as a string resource ─────────────────────────────
/* DefaultMediaNotificationProvider.Builder.setChannelName takes a @StringRes
int and there is no overload that takes a String. Without it the channel
media3 would create on a FRESH install carries media3's own
default_notification_channel_name. On Kyle's phone it changes nothing:
Util.ensureNotificationChannel is a no-op when the channel exists, and
MediaService.createChannel() still gets in first anyway — that is what
keeps the lockscreen-PUBLIC visibility media3 would not set. */
fs.mkdirSync(root + PLUGIN + 'android/src/main/res/values', { recursive: true });
fs.writeFileSync(root + PLUGIN + 'android/src/main/res/values/strings.xml', [
'',
'',
' ',
' Playback',
'',
'',
].join(NL));
// ── the Player media3 shows the world, and the service that holds it ─────
fs.writeFileSync(root + KOTLIN + 'SoulStreamPlayer.kt', ins('ins-r48-player.txt') + NL);
fs.writeFileSync(root + KOTLIN + 'MediaService.kt', ins('ins-r48-service.txt') + NL);
// ── the keep rules: there is no receiver to name any more ────────────────
load(PLUGIN + 'android/consumer-rules.pro');
rep(A_RULES,
'# MediaService is declared in this module\'s AndroidManifest.xml, so AGP\'s' + NL +
'# own merged-manifest keep rules hold it by name, and SoulStreamPlayer needs' + NL +
'# no rule of its own — the only thing that reaches it is a direct reference' + NL +
"# from MediaService. androidx's MediaButtonReceiver went with the move to" + NL +
'# media3: the service\'s own SERVICE_INTERFACE filter routes media buttons' + NL +
'# now, and two receivers would have fought over them.');
save();
// ── the bridge: queueBase and repeat, and two comments that went stale ───
load(KOTLIN + 'MediaSessionPlugin.kt');
rep(A_KT_STALE,
' * Since round forty-eight SoulStreamPlayer.getState() turns this into' + NL +
' * the media3 playlist the card is built from, and the index into it.');
rep(A_KT_INDEX,
A_KT_INDEX + NL +
' /** where the window starts in the WHOLE queue. A stable media3 uid is' + NL +
' * built out of it, so an entry keeps its identity as the window slides' + NL +
' * past it; it is also half of the row -> playQueueAt() mapping Auto' + NL +
' * will want. Null when the window is empty. */' + NL +
' var queueBase: Int? = null' + NL +
" /** Repeat, in the APP's numbering: 0 off, 1 all, 2 one. media3 numbers" + NL +
' * them 0 OFF, 1 ONE, 2 ALL — SWAPPED — and SoulStreamPlayer maps across' + NL +
' * rather than casting. */' + NL +
' var repeat: Int = 0');
/* the log line, which is now the ONLY readback there is for queueBase and
repeat: neither is on a screen, neither is on the card, and a camelCase
key spelled the wrong way arrives as nothing at all on both hops of this
bridge with no error anywhere — a dropped `repeat` would simply have the
card report repeat-off forever, and a dropped `queueBase` would make every
track change look like twenty-one new rows to media3. */
rep(A_KT_LOG,
' /* The window is on no screen anywhere — the card shows ONE track —' + NL +
' so logcat is the only place on the device the whole of it can be' + NL +
' read back, and tests/mobile/smoke.mjs reads it from exactly here.' + NL +
' One line per update, and an update is a track change, a transport' + NL +
' change or the thirty-second top-up, never a position tick.' + NL +
' `base` and `rep` are here for the same reason and a sharper one:' + NL +
' since round forty-eight they steer the media3 playlist and what' + NL +
' a controller is told about repeat, they appear on no screen, and a' + NL +
' camelCase key spelled snake_case arrives as nothing on either hop' + NL +
' of this bridge without an error anywhere. `at=` goes LAST because' + NL +
' a title may hold anything, spaces and `=` included. */' + NL +
' val window = args.queue ?: emptyList()' + NL +
' android.util.Log.i(' + NL +
' "SoulStream",' + NL +
' "media window n=" + window.size + " i=" + (args.queueIndex ?: -1) +' + NL +
' " art=" + window.count { !it.artKey.isNullOrEmpty() } +' + NL +
' " base=" + (args.queueBase ?: -1) + " rep=" + args.repeat +' + NL +
' " at=" + (window.getOrNull(args.queueIndex ?: -1)?.title ?: ""),' + NL +
' )');
save();
load(PLUGIN + 'src/lib.rs');
rep(A_RS_STALE,
' /// Round forty-eight is where this BECAME the card. `SoulStreamPlayer`' + NL +
' /// is a media3 `SimpleBasePlayer`, its `getState()` wants a playlist and' + NL +
' /// an index into one, and this window and `queue_index` are exactly that' + NL +
' /// — so what the lock screen shows is built out of these fields rather' + NL +
' /// than beside them.');
rep(A_RS_INDEX,
A_RS_INDEX + NL +
' /// Where `queue[0]` sits in the WHOLE queue. A media3 `MediaItemData`' + NL +
' /// uid is built from it, so an entry keeps its identity as the window' + NL +
' /// slides past it and a track change is ONE media-item transition' + NL +
' /// rather than twenty-one re-labelled rows; it is also half of the' + NL +
' /// mapping Android Auto needs to turn a tapped queue row back into' + NL +
' /// `playQueueAt()`. `None` when the window is empty.' + NL +
' pub queue_base: Option,' + NL +
" /// Repeat, in the app's own numbering: 0 off, 1 all, 2 one. media3" + NL +
' /// numbers them 0 OFF, 1 ONE, 2 ALL — SWAPPED — and the Kotlin side' + NL +
' /// maps across rather than casting, which would turn repeat-all into' + NL +
' /// repeat-one. It crosses at all so the player\'s state is not a lie to' + NL +
' /// whatever reads it — the system\'s own media controls now, Android' + NL +
' /// Auto later — and so media3\'s view of "is there a next item" agrees' + NL +
' /// with the `has_next` the card\'s next button is advertised on.' + NL +
' #[serde(default)]' + NL +
' pub repeat: u8,');
save();
load('src/mobile/media.svelte.js');
rep(A_JS_STALE,
' the current item and QUEUE_WINDOW either side. Since round forty-eight' + NL +
' SoulStreamPlayer.getState() builds the media3 playlist and the index' + NL +
' into it out of exactly these fields, so what the lock screen shows is' + NL +
' made of them rather than beside them.');
rep(A_JS_INDEX,
A_JS_INDEX + NL +
' /* where the window STARTS in the whole queue. It is what gives an' + NL +
' entry a stable media3 uid as the window slides past it — without it' + NL +
' a track change re-labels all twenty-one rows and media3 reports a' + NL +
' phantom media-item transition — and it is half of the mapping Auto' + NL +
' needs to turn a tapped queue row back into playQueueAt(). camelCase,' + NL +
' like everything on this bridge: a snake_case key is silently ignored' + NL +
' on both hops, with no error anywhere. */' + NL +
' state.queueBase = player.qi < 0 ? null : lo;' + NL +
' /* 0 off, 1 all, 2 one — ours. media3 numbers them 0 OFF, 1 ONE, 2 ALL' + NL +
' and SoulStreamPlayer maps across rather than casting, which would' + NL +
' turn repeat-all into repeat-one. It crosses so the player\'s state is' + NL +
' not a lie to whatever reads it — the system\'s own media controls' + NL +
' now, Auto later — and so media3\'s view of "is there a next item"' + NL +
' agrees with the hasNext the next button is advertised on. */' + NL +
' state.repeat = player.repeat;');
save();
// ── the walk ─────────────────────────────────────────────────────────────
load('tests/mobile/smoke.mjs');
/* twenty-five seconds, and ANDROID.md said so in one place already */
rep(A_SLEEP,
' /* TWENTY-FIVE seconds. Android kills an app about ten seconds' + NL +
' after a foreground start that never called startForeground(),' + NL +
' and since round forty-eight nothing here starts the service at' + NL +
' all — it is bound, and media3 promotes it on its own schedule —' + NL +
' so the margin is over the window rather than beside it. The' + NL +
' doc claimed 25 s in one place and thirteen in another; both' + NL +
' say 25 now. */' + NL +
' await sleep(25000);');
/* the plumbing helper, beside the other dumpsys readers */
rep(A_SESSFN, ins('ins-r48-walk-helper.txt') + NL + NL + A_SESSFN);
/* and a count of OUR session records, for one-session below */
rep(A_SESSDUMP,
' const dump = adb("shell", "dumpsys", "media_session").toString();' + NL +
' /* how many sessions the system holds for US. Two would mean the' + NL +
' old MediaSessionCompat stack and media3\'s are both live, which is' + NL +
' the one way the migration in round forty-eight could have gone' + NL +
' wrong without anything crashing. Counted loosely and REPORTED' + NL +
' rather than trusted: a zero here means the dump did not say' + NL +
' `package=` where this looked, not that there is no session —' + NL +
' `media-session` is what asserts that there is one. */' + NL +
' const sessions = (dump.match(/package=com\\.soulstream\\.app\\b/g) || []).length;' + NL +
' /* sessions are listed as blocks; find ours by package, then the first' + NL +
' playback state after it */');
rep(A_SESSEARLY, ' if (at < 0) return { owner: "", state: "", sessions };');
rep(A_SESSRET, A_SESSRET.replace('position: state[2] ? +state[2] / 1000 : null };',
'position: state[2] ? +state[2] / 1000 : null, sessions };'));
rep(A_SESSCATCH, ' } catch { return { owner: "", state: "", sessions: -1 }; }');
/* one card, one session, and the plumbing — right after media-card */
rep(A_CARDLOG, A_CARDLOG + NL + ins('ins-r48-walk-checks.txt'));
/* the logcat readback, widened to the two fields round forty-eight adds.
Nothing else on the device can be asked about `queueBase` or `repeat`:
they are on no screen and the card shows neither, so without these two
the bridge could drop either one and every check would stay green. */
rep(A_WINPARSE,
' const m = (lines[lines.length - 1] || "").match(' + NL +
' /media window n=(\\d+) i=(-?\\d+) art=(\\d+) base=(-?\\d+) rep=(\\d+) at=(.*)$/);' + NL +
' if (!m) return { n: -1, i: -1, art: -1, base: -2, rep: -1, at: "", lines: lines.length };' + NL +
' return { n: +m[1], i: +m[2], art: +m[3], base: +m[4], rep: +m[5], at: m[6].trim(), lines: lines.length };');
rep(A_WINERR, ' } catch (e) { return { n: -1, i: -1, art: -1, base: -2, rep: -1, at: "ERR " + e.message, lines: 0 }; }');
rep(A_WINOK,
' && wins[1][2].lines > wins[0][2].lines && wins[2][2].lines > wins[1][2].lines' + NL +
' /* and the two fields round forty-eight added, which nothing' + NL +
' else on the device can be asked about. The block arms at' + NL +
' index one, so the window starts at zero and `base` must BE' + NL +
' zero at all three readings; `rep` must be the repeat mode' + NL +
' the page reports, unchanged across the block. Either one' + NL +
' spelled snake_case on either hop arrives as nothing and' + NL +
' lands here as -1: `repeat` dropped has the card report' + NL +
' repeat-off forever, and `queueBase` dropped makes every' + NL +
' track change look like twenty-one new rows to media3. */' + NL +
' && wins.every(([, , got]) => got.base === 0 && got.rep === armed.rep);');
/* and the two comments that still said nothing read the window */
rep(A_WINFN,
' * update — the current item and ten either side. Since round forty-eight' + NL +
' * SoulStreamPlayer builds the media3 playlist out of it, but the card' + NL +
' * still shows ONE track, so logcat remains the only place on the device' + NL +
' * the whole window can be read back: one line per update, and the LAST' + NL +
' * line is the state now.');
rep(A_WINWHY,
' its id, title, artist, album, duration and art key, plus where' + NL +
' the current one sits among them; round forty-eight made it the' + NL +
' media3 playlist the card is built from. Every check above this' + NL +
' one is still blind to the window itself, because the card shows' + NL +
' one track and `dumpsys` can only be asked about that one: a');
save();
// ── the doc ──────────────────────────────────────────────────────────────
load('ANDROID.md');
rep(A_DOC_ROW,
'| `crates/tauri-plugin-media-session/` | a Tauri plugin with a Kotlin `MediaSessionService` (`MediaService`) and a `SimpleBasePlayer` over the Rust engine (`SoulStreamPlayer`), on androidx.media3. media3 posts and owns the card; audio focus and the becoming-noisy pause are still ours. The audio never passes through it; it is what keeps the process alive in the background and what the lock screen shows |');
/* the two opening paragraphs, replaced by what the migration actually did */
rep(A_DOC_BG + NL + NL + A_DOC_PLUGIN, ins('ins-r48-doc-bg.txt'));
rep(A_DOC_ICON,
'The card\'s artwork falls back to the app\'s own icon. `MediaHub.artBytes`' + NL +
'is cleared the moment `artKey` changes and only refilled once the frontend' + NL +
'has decoded a cover into bytes, and a track with no cover never refills it' + NL +
'at all — so without a fallback every track change has a window where the' + NL +
'lock screen shows a grey hole. `MediaHub.appIcon()` encodes the icon ONCE' + NL +
'and keeps the PNG bytes (`getState()` runs on every invalidation, so a' + NL +
'per-update encode would be a real cost), through the PackageManager rather' + NL +
'than a resource id because a library module\'s own R has no `ic_launcher`.');
rep(A_DOC_ART,
'The same bytes go across as' + NL +
'`MediaMetadata.artworkData`, which media3 decodes itself — one' + NL +
'`BitmapFactory` call this plugin no longer makes.');
rep(A_DOC_PROVES, ins('ins-r48-doc-proves.txt'));
rep(A_DOC_QUEUE, ins('ins-r48-doc-queue.txt'));
/* the log line the walk parses, named here field for field — it grew two */
rep(A_DOC_LOGLINE,
'`MediaSessionPlugin.update` logs one' + NL +
'`media window n=… i=… art=… base=… rep=… at=…` line per update under the tag' + NL +
"`SoulStream`, and the phone walk's `queue-window` check holds that against" + NL +
'`__ss.card` — the mirror `media.svelte.js` keeps of what it last sent, beside' + NL +
"xfernote's — field for field, through a next and a previous. Field for field" + NL +
'is deliberate: a key spelled snake_case where the bridge wants camelCase' + NL +
'arrives as nothing at all on both hops, with no error anywhere, which is why' + NL +
'the art-key count is compared and not just the size and the index. `base` and' + NL +
'`rep` are on that line for a sharper version of the same reason: they steer' + NL +
"the media3 playlist and the card's next button, they are on no screen and no" + NL +
'card, and this is the only place on the device either of them can be read' + NL +
'back at all.');
/* the card's own honest list, at the end of the section it belongs to */
rep(A_DOC_SCREENREADER, ins('ins-r48-doc-card-proves.txt') + NL + NL + A_DOC_SCREENREADER);
rep(A_DOC_TAIL, A_DOC_TAIL + NL + NL + ins('ins-r48-doc-auto.txt'));
save();
console.log('round 48 wired');