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