mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-12 03:41:40 +00:00
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>
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
* 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: '🎚',
|
||||
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();
|
||||
})();
|
||||
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* fee[dB]ack — "Now Playing" pane (built-in).
|
||||
*
|
||||
* The reference pane, and the one that proves the contract: it reads song
|
||||
* metadata off the mirrored event bus, a playhead off a stream, and audio levels
|
||||
* off a stream that only exists when the stems plugin does. It touches
|
||||
* `window.feedBack`, `window.highway` and the audio graph exactly zero times —
|
||||
* everything comes through `ctx` — which is what will let it run unchanged
|
||||
* inside a pop-out window, where none of those globals exist.
|
||||
*
|
||||
* Read this before writing a pane of your own.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const panes = window.feedBack && window.feedBack.panes;
|
||||
if (!panes) return;
|
||||
|
||||
function row(parent, key) {
|
||||
const r = document.createElement('div');
|
||||
r.className = 'fb-pane-row';
|
||||
const k = document.createElement('span');
|
||||
k.className = 'fb-pane-key';
|
||||
k.textContent = key;
|
||||
const v = document.createElement('span');
|
||||
v.className = 'fb-pane-val';
|
||||
r.appendChild(k);
|
||||
r.appendChild(v);
|
||||
parent.appendChild(r);
|
||||
return v;
|
||||
}
|
||||
|
||||
function fmtTime(s) {
|
||||
if (!Number.isFinite(s) || s < 0) s = 0;
|
||||
const m = Math.floor(s / 60);
|
||||
return m + ':' + String(Math.floor(s % 60)).padStart(2, '0');
|
||||
}
|
||||
|
||||
panes.register({
|
||||
id: 'now_playing',
|
||||
title: 'Now Playing',
|
||||
icon: '🎵',
|
||||
// Levels are transient; nothing here is worth remembering across a reload.
|
||||
persist: false,
|
||||
|
||||
mount(root, ctx) {
|
||||
const title = row(root, 'Song');
|
||||
const artist = row(root, 'Artist');
|
||||
const arr = row(root, 'Arrangement');
|
||||
const tuning = row(root, 'Tuning');
|
||||
|
||||
const timeVal = row(root, 'Position');
|
||||
timeVal.classList.add('is-num');
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'fb-pane-bar';
|
||||
const fill = document.createElement('div');
|
||||
fill.className = 'fb-pane-bar-fill';
|
||||
bar.appendChild(fill);
|
||||
root.appendChild(bar);
|
||||
|
||||
const levelLabel = document.createElement('div');
|
||||
levelLabel.className = 'fb-pane-dim';
|
||||
levelLabel.style.marginTop = '.6rem';
|
||||
levelLabel.textContent = 'Level — no stems plugin';
|
||||
const level = document.createElement('div');
|
||||
level.className = 'fb-pane-bar';
|
||||
const levelFill = document.createElement('div');
|
||||
levelFill.className = 'fb-pane-bar-fill is-level';
|
||||
level.appendChild(levelFill);
|
||||
root.appendChild(levelLabel);
|
||||
root.appendChild(level);
|
||||
|
||||
function renderSong() {
|
||||
const s = ctx.song();
|
||||
title.textContent = (s && s.title) || '—';
|
||||
artist.textContent = (s && s.artist) || '—';
|
||||
arr.textContent = (s && (s.arrangementSmartName || s.arrangement)) || '—';
|
||||
tuning.textContent = (s && Array.isArray(s.tuning)) ? s.tuning.join(' ') : '—';
|
||||
}
|
||||
|
||||
renderSong();
|
||||
// The mirrored bus. `song:loaded` also fires on an arrangement switch,
|
||||
// which is exactly when the arrangement/tuning rows go stale.
|
||||
ctx.on('song:loaded', renderSong);
|
||||
|
||||
// Streams, not events: the playhead moves every frame, and
|
||||
// `song:position-changed` is throttled to 250 ms — too coarse for a
|
||||
// bar, and too chatty to mirror across a window boundary.
|
||||
ctx.subscribe('playhead', (p) => {
|
||||
timeVal.textContent = fmtTime(p.t) + ' / ' + fmtTime(p.duration);
|
||||
const frac = p.duration > 0 ? Math.min(1, Math.max(0, p.t / p.duration)) : 0;
|
||||
fill.style.transform = 'scaleX(' + frac.toFixed(4) + ')';
|
||||
});
|
||||
|
||||
// The meters stream stays silent when no analyser exists rather than
|
||||
// reporting zeros — so a silent stream and real silence are
|
||||
// distinguishable, and this label is honest either way.
|
||||
ctx.subscribe('meters', (m) => {
|
||||
levelLabel.textContent = 'Level';
|
||||
levelFill.style.transform = 'scaleX(' + Math.min(1, m.master * 3).toFixed(3) + ')';
|
||||
});
|
||||
},
|
||||
|
||||
unmount(root) {
|
||||
// ctx tears down every subscription it handed out; the pane only owns
|
||||
// its DOM.
|
||||
root.replaceChildren();
|
||||
},
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user