mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-10 18:59:56 +00:00
feat(panes)!: move the real element, instead of rebuilding it
The first cut of this got the model wrong. A pane was a SECOND
implementation of the plugin's panel — its own sliders, its own styling,
driven over a cross-realm bridge (ctx, a state store, capability RPC,
mirrorGlobal, a stream sampler). Popping out gave you something that
resembled the panel you popped, and every feature it did not reimplement
(presets, tabs, EQ, language) was simply gone.
What a user wants from "pop this out" is the thing they popped out.
So: MOVE THE REAL ELEMENT. Same-origin windows can adopt each other's
nodes, and an adopted node keeps its event listeners and its closures.
The panel goes on running the plugin's own code, against the plugin's own
state, in the plugin's own realm — it is merely being DISPLAYED in another
window. Copy the app's stylesheets into that window and it looks identical
too, because it is identical.
The plugin's side collapses to two lines:
feedBack.panes.register({ id, title, element: () => panelEl });
feedBack.panes.attachChip(panelEl, id);
and everything comes along: the CSS, the listeners, the presets, the
state. Nothing to keep in step, because there is no second copy.
Deleted, all of it now pointless: pane-bridge (ctx + transports), pane-hub
(the cross-realm server), pane-runtime (the pane realm's boot), pane-streams
(the rAF sampler that existed because an AnalyserNode can't cross a window),
pane-mirror (mirrorGlobal), pane-plugins + the manifest `panes[]` key and its
server-side validation, panes.state(), and both built-in demo panes. ~1200
lines. None of it was wrong — it was all correct machinery for the wrong
problem.
Consequences worth knowing:
- The window MUST be opened by the renderer with window.open(), not by the
desktop's main process: a window we did not open gives this realm no handle
to its document, and without the handle there is nothing to adopt into.
Electron turns the same-origin window.open() into a real BrowserWindow
anyway (setWindowOpenHandler → 'allow'), so we get the OS window AND the
live DOM link. The desktop side finds it by frame name.
- `.fb-paned` neutralises PLACEMENT only (position/inset/width/z-index/shadow).
A plugin panel is nearly always a fixed overlay pinned to a corner of the
app; alone in a 380px window that positioning is nonsense. Colours, borders,
padding, fonts and the panel's own internal layout are untouched — the whole
promise is that what you popped out is what you get.
- The element is returned to its EXACT home on dock: same parent, same position
among its siblings.
- The plugin's code still runs in the main window. So a document.body
.appendChild() inside a panel (a tooltip, a popover) lands in the main
window, not the pane — anchor to the panel instead. And a continuously
animating panel may run slowly while the main window is backgrounded, since
its rAF lives there. Both documented.
Signed-off-by: topkoa <topkoa@gmail.com>
This commit is contained in:
@@ -1,162 +0,0 @@
|
||||
/*
|
||||
* 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: '🎚',
|
||||
script: '/static/panes/builtin/mixer-pane.js',
|
||||
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();
|
||||
})();
|
||||
@@ -1,113 +0,0 @@
|
||||
/*
|
||||
* 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: '🎵',
|
||||
// How the pane REALM loads this same file to get the same mount(). A pane
|
||||
// with no `script` can only ever be docked.
|
||||
script: '/static/panes/builtin/now-playing.js',
|
||||
// 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();
|
||||
},
|
||||
});
|
||||
})();
|
||||
@@ -1,398 +0,0 @@
|
||||
/*
|
||||
* 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
|
||||
const RPC_TIMEOUT_MS = 10000; // cross-realm deadline; generous, but never infinite
|
||||
|
||||
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 || {});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Envelopes ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// { v, type, paneId, hostId, seq, payload }
|
||||
//
|
||||
// type: hello pane→main the pane realm booted; main replies `snapshot`
|
||||
// snapshot main→pane spec + state + current song. Resync-on-open, always.
|
||||
// state both { path, value }. Main is authoritative: a pane's
|
||||
// write is a request; main applies it and echoes to
|
||||
// every realm, so a losing write self-corrects.
|
||||
// rpc pane→main { seq, domain, command, payload }
|
||||
// rpc:reply main→pane { seq, ok, result | error }
|
||||
// event main→pane a mirrored feedBack bus event { name, detail }
|
||||
// stream main→pane coalesced numerics (playhead, meters)
|
||||
// sub/unsub pane→main drives the main-realm sampler's refcount
|
||||
// bye both clean teardown (main-closed / pane-closed)
|
||||
|
||||
function envelope(type, paneId, hostId, payload) {
|
||||
return { v: PROTOCOL_VERSION, type: type, paneId: paneId, hostId: hostId, payload: payload };
|
||||
}
|
||||
|
||||
function openChannel() {
|
||||
if (typeof BroadcastChannel !== 'function') return null;
|
||||
return new BroadcastChannel(CHANNEL_NAME);
|
||||
}
|
||||
|
||||
// ── Remote transport (pane realm) ────────────────────────────────────────
|
||||
|
||||
const MAX_EXTRAP_S = 2.0; // never extrapolate the clock further than this
|
||||
|
||||
function createRemoteTransport(paneId, hostId, channel) {
|
||||
const listeners = new Map(); // event name -> Set<fn>
|
||||
const streamSubs = new Map(); // stream name -> Set<fn>
|
||||
const pending = new Map(); // rpc seq -> { resolve, reject, timer }
|
||||
let rpcSeq = 0;
|
||||
let song = null;
|
||||
|
||||
// The follower clock. The main window broadcasts the playhead every frame
|
||||
// — but Chromium throttles a BACKGROUNDED window's rAF to ~1 Hz, and the
|
||||
// main window is exactly what's in the background while the user looks at
|
||||
// this pane. So we extrapolate between messages instead of rendering 1 Hz
|
||||
// stutter: anchor + observedRate * elapsed.
|
||||
//
|
||||
// observedRate is measured from the broadcasts themselves (Δt / Δwall), so
|
||||
// it tracks the speed slider without being told about it. Capped at
|
||||
// MAX_EXTRAP_S so a dead main window decays into a frozen clock rather
|
||||
// than a clock that confidently runs away.
|
||||
let anchorT = 0, anchorWall = 0, observedRate = 1, playing = false, duration = 0;
|
||||
|
||||
function _onPlayhead(p) {
|
||||
const now = performance.now();
|
||||
if (anchorWall && p.playing && playing) {
|
||||
const dt = p.t - anchorT;
|
||||
const dw = (now - anchorWall) / 1000;
|
||||
// Ignore seeks and pauses when learning the rate: a jump is not a
|
||||
// tempo. Only smooth, forward-moving deltas teach us anything.
|
||||
if (dw > 0.05 && dt > 0 && dt < dw * 4) {
|
||||
const r = dt / dw;
|
||||
observedRate = observedRate * 0.8 + r * 0.2; // light smoothing
|
||||
}
|
||||
}
|
||||
if (!p.playing) observedRate = 1; // a paused clock has no rate to learn
|
||||
anchorT = p.t;
|
||||
anchorWall = now;
|
||||
playing = p.playing;
|
||||
duration = p.duration;
|
||||
}
|
||||
|
||||
function playhead() {
|
||||
if (!anchorWall) return anchorT;
|
||||
if (!playing) return anchorT;
|
||||
const elapsed = Math.min(MAX_EXTRAP_S, (performance.now() - anchorWall) / 1000);
|
||||
return anchorT + observedRate * elapsed;
|
||||
}
|
||||
|
||||
function handle(msg) {
|
||||
const p = msg.payload || {};
|
||||
switch (msg.type) {
|
||||
case 'event': {
|
||||
const set_ = listeners.get(p.name);
|
||||
// Shaped like a CustomEvent so a pane's handler is identical in
|
||||
// both realms — `e.detail`, not `e`.
|
||||
if (set_) set_.forEach((fn) => { try { fn({ detail: p.detail }); } catch (e) { console.error('[pane] event handler threw', e); } });
|
||||
if (p.name === 'song:loaded') song = p.detail || null;
|
||||
break;
|
||||
}
|
||||
case 'stream': {
|
||||
if (p.playhead) _onPlayhead(p.playhead);
|
||||
for (const name in p) {
|
||||
const set_ = streamSubs.get(name);
|
||||
if (set_) set_.forEach((fn) => { try { fn(p[name]); } catch (e) { console.error('[pane] stream handler threw', e); } });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'rpc:reply': {
|
||||
const call = pending.get(p.seq);
|
||||
if (!call) return; // already timed out
|
||||
pending.delete(p.seq);
|
||||
clearTimeout(call.timer);
|
||||
if (p.ok) call.resolve(p.result);
|
||||
else call.reject(new Error(p.error || 'pane rpc failed'));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'remote',
|
||||
handle: handle,
|
||||
setSong: (s) => { song = s; },
|
||||
|
||||
call(domain, command, payload) {
|
||||
if (!channel) return Promise.reject(new Error('pane ctx.call: no channel'));
|
||||
const seq = ++rpcSeq;
|
||||
return new Promise((resolve, reject) => {
|
||||
// Every call gets a deadline. Without one, a main window that
|
||||
// died mid-call leaves the pane's promise pending forever and
|
||||
// its UI stuck on "Pending".
|
||||
const timer = setTimeout(() => {
|
||||
pending.delete(seq);
|
||||
reject(new Error('PaneRpcTimeout: ' + domain + '/' + command));
|
||||
}, RPC_TIMEOUT_MS);
|
||||
pending.set(seq, { resolve, reject, timer });
|
||||
channel.postMessage(envelope('rpc', paneId, hostId, { seq, domain, command, payload: payload || {} }));
|
||||
});
|
||||
},
|
||||
|
||||
on(name, fn) {
|
||||
let set_ = listeners.get(name);
|
||||
if (!set_) { set_ = new Set(); listeners.set(name, set_); }
|
||||
set_.add(fn);
|
||||
return () => set_.delete(fn);
|
||||
},
|
||||
|
||||
subscribe(stream, fn) {
|
||||
let set_ = streamSubs.get(stream);
|
||||
if (!set_) {
|
||||
set_ = new Set();
|
||||
streamSubs.set(stream, set_);
|
||||
if (channel) channel.postMessage(envelope('sub', paneId, hostId, { stream }));
|
||||
}
|
||||
set_.add(fn);
|
||||
return () => {
|
||||
set_.delete(fn);
|
||||
if (!set_.size && channel) channel.postMessage(envelope('unsub', paneId, hostId, { stream }));
|
||||
};
|
||||
},
|
||||
|
||||
playhead: playhead,
|
||||
song: () => song,
|
||||
|
||||
// No toast stack in a pane window, and routing it back to the main
|
||||
// window would pop the message up somewhere the user isn't looking.
|
||||
toast(opts) { console.info('[pane]', (opts && opts.title) || '', (opts && opts.message) || ''); },
|
||||
};
|
||||
}
|
||||
|
||||
// ── 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,
|
||||
RPC_TIMEOUT_MS,
|
||||
MAX_EXTRAP_S,
|
||||
envelope,
|
||||
openChannel,
|
||||
createStateStore,
|
||||
createLocalTransport,
|
||||
createRemoteTransport,
|
||||
createCtx,
|
||||
};
|
||||
})();
|
||||
@@ -1,108 +0,0 @@
|
||||
/*
|
||||
* fee[dB]ack — the desktop pane host.
|
||||
*
|
||||
* When running inside the desktop app, a popped-out pane gets a real
|
||||
* BrowserWindow instead of a browser pop-up: it remembers where you put it, it
|
||||
* can float above everything, it minimizes to the system tray, and the tray
|
||||
* lists every pane you have.
|
||||
*
|
||||
* Registers as the `desktop` host at priority 20 — above `window` (10, the
|
||||
* browser pop-up) and `dock` (0) — so `panes.detach()` picks it whenever the
|
||||
* desktop bridge is present. In a plain browser this file registers nothing and
|
||||
* the browser pop-up host takes over. Nothing else in the pane system changes;
|
||||
* that is what the host registry is for.
|
||||
*
|
||||
* The renderer stays the authority on what a pane IS. The main process only owns
|
||||
* the window. So this file pushes the pane registry up to the tray, and answers
|
||||
* the tray when it asks for a pane to be toggled.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const panes = window.feedBack && window.feedBack.panes;
|
||||
const bus = window.feedBack;
|
||||
// `feedBackDesktop.panes` is absent in a browser, and also in an OLDER
|
||||
// desktop build that predates this feature — in both cases we simply don't
|
||||
// register, and the browser pop-out host handles detach as before.
|
||||
const desktop = window.feedBackDesktop && window.feedBackDesktop.panes;
|
||||
if (!panes || !bus || !desktop) return;
|
||||
|
||||
const open = new Set();
|
||||
|
||||
function mount(spec) {
|
||||
const url = new URL(window.location.origin + '/pane');
|
||||
url.searchParams.set('pane', spec.id);
|
||||
url.searchParams.set('script', spec.script);
|
||||
|
||||
open.add(spec.id);
|
||||
// Fire-and-forget: the pane's contents arrive over BroadcastChannel from
|
||||
// pane-hub.js, not through this call. All the main process does is put a
|
||||
// window on screen pointing at our own origin.
|
||||
desktop.open({
|
||||
paneId: spec.id,
|
||||
url: url.toString(),
|
||||
title: spec.title,
|
||||
width: spec.width,
|
||||
height: spec.height,
|
||||
}).then((ok) => {
|
||||
if (ok) return;
|
||||
console.error('[panes] desktop refused to open a window for', spec.id);
|
||||
open.delete(spec.id);
|
||||
panes.close(spec.id);
|
||||
}).catch((err) => {
|
||||
console.error('[panes] desktop pane open failed', spec.id, err);
|
||||
open.delete(spec.id);
|
||||
panes.close(spec.id);
|
||||
});
|
||||
|
||||
return { paneId: spec.id }; // an opaque handle; the manager only checks it's truthy
|
||||
}
|
||||
|
||||
function unmount(id) {
|
||||
open.delete(id);
|
||||
desktop.close(id).catch(() => { /* window already gone */ });
|
||||
}
|
||||
|
||||
panes.registerHost({
|
||||
id: 'desktop',
|
||||
priority: 20,
|
||||
remote: true,
|
||||
// Unlike a browser pop-up, this needs no user gesture — so a pane the user
|
||||
// left popped out comes back popped out, where they left it, on next launch.
|
||||
autoRestore: true,
|
||||
available: () => typeof BroadcastChannel === 'function',
|
||||
// A pane with no `script` is only a closure in this realm and cannot
|
||||
// honestly cross a window boundary; the router falls back to the dock.
|
||||
canHost: (spec) => !!spec.script,
|
||||
mount,
|
||||
unmount,
|
||||
focus: (id) => { desktop.focus(id).catch(() => { /* window already gone */ }); },
|
||||
});
|
||||
|
||||
// The user closed the pane window (or it crashed). Close the pane, so the
|
||||
// dialog its pop-out chip hid comes back — otherwise the user's own UI is
|
||||
// hidden with no way to reach it.
|
||||
desktop.onClosed((paneId) => {
|
||||
if (!open.has(paneId)) return;
|
||||
open.delete(paneId);
|
||||
panes.close(paneId);
|
||||
});
|
||||
|
||||
// The tray asked to toggle a pane it has no window for. Only this realm knows
|
||||
// what opening one means.
|
||||
desktop.onToggle((paneId) => {
|
||||
if (panes.isOpen(paneId)) panes.close(paneId);
|
||||
else panes.detach(paneId);
|
||||
});
|
||||
|
||||
// Keep the tray's menu in step with the registry. Cheap and rare — panes are
|
||||
// registered at load and toggled by hand, never on a playback path.
|
||||
function sync() {
|
||||
desktop.sync(panes.list().map((p) => ({ id: p.id, title: p.title, icon: p.icon, open: p.open })));
|
||||
}
|
||||
bus.on('panes:registered', sync);
|
||||
bus.on('panes:unregistered', sync);
|
||||
bus.on('panes:opened', sync);
|
||||
bus.on('panes:closed', sync);
|
||||
sync();
|
||||
})();
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* fee[dB]ack — desktop upgrades for pane windows.
|
||||
*
|
||||
* In the desktop app a pane window is a real BrowserWindow: it remembers where you
|
||||
* put it, it stays off the taskbar, it minimizes to the system tray, and the tray
|
||||
* lists every pane you have.
|
||||
*
|
||||
* Note what this file does NOT do: it does not open the window, and it does not
|
||||
* close it. That stays in pane-window-host.js, and it stays `window.open()` —
|
||||
* because the pane's element is MOVED into that window's document, and a window
|
||||
* the main process created for us would give this realm no handle to adopt into.
|
||||
*
|
||||
* Electron turns our same-origin `window.open()` into a real BrowserWindow anyway,
|
||||
* and the main process recognises it by its frame name (`fbpane-<id>`) and takes
|
||||
* over the OS-level behaviour from there. So the only thing left to say across IPC
|
||||
* is "here are the panes that exist" — for the tray — and to listen for the tray
|
||||
* saying "open that one".
|
||||
*
|
||||
* In a browser, or on an older desktop build, this file does nothing and pop-out
|
||||
* works anyway. Everything here is an upgrade, not a dependency.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const panes = window.feedBack && window.feedBack.panes;
|
||||
const bus = window.feedBack;
|
||||
const desktop = window.feedBackDesktop && window.feedBackDesktop.panes;
|
||||
if (!panes || !bus || !desktop) return;
|
||||
|
||||
// The tray asked to toggle a pane. Only this realm knows what that means — the
|
||||
// pane might belong in the dock, and its element lives here.
|
||||
desktop.onToggle((paneId) => {
|
||||
if (panes.isOpen(paneId)) panes.close(paneId);
|
||||
else panes.detach(paneId);
|
||||
});
|
||||
|
||||
// Keep the tray's menu in step with the registry. Cheap and rare — panes are
|
||||
// registered at load and toggled by hand, never on a playback path.
|
||||
function sync() {
|
||||
desktop.sync(panes.list().map((p) => ({ id: p.id, title: p.title, icon: p.icon, open: p.open })));
|
||||
}
|
||||
bus.on('panes:registered', sync);
|
||||
bus.on('panes:unregistered', sync);
|
||||
bus.on('panes:opened', sync);
|
||||
bus.on('panes:closed', sync);
|
||||
sync();
|
||||
})();
|
||||
+30
-24
@@ -1,18 +1,19 @@
|
||||
/*
|
||||
* 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.
|
||||
* A right-edge stack of cards, one per open pane. Deliberately NOT a rail popover:
|
||||
* the rail is exclusive (player-chrome.js's openPopFor closes the last one before
|
||||
* opening the next), which is exactly why you cannot 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.
|
||||
* As everywhere in this system, the card holds the plugin's REAL element — moved,
|
||||
* not copied. The dock is a frame; the panel inside it is the panel.
|
||||
*
|
||||
* Registers itself as the `dock` host at priority 0 — the floor. Whatever else
|
||||
* exists (an OS pane window), a pane can always land here.
|
||||
* Song-switch survival is structural, not defended: #fb-pane-dock is a <body>
|
||||
* child outside every .screen, so the per-song teardown never sees it.
|
||||
*
|
||||
* Registers as the `dock` host at priority 0 — the floor. Whatever else exists
|
||||
* (an OS window), a pane can always land here, so opening one can never fail.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
@@ -41,11 +42,10 @@
|
||||
}
|
||||
|
||||
function _syncEmpty() {
|
||||
const d = dock();
|
||||
d.classList.toggle('is-empty', cards.size === 0);
|
||||
dock().classList.toggle('is-empty', cards.size === 0);
|
||||
}
|
||||
|
||||
function mount(spec) {
|
||||
function place(spec, el) {
|
||||
const card = document.createElement('section');
|
||||
card.className = 'fb-pane-card';
|
||||
card.dataset.paneId = spec.id;
|
||||
@@ -56,8 +56,7 @@
|
||||
|
||||
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.
|
||||
// textContent, not innerHTML — a pane title comes from a plugin.
|
||||
title.textContent = spec.icon + ' ' + spec.title;
|
||||
|
||||
const close = document.createElement('button');
|
||||
@@ -72,20 +71,27 @@
|
||||
head.appendChild(close);
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'fb-pane-card-body fb-selectable';
|
||||
body.className = 'fb-pane-card-body';
|
||||
|
||||
// Same neutralisation as the window host: the panel was a fixed overlay
|
||||
// pinned to a corner of the app, and inside a card that positioning is
|
||||
// nonsense. .fb-paned unpins it and nothing else.
|
||||
el.classList.add('fb-paned');
|
||||
el.hidden = false;
|
||||
body.appendChild(el);
|
||||
|
||||
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) {
|
||||
function unplace(id, el) {
|
||||
// Hand the element back unmarked. The manager returns it to its home right
|
||||
// after this, and it must arrive as the plugin left it — a panel that
|
||||
// stayed .fb-paned would come back with its own positioning stripped.
|
||||
if (el) el.classList.remove('fb-paned');
|
||||
const card = cards.get(id);
|
||||
if (card) card.remove();
|
||||
cards.delete(id);
|
||||
@@ -96,13 +102,13 @@
|
||||
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).
|
||||
// Re-trigger the flash even if the class is still 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 });
|
||||
panes.registerHost({ id: 'dock', priority: 0, available: () => !!document.body, place, unplace, focus });
|
||||
})();
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
/*
|
||||
* fee[dB]ack — pane hub (main realm).
|
||||
*
|
||||
* The server side of the pane channel. Every popped-out pane talks to exactly
|
||||
* this file, and this file is the only thing in the app that knows a pane might
|
||||
* be in another window.
|
||||
*
|
||||
* It answers `hello` with a snapshot, forwards allowlisted bus events, runs the
|
||||
* pane's capability calls on its behalf, applies its state writes (the main realm
|
||||
* is the sole authority), and samples the streams it asks for — pushing plain
|
||||
* numbers, because the AnalyserNode behind `meters` can never cross a window
|
||||
* boundary.
|
||||
*
|
||||
* Nothing here is reachable from a pane. A pane sees `ctx`, and `ctx` is all.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const B = window.__fbPaneBridge;
|
||||
const panes = window.feedBack && window.feedBack.panes;
|
||||
const bus = window.feedBack;
|
||||
if (!B || !panes || !bus) { console.error('[panes] pane-hub.js loaded too early'); return; }
|
||||
|
||||
const channel = B.openChannel();
|
||||
if (!channel) return; // no BroadcastChannel → the window host declines to open anything anyway
|
||||
|
||||
// paneId -> { streams: Map<name, unsub>, pending: Object|null, rafId }
|
||||
const conns = new Map();
|
||||
// Bus events we have hooked, so N panes share one listener per event.
|
||||
const busHooks = new Map(); // event name -> { fn, refs }
|
||||
|
||||
function send(type, paneId, payload) {
|
||||
channel.postMessage(B.envelope(type, paneId, 'main', payload));
|
||||
}
|
||||
|
||||
// ── Bus mirroring ────────────────────────────────────────────────────────
|
||||
|
||||
function _hookEvent(name) {
|
||||
let hook = busHooks.get(name);
|
||||
if (hook) { hook.refs++; return; }
|
||||
const fn = (e) => {
|
||||
// Only forward to panes that actually want this event, and only
|
||||
// structured-cloneable detail — a CustomEvent carrying a DOM node
|
||||
// (highway:canvas-replaced does) would throw on postMessage and kill
|
||||
// the channel for everyone.
|
||||
let detail = null;
|
||||
try { detail = JSON.parse(JSON.stringify(e.detail === undefined ? null : e.detail)); }
|
||||
catch (err) { detail = null; } // not serialisable: the event still fires, sans payload
|
||||
conns.forEach((_, paneId) => {
|
||||
const spec = panes.get(paneId);
|
||||
if (spec && spec.events.indexOf(name) >= 0) send('event', paneId, { name, detail });
|
||||
});
|
||||
};
|
||||
bus.on(name, fn);
|
||||
busHooks.set(name, { fn, refs: 1 });
|
||||
}
|
||||
|
||||
function _unhookEvent(name) {
|
||||
const hook = busHooks.get(name);
|
||||
if (!hook) return;
|
||||
if (--hook.refs > 0) return;
|
||||
bus.off(name, hook.fn);
|
||||
busHooks.delete(name);
|
||||
}
|
||||
|
||||
// ── Streams ──────────────────────────────────────────────────────────────
|
||||
//
|
||||
// The sampler (pane-streams.js) fires per frame. We do NOT post per stream
|
||||
// per frame — we coalesce every stream a pane wants into ONE message and post
|
||||
// it on the next frame, overwriting anything not yet flushed.
|
||||
//
|
||||
// Overwriting rather than queueing is the whole trick: Chromium throttles a
|
||||
// backgrounded window (which the MAIN window is, while the user looks at the
|
||||
// pane), so a queue would grow a backlog of stale frames and then dump them.
|
||||
// The pane extrapolates its own clock between whatever it does receive.
|
||||
|
||||
function _flush(paneId) {
|
||||
const conn = conns.get(paneId);
|
||||
if (!conn) return;
|
||||
conn.rafId = null;
|
||||
if (!conn.pending) return;
|
||||
const payload = conn.pending;
|
||||
conn.pending = null;
|
||||
send('stream', paneId, payload);
|
||||
}
|
||||
|
||||
function _onStreamValue(paneId, name, value) {
|
||||
const conn = conns.get(paneId);
|
||||
if (!conn) return;
|
||||
if (!conn.pending) conn.pending = {};
|
||||
conn.pending[name] = value; // last value for this frame wins
|
||||
if (conn.rafId == null) conn.rafId = requestAnimationFrame(() => _flush(paneId));
|
||||
}
|
||||
|
||||
function _subscribe(paneId, name) {
|
||||
const conn = conns.get(paneId);
|
||||
if (!conn || conn.streams.has(name)) return;
|
||||
conn.streams.set(name, window.__fbPaneStreams.subscribe(name, (v) => _onStreamValue(paneId, name, v)));
|
||||
}
|
||||
|
||||
function _unsubscribe(paneId, name) {
|
||||
const conn = conns.get(paneId);
|
||||
if (!conn) return;
|
||||
const unsub = conn.streams.get(name);
|
||||
if (unsub) { unsub(); conn.streams.delete(name); }
|
||||
}
|
||||
|
||||
// ── Connections ──────────────────────────────────────────────────────────
|
||||
|
||||
function _connect(paneId) {
|
||||
if (conns.has(paneId)) _disconnect(paneId); // a reloaded pane window says hello again
|
||||
const entry = panes._entry(paneId);
|
||||
|
||||
// Broadcast EVERY change to the authoritative store, whoever made it —
|
||||
// not just the ones a pane asked for. A plugin's main-realm code is often
|
||||
// the real authority (it clamps, it persists, it owns the rig), and when
|
||||
// it corrects or seeds a value through panes.state(id).set(), the pane
|
||||
// window has to see that too. Subscribing here means the pane's own write
|
||||
// is echoed by the same path that carries a main-realm write, so there is
|
||||
// exactly one way state reaches a pane, and it cannot drift.
|
||||
const unsubState = entry ? entry.state.subscribe((_all, change) => {
|
||||
if (change) send('state', paneId, change);
|
||||
}) : null;
|
||||
|
||||
conns.set(paneId, { streams: new Map(), pending: null, rafId: null, unsubState });
|
||||
const spec = panes.get(paneId);
|
||||
if (spec) spec.events.forEach(_hookEvent);
|
||||
}
|
||||
|
||||
function _disconnect(paneId) {
|
||||
const conn = conns.get(paneId);
|
||||
if (!conn) return;
|
||||
conn.streams.forEach((unsub) => unsub());
|
||||
if (conn.unsubState) conn.unsubState();
|
||||
if (conn.rafId != null) cancelAnimationFrame(conn.rafId);
|
||||
conns.delete(paneId);
|
||||
const spec = panes.get(paneId);
|
||||
if (spec) spec.events.forEach(_unhookEvent);
|
||||
}
|
||||
|
||||
function _snapshot(paneId) {
|
||||
const entry = panes._entry(paneId);
|
||||
const spec = panes.get(paneId);
|
||||
if (!entry || !spec) return null;
|
||||
return {
|
||||
spec: { id: spec.id, title: spec.title, icon: spec.icon, script: spec.script },
|
||||
state: entry.state.all(),
|
||||
song: (window.feedBack && window.feedBack.currentSong) || null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Channel ──────────────────────────────────────────────────────────────
|
||||
|
||||
channel.addEventListener('message', (e) => {
|
||||
const msg = e.data;
|
||||
if (!msg || msg.v !== B.PROTOCOL_VERSION || msg.hostId === 'main') return;
|
||||
const paneId = msg.paneId;
|
||||
const p = msg.payload || {};
|
||||
|
||||
switch (msg.type) {
|
||||
case 'hello': {
|
||||
const snap = _snapshot(paneId);
|
||||
if (!snap) {
|
||||
// The pane window outlived its registration (main window
|
||||
// reloaded while a pane window stayed open). Tell it so it can
|
||||
// close itself rather than sit there frozen.
|
||||
send('bye', paneId, { reason: 'unknown-pane' });
|
||||
return;
|
||||
}
|
||||
_connect(paneId);
|
||||
send('snapshot', paneId, snap);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'rpc': {
|
||||
const caps = window.feedBack.capabilities;
|
||||
const reply = (ok, result, error) => send('rpc:reply', paneId, { seq: p.seq, ok, result, error });
|
||||
if (!caps || typeof caps.command !== 'function') { reply(false, null, 'capability bus unavailable'); return; }
|
||||
caps.command(p.domain, p.command, {
|
||||
requester: 'pane.' + paneId,
|
||||
origin: 'pane',
|
||||
payload: p.payload || {},
|
||||
timeoutMs: B.CALL_TIMEOUT_MS,
|
||||
}).then((result) => {
|
||||
// The result crosses a window boundary, so it must survive
|
||||
// structured clone. A capability that answers with a live
|
||||
// object (a node, a function) would otherwise throw here and
|
||||
// take the channel down with it.
|
||||
let safe = null;
|
||||
try { safe = JSON.parse(JSON.stringify(result === undefined ? null : result)); }
|
||||
catch (err) { reply(false, null, 'result is not serialisable'); return; }
|
||||
reply(true, safe, null);
|
||||
}).catch((err) => reply(false, null, String((err && err.message) || err)));
|
||||
break;
|
||||
}
|
||||
|
||||
case 'state': {
|
||||
const entry = panes._entry(paneId);
|
||||
if (!entry) return;
|
||||
// The main realm is authoritative: a pane's write is a request. We
|
||||
// apply it here; the store subscription in _connect() does the
|
||||
// echoing, so a pane's optimistic paint is corrected by exactly the
|
||||
// same path that carries a main-realm write.
|
||||
entry.state.set(p.path, p.value);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'sub': _subscribe(paneId, p.stream); break;
|
||||
case 'unsub': _unsubscribe(paneId, p.stream); break;
|
||||
|
||||
case 'bye': {
|
||||
_disconnect(paneId);
|
||||
// The pane window is going away for good (the user closed it).
|
||||
// Closing the pane un-hides whatever dialog the chip hid, which is
|
||||
// the only outcome that leaves the user able to find their UI again.
|
||||
if (panes.isOpen(paneId)) panes.close(paneId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// The main window is the only thing feeding the panes. When it goes, they
|
||||
// cannot be fed — tell them, so they show a dead state instead of a
|
||||
// convincing but frozen one. (The window host also closes them outright; this
|
||||
// covers a host that can't, such as the desktop's own windows.)
|
||||
window.addEventListener('beforeunload', () => {
|
||||
conns.forEach((_, paneId) => send('bye', paneId, { reason: 'main-closed' }));
|
||||
});
|
||||
|
||||
// A pane that is closed from THIS side (the stub, the launcher, the tray)
|
||||
// must be told, or its window sits there orphaned.
|
||||
bus.on('panes:closed', (e) => {
|
||||
const id = e.detail && e.detail.id;
|
||||
if (conns.has(id)) { send('bye', id, { reason: 'closed-by-host' }); _disconnect(id); }
|
||||
});
|
||||
})();
|
||||
+144
-240
@@ -1,43 +1,49 @@
|
||||
/*
|
||||
* fee[dB]ack — pane manager.
|
||||
*
|
||||
* The registry and host router behind `window.feedBack.panes`. Main realm only.
|
||||
* The registry and host router behind `window.feedBack.panes`.
|
||||
*
|
||||
* 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.
|
||||
* A "pane" is a piece of UI a plugin already has — a mixer panel, a camera rig,
|
||||
* a settings board — that the user can pop out into its own OS window and leave
|
||||
* open: while they play, across song switches, on a second monitor, minimized to
|
||||
* the tray.
|
||||
*
|
||||
* 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.
|
||||
* The whole design is one sentence: WE MOVE THE REAL ELEMENT.
|
||||
*
|
||||
* Hosts register themselves; the manager never imports one. That is what lets
|
||||
* the pop-out window host drop in later without this file changing.
|
||||
* Not a copy of it, not a re-implementation of it in the pop-out window — the
|
||||
* actual DOM node. Same-origin windows can adopt each other's nodes, and an
|
||||
* adopted node keeps its event listeners and its closures. So the panel goes on
|
||||
* running the plugin's own code, against the plugin's own state, in the plugin's
|
||||
* own realm. It looks and behaves exactly like the thing that was popped out,
|
||||
* because it IS the thing that was popped out.
|
||||
*
|
||||
* That is what makes the plugin's side of this two lines:
|
||||
*
|
||||
* feedBack.panes.register({ id: 'camera_director', title: 'Camera', element: () => panelEl });
|
||||
* feedBack.panes.attachChip(panelEl, 'camera_director');
|
||||
*
|
||||
* No state mirroring, no cross-window RPC, no second copy of the UI to keep in
|
||||
* step with the first. Those were all workarounds for a problem we simply do not
|
||||
* have once the node itself moves.
|
||||
*
|
||||
* The manager owns which pane is open and where, and — crucially — where each
|
||||
* pane's element CAME FROM, so docking it puts it back exactly where it was.
|
||||
*/
|
||||
(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 }
|
||||
// id -> { spec, hostId, el, home: { parent, next } }
|
||||
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.
|
||||
// Only which pane was open, and where. A pane's CONTENTS are the plugin's own
|
||||
// DOM and the plugin's own state — none of our business.
|
||||
|
||||
function _readJSON(key, fallback) {
|
||||
try {
|
||||
@@ -48,71 +54,48 @@
|
||||
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');
|
||||
if (typeof spec.element !== 'function' && !(spec.element instanceof Element)) {
|
||||
throw new TypeError('panes.register(' + spec.id + '): spec.element must be an Element, or a function returning one');
|
||||
}
|
||||
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',
|
||||
// URL of the module the pane REALM loads to obtain this pane's
|
||||
// mount(). A pane with no script can only ever be docked — it exists
|
||||
// solely as a closure in this realm, and there is no honest way to
|
||||
// move a closure across a window boundary. The window host declines
|
||||
// such panes and the router falls back to the dock.
|
||||
script: spec.script || null,
|
||||
mirrorGlobal: spec.mirrorGlobal || null, // honoured by pane-mirror.js
|
||||
// Resolved lazily: a plugin often builds its panel on first use, so the
|
||||
// element may not exist at registration time — and it may be rebuilt
|
||||
// later (Camera Director rebuilds its panel on every mode change).
|
||||
// Asking for it at open time means we always move the live one.
|
||||
element: typeof spec.element === 'function' ? spec.element : () => spec.element,
|
||||
width: spec.width || 380,
|
||||
height: spec.height || 560,
|
||||
defaultHost: spec.defaultHost || 'window',
|
||||
// Called after the element lands in (or returns from) a pane window,
|
||||
// for a plugin that needs to re-measure or re-anchor something.
|
||||
onHost: typeof spec.onHost === 'function' ? spec.onHost : null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── 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, spec) {
|
||||
function _resolveHost(preferred) {
|
||||
const wanted = hosts.get(preferred);
|
||||
if (wanted && wanted.available() && wanted.canHost(spec)) return wanted;
|
||||
// Fall back to the best host that IS available and WILL take this pane,
|
||||
// preferring the highest priority. The dock registers at priority 0 and
|
||||
// accepts everything, so it is always the floor — a pane can never fail
|
||||
// to open just because the window host is unavailable or declines it.
|
||||
if (wanted && wanted.available()) return wanted;
|
||||
// Fall back to the best available host. The dock registers at priority 0
|
||||
// and is always available, so a pane can never fail to open.
|
||||
let best = null;
|
||||
hosts.forEach((h) => {
|
||||
if (!h.available() || !h.canHost(spec)) return;
|
||||
if (!h.available()) return;
|
||||
if (!best || h.priority > best.priority) best = h;
|
||||
});
|
||||
return best;
|
||||
@@ -123,14 +106,101 @@
|
||||
if (bus && typeof bus.emit === 'function') bus.emit(name, detail);
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────────────
|
||||
// ── Open / close ─────────────────────────────────────────────────────────
|
||||
|
||||
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; }
|
||||
|
||||
let el;
|
||||
try { el = spec.element(); } catch (e) { el = null; }
|
||||
if (!(el instanceof Element)) {
|
||||
console.warn('[panes] open: pane has no element yet:', id);
|
||||
return false;
|
||||
}
|
||||
|
||||
const host = _resolveHost(opts.host || spec.defaultHost);
|
||||
if (!host) { console.error('[panes] open: no host available for', id); return false; }
|
||||
|
||||
// Where the element lives right now, so docking can put it back EXACTLY
|
||||
// there — same parent, same position among its siblings. Anything less and
|
||||
// a docked panel reappears at the bottom of its container, or not at all.
|
||||
const home = { parent: el.parentNode, next: el.nextSibling };
|
||||
|
||||
try {
|
||||
host.place(spec, el);
|
||||
} catch (e) {
|
||||
console.error('[panes] host', host.id, 'failed to take', id, e);
|
||||
return false;
|
||||
}
|
||||
|
||||
open.set(id, { spec, hostId: host.id, el, home });
|
||||
if (opts.remember !== false) _rememberHost(id, host.id);
|
||||
if (spec.onHost) { try { spec.onHost(host.id, el); } catch (e) { console.error('[panes]', id, 'onHost threw', e); } }
|
||||
_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);
|
||||
|
||||
const host = hosts.get(entry.hostId);
|
||||
try { if (host) host.unplace(id, entry.el); } catch (e) { console.error('[panes] host', entry.hostId, 'threw releasing', id, e); }
|
||||
|
||||
// Put the element back where it came from. Re-adopting it into THIS
|
||||
// document is what undoes the pop-out: a node adopted by another window
|
||||
// has that window's document as its owner, and appending it here without
|
||||
// adopting first would throw in some engines and leave it in a half-moved
|
||||
// state in others.
|
||||
const home = entry.home;
|
||||
if (home && home.parent && home.parent.isConnected) {
|
||||
try {
|
||||
const node = document.adoptNode(entry.el);
|
||||
if (home.next && home.next.parentNode === home.parent) home.parent.insertBefore(node, home.next);
|
||||
else home.parent.appendChild(node);
|
||||
} catch (e) {
|
||||
console.error('[panes] could not return', id, 'to its home', e);
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.remember !== false) _rememberHost(id, null);
|
||||
if (entry.spec.onHost) { try { entry.spec.onHost(null, entry.el); } catch (e) { /* non-fatal */ } }
|
||||
_emit('panes:closed', { id: id, host: entry.hostId });
|
||||
return true;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// What the pop-out chip calls: put this pane wherever a pane most wants to
|
||||
// live. That is a window if one can be had, and the dock otherwise.
|
||||
function detach(id) {
|
||||
const spec = specs.get(id);
|
||||
return openPane(id, { host: (spec && spec.defaultHost) || 'window' });
|
||||
}
|
||||
|
||||
function dock(id) {
|
||||
if (open.has(id)) closePane(id, { remember: false });
|
||||
return openPane(id, { host: 'dock' });
|
||||
}
|
||||
|
||||
// ── Registry ─────────────────────────────────────────────────────────────
|
||||
|
||||
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.
|
||||
// silent overwrite would swap the element out from under an open pane.
|
||||
console.warn('[panes] pane already registered, ignoring:', s.id);
|
||||
return () => {};
|
||||
}
|
||||
@@ -138,14 +208,13 @@
|
||||
_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.
|
||||
// register() and attachChip() back to back — the chip must exist before
|
||||
// the pane opens, or it has nothing to hide.
|
||||
//
|
||||
// A host may refuse to be auto-restored: a browser blocks window.open()
|
||||
// without a user gesture, so restoring a popped-out pane on page load
|
||||
// would only ever produce a "pop-up blocked" toast. Such a pane comes back
|
||||
// in the dock, and the chip pops it out again on the user's next click.
|
||||
// (The desktop host has no such restriction and restores in place.)
|
||||
let remembered = _readJSON(HOSTS_KEY, {})[s.id];
|
||||
if (remembered) {
|
||||
const h = hosts.get(remembered);
|
||||
@@ -162,201 +231,36 @@
|
||||
_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, spec);
|
||||
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));
|
||||
|
||||
// A REMOTE host (a pop-out window) runs the pane's mount() in its own
|
||||
// realm — this realm never sees the pane's DOM and must not call mount()
|
||||
// itself. All we own here is the authoritative state store; pane-hub.js
|
||||
// serves the other realm from it.
|
||||
if (host.remote) {
|
||||
let handle;
|
||||
try {
|
||||
handle = host.mount(spec);
|
||||
} catch (e) {
|
||||
console.error('[panes] host', host.id, 'failed to open a window for', id, e);
|
||||
return false;
|
||||
}
|
||||
if (!handle) return false; // host already explained itself (popup blocked, etc.)
|
||||
open.set(id, { spec, hostId: host.id, state, remote: true, handle });
|
||||
if (opts.remember !== false) _rememberHost(id, host.id);
|
||||
_emit('panes:opened', { id: id, host: host.id });
|
||||
return true;
|
||||
}
|
||||
|
||||
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 for a local pane: the pane tears down its own DOM and
|
||||
// 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. A remote pane's mount/unmount ran in the other realm and
|
||||
// teardown goes with the window, so there is nothing to do but close it.
|
||||
if (!entry.remote) {
|
||||
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,
|
||||
// `remote: true` means the pane's mount() runs in ANOTHER JS realm.
|
||||
// The manager then owns only the state store, and pane-hub.js serves
|
||||
// the pane over the channel.
|
||||
remote: !!host.remote,
|
||||
// `autoRestore: false` — this host cannot be opened without a user
|
||||
// gesture, so a pane remembered here comes back in the dock instead.
|
||||
autoRestore: host.autoRestore !== false,
|
||||
available: typeof host.available === 'function' ? host.available : () => true,
|
||||
canHost: typeof host.canHost === 'function' ? host.canHost : () => true,
|
||||
mount: host.mount,
|
||||
unmount: host.unmount,
|
||||
place: host.place,
|
||||
unplace: host.unplace,
|
||||
focus: host.focus,
|
||||
});
|
||||
}
|
||||
|
||||
const api = {
|
||||
version: 1,
|
||||
version: 2,
|
||||
register,
|
||||
unregister,
|
||||
open: openPane,
|
||||
close: closePane,
|
||||
move: movePane,
|
||||
focus: focusPane,
|
||||
detach,
|
||||
dock,
|
||||
focus: focusPane,
|
||||
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.
|
||||
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,
|
||||
})),
|
||||
registerHost,
|
||||
|
||||
// The main realm's handle on an open pane's state.
|
||||
//
|
||||
// `mirrorGlobal` covers the case where a pane drives a plain global that
|
||||
// some renderer reads. It does NOT cover the far more common one: a
|
||||
// plugin whose main-realm code is the authority — it clamps, it persists,
|
||||
// it emits events, it owns the audio graph or the camera rig — and which
|
||||
// therefore needs to APPLY the pane's values itself rather than have core
|
||||
// splat them somewhere.
|
||||
//
|
||||
// Such a plugin subscribes here on `panes:opened`, seeds the store with
|
||||
// its current values, and applies whatever comes back. The pane stays
|
||||
// realm-agnostic (it only ever touches ctx.state) and the plugin keeps
|
||||
// being the single source of truth. Returns null when the pane is closed.
|
||||
state: (id) => {
|
||||
const entry = open.get(id);
|
||||
return entry ? {
|
||||
get: (path) => entry.state.get(path),
|
||||
set: (path, value) => entry.state.set(path, value),
|
||||
all: () => entry.state.all(),
|
||||
subscribe: (fn) => entry.state.subscribe(fn),
|
||||
} : null;
|
||||
},
|
||||
|
||||
// Host-internal. pane-hub.js serves a pop-out realm from the
|
||||
// authoritative state store, which only lives here. Not part of the pane
|
||||
// API — panes must never reach for this.
|
||||
_entry: (id) => open.get(id) || null,
|
||||
};
|
||||
|
||||
window.feedBack = window.feedBack || {};
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
/*
|
||||
* fee[dB]ack — global mirroring for panes.
|
||||
*
|
||||
* The camera-director problem, solved without touching a single renderer.
|
||||
*
|
||||
* The 3D highways read their free-camera state from a plain global —
|
||||
* `window.__h3dCamCtl = { enabled, heightMul, distMul, yaw, pitch, panX, panY }`
|
||||
* (plugins/highway_3d/FREECAM_BRIDGE.md) — once per frame, in _resolveFreeCam().
|
||||
* A camera panel in the main window just writes that object and the camera moves.
|
||||
*
|
||||
* A panel in a POP-OUT window cannot: `window.__h3dCamCtl` there is a different
|
||||
* object in a different realm, and writing it moves nothing.
|
||||
*
|
||||
* So a pane declares one manifest field:
|
||||
*
|
||||
* panes.register({ id: 'camera_director', mirrorGlobal: '__h3dCamCtl', ... })
|
||||
*
|
||||
* and this file — which runs in the MAIN realm, where the renderers live — copies
|
||||
* that pane's state onto the global whenever it changes. The pane writes
|
||||
* ctx.state, the state store is authoritative here, and the renderers keep reading
|
||||
* the plain global they always read. highway_3d, keys_highway_3d and
|
||||
* drum_highway_3d are not modified, and do not know panes exist.
|
||||
*
|
||||
* The one rule that makes it work: MUTATE THE OBJECT, NEVER REPLACE IT. A renderer
|
||||
* may be holding the reference (_resolveFreeCam caches it), and swapping in a new
|
||||
* object would leave it writing to — and reading from — an orphan.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const panes = window.feedBack && window.feedBack.panes;
|
||||
const bus = window.feedBack;
|
||||
if (!panes || !bus) return;
|
||||
|
||||
// paneId -> unsubscribe
|
||||
const mirrors = new Map();
|
||||
|
||||
function _target(name) {
|
||||
// Create the global if the plugin that owns it hasn't yet (a pane can be
|
||||
// restored at boot before its renderer ever runs). Reuse it if it exists —
|
||||
// see the rule above.
|
||||
if (!window[name] || typeof window[name] !== 'object') window[name] = {};
|
||||
return window[name];
|
||||
}
|
||||
|
||||
function _apply(name, state) {
|
||||
const target = _target(name);
|
||||
// Copy the pane's whole state root onto the global. Keys the pane doesn't
|
||||
// set are left alone rather than deleted — the global may carry fields the
|
||||
// pane knows nothing about (a renderer's own bookkeeping), and clearing
|
||||
// them would be a silent, spooky breakage.
|
||||
Object.keys(state).forEach((k) => { target[k] = state[k]; });
|
||||
}
|
||||
|
||||
bus.on('panes:opened', (e) => {
|
||||
const id = e.detail && e.detail.id;
|
||||
const spec = panes.get(id);
|
||||
if (!spec || !spec.mirrorGlobal || mirrors.has(id)) return;
|
||||
const entry = panes._entry(id);
|
||||
if (!entry) return;
|
||||
|
||||
// Sync once on open: a pane's persisted state is the user's last camera,
|
||||
// and it should take effect the moment the pane exists — not on their next
|
||||
// nudge of a slider.
|
||||
_apply(spec.mirrorGlobal, entry.state.all());
|
||||
|
||||
mirrors.set(id, entry.state.subscribe((all) => _apply(spec.mirrorGlobal, all)));
|
||||
});
|
||||
|
||||
bus.on('panes:closed', (e) => {
|
||||
const id = e.detail && e.detail.id;
|
||||
const unsub = mirrors.get(id);
|
||||
if (!unsub) return;
|
||||
unsub();
|
||||
mirrors.delete(id);
|
||||
// The global is deliberately LEFT AS IT IS. Closing the camera panel should
|
||||
// not snap the camera back to a default — that is exactly what happens
|
||||
// today (nobody clears __h3dCamCtl), and it is the behaviour users expect.
|
||||
});
|
||||
})();
|
||||
@@ -1,122 +0,0 @@
|
||||
/*
|
||||
* fee[dB]ack — manifest-declared plugin panes.
|
||||
*
|
||||
* A plugin can ship a pane by declaring it in plugin.json, with no code in
|
||||
* screen.js at all:
|
||||
*
|
||||
* "panes": [{
|
||||
* "id": "camera_director",
|
||||
* "title": "Camera Director",
|
||||
* "icon": "🎥",
|
||||
* "script": "panes/camera.js",
|
||||
* "mirrorGlobal": "__h3dCamCtl"
|
||||
* }]
|
||||
*
|
||||
* The point of declaring it rather than calling panes.register() is that the
|
||||
* pane becomes openable — from the rail, from the tray — WITHOUT THE PLUGIN'S
|
||||
* SCREEN EVER HAVING BEEN VISITED. We register a stub from the manifest and
|
||||
* fetch the script only when the user actually opens it. A pane you can only
|
||||
* reach by first navigating to the screen it was supposed to replace is not
|
||||
* much of a pane.
|
||||
*
|
||||
* The script sets a factory global, the same shape the viz contract already uses
|
||||
* (`window.feedBackViz_<id>`):
|
||||
*
|
||||
* window.feedBackPane_camera_director = {
|
||||
* mount(root, ctx) { ... },
|
||||
* unmount(root, ctx) { ... },
|
||||
* };
|
||||
*
|
||||
* It is loaded from the sandboxed /api/plugins/<plugin>/src/<script> route, and
|
||||
* the SAME file is what a pop-out window loads in its own realm — so it must not
|
||||
* assume the app is there. `ctx` is everything it gets. See docs/plugin-panes.md.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const panes = window.feedBack && window.feedBack.panes;
|
||||
if (!panes) return;
|
||||
|
||||
// Loaded factories, so re-opening a pane doesn't re-fetch its script.
|
||||
const loading = new Map(); // paneId -> Promise<factory>
|
||||
|
||||
function _factory(paneId) {
|
||||
return window['feedBackPane_' + paneId] || null;
|
||||
}
|
||||
|
||||
function _load(paneId, url) {
|
||||
const already = _factory(paneId);
|
||||
if (already) return Promise.resolve(already);
|
||||
if (loading.has(paneId)) return loading.get(paneId);
|
||||
|
||||
const p = new Promise((resolve, reject) => {
|
||||
const s = document.createElement('script');
|
||||
s.src = url;
|
||||
s.onload = () => {
|
||||
const f = _factory(paneId);
|
||||
if (f) resolve(f);
|
||||
else reject(new Error('pane script loaded but set no window.feedBackPane_' + paneId));
|
||||
};
|
||||
s.onerror = () => reject(new Error('failed to load pane script: ' + url));
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
loading.set(paneId, p);
|
||||
p.catch(() => loading.delete(paneId)); // let a transient failure be retried
|
||||
return p;
|
||||
}
|
||||
|
||||
function register(plugin, def) {
|
||||
const url = '/api/plugins/' + plugin.id + '/src/' + def.script;
|
||||
|
||||
panes.register({
|
||||
id: def.id,
|
||||
title: def.title || def.id,
|
||||
icon: def.icon || '▣',
|
||||
// The pop-out realm loads exactly the same file.
|
||||
script: url,
|
||||
defaultHost: def.defaultHost || 'window',
|
||||
mirrorGlobal: def.mirrorGlobal || null,
|
||||
width: def.width || undefined,
|
||||
height: def.height || undefined,
|
||||
|
||||
// The stub. Docked, the script is fetched on first open and its
|
||||
// factory takes over from here; the root is already on screen, so a
|
||||
// slow fetch shows an empty card rather than blocking the click.
|
||||
mount(root, ctx) {
|
||||
_load(def.id, url).then((factory) => {
|
||||
if (!root.isConnected) return; // closed again while we were fetching
|
||||
factory.mount(root, ctx);
|
||||
root.__fbPaneFactory = factory;
|
||||
}).catch((err) => {
|
||||
console.error('[panes]', def.id, err);
|
||||
const oops = document.createElement('div');
|
||||
oops.className = 'fb-pane-dim';
|
||||
oops.textContent = 'This pane failed to load.';
|
||||
root.appendChild(oops);
|
||||
});
|
||||
},
|
||||
|
||||
unmount(root, ctx) {
|
||||
const factory = root.__fbPaneFactory;
|
||||
delete root.__fbPaneFactory;
|
||||
// Only the factory that actually mounted gets to unmount. A pane
|
||||
// closed before its script arrived never mounted, and calling
|
||||
// unmount() on it would hand the plugin a root it has never seen.
|
||||
if (factory && typeof factory.unmount === 'function') {
|
||||
try { factory.unmount(root, ctx); } catch (e) { console.error('[panes]', def.id, 'unmount threw', e); }
|
||||
}
|
||||
root.replaceChildren();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
fetch('/api/plugins')
|
||||
.then((r) => r.json())
|
||||
.then((list) => {
|
||||
(Array.isArray(list) ? list : []).forEach((plugin) => {
|
||||
if (plugin.enabled === false || !Array.isArray(plugin.panes)) return;
|
||||
plugin.panes.forEach((def) => register(plugin, def));
|
||||
});
|
||||
})
|
||||
.catch((err) => console.error('[panes] could not read the plugin list', err));
|
||||
})();
|
||||
@@ -1,181 +0,0 @@
|
||||
/*
|
||||
* fee[dB]ack — pane runtime (the pop-out realm).
|
||||
*
|
||||
* This is what runs inside a pane window. It is NOT the app: there is no
|
||||
* highway, no library, no shell, no <audio>, no capability bus, no audio graph.
|
||||
* There is this file, the bridge, and the pane's own script.
|
||||
*
|
||||
* That is deliberate. The splitscreen follower reuses the full app shell with a
|
||||
* `?ssFollower=1` flag and pays for it — an anti-flash block that must run before
|
||||
* any script parses, bail-outs in app.js and shell.js, and ~40 lines of CSS
|
||||
* hiding core elements by id. It loads the whole app to throw it away. A pane
|
||||
* window has nothing to throw away.
|
||||
*
|
||||
* The cost is that `window.feedBack` here is a deliberate, documented SUBSET.
|
||||
* We install exactly what a pane is promised and nothing more, so a pane reaching
|
||||
* for something it was never given fails loudly at authoring time instead of
|
||||
* subtly at runtime.
|
||||
*
|
||||
* Boot: hello → snapshot → load the pane's script → mount(root, ctx).
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const B = window.__fbPaneBridge;
|
||||
const params = new URLSearchParams(location.search);
|
||||
const paneId = params.get('pane');
|
||||
const scriptUrl = params.get('script');
|
||||
|
||||
const rootEl = document.getElementById('pane-root');
|
||||
const titleEl = document.getElementById('pane-title');
|
||||
const statusEl = document.getElementById('pane-status');
|
||||
|
||||
function fail(message) {
|
||||
statusEl.textContent = message;
|
||||
statusEl.hidden = false;
|
||||
rootEl.hidden = true;
|
||||
}
|
||||
|
||||
if (!paneId || !scriptUrl) { fail('This window was opened without a pane to show.'); return; }
|
||||
if (!B) { fail('Pane bridge failed to load.'); return; }
|
||||
|
||||
const channel = B.openChannel();
|
||||
if (!channel) { fail('This browser has no BroadcastChannel, so a pane window cannot be kept in sync.'); return; }
|
||||
|
||||
// The registration shim. A pane script is the SAME file whether it runs in the
|
||||
// app (where it registers with the real pane manager) or here — it calls
|
||||
// `feedBack.panes.register(spec)` either way. Here, that call just hands us
|
||||
// the spec.
|
||||
let captured = null;
|
||||
window.feedBack = {
|
||||
panes: {
|
||||
register(spec) {
|
||||
if (spec && spec.id === paneId) captured = spec;
|
||||
return () => {};
|
||||
},
|
||||
// A pane window hosts one pane. Chip/dock/launcher calls are
|
||||
// meaningless here, but a shared pane script may make them at load —
|
||||
// so they must exist and do nothing rather than throw and take the
|
||||
// pane's module down with them.
|
||||
attachChip: () => () => {},
|
||||
get: () => null,
|
||||
isOpen: () => false,
|
||||
list: () => [],
|
||||
},
|
||||
};
|
||||
|
||||
const transport = B.createRemoteTransport(paneId, 'pane:' + paneId, channel);
|
||||
let ctx = null;
|
||||
let mounted = false;
|
||||
|
||||
channel.addEventListener('message', (e) => {
|
||||
const msg = e.data;
|
||||
if (!msg || msg.v !== B.PROTOCOL_VERSION || msg.paneId !== paneId) return;
|
||||
if (msg.hostId !== 'main') return; // ignore our own echoes
|
||||
|
||||
if (msg.type === 'snapshot') { onSnapshot(msg.payload); return; }
|
||||
|
||||
if (msg.type === 'bye') {
|
||||
const reason = (msg.payload && msg.payload.reason) || '';
|
||||
// 'main-closed' is the interesting one: nothing will ever feed this
|
||||
// window again. Say so plainly rather than leaving a frozen playhead
|
||||
// that looks live.
|
||||
if (reason === 'main-closed') fail('fee[dB]ack closed. This pane is no longer live.');
|
||||
else window.close(); // closed deliberately from the app side
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'state' && state) {
|
||||
// The authoritative echo. Applying it unconditionally is what makes a
|
||||
// losing write self-correct.
|
||||
state.set(msg.payload.path, msg.payload.value);
|
||||
return;
|
||||
}
|
||||
|
||||
transport.handle(msg);
|
||||
});
|
||||
|
||||
let state = null;
|
||||
|
||||
function onSnapshot(snap) {
|
||||
if (mounted) return; // a duplicate snapshot (main reloaded) — the window will be told `bye` if stale
|
||||
titleEl.textContent = snap.spec.icon + ' ' + snap.spec.title;
|
||||
document.title = snap.spec.title + ' — fee[dB]ack';
|
||||
transport.setSong(snap.song);
|
||||
|
||||
state = B.createStateStore(snap.state);
|
||||
// A pane's write is a REQUEST. We send it and let the main realm's echo
|
||||
// apply it, so there is exactly one authority and no split brain. The
|
||||
// local store is not written here — the echo does that.
|
||||
const authoritative = {
|
||||
get: (path) => state.get(path),
|
||||
all: () => state.all(),
|
||||
subscribe: (fn) => state.subscribe(fn),
|
||||
set: (path, value) => {
|
||||
channel.postMessage(B.envelope('state', paneId, 'pane:' + paneId, { path, value }));
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
ctx = B.createCtx({
|
||||
paneId: paneId,
|
||||
host: 'pane:' + paneId,
|
||||
transport: transport,
|
||||
state: authoritative,
|
||||
onClose: () => window.close(),
|
||||
});
|
||||
|
||||
loadScript(snap.spec.script).then(() => {
|
||||
// Two ways a script can hand us a pane, and the same file must work in
|
||||
// both realms:
|
||||
// - a plugin pane sets window.feedBackPane_<id> (the factory global,
|
||||
// mirroring the existing window.feedBackViz_<id> contract), because
|
||||
// the main realm loads it lazily and must not have to re-register;
|
||||
// - a core built-in calls feedBack.panes.register(), which our shim
|
||||
// above captured.
|
||||
const pane = window['feedBackPane_' + paneId] || captured;
|
||||
if (!pane || typeof pane.mount !== 'function') {
|
||||
fail('The pane script loaded but provided no pane.');
|
||||
return;
|
||||
}
|
||||
statusEl.hidden = true;
|
||||
rootEl.hidden = false;
|
||||
try {
|
||||
pane.mount(rootEl, ctx);
|
||||
mounted = true;
|
||||
} catch (err) {
|
||||
console.error('[pane] mount() threw', err);
|
||||
fail('This pane failed to start.');
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.error('[pane] failed to load', snap.spec.script, err);
|
||||
fail('Could not load this pane.');
|
||||
});
|
||||
}
|
||||
|
||||
function loadScript(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const s = document.createElement('script');
|
||||
s.src = url;
|
||||
s.onload = resolve;
|
||||
s.onerror = () => reject(new Error('script load failed: ' + url));
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
|
||||
// Tell the app we're gone so it can un-hide the dialog the chip hid. If this
|
||||
// never arrives (a crash), the host's `closed` poll reaps us anyway — but the
|
||||
// clean path should not depend on the fallback.
|
||||
window.addEventListener('beforeunload', () => {
|
||||
try { channel.postMessage(B.envelope('bye', paneId, 'pane:' + paneId, { reason: 'pane-closed' })); }
|
||||
catch (e) { /* channel already torn down */ }
|
||||
});
|
||||
|
||||
// Resync-on-open, always: the snapshot is the only way this realm learns
|
||||
// anything, so ask for it as the very first thing we do.
|
||||
channel.postMessage(B.envelope('hello', paneId, 'pane:' + paneId, {}));
|
||||
|
||||
// If nobody answers, the main window is gone or never had this pane. Don't
|
||||
// spin forever on a blank window.
|
||||
setTimeout(() => { if (!mounted && !state) fail('fee[dB]ack is not running, or this pane is no longer available.'); }, 5000);
|
||||
})();
|
||||
@@ -1,137 +0,0 @@
|
||||
/*
|
||||
* 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) };
|
||||
})();
|
||||
@@ -1,20 +1,27 @@
|
||||
/*
|
||||
* fee[dB]ack — the pop-out window host (browser path).
|
||||
* fee[dB]ack — the pop-out window host.
|
||||
*
|
||||
* Opens a real OS window per pane and hands it off to pane-hub.js, which serves
|
||||
* it over BroadcastChannel. Registers as the `window` host at priority 10, so it
|
||||
* outranks the dock and `panes.detach()` prefers it.
|
||||
* Opens a real OS window and MOVES THE PANE'S ELEMENT INTO IT.
|
||||
*
|
||||
* This is the BROWSER implementation: a plain same-origin `window.open()`, which
|
||||
* is what the splitscreen follower has done for years. Electron already permits
|
||||
* it — main.ts's setWindowOpenHandler returns `action: 'allow'` for same-origin
|
||||
* URLs, and that is load-bearing: `deny` would push the URL to the system
|
||||
* browser, a different Chromium instance, where BroadcastChannel cannot reach it
|
||||
* and the pane would silently never sync.
|
||||
* The move is the whole trick, and it works because the pane window is same-origin
|
||||
* and opener-linked: `document.adoptNode()` re-parents a live node into another
|
||||
* window's document, and an adopted node keeps its event listeners, its closures,
|
||||
* and every reference anything else holds to it. So the plugin's panel goes on
|
||||
* running the plugin's own code in the plugin's own realm — it is just being
|
||||
* *displayed* somewhere else. It looks and behaves exactly like what was popped
|
||||
* out, because it is exactly what was popped out.
|
||||
*
|
||||
* The desktop app will register its own host at a higher priority (a real
|
||||
* BrowserWindow, with a system tray, always-on-top, and remembered bounds).
|
||||
* Nothing else changes when it does — that is the point of the host registry.
|
||||
* That is why this file must use `window.open()` and not ask the desktop's main
|
||||
* process to make a BrowserWindow: a window we didn't open gives us no handle to
|
||||
* its document, and without the handle there is nothing to adopt into. Electron
|
||||
* turns this same-origin `window.open()` into a real BrowserWindow anyway (see
|
||||
* main.ts's setWindowOpenHandler → `action: 'allow'`), and the main process
|
||||
* recognises it by its frame name and gives it remembered bounds, always-on-top
|
||||
* and the system tray. We get the OS window AND the DOM link.
|
||||
*
|
||||
* Styles come across too — the pane document starts empty, so we copy the app's
|
||||
* stylesheets into it. Without that the panel would land unstyled, which is the
|
||||
* one thing a "pop out exactly this" feature cannot do.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
@@ -25,38 +32,72 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// The desktop's main process finds a pane window by this name and attaches
|
||||
// bounds, tray and always-on-top to it. Keep it in sync with pane-hosts.ts.
|
||||
const FRAME_PREFIX = 'fbpane-';
|
||||
|
||||
const wins = new Map(); // paneId -> Window
|
||||
let reaper = null;
|
||||
|
||||
// A pane window the user closed with the OS X button never gets to say `bye`
|
||||
// reliably (a crashed renderer certainly doesn't). Poll `closed` and reap —
|
||||
// otherwise the pane stays "open" forever, its chip stays stubbed out, and
|
||||
// the user has no way back to their dialog. Same trick splitscreen uses.
|
||||
// A pane window the user closed with the OS X button gets no reliable
|
||||
// beforeunload (a crashed renderer certainly gets none). Poll `closed` and
|
||||
// reap — otherwise the pane stays "open" forever, its chip stays stubbed out,
|
||||
// and the element it holds is stranded in a dead document with no way back.
|
||||
function _startReaper() {
|
||||
if (reaper != null) return;
|
||||
reaper = setInterval(() => {
|
||||
wins.forEach((w, id) => {
|
||||
if (w.closed) panes.close(id); // → unmount() below clears the entry
|
||||
});
|
||||
wins.forEach((w, id) => { if (w.closed) panes.close(id); });
|
||||
if (!wins.size) { clearInterval(reaper); reaper = null; }
|
||||
}, 500);
|
||||
}, 400);
|
||||
}
|
||||
|
||||
function mount(spec) {
|
||||
const url = new URL(window.location.origin + '/pane');
|
||||
url.searchParams.set('pane', spec.id);
|
||||
// The pane realm loads its mount() from this URL. Everything else it
|
||||
// needs (title, state, the song) arrives in the snapshot — URL params
|
||||
// are open-time only and must never be a state channel.
|
||||
url.searchParams.set('script', spec.script);
|
||||
// Give the pane document the app's styles, so the panel looks identical.
|
||||
// Cloned rather than shared: a <link> node can only live in one document, and
|
||||
// we are not about to steal the app's own stylesheet out of its head.
|
||||
function _copyStyles(doc) {
|
||||
document.querySelectorAll('link[rel="stylesheet"], style').forEach((node) => {
|
||||
try { doc.head.appendChild(node.cloneNode(true)); } catch (e) { /* skip a node we can't clone */ }
|
||||
});
|
||||
// Carry the theme/scale hooks the app hangs on <html> and <body>. v3 keys
|
||||
// off these for its colour tokens and interface scale, and a panel that
|
||||
// lands without them renders in the wrong palette at the wrong size.
|
||||
try {
|
||||
doc.documentElement.className = document.documentElement.className;
|
||||
doc.documentElement.setAttribute('style', document.documentElement.getAttribute('style') || '');
|
||||
doc.body.className = document.body.className;
|
||||
} catch (e) { /* non-fatal */ }
|
||||
}
|
||||
|
||||
const w = window.open(url.toString(), 'fbpane-' + spec.id,
|
||||
'popup,width=' + spec.width + ',height=' + spec.height);
|
||||
function _adopt(w, spec, el) {
|
||||
const doc = w.document;
|
||||
_copyStyles(doc);
|
||||
|
||||
const root = doc.getElementById('fb-pane-root') || doc.body;
|
||||
// The panel was almost certainly a fixed/absolute overlay pinned to a
|
||||
// corner of the app. In a window of its own that positioning is nonsense —
|
||||
// it would sit 72px from the top of a 380px window, still 288px wide, still
|
||||
// casting a drop shadow over nothing. Neutralise the *placement* while
|
||||
// touching nothing else about how it looks.
|
||||
el.classList.add('fb-paned');
|
||||
// Some panels are hidden until opened (Camera Director's is `hidden` until
|
||||
// you click its launcher). It is being shown on purpose now.
|
||||
el.hidden = false;
|
||||
|
||||
root.appendChild(doc.adoptNode(el));
|
||||
doc.title = spec.title + ' — fee[dB]ack';
|
||||
}
|
||||
|
||||
function place(spec, el) {
|
||||
const w = window.open(
|
||||
window.location.origin + '/pane',
|
||||
FRAME_PREFIX + spec.id,
|
||||
'popup,width=' + spec.width + ',height=' + spec.height,
|
||||
);
|
||||
|
||||
if (!w) {
|
||||
// Popup blocked. Bail BEFORE the manager records anything, so the
|
||||
// caller's dialog stays exactly where it was — and say so out loud
|
||||
// rather than appearing to do nothing.
|
||||
// Popup blocked. Throw BEFORE the manager records anything, so the
|
||||
// caller's panel stays exactly where it is — and say so out loud rather
|
||||
// than appearing to do nothing.
|
||||
if (window.fbNotify) {
|
||||
window.fbNotify.show({
|
||||
title: 'Pop-out blocked',
|
||||
@@ -64,51 +105,78 @@
|
||||
icon: '⚠️', accent: '#f59e0b',
|
||||
});
|
||||
}
|
||||
return null;
|
||||
throw new Error('pop-up blocked');
|
||||
}
|
||||
|
||||
wins.set(spec.id, w);
|
||||
_startReaper();
|
||||
return w;
|
||||
|
||||
// The document may or may not have parsed yet. Both paths must work, and
|
||||
// must not run twice — a double adopt would move the element into the
|
||||
// window and then move it in again, firing the plugin's own observers for
|
||||
// no reason.
|
||||
let done = false;
|
||||
const go = () => {
|
||||
if (done || w.closed) return;
|
||||
done = true;
|
||||
try { _adopt(w, spec, el); }
|
||||
catch (e) {
|
||||
console.error('[panes] failed to move', spec.id, 'into its window', e);
|
||||
panes.close(spec.id); // returns the element home
|
||||
}
|
||||
};
|
||||
if (w.document && w.document.readyState === 'complete') go();
|
||||
else w.addEventListener('load', go, { once: true });
|
||||
|
||||
// The window is ours and must not outlive the document that owns the
|
||||
// element inside it.
|
||||
w.addEventListener('beforeunload', () => {
|
||||
// Only react to the user closing the window — not to us closing it
|
||||
// during a dock, which has already taken the element back.
|
||||
if (wins.get(spec.id) === w && panes.isOpen(spec.id)) setTimeout(() => panes.close(spec.id), 0);
|
||||
});
|
||||
}
|
||||
|
||||
function unmount(id) {
|
||||
function unplace(id, el) {
|
||||
// Hand the element back unmarked. The manager returns it to its home right
|
||||
// after this, and it must arrive as the plugin left it — a panel that
|
||||
// stayed .fb-paned would come back with its own positioning stripped.
|
||||
if (el) el.classList.remove('fb-paned');
|
||||
const w = wins.get(id);
|
||||
wins.delete(id);
|
||||
// Closing an already-closed window is a no-op, and closing one we opened
|
||||
// is always permitted (same-origin, script-opened).
|
||||
// The manager adopts the element back into this document immediately after
|
||||
// this returns, so the window is empty by the time it closes.
|
||||
if (w && !w.closed) { try { w.close(); } catch (e) { /* already gone */ } }
|
||||
}
|
||||
|
||||
function focus(id) {
|
||||
const w = wins.get(id);
|
||||
if (w && !w.closed) { try { w.focus(); } catch (e) { /* OS may refuse */ } }
|
||||
if (w && !w.closed) { try { w.focus(); } catch (e) { /* the OS may refuse */ } }
|
||||
}
|
||||
|
||||
// A BROWSER blocks window.open() outside a user gesture, so a pane remembered
|
||||
// here cannot be restored on page load — it would only ever produce a "blocked"
|
||||
// toast. Such a pane comes back in the dock, and the chip pops it out again on
|
||||
// the user's next click. The DESKTOP app has no such restriction, so there a
|
||||
// pane left popped out comes back popped out, where you left it.
|
||||
const isDesktop = !!(window.feedBackDesktop && window.feedBackDesktop.panes);
|
||||
|
||||
panes.registerHost({
|
||||
id: 'window',
|
||||
priority: 10,
|
||||
remote: true,
|
||||
// A browser blocks window.open() outside a user gesture, so a pane
|
||||
// remembered here cannot be restored on page load — it would only ever
|
||||
// produce a "pop-up blocked" toast. The manager brings it back in the dock
|
||||
// and the chip pops it out again on the user's next click. The desktop
|
||||
// host overrides this: it opens real BrowserWindows and needs no gesture.
|
||||
autoRestore: false,
|
||||
// No BroadcastChannel means no way to feed the pane once it's open. Better
|
||||
// to keep it docked than to open a window that renders forever-stale data.
|
||||
available: () => typeof BroadcastChannel === 'function',
|
||||
// A pane with no `script` exists only as a closure in this realm. There is
|
||||
// no honest way to move a closure across a window boundary, so decline it
|
||||
// and let the router fall back to the dock.
|
||||
canHost: (spec) => !!spec.script,
|
||||
mount, unmount, focus,
|
||||
autoRestore: isDesktop,
|
||||
place, unplace, focus,
|
||||
});
|
||||
|
||||
// The pane windows are ours; they must not outlive us. A pane window whose
|
||||
// main window is gone can never be fed again — leaving it on screen showing a
|
||||
// frozen playhead is worse than closing it.
|
||||
// Our windows; they must not outlive us. A pane window whose opener is gone
|
||||
// holds an element belonging to a dead document — there is nothing left to
|
||||
// dock it back into.
|
||||
window.addEventListener('beforeunload', () => {
|
||||
wins.forEach((w) => { if (!w.closed) { try { w.close(); } catch (e) { /* ignore */ } } });
|
||||
});
|
||||
|
||||
// Exposed for pane-desktop.js, which upgrades this host in place rather than
|
||||
// registering a competing one — the window still has to be opened HERE, by
|
||||
// window.open(), or there would be no document to adopt into.
|
||||
window.__fbPaneWindows = { FRAME_PREFIX, get: (id) => wins.get(id) || null };
|
||||
})();
|
||||
|
||||
+13
-17
@@ -3,26 +3,22 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Pane — fee[dB]ack</title>
|
||||
<title>fee[dB]ack</title>
|
||||
<link rel="icon" href="/static/assets/favicon.png">
|
||||
<!-- Deliberately minimal. This is NOT the app shell with a flag on it: no
|
||||
highway, no library, no v3 shell, no <audio>, no Tailwind. A pane window
|
||||
has nothing to hide, so it needs no anti-flash hack and boots in
|
||||
milliseconds. Panes needing utility classes ship their own stylesheet via
|
||||
the `styles` manifest key, exactly as they must in the main window. -->
|
||||
<!-- Deliberately almost empty.
|
||||
|
||||
This document does not build a pane; it RECEIVES one. The opener moves the
|
||||
real element in here with document.adoptNode() and copies the app's
|
||||
stylesheets across, so the panel arrives complete — its own markup, its own
|
||||
CSS, its own listeners, its own closures, still running the plugin's code
|
||||
back in the main window.
|
||||
|
||||
So there is nothing to load, nothing to boot, and nothing to keep in step
|
||||
with the app. Only panes.css, for the window chrome and the layout reset the
|
||||
adopted element needs. -->
|
||||
<link rel="stylesheet" href="/static/panes/panes.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="fb-pane-window-head">
|
||||
<span id="pane-title" class="fb-pane-window-title">fee[dB]ack</span>
|
||||
</header>
|
||||
<p id="pane-status" class="fb-pane-window-status">Connecting…</p>
|
||||
<main id="pane-root" class="fb-pane-window-body fb-selectable" hidden></main>
|
||||
|
||||
<!-- Order matters: the bridge defines the transport the runtime uses. Both are
|
||||
classic scripts, so the pane's own script (injected by the runtime) sees
|
||||
the same global scope it would in the main window. -->
|
||||
<script src="/static/panes/pane-bridge.js"></script>
|
||||
<script src="/static/panes/pane-runtime.js"></script>
|
||||
<main id="fb-pane-root"></main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+51
-118
@@ -1,20 +1,43 @@
|
||||
/* 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.
|
||||
* Hand-authored (not Tailwind-scanned) so a runtime-installed plugin gets 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.
|
||||
* z-index:100` covering the viewport. A dock below 100 is invisible on the one
|
||||
* screen panes exist for. Body-level ladder: #player 100 < dock 110 < toasts 120
|
||||
* < modals 200.
|
||||
*/
|
||||
|
||||
/* ── The popped-out element ──────────────────────────────────────────────────
|
||||
*
|
||||
* The single most important rule in this file.
|
||||
*
|
||||
* A plugin's panel is almost always a fixed overlay pinned to a corner of the app:
|
||||
* `position:fixed; top:72px; right:18px; width:288px; z-index:99999`, with a drop
|
||||
* shadow and a max-height sized against the viewport. Inside a dock card, or alone
|
||||
* in a 320px window, every one of those is wrong — it would float 72px down from
|
||||
* the top of its own window, still 288px wide, still casting a shadow over nothing.
|
||||
*
|
||||
* So we neutralise PLACEMENT and nothing else. Colours, borders, radius, padding,
|
||||
* fonts, the panel's own internal layout: all untouched, because the whole promise
|
||||
* of this feature is that what you popped out is what you get. */
|
||||
.fb-paned {
|
||||
position: static !important;
|
||||
inset: auto !important;
|
||||
margin: 0 !important;
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
z-index: auto !important;
|
||||
box-shadow: none !important;
|
||||
/* A panel hidden until its launcher is clicked is being shown on purpose now. */
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
/* ── The pop-out chip ────────────────────────────────────────────────────── */
|
||||
|
||||
.fb-pane-chip {
|
||||
@@ -24,7 +47,6 @@
|
||||
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);
|
||||
@@ -39,18 +61,14 @@
|
||||
border-color: #4080e0;
|
||||
background: rgba(64, 128, 224, .18);
|
||||
}
|
||||
.fb-pane-chip:focus-visible {
|
||||
outline: 2px solid #4080e0;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.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. */
|
||||
/* The panel, while its pane is popped out. A dedicated class rather than
|
||||
.hidden/[hidden]: the panels we attach to toggle those themselves, and two owners
|
||||
of one class is a bug waiting for a bad day. */
|
||||
.fb-pane-detached { display: none !important; }
|
||||
|
||||
/* What the user sees in the dialog's place. */
|
||||
/* What the user sees in the panel's place. */
|
||||
.fb-pane-stub {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -64,17 +82,14 @@
|
||||
cursor: pointer;
|
||||
transition: background .15s, border-color .15s;
|
||||
}
|
||||
.fb-pane-stub:hover {
|
||||
background: rgba(64, 128, 224, .18);
|
||||
border-color: #4080e0;
|
||||
}
|
||||
.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 */
|
||||
top: 4.5rem;
|
||||
right: 1rem;
|
||||
bottom: 1rem;
|
||||
z-index: 110; /* above #player (100), below toasts (120) */
|
||||
@@ -85,9 +100,8 @@
|
||||
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. */
|
||||
/* A frame around cards, not a surface — the empty space below them must never
|
||||
eat a click meant for the highway. */
|
||||
pointer-events: none;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
@@ -141,13 +155,9 @@
|
||||
.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;
|
||||
}
|
||||
.fb-pane-card-body { overflow: auto; }
|
||||
|
||||
/* focus(id) — brief highlight so a re-open of an already-open pane says so
|
||||
/* focus(id) — a brief highlight, so re-opening 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 {
|
||||
@@ -158,9 +168,10 @@
|
||||
.fb-pane-card.is-flash { animation: none; }
|
||||
}
|
||||
|
||||
/* ── The pop-out window (static/panes/pane.html) ─────────────────────────── */
|
||||
/* This document loads no Tailwind and no app stylesheet — it is the whole page,
|
||||
so it carries its own reset. Keep it tiny; a pane window must boot instantly. */
|
||||
/* ── The pop-out window (static/panes/pane.html) ─────────────────────────────
|
||||
*
|
||||
* The window's own chrome — everything INSIDE it is the adopted element, styled by
|
||||
* the app's stylesheets, which the host copies into this document. */
|
||||
|
||||
html.fb-pane-window,
|
||||
html.fb-pane-window body {
|
||||
@@ -168,88 +179,10 @@ html.fb-pane-window body {
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
background: #0f172a;
|
||||
color: #cbd5e1;
|
||||
font-family: Inter, system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
font-size: 14px;
|
||||
-webkit-user-select: none;
|
||||
user-select: none; /* chrome isn't selectable; .fb-selectable opts content back in */
|
||||
}
|
||||
html.fb-pane-window body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.fb-pane-window-head {
|
||||
flex: 0 0 auto;
|
||||
padding: .55rem .8rem;
|
||||
border-bottom: 1px solid rgba(51, 65, 85, .6);
|
||||
background: rgba(30, 41, 59, .6);
|
||||
-webkit-app-region: drag; /* the desktop host opens this frameless */
|
||||
}
|
||||
.fb-pane-window-title {
|
||||
font-size: .8rem;
|
||||
font-weight: 600;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.fb-pane-window-status {
|
||||
margin: 0;
|
||||
padding: 1.25rem;
|
||||
color: #64748b;
|
||||
font-size: .8rem;
|
||||
text-align: center;
|
||||
}
|
||||
.fb-pane-window-body {
|
||||
html.fb-pane-window body { display: flex; flex-direction: column; overflow: hidden; }
|
||||
#fb-pane-root {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
padding: .85rem;
|
||||
overflow: auto;
|
||||
padding: .6rem;
|
||||
}
|
||||
|
||||
/* Core CSS defines this for the app; a pane window loads no app CSS, so repeat
|
||||
it here. Same rule, same intent: copy-worthy text stays copyable. */
|
||||
.fb-selectable, .fb-selectable * { -webkit-user-select: text; user-select: text; }
|
||||
|
||||
/* ── 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); }
|
||||
|
||||
Reference in New Issue
Block a user