Files
feedBack/static/panes/pane-manager.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

289 lines
12 KiB
JavaScript

/*
* fee[dB]ack — pane manager.
*
* The registry and host router behind `window.feedBack.panes`. Main realm only.
*
* A "pane" is a piece of live UI — a mixer, a camera rig, a readout — authored
* once as `mount(root, ctx)` and mountable into any *host*: the in-window dock
* today, a pop-out OS window later. The manager owns which pane is open and
* where; hosts own the chrome; the pane owns nothing but its own DOM.
*
* The problem this exists to solve: the player's rail popovers are exclusive
* (opening one closes the last), so you cannot watch the mixer while riding the
* camera — and both vanish the moment you want to look at the highway. Panes
* are non-exclusive by construction and survive song switches, because nothing
* about them is tied to the per-song teardown.
*
* Hosts register themselves; the manager never imports one. That is what lets
* the pop-out window host drop in later without this file changing.
*/
(function () {
'use strict';
const B = window.__fbPaneBridge;
if (!B) { console.error('[panes] pane-bridge.js must load before pane-manager.js'); return; }
const HOSTS_KEY = 'fbPaneHosts'; // { paneId: hostId } — panes open at last unload
const STATE_KEY = (id) => 'fbPane:' + id;
// id -> normalized spec
const specs = new Map();
// id -> { spec, hostId, root, ctx, state }
const open = new Map();
// hostId -> host provider
const hosts = new Map();
// ── Persistence ──────────────────────────────────────────────────────────
// localStorage is shared with any pop-out realm (same origin), so a
// concurrent writer there would silently clobber us. The rule, enforced by
// this file being main-realm-only: THE MAIN REALM IS THE ONLY WRITER. A pane
// asks; the manager writes.
function _readJSON(key, fallback) {
try {
const raw = localStorage.getItem(key);
return raw ? JSON.parse(raw) : fallback;
} catch (e) { return fallback; } // private mode / corrupt value
}
function _writeJSON(key, value) {
try { localStorage.setItem(key, JSON.stringify(value)); } catch (e) { /* quota / private mode: non-fatal */ }
}
function _rememberHost(id, hostId) {
const map = _readJSON(HOSTS_KEY, {});
if (hostId) map[id] = hostId; else delete map[id];
_writeJSON(HOSTS_KEY, map);
}
// Pane state is saved on a trailing debounce — a fader drag writes on every
// input event, and localStorage is synchronous.
const _saveTimers = new Map();
function _scheduleSave(id, state) {
clearTimeout(_saveTimers.get(id));
_saveTimers.set(id, setTimeout(() => {
_saveTimers.delete(id);
_writeJSON(STATE_KEY(id), state.all());
}, 250));
}
// ── Spec ─────────────────────────────────────────────────────────────────
function _normalize(spec) {
if (!spec || typeof spec !== 'object') throw new TypeError('panes.register: spec must be an object');
if (!spec.id || typeof spec.id !== 'string') throw new TypeError('panes.register: spec.id is required');
if (typeof spec.mount !== 'function') throw new TypeError('panes.register(' + spec.id + '): spec.mount is required');
return {
id: spec.id,
title: spec.title || spec.id,
icon: spec.icon || '▣',
mount: spec.mount,
unmount: typeof spec.unmount === 'function' ? spec.unmount : null,
// Bus events mirrored into a pop-out realm for this pane. Docked, ctx.on()
// reaches the real bus regardless — this list only matters once the
// pane is in another realm, and it is declared here so it is the same
// list in both.
events: Array.isArray(spec.events) ? B.DEFAULT_EVENTS.concat(spec.events) : B.DEFAULT_EVENTS,
persist: spec.persist !== false, // default on; opt out with `persist: false`
initialState: spec.initialState || {},
defaultHost: spec.defaultHost || 'window',
mirrorGlobal: spec.mirrorGlobal || null, // honoured by pane-mirror.js
width: spec.width || 380,
height: spec.height || 560,
};
}
// ── Host routing ─────────────────────────────────────────────────────────
// A host provider is `{ id, priority, available(), mount(spec) -> Element,
// unmount(id), focus(id) }`. Higher priority wins when a pane asks for a
// host it can't have.
function _resolveHost(preferred) {
const wanted = hosts.get(preferred);
if (wanted && wanted.available()) return wanted;
// Fall back to the best host that IS available, preferring the highest
// priority. The dock registers at priority 0, so it is always the floor —
// a pane can never fail to open just because no window host exists.
let best = null;
hosts.forEach((h) => {
if (!h.available()) return;
if (!best || h.priority > best.priority) best = h;
});
return best;
}
function _emit(name, detail) {
const bus = window.feedBack;
if (bus && typeof bus.emit === 'function') bus.emit(name, detail);
}
// ── Public API ───────────────────────────────────────────────────────────
function register(spec) {
const s = _normalize(spec);
if (specs.has(s.id)) {
// First registration wins, matching libraryCardActions.register. A
// silent overwrite would let a re-injected plugin script swap the
// mount function out from under an already-open pane.
console.warn('[panes] pane already registered, ignoring:', s.id);
return () => {};
}
specs.set(s.id, s);
_emit('panes:registered', { id: s.id, title: s.title });
// Reopen where the user left it. Deferred a tick so a plugin can call
// register() and attachChip() back-to-back — the chip must exist before
// the pane opens or it has nothing to hide.
const remembered = _readJSON(HOSTS_KEY, {})[s.id];
if (remembered) setTimeout(() => { if (specs.has(s.id) && !open.has(s.id)) openPane(s.id, { host: remembered, remember: false }); }, 0);
return () => unregister(s.id);
}
function unregister(id) {
if (open.has(id)) closePane(id, { remember: false });
specs.delete(id);
_emit('panes:unregistered', { id: id });
}
function openPane(id, opts) {
opts = opts || {};
const spec = specs.get(id);
if (!spec) { console.warn('[panes] open: no such pane:', id); return false; }
if (open.has(id)) { focusPane(id); return true; }
const host = _resolveHost(opts.host || spec.defaultHost);
if (!host) { console.error('[panes] open: no host available for', id); return false; }
const state = B.createStateStore(spec.persist ? _readJSON(STATE_KEY(id), spec.initialState) : spec.initialState);
if (spec.persist) state.subscribe(() => _scheduleSave(id, state));
let root;
try {
root = host.mount(spec);
} catch (e) {
console.error('[panes] host', host.id, 'failed to mount', id, e);
return false;
}
const ctx = B.createCtx({
paneId: id,
host: host.id,
transport: B.createLocalTransport(id),
state: state,
onClose: () => closePane(id),
});
const entry = { spec, hostId: host.id, root, ctx, state };
open.set(id, entry);
try {
spec.mount(root, ctx);
} catch (e) {
// A pane that throws in mount() must not leave a half-open shell
// behind — tear the whole thing back down and tell the user, rather
// than leaving an empty card they can't explain.
console.error('[panes] pane threw in mount():', id, e);
closePane(id, { remember: false });
if (window.fbNotify) window.fbNotify.show({ title: spec.title, message: 'Failed to open.', icon: '⚠️', accent: '#f59e0b' });
return false;
}
if (opts.remember !== false) _rememberHost(id, host.id);
_emit('panes:opened', { id: id, host: host.id });
return true;
}
function closePane(id, opts) {
opts = opts || {};
const entry = open.get(id);
if (!entry) return false;
open.delete(id);
// Order matters: the pane tears down its own DOM/listeners first, then
// ctx drops everything it handed out, then the host removes the shell.
// Reversing any of these hands the pane a root that has already been
// detached, or leaks the subscriptions its unmount() assumed it kept.
try { if (entry.spec.unmount) entry.spec.unmount(entry.root, entry.ctx); }
catch (e) { console.error('[panes] pane threw in unmount():', id, e); }
try { entry.ctx._dispose(); } catch (e) { console.error('[panes] ctx dispose threw:', id, e); }
const host = hosts.get(entry.hostId);
try { if (host) host.unmount(id); } catch (e) { console.error('[panes] host', entry.hostId, 'threw in unmount:', id, e); }
// Flush any pending debounced state write — closing must not lose the
// last fader nudge.
if (entry.spec.persist) {
clearTimeout(_saveTimers.get(id));
_saveTimers.delete(id);
_writeJSON(STATE_KEY(id), entry.state.all());
}
if (opts.remember !== false) _rememberHost(id, null);
_emit('panes:closed', { id: id, host: entry.hostId });
return true;
}
// Move an open pane to a different host without losing its state: the pane's
// DOM is rebuilt (mount runs again against the new root) but its state store
// is the persisted one, so a fader sits where the user left it.
function movePane(id, hostId) {
const wasOpen = open.has(id);
if (wasOpen) closePane(id, { remember: false });
return openPane(id, { host: hostId });
}
function focusPane(id) {
const entry = open.get(id);
if (!entry) return false;
const host = hosts.get(entry.hostId);
if (host && typeof host.focus === 'function') host.focus(id);
return true;
}
// `detach` is what the pop-out chip calls: put this pane wherever a pane
// most wants to live. Today the dock is usually the only host; once the
// window host registers, it outranks the dock and the same call opens an OS
// window instead. The chip never changes.
function detach(id) {
const spec = specs.get(id);
return openPane(id, { host: (spec && spec.defaultHost) || 'window' });
}
function dock(id) { return movePane(id, 'dock'); }
function registerHost(host) {
if (!host || !host.id) throw new TypeError('panes: host needs an id');
hosts.set(host.id, {
id: host.id,
priority: host.priority || 0,
available: typeof host.available === 'function' ? host.available : () => true,
mount: host.mount,
unmount: host.unmount,
focus: host.focus,
});
}
const api = {
version: 1,
register,
unregister,
open: openPane,
close: closePane,
move: movePane,
focus: focusPane,
detach,
dock,
isOpen: (id) => open.has(id),
hostOf: (id) => { const e = open.get(id); return e ? e.hostId : null; },
get: (id) => specs.get(id) || null,
list: () => Array.from(specs.values()).map((s) => ({ id: s.id, title: s.title, icon: s.icon, open: open.has(s.id), host: (open.get(s.id) || {}).hostId || null })),
// Host registration is host-internal, but it lives on the same object so
// a future out-of-tree host (a plugin shipping its own window shell) can
// participate without a private import.
registerHost,
};
window.feedBack = window.feedBack || {};
window.feedBack.panes = Object.assign(window.feedBack.panes || {}, api);
})();