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>
289 lines
12 KiB
JavaScript
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);
|
|
})();
|