Files
feedBack/static/panes/pane-streams.js
T
topkoa e5cbea2e9f feat(panes): core detachable pane system + pop-out chip
The option-heavy player UIs (mixer, camera director, viz, audio routing)
all live in the rail popovers, which are exclusive: openPopFor() closes
the last one before opening the next. You cannot watch the mixer while
riding the camera, and both vanish the moment you look at the highway.

Add `window.feedBack.panes` — a core registry for live UI that is
authored once as `mount(root, ctx)` and hosted anywhere. Panes are
non-exclusive, and they survive song switches structurally: the dock is a
body child outside every .screen, so the per-song teardown never sees it.

The adoption cost for a plugin is two calls:

    feedBack.panes.register({ id, title, icon, mount, unmount });
    feedBack.panes.attachChip(myExistingDialogEl, id);

attachChip injects THE standard pop-out chip. Clicking it opens the pane
and hides the plugin's dialog, leaving a stub to bring it back. Core owns
the hide/restore, so every plugin's pop-out looks and behaves the same —
which is the point. It hides via a dedicated .fb-pane-detached class, not
.hidden/[hidden], because the dialogs we attach to already toggle those.

Everything a pane may touch arrives through `ctx` — never a global. That
is what will let the same mount() run inside a pop-out window, a separate
JS realm with no window.feedBack, no window.highway and no audio graph:

  ctx.call(domain, cmd, payload)  -> the capability bus
  ctx.on(event, fn)              -> the feedBack bus (allowlisted)
  ctx.subscribe(stream, fn)      -> playhead / meters
  ctx.state.get/set              -> persisted, main realm is the only writer
  ctx.playhead(), ctx.song(), ctx.toast(), ctx.close()

ctx tracks every subscription it hands out and drops them on unmount, so
a pane cannot leak listeners across a dock/undock cycle.

Streams exist because an AnalyserNode cannot cross a window boundary:
levels are reduced to numbers in the realm that owns the audio graph.
One shared rAF loop, refcounted against live subscriptions, dirty-checked
before fan-out, and stopped dead when the last pane closes.

Hosts register themselves with the manager rather than being imported by
it — the dock lands at priority 0 (the floor, always available), so the
OS pane window can drop in later without this code changing.

