android: the media mirror grows a queue window the card never shows

The Kotlin side of the mirror has always known 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 data lands a round
before the migration that needs it, where it cannot break anything:
`NowPlaying` and `NowPlayingArgs` carry a bounded `queue` of at most
twenty-one entries, the current item and ten either side, each with its id,
title, artist, album, duration and art key, plus `queueIndex`, where the
current one sits within that window rather than within the whole queue.
`src/mobile/media.svelte.js` fills them from `player.queue` and `player.qi`;
`MediaService` ignores every bit of it, so the card is byte for byte the card
it was. Ten either side because one covers the card's own prev and next and
nothing else while a queue list — which is what Auto asks for — wants a
screenful plus a scroll both ways, and it is a cap rather than a length: a
two-thousand-track queue crosses the bridge as twenty-one entries exactly
like a twenty-one-track one, measured at two kilobytes of JSON for twenty-one
short titles and six with long ones and real cover paths, against the thirty
to eighty of base64 cover that already ride the same call once per record.

Nothing renders it, which is the whole difficulty in checking it — and both
hops of this bridge fail silently, because serde drops an unknown field and
tauri's Jackson mapper has `FAIL_ON_UNKNOWN_PROPERTIES` off, so a key spelled
snake_case where camelCase was wanted arrives as nothing at all with no error
anywhere. `MediaSessionPlugin.update` logs one line per update, and the phone
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. The art-key
count is compared as well as the size and the index, since that is the only
camelCase field an entry has. The check arms at queue index one rather than
at wherever the walk left off, which buys a row on either side — `prev()`
seeks to zero at the top of a queue and `next()` stops at the end — a track
that has only just started, so `prev()` is still a step rather than the seek
it becomes past five seconds, and a window whose own index is the queue
index, because nothing is clamped off the front while the index is under ten.
Each of the three readings has to be a fresh one at both ends, the push
counter up and a new line in the log, since a `prev()` that lands back where
it started reads exactly like a stale line otherwise; and a queue too short
to arm, or repeat-one, under which `next()` seeks instead of stepping, fails
the check rather than skipping it.

The plugin's `consumer-rules.pro` keeps the new `@InvokeArg` class by name
beside the one it already keeps; without that line a release build would fill
`NowPlayingArgs` and leave every queue entry null, quietly. Rust and Kotlin
both changed, so this one cannot go down `dev.sh` — it needs the full
`deploy.sh` detour. And everything the walk already asserts about the card is
untouched, which is the real check on this round.
This commit is contained in:
KeelrLeek committed 2026-09-15 07:22:02 -05:00
1 parent d42645d2a0
commit afcd3cc7f3
7 files changed
+301 -7

No files matched your search

@@ -7,8 +7,9 @@
# FindClass), constructed through "(Landroid/app/Activity;)V", and its two
# @Command methods — update and clear — are dispatched by NAME through
# PluginHandle's commands map. NowPlayingArgs is filled by Jackson field by
# 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.
#
# The other half of this plugin is in the app-wide file: the card's buttons
# come back as the plugin event "action", which the WebView subscribes to
@@ -20,3 +21,4 @@
# them by name. Not repeated here: one place decides each of them.
-keep class com.soulstream.mediasession.MediaSessionPlugin { *; }
-keep class com.soulstream.mediasession.NowPlayingArgs { *; }
-keep class com.soulstream.mediasession.QueueItemArgs { *; }
@@ -28,6 +28,32 @@ class NowPlayingArgs {
var art: String? = null
var hasNext: Boolean = true
var hasPrev: Boolean = true
/** the queue around the current item — it and up to ten either side.
* NOTHING READS IT: MediaService ignores both of these on purpose, so
* the card is byte for byte the card it was. media3's
* SimpleBasePlayer.getState() wants a playlist and an index into it,
* and this is that data arriving a round before the migration does.
* Nullable because a non-null Kotlin property whose setter Jackson
* hands a null throws, and the only thing between here and the JS that
* builds it is two JSON hops. */
var queue: List<QueueItemArgs>? = null
/** where the current item sits in `queue` — not in the whole queue;
* null when the window is empty */
var queueIndex: Int? = null
}
/** One entry of the queue window. Flat and small: it crosses the bridge on
* every update, so it holds what a media3 MediaItem is built from and no
* more. `artKey` names the cover the way NowPlayingArgs.artKey does; the
* bytes never ride along here. */
@InvokeArg
class QueueItemArgs {
var id: String? = null
var title: String? = null
var artist: String? = null
var album: String? = null
var duration: Double? = null
var artKey: String? = null
}
/**
@@ -52,6 +78,20 @@ class MediaSessionPlugin(private val activity: Activity) : Plugin(activity) {
@Command
fun update(invoke: Invoke) {
val args = invoke.parseArgs(NowPlayingArgs::class.java)
/* The window is on no screen anywhere — MediaService ignores it and
the card cannot show it — so logcat is the only place on the
device it can be read back, and tests/mobile/smoke.mjs reads it
from exactly here. One line per update, and an update is a track
change, a transport change or the thirty-second top-up, never a
position tick. `at=` goes LAST because a title may hold anything,
spaces and `=` included. */
val window = args.queue ?: emptyList()
android.util.Log.i(
"SoulStream",
"media window n=" + window.size + " i=" + (args.queueIndex ?: -1) +
" art=" + window.count { !it.artKey.isNullOrEmpty() } +
" at=" + (window.getOrNull(args.queueIndex ?: -1)?.title ?: ""),
)
ensureNotificationPermission()
MediaHub.update(activity.applicationContext, args)
invoke.resolve()
@@ -41,12 +41,50 @@ pub struct NowPlaying {
pub has_next: bool,
#[serde(default = "yes")]
pub has_prev: bool,
/// The queue around what is playing: the current item and up to ten
/// either side, rebuilt on every update.
///
/// NOTHING READS IT YET, and that is the point — `MediaService` ignores
/// both of these fields, so the card is exactly the card it was. media3's
/// `SimpleBasePlayer.getState()` wants a playlist and an index into it,
/// and when that migration comes the data it needs should have been
/// running on the phone for a while rather than have been written the
/// same day.
///
/// A CAP, not a length: a two-thousand-track queue crosses the bridge as
/// twenty-one entries exactly like a twenty-one-track one does.
#[serde(default)]
pub queue: Vec<QueueItem>,
/// Where the current item sits IN `queue` — not in the whole queue.
/// `None` when the window is empty.
pub queue_index: Option<u32>,
}
fn yes() -> bool {
true
}
/// One entry of the queue window. Deliberately flat and small: this crosses
/// two hops of IPC on every update — the WebView to Rust, Rust to Kotlin —
/// so it carries what a media3 `MediaItem` is built from and nothing else.
/// `art_key` is the same kind of key `NowPlaying::art_key` is, the cover's
/// path, so whatever decodes a bitmap for the card can key the window's
/// covers off the same thing; no bytes ride along here.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct QueueItem {
/// The track's own id as a string — a library track's is a number, a
/// catalog track's is `cat:<n>`, a playlist row's is `pl<n>`. NOT a
/// unique key: one queue may hold the same track twice, and a window
/// entry is identified by where it sits, not by this.
pub id: Option<String>,
pub title: Option<String>,
pub artist: Option<String>,
pub album: Option<String>,
pub duration: Option<f64>,
pub art_key: Option<String>,
}
#[cfg(mobile)]
pub struct MediaSession<R: Runtime>(tauri::plugin::PluginHandle<R>);