mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 19:29:33 +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();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
/*
|
||||||
|
* fee[dB]ack — pane bridge.
|
||||||
|
*
|
||||||
|
* The transport + context layer for detachable panes. Zero DOM, zero UI.
|
||||||
|
*
|
||||||
|
* A pane is authored ONCE, as `mount(root, ctx)`, and must run unchanged in two
|
||||||
|
* places: docked in the main window, or inside a pop-out window (a separate JS
|
||||||
|
* realm where `window.feedBack`, `window.highway` and the audio graph do not
|
||||||
|
* exist). Everything a pane is allowed to touch therefore arrives through `ctx`
|
||||||
|
* — never through globals. That is the whole point of this file: it is the only
|
||||||
|
* seam between "pane code" and "which realm am I in".
|
||||||
|
*
|
||||||
|
* Two transports implement that seam:
|
||||||
|
*
|
||||||
|
* LocalTransport — main realm. Calls straight through to the capability bus,
|
||||||
|
* the feedBack event bus, and the stream sampler.
|
||||||
|
* RemoteTransport — pane realm (added with the pop-out window). Same methods,
|
||||||
|
* marshalled over BroadcastChannel.
|
||||||
|
*
|
||||||
|
* A pane cannot tell them apart, and must not try.
|
||||||
|
*
|
||||||
|
* Exposes `window.__fbPaneBridge` (host-internal — panes never touch it).
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Bumped only on a breaking envelope change. The pane realm refuses to talk
|
||||||
|
// to a main realm with a different major, rather than half-working.
|
||||||
|
const PROTOCOL_VERSION = 1;
|
||||||
|
const CHANNEL_NAME = 'feedback-panes';
|
||||||
|
|
||||||
|
// Bus events mirrored into a pane realm by default. Deliberately an
|
||||||
|
// allowlist, not a firehose: `song:position-changed` fires every 250ms and
|
||||||
|
// `capability:event` fires constantly, and neither belongs on a
|
||||||
|
// cross-window channel — position rides the `playhead` stream instead.
|
||||||
|
// A pane widens this with `spec.events: [...]`.
|
||||||
|
const DEFAULT_EVENTS = [
|
||||||
|
'song:loading', 'song:loaded', 'song:ready',
|
||||||
|
'song:play', 'song:pause', 'song:ended', 'song:stop', 'song:seek',
|
||||||
|
'song:arrangement-changed',
|
||||||
|
'screen:changed', 'theme:changed', 'library:changed',
|
||||||
|
'highway:canvas-replaced', 'highway:visibility',
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── State store ──────────────────────────────────────────────────────────
|
||||||
|
// A dotted-path key/value tree, one per pane. In the main realm this is the
|
||||||
|
// authoritative copy; a pane realm holds a replica and its writes are
|
||||||
|
// requests (see RemoteTransport). Subscribers get (snapshot, change).
|
||||||
|
|
||||||
|
function _split(path) {
|
||||||
|
if (typeof path !== 'string' || !path) throw new TypeError('pane state: path must be a non-empty string');
|
||||||
|
return path.split('.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStateStore(initial) {
|
||||||
|
let data = (initial && typeof initial === 'object') ? JSON.parse(JSON.stringify(initial)) : {};
|
||||||
|
const subs = new Set();
|
||||||
|
|
||||||
|
function get(path) {
|
||||||
|
if (path == null) return data;
|
||||||
|
let node = data;
|
||||||
|
for (const k of _split(path)) {
|
||||||
|
if (node == null || typeof node !== 'object') return undefined;
|
||||||
|
node = node[k];
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function set(path, value) {
|
||||||
|
const keys = _split(path);
|
||||||
|
let node = data;
|
||||||
|
for (let i = 0; i < keys.length - 1; i++) {
|
||||||
|
const k = keys[i];
|
||||||
|
// Walk-and-create. A non-object on the way down is replaced —
|
||||||
|
// the writer's shape wins over a stale scalar.
|
||||||
|
if (node[k] == null || typeof node[k] !== 'object') node[k] = {};
|
||||||
|
node = node[k];
|
||||||
|
}
|
||||||
|
const last = keys[keys.length - 1];
|
||||||
|
if (node[last] === value) return false; // no-op writes don't notify
|
||||||
|
node[last] = value;
|
||||||
|
const change = { path: path, value: value };
|
||||||
|
subs.forEach((fn) => { try { fn(data, change); } catch (e) { console.error('[panes] state subscriber threw', e); } });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bulk replace, used on snapshot/resync. Notifies once with a null change.
|
||||||
|
function replace(next) {
|
||||||
|
data = (next && typeof next === 'object') ? next : {};
|
||||||
|
subs.forEach((fn) => { try { fn(data, null); } catch (e) { console.error('[panes] state subscriber threw', e); } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribe(fn) {
|
||||||
|
subs.add(fn);
|
||||||
|
return () => subs.delete(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { get, set, replace, subscribe, all: () => data };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Local transport (main realm) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
const CALL_TIMEOUT_MS = 2100; // matches core's own audio-mix calls
|
||||||
|
|
||||||
|
function createLocalTransport(paneId) {
|
||||||
|
return {
|
||||||
|
kind: 'local',
|
||||||
|
|
||||||
|
// Route to the capability bus. Panes get exactly this, and nothing
|
||||||
|
// else, as their door into app services — so a pane written against
|
||||||
|
// ctx.call() keeps working when it moves realms.
|
||||||
|
//
|
||||||
|
// A pane passes a plain PAYLOAD; the requester/origin/timeout
|
||||||
|
// envelope is core's to build. That keeps the pane-side call
|
||||||
|
// identical in both realms, where the remote transport has to
|
||||||
|
// reconstruct the envelope on this side of the channel anyway.
|
||||||
|
call(domain, command, payload) {
|
||||||
|
const caps = window.feedBack && window.feedBack.capabilities;
|
||||||
|
if (!caps || typeof caps.command !== 'function') {
|
||||||
|
return Promise.reject(new Error('pane ctx.call: capability bus unavailable'));
|
||||||
|
}
|
||||||
|
return caps.command(domain, command, {
|
||||||
|
requester: 'pane.' + paneId,
|
||||||
|
origin: 'pane',
|
||||||
|
payload: payload || {},
|
||||||
|
timeoutMs: CALL_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
on(name, fn) {
|
||||||
|
const bus = window.feedBack;
|
||||||
|
if (!bus || typeof bus.on !== 'function') return () => {};
|
||||||
|
bus.on(name, fn);
|
||||||
|
return () => bus.off(name, fn);
|
||||||
|
},
|
||||||
|
|
||||||
|
subscribe(stream, fn) {
|
||||||
|
const s = window.__fbPaneStreams;
|
||||||
|
if (!s) return () => {};
|
||||||
|
return s.subscribe(stream, fn);
|
||||||
|
},
|
||||||
|
|
||||||
|
// In the main realm the clock needs no interpolation — the highway's
|
||||||
|
// own time IS the source of truth. (The pane realm has to
|
||||||
|
// extrapolate; see RemoteTransport, added with the pop-out window.)
|
||||||
|
playhead() {
|
||||||
|
const hw = window.highway;
|
||||||
|
const t = (hw && typeof hw.getTime === 'function') ? hw.getTime() : NaN;
|
||||||
|
return Number.isFinite(t) ? t : 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
song() {
|
||||||
|
return (window.feedBack && window.feedBack.currentSong) || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
toast(opts) {
|
||||||
|
if (window.fbNotify && typeof window.fbNotify.show === 'function') window.fbNotify.show(opts || {});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ctx ──────────────────────────────────────────────────────────────────
|
||||||
|
// What a pane's mount() actually receives. Every subscription it hands out
|
||||||
|
// is tracked, so unmount() can drop them all — a pane physically cannot
|
||||||
|
// leak a listener across a dock/undock cycle, which is the failure mode
|
||||||
|
// that would otherwise show up as duplicate handlers after three song
|
||||||
|
// switches.
|
||||||
|
|
||||||
|
function createCtx(opts) {
|
||||||
|
const paneId = opts.paneId;
|
||||||
|
const transport = opts.transport;
|
||||||
|
const state = opts.state;
|
||||||
|
const disposers = [];
|
||||||
|
let disposed = false;
|
||||||
|
|
||||||
|
function track(unsub) {
|
||||||
|
if (typeof unsub !== 'function') return () => {};
|
||||||
|
// Late subscriptions (a pane calling ctx.on() from a setTimeout that
|
||||||
|
// outlived unmount) are torn down immediately rather than silently
|
||||||
|
// registered against a dead pane.
|
||||||
|
if (disposed) { try { unsub(); } catch (e) { /* already gone */ } return () => {}; }
|
||||||
|
disposers.push(unsub);
|
||||||
|
return () => {
|
||||||
|
const i = disposers.indexOf(unsub);
|
||||||
|
if (i >= 0) disposers.splice(i, 1);
|
||||||
|
try { unsub(); } catch (e) { /* already gone */ }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
paneId: paneId,
|
||||||
|
host: opts.host, // 'dock' | 'shared' | 'pane:<id>'
|
||||||
|
isRemote: transport.kind !== 'local',
|
||||||
|
|
||||||
|
state: {
|
||||||
|
get: (path) => state.get(path),
|
||||||
|
set: (path, value) => state.set(path, value),
|
||||||
|
all: () => state.all(),
|
||||||
|
subscribe: (fn) => track(state.subscribe(fn)),
|
||||||
|
},
|
||||||
|
|
||||||
|
call: (domain, command, args) => transport.call(domain, command, args),
|
||||||
|
on: (name, fn) => track(transport.on(name, fn)),
|
||||||
|
subscribe: (stream, fn) => track(transport.subscribe(stream, fn)),
|
||||||
|
playhead: () => transport.playhead(),
|
||||||
|
song: () => transport.song(),
|
||||||
|
toast: (o) => transport.toast(o),
|
||||||
|
|
||||||
|
// Ask the host to put this pane away. The pane does not know or care
|
||||||
|
// whether that means closing a dock card or an OS window.
|
||||||
|
close: () => { if (typeof opts.onClose === 'function') opts.onClose(); },
|
||||||
|
};
|
||||||
|
|
||||||
|
ctx._dispose = function () {
|
||||||
|
if (disposed) return;
|
||||||
|
disposed = true;
|
||||||
|
// Copy-then-clear: a disposer that removes itself from the list
|
||||||
|
// (the closure returned by track) would otherwise skip its neighbour.
|
||||||
|
const list = disposers.slice();
|
||||||
|
disposers.length = 0;
|
||||||
|
list.forEach((fn) => { try { fn(); } catch (e) { console.error('[panes] disposer threw', e); } });
|
||||||
|
};
|
||||||
|
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.__fbPaneBridge = {
|
||||||
|
PROTOCOL_VERSION,
|
||||||
|
CHANNEL_NAME,
|
||||||
|
DEFAULT_EVENTS,
|
||||||
|
CALL_TIMEOUT_MS,
|
||||||
|
createStateStore,
|
||||||
|
createLocalTransport,
|
||||||
|
createCtx,
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
/*
|
||||||
|
* fee[dB]ack — the pop-out chip.
|
||||||
|
*
|
||||||
|
* One affordance, core-owned, identical everywhere: the small ⇱ button a plugin
|
||||||
|
* drops into a dialog it already has.
|
||||||
|
*
|
||||||
|
* feedBack.panes.register({ id: 'camera_director', title: 'Camera', mount, unmount });
|
||||||
|
* feedBack.panes.attachChip(myDialogEl, 'camera_director');
|
||||||
|
*
|
||||||
|
* That is the entire adoption cost. Clicking the chip opens the pane in its host
|
||||||
|
* and hides `myDialogEl`; a stub takes its place so the user can find it again;
|
||||||
|
* closing the pane un-hides the dialog and restores the chip. The plugin writes
|
||||||
|
* no show/hide logic — if it did, every plugin would invent a slightly different
|
||||||
|
* one, which is exactly the inconsistency this exists to prevent.
|
||||||
|
*
|
||||||
|
* Hiding uses `.fb-pane-detached`, NOT the `hidden` class or `[hidden]`, because
|
||||||
|
* the dialogs being hidden here already toggle those themselves (the core mixer
|
||||||
|
* popover, every rail popover). Two owners of one class is a bug waiting for a
|
||||||
|
* bad day; a dedicated class composes cleanly with whatever the dialog does.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const panes = window.feedBack && window.feedBack.panes;
|
||||||
|
if (!panes || typeof panes.register !== 'function') {
|
||||||
|
console.error('[panes] pane-manager.js must load before pane-chip.js');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// paneId -> { el, chip, stub, spec }
|
||||||
|
const attached = new Map();
|
||||||
|
|
||||||
|
function _makeChip(spec) {
|
||||||
|
const b = document.createElement('button');
|
||||||
|
b.type = 'button';
|
||||||
|
b.className = 'fb-pane-chip';
|
||||||
|
b.title = 'Pop out';
|
||||||
|
b.setAttribute('aria-label', 'Pop out ' + spec.title);
|
||||||
|
b.textContent = '⇱';
|
||||||
|
b.addEventListener('click', (e) => {
|
||||||
|
// Rail popovers close on any document click that lands outside them
|
||||||
|
// (player-chrome.js). Without this the popover would close under the
|
||||||
|
// chip mid-click, which reads as the button not working.
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
panes.detach(spec.id);
|
||||||
|
});
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _makeStub(spec) {
|
||||||
|
const s = document.createElement('button');
|
||||||
|
s.type = 'button';
|
||||||
|
s.className = 'fb-pane-stub';
|
||||||
|
s.setAttribute('aria-label', 'Bring ' + spec.title + ' back');
|
||||||
|
s.title = 'Bring it back';
|
||||||
|
const glyph = document.createElement('span');
|
||||||
|
glyph.className = 'fb-pane-stub-glyph';
|
||||||
|
glyph.textContent = '⇲';
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.textContent = spec.title + ' is popped out';
|
||||||
|
s.appendChild(glyph);
|
||||||
|
s.appendChild(label);
|
||||||
|
s.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
panes.close(spec.id);
|
||||||
|
});
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _onOpened(rec) {
|
||||||
|
rec.el.classList.add('fb-pane-detached');
|
||||||
|
if (!rec.stub.isConnected) rec.el.parentNode.insertBefore(rec.stub, rec.el);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _onClosed(rec) {
|
||||||
|
rec.el.classList.remove('fb-pane-detached');
|
||||||
|
rec.stub.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* attachChip(el, paneId, opts)
|
||||||
|
*
|
||||||
|
* `el` — the dialog to hide when the pane pops out. The chip is injected
|
||||||
|
* into `el.querySelector('[data-pane-header]')` when present, else
|
||||||
|
* prepended to `el` itself.
|
||||||
|
* `opts` — { header: Element } to place the chip somewhere specific.
|
||||||
|
*
|
||||||
|
* Returns a detach function that removes the chip and stub and restores the
|
||||||
|
* dialog — call it if your plugin tears its dialog down.
|
||||||
|
*/
|
||||||
|
function attachChip(el, paneId, opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
if (!(el instanceof Element)) throw new TypeError('panes.attachChip: el must be an Element');
|
||||||
|
const spec = panes.get(paneId);
|
||||||
|
if (!spec) { console.warn('[panes] attachChip: register the pane first:', paneId); return () => {}; }
|
||||||
|
if (attached.has(paneId)) { console.warn('[panes] attachChip: already attached:', paneId); return () => {}; }
|
||||||
|
|
||||||
|
const chip = _makeChip(spec);
|
||||||
|
const stub = _makeStub(spec);
|
||||||
|
const host = opts.header || el.querySelector('[data-pane-header]') || el;
|
||||||
|
if (host === el) host.insertBefore(chip, host.firstChild);
|
||||||
|
else host.appendChild(chip);
|
||||||
|
|
||||||
|
const rec = { el, chip, stub, spec };
|
||||||
|
attached.set(paneId, rec);
|
||||||
|
|
||||||
|
// Reconcile immediately: register() reopens a pane the user left open at
|
||||||
|
// last unload, and that can land before (or after) attachChip runs.
|
||||||
|
if (panes.isOpen(paneId)) _onOpened(rec);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (attached.get(paneId) !== rec) return;
|
||||||
|
attached.delete(paneId);
|
||||||
|
chip.remove();
|
||||||
|
_onClosed(rec);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// One pair of bus listeners for every chip, rather than one pair per chip.
|
||||||
|
const bus = window.feedBack;
|
||||||
|
if (bus && typeof bus.on === 'function') {
|
||||||
|
bus.on('panes:opened', (e) => {
|
||||||
|
const rec = attached.get(e.detail && e.detail.id);
|
||||||
|
if (rec) _onOpened(rec);
|
||||||
|
});
|
||||||
|
bus.on('panes:closed', (e) => {
|
||||||
|
const rec = attached.get(e.detail && e.detail.id);
|
||||||
|
if (rec) _onClosed(rec);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.feedBack.panes.attachChip = attachChip;
|
||||||
|
})();
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
/*
|
||||||
|
* fee[dB]ack — pane dock (the in-window pane host).
|
||||||
|
*
|
||||||
|
* A right-edge stack of cards, one per open pane. Deliberately NOT a rail
|
||||||
|
* popover: the rail is exclusive (player-chrome.js openPopFor closes the last
|
||||||
|
* one before opening the next), which is precisely why you can't watch the
|
||||||
|
* mixer while riding the camera. Cards here coexist.
|
||||||
|
*
|
||||||
|
* Song-switch survival is structural, not defended. `#fb-pane-dock` is appended
|
||||||
|
* to <body>, outside every `.screen`, so the per-song teardown never sees it —
|
||||||
|
* and `playSong()` ends in `showScreen('player')`, whose id === 'player' short-
|
||||||
|
* circuits the teardown branch anyway. Nothing to reset, nothing to re-mount.
|
||||||
|
*
|
||||||
|
* Registers itself as the `dock` host at priority 0 — the floor. Whatever else
|
||||||
|
* exists (an OS pane window), a pane can always land here.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const panes = window.feedBack && window.feedBack.panes;
|
||||||
|
if (!panes || typeof panes.registerHost !== 'function') {
|
||||||
|
console.error('[panes] pane-manager.js must load before pane-dock.js');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dockEl = null;
|
||||||
|
const cards = new Map(); // paneId -> card element
|
||||||
|
|
||||||
|
function dock() {
|
||||||
|
if (dockEl && dockEl.isConnected) return dockEl;
|
||||||
|
dockEl = document.getElementById('fb-pane-dock');
|
||||||
|
if (!dockEl) {
|
||||||
|
dockEl = document.createElement('div');
|
||||||
|
dockEl.id = 'fb-pane-dock';
|
||||||
|
dockEl.className = 'fb-pane-dock';
|
||||||
|
dockEl.setAttribute('role', 'region');
|
||||||
|
dockEl.setAttribute('aria-label', 'Panes');
|
||||||
|
document.body.appendChild(dockEl);
|
||||||
|
}
|
||||||
|
return dockEl;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _syncEmpty() {
|
||||||
|
const d = dock();
|
||||||
|
d.classList.toggle('is-empty', cards.size === 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mount(spec) {
|
||||||
|
const card = document.createElement('section');
|
||||||
|
card.className = 'fb-pane-card';
|
||||||
|
card.dataset.paneId = spec.id;
|
||||||
|
card.setAttribute('aria-label', spec.title);
|
||||||
|
|
||||||
|
const head = document.createElement('header');
|
||||||
|
head.className = 'fb-pane-card-head';
|
||||||
|
|
||||||
|
const title = document.createElement('span');
|
||||||
|
title.className = 'fb-pane-card-title';
|
||||||
|
// textContent, not innerHTML — a pane title can come from a plugin
|
||||||
|
// manifest, i.e. from outside core.
|
||||||
|
title.textContent = spec.icon + ' ' + spec.title;
|
||||||
|
|
||||||
|
const close = document.createElement('button');
|
||||||
|
close.type = 'button';
|
||||||
|
close.className = 'fb-pane-card-btn';
|
||||||
|
close.setAttribute('aria-label', 'Close ' + spec.title);
|
||||||
|
close.title = 'Close';
|
||||||
|
close.textContent = '✕';
|
||||||
|
close.addEventListener('click', () => panes.close(spec.id));
|
||||||
|
|
||||||
|
head.appendChild(title);
|
||||||
|
head.appendChild(close);
|
||||||
|
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.className = 'fb-pane-card-body fb-selectable';
|
||||||
|
|
||||||
|
card.appendChild(head);
|
||||||
|
card.appendChild(body);
|
||||||
|
dock().appendChild(card);
|
||||||
|
cards.set(spec.id, card);
|
||||||
|
_syncEmpty();
|
||||||
|
|
||||||
|
// The pane mounts into the body, never the card — so it cannot reach
|
||||||
|
// (or accidentally destroy) the chrome that owns its close button.
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unmount(id) {
|
||||||
|
const card = cards.get(id);
|
||||||
|
if (card) card.remove();
|
||||||
|
cards.delete(id);
|
||||||
|
_syncEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
function focus(id) {
|
||||||
|
const card = cards.get(id);
|
||||||
|
if (!card) return;
|
||||||
|
card.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||||
|
// Re-trigger the flash even if the class is already there (repeat focus
|
||||||
|
// of the same card would otherwise be a no-op animation).
|
||||||
|
card.classList.remove('is-flash');
|
||||||
|
void card.offsetWidth;
|
||||||
|
card.classList.add('is-flash');
|
||||||
|
setTimeout(() => card.classList.remove('is-flash'), 700);
|
||||||
|
}
|
||||||
|
|
||||||
|
panes.registerHost({ id: 'dock', priority: 0, available: () => !!document.body, mount, unmount, focus });
|
||||||
|
})();
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/*
|
||||||
|
* fee[dB]ack — pane launcher (the "Panes" rail popover).
|
||||||
|
*
|
||||||
|
* A chip only works for a pane that already has a dialog to hide. Panes with no
|
||||||
|
* dialog — a readout, a plugin's optional extra — need somewhere to be opened
|
||||||
|
* from, so every registered pane gets one: a checkbox list in the rail.
|
||||||
|
*
|
||||||
|
* The rail popover is the right home for this precisely because it IS exclusive
|
||||||
|
* and transient. It's a menu, not a workspace; the panes it opens are the
|
||||||
|
* workspace, and they persist.
|
||||||
|
*
|
||||||
|
* Populated from the registry, so a plugin that calls panes.register() appears
|
||||||
|
* here with no further work. (The system tray will mirror this list.)
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const panes = window.feedBack && window.feedBack.panes;
|
||||||
|
const bus = window.feedBack;
|
||||||
|
if (!panes || !bus || typeof bus.on !== 'function') return;
|
||||||
|
|
||||||
|
let listEl = null;
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (!listEl || !listEl.isConnected) listEl = document.getElementById('v3-rail-panes-list');
|
||||||
|
if (!listEl) return;
|
||||||
|
const all = panes.list();
|
||||||
|
listEl.replaceChildren();
|
||||||
|
|
||||||
|
if (!all.length) {
|
||||||
|
const empty = document.createElement('div');
|
||||||
|
empty.className = 'v3-pop-empty';
|
||||||
|
empty.textContent = 'No panes available.';
|
||||||
|
listEl.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
all.forEach((p) => {
|
||||||
|
const b = document.createElement('button');
|
||||||
|
b.type = 'button';
|
||||||
|
b.className = 'v3-pop-btn';
|
||||||
|
b.setAttribute('aria-pressed', p.open ? 'true' : 'false');
|
||||||
|
b.textContent = (p.open ? '● ' : '○ ') + p.icon + ' ' + p.title;
|
||||||
|
b.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (panes.isOpen(p.id)) panes.close(p.id); else panes.detach(p.id);
|
||||||
|
});
|
||||||
|
listEl.appendChild(b);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// The registry changes when plugins load and when panes open/close. Render is
|
||||||
|
// cheap and rare (never on a playback path), so just re-run it.
|
||||||
|
bus.on('panes:registered', render);
|
||||||
|
bus.on('panes:unregistered', render);
|
||||||
|
bus.on('panes:opened', render);
|
||||||
|
bus.on('panes:closed', render);
|
||||||
|
|
||||||
|
if (document.readyState !== 'complete') document.addEventListener('DOMContentLoaded', render);
|
||||||
|
else render();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
/*
|
||||||
|
* 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);
|
||||||
|
})();
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
/*
|
||||||
|
* 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) };
|
||||||
|
})();
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
/* fee[dB]ack — detachable panes.
|
||||||
|
*
|
||||||
|
* Hand-authored (not Tailwind-scanned) so a runtime-installed plugin can use the
|
||||||
|
* chip and the dock without shipping its own stylesheet — the same reason
|
||||||
|
* .fb-selectable is hand-authored in core CSS.
|
||||||
|
*
|
||||||
|
* Z-index: the dock is a child of <body>, so it is NOT on the ladder from
|
||||||
|
* docs/plugin-v3-ui.md (transport 20, rail 30, popovers 40) — those numbers live
|
||||||
|
* *inside* #player's stacking context, and #player itself is `position:fixed;
|
||||||
|
* z-index:100` covering the viewport. A dock below 100 is invisible on the
|
||||||
|
* player screen, which is the one screen panes exist for.
|
||||||
|
*
|
||||||
|
* So: body-level ladder. #player 100 < dock 110 < toasts 120 < modals 200.
|
||||||
|
* A pane is persistent furniture that outranks the player, but never outranks a
|
||||||
|
* notification or an interruption.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ── The pop-out chip ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.fb-pane-chip {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
margin-left: auto; /* right-align inside a flex header */
|
||||||
|
border: 1px solid rgba(51, 65, 85, .7);
|
||||||
|
border-radius: .4rem;
|
||||||
|
background: rgba(30, 41, 59, .8);
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: .8rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color .15s, border-color .15s, background .15s;
|
||||||
|
}
|
||||||
|
.fb-pane-chip:hover {
|
||||||
|
color: #e2e8f0;
|
||||||
|
border-color: #4080e0;
|
||||||
|
background: rgba(64, 128, 224, .18);
|
||||||
|
}
|
||||||
|
.fb-pane-chip:focus-visible {
|
||||||
|
outline: 2px solid #4080e0;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The dialog the chip lives in, while its pane is popped out. A dedicated class
|
||||||
|
rather than `.hidden`/[hidden]: the dialogs we attach to (the mixer popover,
|
||||||
|
the rail popovers) toggle those themselves, and two owners of one class is a
|
||||||
|
bug waiting to happen. */
|
||||||
|
.fb-pane-detached { display: none !important; }
|
||||||
|
|
||||||
|
/* What the user sees in the dialog's place. */
|
||||||
|
.fb-pane-stub {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .4rem;
|
||||||
|
padding: .35rem .6rem;
|
||||||
|
border: 1px dashed rgba(64, 128, 224, .55);
|
||||||
|
border-radius: .5rem;
|
||||||
|
background: rgba(64, 128, 224, .08);
|
||||||
|
color: #93b4e8;
|
||||||
|
font-size: .72rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background .15s, border-color .15s;
|
||||||
|
}
|
||||||
|
.fb-pane-stub:hover {
|
||||||
|
background: rgba(64, 128, 224, .18);
|
||||||
|
border-color: #4080e0;
|
||||||
|
}
|
||||||
|
.fb-pane-stub-glyph { font-size: .85rem; }
|
||||||
|
|
||||||
|
/* ── The dock ────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.fb-pane-dock {
|
||||||
|
position: fixed;
|
||||||
|
top: 4.5rem; /* clear of the topbar */
|
||||||
|
right: 1rem;
|
||||||
|
bottom: 1rem;
|
||||||
|
z-index: 110; /* above #player (100), below toasts (120) */
|
||||||
|
width: 22rem;
|
||||||
|
max-width: calc(100vw - 2rem);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .6rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
/* The dock is a frame around cards, not a surface — pointer events belong to
|
||||||
|
the cards, so the empty space below them never eats a click meant for the
|
||||||
|
highway. */
|
||||||
|
pointer-events: none;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
.fb-pane-dock.is-empty { display: none; }
|
||||||
|
|
||||||
|
.fb-pane-card {
|
||||||
|
pointer-events: auto;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: rgba(15, 23, 42, .96);
|
||||||
|
border: 1px solid rgba(51, 65, 85, .6);
|
||||||
|
border-radius: .9rem;
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, .5);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-pane-card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .5rem;
|
||||||
|
padding: .5rem .7rem;
|
||||||
|
border-bottom: 1px solid rgba(51, 65, 85, .5);
|
||||||
|
background: rgba(30, 41, 59, .6);
|
||||||
|
}
|
||||||
|
.fb-pane-card-title {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: .78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
.fb-pane-card-btn {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 1.4rem;
|
||||||
|
height: 1.4rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 0;
|
||||||
|
border-radius: .35rem;
|
||||||
|
background: transparent;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: .75rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.fb-pane-card-btn:hover { background: rgba(51, 65, 85, .7); color: #e2e8f0; }
|
||||||
|
.fb-pane-card-btn:focus-visible { outline: 2px solid #4080e0; outline-offset: 1px; }
|
||||||
|
|
||||||
|
.fb-pane-card-body {
|
||||||
|
padding: .75rem;
|
||||||
|
font-size: .8rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* focus(id) — brief highlight so a re-open of an already-open pane says so
|
||||||
|
instead of appearing to do nothing. */
|
||||||
|
.fb-pane-card.is-flash { animation: fb-pane-flash .7s ease-out; }
|
||||||
|
@keyframes fb-pane-flash {
|
||||||
|
0% { border-color: #4080e0; box-shadow: 0 0 0 3px rgba(64, 128, 224, .35), 0 12px 40px rgba(0, 0, 0, .5); }
|
||||||
|
100% { border-color: rgba(51, 65, 85, .6); box-shadow: 0 12px 40px rgba(0, 0, 0, .5); }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.fb-pane-card.is-flash { animation: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Shared widgets for built-in panes ───────────────────────────────────── */
|
||||||
|
|
||||||
|
.fb-pane-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: .75rem;
|
||||||
|
padding: .2rem 0;
|
||||||
|
}
|
||||||
|
.fb-pane-key {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: .7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.fb-pane-val {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: .78rem;
|
||||||
|
color: #e2e8f0;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.fb-pane-val.is-num { font-variant-numeric: tabular-nums; }
|
||||||
|
.fb-pane-dim { color: #64748b; font-size: .72rem; }
|
||||||
|
|
||||||
|
.fb-pane-bar {
|
||||||
|
position: relative;
|
||||||
|
height: .35rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(51, 65, 85, .8);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.fb-pane-bar-fill {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 auto 0 0;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: #4080e0;
|
||||||
|
transform: scaleX(0);
|
||||||
|
transform-origin: left;
|
||||||
|
/* scaleX is compositor-only; a width write would re-run layout every frame. */
|
||||||
|
}
|
||||||
|
.fb-pane-bar-fill.is-level { background: linear-gradient(90deg, #22c55e, #eab308 70%, #ef4444); }
|
||||||
@@ -99,6 +99,10 @@
|
|||||||
<link rel="stylesheet" href="/static/tour-engine.css">
|
<link rel="stylesheet" href="/static/tour-engine.css">
|
||||||
<!-- v0.3.0 shell styles (radial-gradient bg, custom scrollbars). -->
|
<!-- v0.3.0 shell styles (radial-gradient bg, custom scrollbars). -->
|
||||||
<link rel="stylesheet" href="/static/v3/v3.css">
|
<link rel="stylesheet" href="/static/v3/v3.css">
|
||||||
|
<!-- Detachable panes: the pop-out chip, the dock, and the widgets built-in
|
||||||
|
panes render with. Hand-authored (not Tailwind-scanned) so a
|
||||||
|
runtime-installed plugin can use the chip without shipping its own CSS. -->
|
||||||
|
<link rel="stylesheet" href="/static/panes/panes.css">
|
||||||
<!-- EVERY external script below is `defer`. Do not add a plain one.
|
<!-- EVERY external script below is `defer`. Do not add a plain one.
|
||||||
`defer` and `type="module"` scripts share a single "execute after
|
`defer` and `type="module"` scripts share a single "execute after
|
||||||
parsing" list and run in DOCUMENT ORDER; a plain classic script runs
|
parsing" list and run in DOCUMENT ORDER; a plain classic script runs
|
||||||
@@ -1048,6 +1052,10 @@
|
|||||||
<span class="v3-rail-border"></span>
|
<span class="v3-rail-border"></span>
|
||||||
<svg class="v3-rail-svg" viewBox="0 0 24 24" aria-hidden="true"><path d="M12,2A3,3 0 0,1 15,5V11A3,3 0 0,1 12,14A3,3 0 0,1 9,11V5A3,3 0 0,1 12,2M19,11C19,14.53 16.39,17.44 13,17.93V21H11V17.93C7.61,17.44 5,14.53 5,11H7A5,5 0 0,0 12,16A5,5 0 0,0 17,11H19Z"/></svg>
|
<svg class="v3-rail-svg" viewBox="0 0 24 24" aria-hidden="true"><path d="M12,2A3,3 0 0,1 15,5V11A3,3 0 0,1 12,14A3,3 0 0,1 9,11V5A3,3 0 0,1 12,2M19,11C19,14.53 16.39,17.44 13,17.93V21H11V17.93C7.61,17.44 5,14.53 5,11H7A5,5 0 0,0 12,16A5,5 0 0,0 17,11H19Z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="v3-rail-icon" type="button" data-rail="panes" aria-haspopup="true" aria-expanded="false" aria-controls="v3-rail-pop-panes" title="Panes" aria-label="Panes">
|
||||||
|
<span class="v3-rail-border"></span>
|
||||||
|
<svg class="v3-rail-svg" viewBox="0 0 24 24" aria-hidden="true"><path d="M19,4H5A2,2 0 0,0 3,6V18A2,2 0 0,0 5,20H19A2,2 0 0,0 21,18V6A2,2 0 0,0 19,4M13,18H5V6H13V18M19,18H15V6H19V18Z"/></svg>
|
||||||
|
</button>
|
||||||
<button class="v3-rail-icon" type="button" data-rail="plugins" aria-haspopup="true" aria-expanded="false" aria-controls="v3-rail-pop-plugins" title="Plugin controls" aria-label="Plugin controls">
|
<button class="v3-rail-icon" type="button" data-rail="plugins" aria-haspopup="true" aria-expanded="false" aria-controls="v3-rail-pop-plugins" title="Plugin controls" aria-label="Plugin controls">
|
||||||
<span class="v3-rail-border"></span>
|
<span class="v3-rail-border"></span>
|
||||||
<span class="v3-rail-badge" id="v3-plugin-count" hidden></span>
|
<span class="v3-rail-badge" id="v3-plugin-count" hidden></span>
|
||||||
@@ -1064,6 +1072,15 @@
|
|||||||
injects into #player-controls (the auto-hiding transport) into
|
injects into #player-controls (the auto-hiding transport) into
|
||||||
this stable, always-reachable popover. See player-chrome.js
|
this stable, always-reachable popover. See player-chrome.js
|
||||||
(rehoming MutationObserver). -->
|
(rehoming MutationObserver). -->
|
||||||
|
<!-- Panes: open/close any registered detachable pane. Populated from
|
||||||
|
the pane registry by static/panes/pane-launcher.js — a plugin
|
||||||
|
that calls feedBack.panes.register() shows up here for free. -->
|
||||||
|
<div id="v3-rail-pop-panes" class="v3-rail-pop hidden" role="group" aria-label="Panes">
|
||||||
|
<div class="v3-pop-label">Panes</div>
|
||||||
|
<div id="v3-rail-panes-list" class="flex flex-col gap-1"></div>
|
||||||
|
<p class="text-xs text-gray-500 px-1 pb-1 leading-snug">Panes stay open while you play, and across song switches.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="v3-rail-pop-plugins" class="v3-rail-pop hidden" role="group" aria-label="Plugin controls">
|
<div id="v3-rail-pop-plugins" class="v3-rail-pop hidden" role="group" aria-label="Plugin controls">
|
||||||
<div class="v3-pop-label">Plugin controls</div>
|
<div class="v3-pop-label">Plugin controls</div>
|
||||||
<div id="v3-plugin-controls-slot" class="v3-plugin-slot"></div>
|
<div id="v3-plugin-controls-slot" class="v3-plugin-slot"></div>
|
||||||
@@ -1300,6 +1317,19 @@
|
|||||||
<script defer src="/static/v3/interface-size-nudge.js"></script>
|
<script defer src="/static/v3/interface-size-nudge.js"></script>
|
||||||
<script defer src="/static/v3/feedbarcade.js"></script>
|
<script defer src="/static/v3/feedbarcade.js"></script>
|
||||||
<script defer src="/static/v3/player-chrome.js"></script>
|
<script defer src="/static/v3/player-chrome.js"></script>
|
||||||
|
<!-- Detachable panes. Strict order: the bridge defines the ctx contract, the
|
||||||
|
sampler and the manager build on it, hosts register themselves with the
|
||||||
|
manager, and the built-in panes register last (they call panes.register
|
||||||
|
+ panes.attachChip at load). Everything here runs after app.js, so
|
||||||
|
window.feedBack and the capability bus already exist. -->
|
||||||
|
<script defer src="/static/panes/pane-bridge.js"></script>
|
||||||
|
<script defer src="/static/panes/pane-streams.js"></script>
|
||||||
|
<script defer src="/static/panes/pane-manager.js"></script>
|
||||||
|
<script defer src="/static/panes/pane-dock.js"></script>
|
||||||
|
<script defer src="/static/panes/pane-chip.js"></script>
|
||||||
|
<script defer src="/static/panes/pane-launcher.js"></script>
|
||||||
|
<script defer src="/static/panes/builtin/now-playing.js"></script>
|
||||||
|
<script defer src="/static/panes/builtin/mixer-pane.js"></script>
|
||||||
<script>
|
<script>
|
||||||
// Navbar scroll effect
|
// Navbar scroll effect
|
||||||
window.addEventListener('scroll', () => {
|
window.addEventListener('scroll', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user