/* 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); }