mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-10 18:59:56 +00:00
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>
138 lines
5.7 KiB
JavaScript
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) };
|
|
})();
|