Files
feedBack/static/panes/builtin/mixer-pane.js
T
topkoa fefb9051a4 feat(panes): pop-out windows — the pane realm, hub, and remote transport
A pane can now leave the main window entirely. Same `mount(root, ctx)`,
same file, different JS realm — which is what the ctx-only contract in the
previous commit was for.

## A purpose-built document, not the app shell with a flag on it

`GET /pane` serves static/panes/pane.html: the bridge, the runtime, and
the pane's own script. No highway, no library, no v3 shell, no <audio>,
no Tailwind.

The splitscreen follower takes the other road — it reloads the whole app
at `/?ssFollower=1` and hides what it doesn't want — and pays for it with
an anti-flash block that must run before any script parses (index.html),
bail-outs in app.js and shell.js, and ~40 lines of CSS hiding core
elements by id. It loads the entire app to throw it away. A pane window
has nothing to throw away, so it boots in milliseconds and there is
nothing to flash.

The cost is that `window.feedBack` in a pane realm is a deliberate,
documented SUBSET. The runtime installs exactly what a pane is promised —
`panes.register`, and the no-op chip/dock calls a shared script may make
at load — so a pane reaching for something it was never given fails
loudly at authoring time instead of subtly at runtime.

## The channel

BroadcastChannel('feedback-panes'), same origin. This works only because
Electron's setWindowOpenHandler returns `action: 'allow'` for same-origin
URLs: `deny` would push the window to the system browser, a different
Chromium instance, where BroadcastChannel cannot reach it and the pane
would silently never sync. That flag is load-bearing.

  hello -> snapshot   resync-on-open, always. The snapshot is the only way
                      the pane realm learns anything.
  state               main is authoritative. A pane's write is a REQUEST;
                      main applies it and echoes to every realm, so a
                      losing write self-corrects instead of splitting brain.
  rpc / rpc:reply     ctx.call() -> the capability bus, with a 10s deadline.
                      Without one, a main window that died mid-call leaves
                      the pane's promise pending forever.
  event               allowlisted bus events, JSON-safe. A CustomEvent
                      carrying a DOM node (highway:canvas-replaced does)
                      would throw on postMessage and take the channel down
                      for everyone, so detail is round-tripped through JSON.
  stream              one coalesced message per pane per frame, OVERWRITING
                      anything not yet flushed. Queueing would build a
                      backlog: Chromium throttles a backgrounded window, and
                      the main window is exactly what's backgrounded while
                      the user looks at the pane.
  sub / unsub         refcounts the main-realm sampler.
  bye                 both directions.

## The follower clock

The pane extrapolates between broadcasts: anchor + observedRate * elapsed,
capped at 2s. observedRate is learned from the broadcasts themselves
(dt/dwall) so it tracks the speed slider without being told about it, and
seeks/pauses are excluded from the fit — a jump is not a tempo. Capping it
means a dead main window decays into a frozen clock rather than one that
confidently runs away. This is splitscreen's hard-won trick, generalized:
panes just call ctx.playhead().

## Failure modes, all of them

- Main window closes -> `bye {main-closed}` and the pane says so plainly,
  rather than showing a frozen playhead that looks live. The host also
  closes its windows outright; a pane that cannot be fed should not be on
  screen.
- Pane window X'd or crashed -> a `closed` poll reaps it (a crashed
  renderer never sends `bye`), the pane closes, and the chip's dialog comes
  back. Without this the user's dialog stays hidden with no way back.
- Popup blocked -> a toast, and we bail BEFORE the manager records
  anything, so the caller's dialog stays exactly where it was.
- Nobody answers `hello` in 5s -> the window says so instead of spinning.
- A pane with no `script` is a closure in this realm and cannot honestly
  cross a window boundary. The window host declines it (canHost) and the
  router falls back to the dock.
- A browser blocks window.open() outside a user gesture, so a popped-out
  pane cannot be auto-restored on page load — it would only ever produce a
  "blocked" toast. Such a pane comes back in the DOCK, and the chip pops it
  out again on the next click. (autoRestore: false. The desktop host will
  set it true.)

Hosts may now declare `remote: true`, meaning the pane's mount() runs in
another realm: the manager then owns only the authoritative state store and
never calls mount() itself. That is the seam the Electron BrowserWindow +
tray host drops into next, with no change here.

Verified: popped Now Playing and Mixer into real windows. The pane realm has
no window.highway, no capability bus and no <audio>, yet the Mixer renders
its faders via ctx.call('audio-mix','list-faders') across the channel — and
dragging that fader IN THE PANE WINDOW moved the main window's song volume
to 55 and persisted it. Closing the pane window un-hid the mixer dialog,
removed the stub and restored the chip, while the other pane window stayed
open.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 17:03:21 -04:00

163 lines
6.7 KiB
JavaScript

