Files
SoulStream/src/mobile/media.svelte.js
T
KeelrLeek 3a8ccd4640 android: the lock-screen card, moved to media3 before anything is built on it
The card Kyle sees every day was a hand-built MediaStyle notification over a
MediaSessionCompat, on `androidx.media`, deprecated since May. Android Auto
needs a media3 session to browse, so the choice was to write the new thing
against a deprecated library or to move the old thing first; this moves the
old thing and adds no Auto. `MediaService` is a `MediaSessionService` and a
new `SoulStreamPlayer` is the `SimpleBasePlayer` media3 asks for when the
engine lives somewhere else — rodio, in Rust, behind a WebView round trip —
so `getState()` is a pure read of the last `NowPlayingArgs` off the bridge,
queue window and all, and every button routes back out through `MediaHub.act`.

Three corrections to the plan, each the kind that only shows up on a phone.
media3 **1.10.1**, not 1.11.0, whose POM drags kotlin-stdlib 2.2.10 into a
project that compiles with Kotlin 1.9.25. The audio-focus and becoming-noisy
blocks STAY hand-rolled: `setAudioAttributes(…, handleAudioFocus)` is an
ExoPlayer convenience that lands in a `@ForOverride` method which throws, and
deleting them would have lost "another app takes the output" and "headphones
out" with nothing failing anywhere. And the service is BOUND, never started —
`MediaSessionService.onStartCommand` returns `START_STICKY` without promoting
for any intent that is not media3's own, so a `startForegroundService` would
arm the ten-second kill timer and satisfy nothing, which is the 2026-09-13 bug
in a new coat. Four more things are load-bearing and silent when they break: a
playlist that must never be empty while a track exists, `COMMAND_GET_TIMELINE`
with `COMMAND_GET_CURRENT_MEDIA_ITEM`, `STATE_READY` while paused, and both
spellings of next and previous. Each ends in a card that is simply never
posted, with nothing in any log. The window carries `queueBase` now, for a
stable media-item uid as it slides, and `repeat`, whose numbering is swapped
between the app and media3, so that what a controller is told about repeat is
the truth and media3's own view of the end of the queue agrees with the
`hasNext` the next button is advertised on.

The 2026-09-13 bug has a second door, and `onStartCommand` is what shuts it.
media3 hands the framework a media-button `PendingIntent` built with
`PendingIntent.getForegroundService`, unconditionally, because the receiver the
legacy stub resolves is now this service — there being no `MediaButtonReceiver`
left for it to find. So the SYSTEM starts this service in the foreground on
every media KEY, while media3 promotes only for a session it calls
user-engaged. A key into a process built for that key, a key on a card paused
past the ten-minute tail that does not start playback, any key with the bar
cleared: none of them qualify, and each would take the app down ten seconds
later. (The card's own buttons are milder — only play/pause, and only while
paused, is a foreground start.) So `onStartCommand` promotes first, the way
this service always did — through media3's own `onUpdateNotification` when
there is a card to post, and otherwise with a plain notification of its own,
posted and withdrawn in the same breath. `Service`'s
`startForeground` is final, so the split is by what media3 can be asked to do,
not by watching it.

The walk gains `one-card` and `one-session` — the only checks that tell a
landed migration from a doubled one — plus an advisory `media-plumbing` line;
the logged window grew `base` and `rep`, which are the only readback either
field has anywhere on the device and which `queue-window` now asserts; and the
paused-restore wait goes to 25 s, which ANDROID.md had already claimed in one
place and contradicted in another. ANDROID.md also says plainly what the walk
cannot prove and Kyle has to try himself: headphones out, another app taking
focus, the card's actual look, and a Bluetooth headset's own buttons. The
Kotlin is unbuilt — no Kotlin compiler here — and was read against javap of
media3 1.10.1 and the real sources of `SimpleBasePlayer`, `MediaSessionService`,
`MediaNotificationManager` and `DefaultMediaNotificationProvider`; the manifest
and resources compile and link under aapt2, and the Rust type-checks.
2026-09-15 09:09:36 -05:00

158 lines
7.2 KiB
JavaScript

/* The phone's media session: the lock-screen card, the shade's transport,
the headset buttons, audio focus. The card is a mirror of the bar — fed
on every change of track or transport, topped up now and then so its
progress does not drift — and its buttons come back as actions the
player carries out. Everything real lives in the plugin
(crates/tauri-plugin-media-session); this is the wiring. */
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. Since round forty-eight
SoulStreamPlayer.getState() builds the media3 playlist and the index
into it out of exactly these fields, so what the lock screen shows is
made of them rather than beside them.
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;
async function push() {
timer = null;
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;
}
const key = player.album?.art_path || null;
const url = key ? art[key] : "";
const state = {
title: t.title,
artist: t.artist || player.album?.artist || "",
album: player.album?.title || "",
playing: player.status === "playing" || player.status === "buffering",
position: player.position,
duration: player.duration,
artKey: key,
art: null,
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;
/* where the window STARTS in the whole queue. It is what gives an
entry a stable media3 uid as the window slides past it — without it
a track change re-labels all twenty-one rows and media3 reports a
phantom media-item transition — and it is half of the mapping Auto
needs to turn a tapped queue row back into playQueueAt(). camelCase,
like everything on this bridge: a snake_case key is silently ignored
on both hops, with no error anywhere. */
state.queueBase = player.qi < 0 ? null : lo;
/* 0 off, 1 all, 2 one — ours. media3 numbers them 0 OFF, 1 ONE, 2 ALL
and SoulStreamPlayer maps across rather than casting, which would
turn repeat-all into repeat-one. It crosses so the player's state is
not a lie to whatever reads it — the system's own media controls
now, Auto later — and so media3's view of "is there a next item"
agrees with the hasNext the next button is advertised on. */
state.repeat = player.repeat;
/* 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(() => {});
}
/** Coalesce a burst of changes (track, then status, then position) into one card update. */
export function syncMedia() {
if (timer) clearTimeout(timer);
timer = setTimeout(push, 60);
}
/** Wire the card to the player. Harmless where there is no plugin. */
export async function bindMedia() {
if (bound) return;
bound = true;
hooks.mediaSync = syncMedia;
try {
await addPluginListener("media-session", "action", (p) => {
const playing = player.status === "playing" || player.status === "buffering";
switch (p?.action) {
case "play": if (!playing) toggle(); break;
case "pause": if (playing) toggle(); break;
case "toggle": toggle(); break;
case "next": next(); break;
case "prev": prev(); break;
case "seek": if (typeof p.position === "number" && p.position >= 0) seekSecs(p.position); break;
case "stop": if (playing) toggle(); break;
}
});
} catch {
/* not a phone */
}
/* the card interpolates its progress from the last update; a top-up
every half minute keeps a long track honest */
setInterval(() => { if (player.track) syncMedia(); }, 30_000);
}