Ships two built-in panes: Now Playing (the reference pane — reads the bus,
a stream, and levels, and touches no globals) and Mixer (the same faders
as the rail, via ctx.call('audio-mix', ...), with the chip attached to the
real #mixer-control). Plus a "Panes" rail popover to open panes that have
no dialog of their own; the system tray will mirror that list.

Note the dock sits at z-index 110, not on the docs/plugin-v3-ui.md ladder
(transport 20, rail 30, popovers 40) — those live INSIDE #player's
stacking context, and #player is itself fixed at z-index 100. A dock below
100 is invisible on the one screen panes exist for. Body-level ladder:
#player 100 < dock 110 < toasts 120 < modals 200.

Pop-out windows, the system tray, manifest-declared panes and mirrorGlobal
(the window.__h3dCamCtl proxy the camera director needs) follow.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 16:54:15 -04:00

138 lines
5.7 KiB
JavaScript

/*
* fee[dB]ack — pane streams.
*
* The main realm's sampler for high-rate numeric data a pane wants to display:
* the playhead, and audio levels.
*
* Why a sampler rather than letting panes read the sources directly:
*
* 1. An AnalyserNode cannot cross a window boundary. A popped-out pane can
* never hold one. So levels must be reduced to plain numbers HERE, in the
* realm that owns the audio graph, and shipped as numbers. Making the
* docked path work the same way is what keeps one `mount()` valid in both
* realms.
* 2. Per the plugin performance rules, playback-tied loops must stop when
* nothing is looking at them. One shared rAF loop, reference-counted
* against live subscriptions, is strictly cheaper than N plugin loops —
* and it stops dead when the last pane closes.
*
* Exposes `window.__fbPaneStreams` (host-internal; panes reach this through
* ctx.subscribe()).
*/
(function () {
'use strict';
// Sources are sampled every frame; a source that returns `undefined` is
// simply unavailable right now (no stems plugin, no song loaded) and its
// subscribers are not called at all — better than feeding them zeros they'd
// render as a real silent signal.
const SOURCES = {
// { t, duration, playing } — the transport position.
playhead() {
const hw = window.highway;
if (!hw || typeof hw.getTime !== 'function') return undefined;
const t = hw.getTime();
if (!Number.isFinite(t)) return undefined;
const info = (typeof hw.getSongInfo === 'function' && hw.getSongInfo()) || {};
const bus = window.feedBack;
return {
t: t,
duration: Number.isFinite(info.duration) ? info.duration : 0,
playing: !!(bus && bus.isPlaying),
};
},
// { master } — 0..1 RMS of the master bus.
//
// Read from the stems plugin's analyser when present. It mutes the core
// <audio> element and routes everything through its own graph, so its
// analyser is the only honest tap; with no stems plugin there is no
// analyser to read and the stream stays silent (subscribers see nothing
// and can render an "unavailable" state) rather than reporting zeros.
meters() {
const stems = window.feedBack && window.feedBack.stems;
if (!stems || typeof stems.getAnalyser !== 'function') return undefined;
const an = stems.getAnalyser();
if (!an || typeof an.getFloatTimeDomainData !== 'function') return undefined;
const n = an.fftSize;
// One buffer for the life of the analyser — allocating a
// Float32Array per frame is exactly the GC churn the perf rules warn
// about. Re-allocate only if fftSize changed under us.
if (!_buf || _buf.length !== n) _buf = new Float32Array(n);
an.getFloatTimeDomainData(_buf);
let sum = 0;
for (let i = 0; i < n; i++) sum += _buf[i] * _buf[i];
return { master: Math.sqrt(sum / n) };
},
};
let _buf = null;
// stream name -> Set<fn>
const subs = new Map();
// stream name -> last value posted, for dirty-checking
const last = new Map();
let rafId = null;
function _changed(name, value) {
const prev = last.get(name);
if (prev === undefined && value === undefined) return false;
if (prev === undefined || value === undefined) return true;
// Values are flat objects of numbers/booleans — a key-wise compare is
// enough and avoids JSON.stringify on a 60 Hz path.
for (const k in value) if (prev[k] !== value[k]) return true;
for (const k in prev) if (!(k in value)) return true;
return false;
}
function tick() {
rafId = null;
let live = false;
subs.forEach((set_, name) => {
if (!set_.size) return;
live = true;
const src = SOURCES[name];
const value = src ? src() : undefined;
// Dirty-check before fanning out. While paused the playhead is
// constant and the meters are silent — this drops ~60 no-op
// callbacks per second per pane to zero.
if (!_changed(name, value)) return;
last.set(name, value);
if (value === undefined) return; // unavailable: stay quiet
set_.forEach((fn) => { try { fn(value); } catch (e) { console.error('[panes] stream subscriber threw', e); } });
});
if (live) rafId = requestAnimationFrame(tick);
}
function _kick() {
if (rafId == null) rafId = requestAnimationFrame(tick);
}
function subscribe(name, fn) {
if (!SOURCES[name]) {
console.warn('[panes] unknown stream:', name, '— known:', Object.keys(SOURCES).join(', '));
return () => {};
}
if (typeof fn !== 'function') return () => {};
let set_ = subs.get(name);
if (!set_) { set_ = new Set(); subs.set(name, set_); }
set_.add(fn);
// Forget the dirty-check baseline so a fresh subscriber gets the current
// value on the next frame instead of waiting for it to change.
last.delete(name);
_kick();
return () => {
set_.delete(fn);
// The loop stops itself on the next tick when nothing is subscribed.
};
}
function activeStreams() {
const out = [];
subs.forEach((set_, name) => { if (set_.size) out.push(name); });
return out;
}
window.__fbPaneStreams = { subscribe, activeStreams, sources: () => Object.keys(SOURCES) };
})();