/*
* fee[dB]ack — Mixer pane (built-in).
*
* The same faders as the rail's mixer popover, in a pane that stays open.
*
* Two reasons this exists rather than "pop out the existing popover":
*
* 1. It is the second half of the chip proof. This file attaches the standard
* ⇱ chip to the EXISTING `#mixer-control` in the rail — real core UI we
* already own — so popping out hides the rail mixer and leaves the stub in
* its place. No new dialog was invented to demo the flow.
* 2. It talks to the `audio-mix` capability bus through `ctx.call()` and to
* nothing else. That is what makes it realm-portable: the rail popover
* reaches into `window.feedBack.capabilities` directly and could never
* survive a move into a pop-out window; this can.
*
* The mixer registry deliberately stores specs, not values — each fader's owner
* persists its own. So this pane persists nothing either (`persist: false`); it
* reads the live values every time it opens.
*/
(function () {
'use strict';
const panes = window.feedBack && window.feedBack.panes;
if (!panes) return;
function fmt(v, unit) {
const s = v === Math.round(v) ? v.toFixed(0) : v.toFixed(2);
return unit ? s + unit : s;
}
function strip(fader, ctx) {
const min = Number(fader.min), max = Number(fader.max);
let cur = Number(fader.currentValue);
if (!Number.isFinite(cur)) cur = Number(fader.defaultValue) || 0;
cur = Math.min(max, Math.max(min, cur));
const available = fader.availability === 'available' && fader.userAdjustable !== false;
const wrap = document.createElement('div');
wrap.className = 'fb-pane-row';
const label = document.createElement('span');
label.className = 'fb-pane-key';
label.textContent = fader.label || fader.faderLabel || fader.faderId || fader.id;
const slider = document.createElement('input');
slider.type = 'range';
slider.className = 'accent-accent slider-input';
slider.min = String(min);
slider.max = String(max);
slider.step = String(fader.step);
slider.value = String(cur);
slider.disabled = !available;
slider.setAttribute('aria-label', label.textContent + ' volume');
const value = document.createElement('span');
value.className = 'fb-pane-val is-num';
value.textContent = available ? fmt(cur, fader.unit) : 'Unavailable';
// A drag fires `input` faster than the capability round-trip resolves, so
// responses can land out of order. Only the newest write may paint.
let seq = 0;
slider.addEventListener('input', () => {
if (slider.disabled) return;
const mine = ++seq;
const requested = parseFloat(slider.value);
ctx.call('audio-mix', 'set-fader-value', {
participantId: fader.participantId,
faderId: fader.faderId || fader.id,
value: Number.isFinite(requested) ? requested : cur,
}).then((result) => {
if (mine !== seq) return;
const payload = (result && result.payload) || {};
const committed = Number(payload.committedValue);
if (Number.isFinite(committed)) {
cur = Math.min(max, Math.max(min, committed));
slider.value = String(cur);
}
value.textContent = (result && result.outcome === 'handled') ? fmt(cur, fader.unit) : 'Failed';
}).catch(() => {
if (mine !== seq) return;
slider.value = String(cur);
value.textContent = 'Failed';
});
});
wrap.appendChild(label);
wrap.appendChild(slider);
wrap.appendChild(value);
return wrap;
}
panes.register({
id: 'core_mixer',
title: 'Mixer',
icon: '🎚',
script: '/static/panes/builtin/mixer-pane.js',
persist: false,
// Faders come and go with the song (a song with no stems unregisters
// them), so the pane has to hear about it. These ride on top of the
// default event allowlist.
events: [
'audio-mix:participant-registered',
'audio-mix:participant-removed',
'audio-mix:fader-unavailable',
],
mount(root, ctx) {
const list = document.createElement('div');
root.appendChild(list);
let renderSeq = 0;
function render() {
const mine = ++renderSeq;
ctx.call('audio-mix', 'list-faders', {}).then((result) => {
if (mine !== renderSeq) return; // a newer render superseded us
const faders = (result && result.payload && Array.isArray(result.payload.faders))
? result.payload.faders : [];
list.replaceChildren();
if (!faders.length) {
const empty = document.createElement('div');
empty.className = 'fb-pane-dim';
empty.textContent = 'No audio sources.';
list.appendChild(empty);
return;
}
faders.forEach((f) => list.appendChild(strip(f, ctx)));
}).catch((err) => {
if (mine !== renderSeq) return;
console.error('[panes] mixer: list-faders failed', err);
list.replaceChildren();
const oops = document.createElement('div');
oops.className = 'fb-pane-dim';
oops.textContent = 'Mixer unavailable.';
list.appendChild(oops);
});
}
render();
ctx.on('audio-mix:participant-registered', render);
ctx.on('audio-mix:participant-removed', render);
ctx.on('audio-mix:fader-unavailable', render);
// A new song brings a new set of stems.
ctx.on('song:ready', render);
},
unmount(root) {
root.replaceChildren();
},
});
// The chip, on the real rail mixer. Hiding #mixer-control (button + popover)
// rather than #mixer-popover alone means the rail doesn't keep offering a
// "Mixer ▾" button that opens an empty popover while the pane owns the faders.
function _attach() {
const el = document.getElementById('mixer-control');
if (!el) return;
panes.attachChip(el, 'core_mixer');
}
if (document.readyState !== 'complete') document.addEventListener('DOMContentLoaded', _attach);
else _attach();
})();