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

+2 -2
View File
@@ -47,7 +47,7 @@
import { loadPrefs } from "./prefs.svelte.js";
import { setup, loadSetup, maybeOpenFirstRun, openSetup, finishSetup, setupState } from "./setup.svelte.js";
import { catalog, artistKey } from "./catalog.svelte.js";
import { bindMedia, syncMedia } from "./media.svelte.js";
import { bindMedia, syncMedia, mediacard } from "./media.svelte.js";
import { slsk, bindSlsk, connectStored, refreshRooms, unreadTotal } from "./slsk.svelte.js";
import { net, bindNet } from "./net.svelte.js";
import { resolver, streamSong, streamCatalogAlbum } from "./resolve.svelte.js";
@@ -323,7 +323,7 @@
requestAnimationFrame(() => requestAnimationFrame(() => { clearTimeout(sealTimer); sealBoot(); }));
/* the probe handle — the desktop has __soulstream for the same reason:
tests/mobile/smoke.mjs drives the real app through it */
window.__ss = { nav, top, go, push, back, openSheet, closeSheet, openHalf, closeHalf, player, lib, art, catalog, home, loadHome, lyr, loadLyrics, slsk, boot, net, resolver, streamSong, streamCatalogAlbum, sources, api, transfers, xfn: xfernote, pls, notice, importer, artTally, imp: { startFolder, startFile }, setup, openSetup, setupState, wifi: { downloadsAllowed, cellular }, next, prev, toggle, seekSecs, playQueueAt, patch, contacts, loadContacts, say, refreshRooms };
window.__ss = { nav, top, go, push, back, openSheet, closeSheet, openHalf, closeHalf, player, lib, art, catalog, home, loadHome, lyr, loadLyrics, slsk, boot, net, resolver, streamSong, streamCatalogAlbum, sources, api, transfers, xfn: xfernote, card: mediacard, pls, notice, importer, artTally, imp: { startFolder, startFile }, setup, openSetup, setupState, wifi: { downloadsAllowed, cellular }, next, prev, toggle, seekSecs, playQueueAt, patch, contacts, loadContacts, say, refreshRooms };
return () => { if (unlisten) unlisten(); };
});
</script>
+68
View File
@@ -8,6 +8,54 @@ import { invoke, addPluginListener } from "@tauri-apps/api/core";
import { player, hooks, toggle, next, prev, seekSecs } from "./player.svelte.js";
import { art } from "./art.svelte.js";
/* THE QUEUE WINDOW. Every update carries a bounded slice of the queue —
the current item and QUEUE_WINDOW either side — because media3's
SimpleBasePlayer.getState() wants a playlist and an index into it, and
the migration to it should find this data already running rather than
written the same day. Nothing reads it yet: MediaService ignores both
fields and the card is exactly the card it was.
TEN either side, twenty-one entries at most. One would cover the card's
own prev and next and nothing else; a queue LIST — which is what Auto
asks for — wants a screenful plus a scroll in either direction, and a
car's list pages about five rows, so ten is two of those each way. It is
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. Measured at
two kilobytes of JSON for twenty-one short titles and six with long ones
and real Android cover paths — against the thirty to eighty kilobytes of
base64 cover that already ride this same call once per record, and an
update is a track change, a transport change or the thirty-second top-up,
never a position tick. */
const QUEUE_WINDOW = 10;
/** A queue entry as the window carries it: what a media3 MediaItem is built
* from, flat, with the cover NAMED rather than carried. The artist falls
* back to the record's the way the bar and the sheet's rows do. */
function windowItem(e) {
return {
id: e?.t?.id != null ? String(e.t.id) : null,
title: e?.t?.title || "",
artist: e?.t?.artist || e?.album?.artist || "",
album: e?.album?.title || "",
duration: e?.t?.duration_secs ?? null,
artKey: e?.album?.art_path || null,
};
}
/* What the card was last told about the queue around it. A PLAIN object,
not $state — nothing renders it, and push() must never make the bar's own
effects re-run — exactly as xfernote keeps its own mirror of the shade.
The walk reads it through __ss.card and holds it against what the plugin
logs on the far side of the bridge: a lock-screen card is not a thing a
page can be asked about, and this window is not even on the card. */
export const mediacard = {
updates: 0,
window: 0,
index: -1,
art: 0,
at: "",
};
let lastArtKey = null;
let timer = null;
let bound = false;
@@ -17,6 +65,10 @@ async function push() {
const t = player.track;
if (!t) {
lastArtKey = null;
mediacard.window = 0;
mediacard.index = -1;
mediacard.art = 0;
mediacard.at = "";
await invoke("plugin:media-session|clear").catch(() => {});
return;
}
@@ -34,11 +86,27 @@ async function push() {
hasNext: player.qi + 1 < player.queue.length || player.repeat > 0,
hasPrev: true,
};
/* the window, clamped at both ends. `at` is recomputed rather than
assumed to be QUEUE_WINDOW: at the top of the queue the slice starts
at 0 and the current item is the first entry, not the eleventh. */
const lo = Math.max(0, player.qi - QUEUE_WINDOW);
const win = player.qi < 0 ? [] : player.queue.slice(lo, player.qi + QUEUE_WINDOW + 1);
const at = player.qi - lo;
state.queue = win.map(windowItem);
state.queueIndex = at >= 0 && at < win.length ? at : null;
/* the cover's bytes ride along once per record, and only once they exist */
if (key && key !== lastArtKey && url && url.startsWith("data:image")) {
state.art = url.slice(url.indexOf(",") + 1);
lastArtKey = key;
}
/* what was SENT, recorded before the call and whether or not it lands —
a desktop build and the mock both reject it, and neither is a reason
to forget what the window looked like */
mediacard.updates++;
mediacard.window = state.queue.length;
mediacard.index = state.queueIndex ?? -1;
mediacard.art = state.queue.filter((q) => !!q.artKey).length;
mediacard.at = state.queueIndex == null ? "" : (state.queue[state.queueIndex].title || "");
await invoke("plugin:media-session|update", { state }).catch(() => {});
}