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:
topkoa
2026-07-12 18:12:46 -04:00
parent 330995588c
commit 188bdaa837
18 changed files with 518 additions and 2295 deletions
+104 -206
View File
@@ -1,249 +1,147 @@
# Detachable panes (`window.feedBack.panes`) # Detachable panes (`window.feedBack.panes`)
A **pane** is live UI that stays put: a mixer, a camera rig, a readout, a settings Pop a panel out of the app into its own OS window, and leave it there: while you
board. You author it once, and the host decides where it lives — docked beside the play, across song switches, on a second monitor, minimized to the system tray.
player, or popped out into its own OS window that remembers where you put it and
minimizes to the system tray.
Panes exist because the player's rail popovers are **exclusive**: opening one closes Panes exist because the player's rail popovers are **exclusive** opening one
the last. You cannot watch the mixer while riding the camera, and both vanish the closes the last. You cannot watch the mixer while riding the camera, and both
moment you want to look at the highway. Panes are non-exclusive, and they survive vanish the moment you want to look at the highway.
song switches.
--- ---
## The two-line version ## The whole idea, in one sentence
If your plugin already has a dialog, give it a pop-out chip: **We move the real element.**
Not a copy of your panel. 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 your panel goes on
running *your* code, against *your* state, in *your* realm. The app's stylesheets
are copied into the pane window, so it looks identical too.
What you popped out is what you get. That is the promise, and it is the reason
there is no `ctx`, no state mirroring, no cross-window RPC and no second copy of
your UI to keep in step with the first. Those are all solutions to a problem we
simply do not have.
---
## Adding a pane to your plugin
Two lines.
```js ```js
feedBack.panes.register({ feedBack.panes.register({
id: 'camera_director', id: 'camera_director',
title: 'Camera Director', title: 'Camera Director',
icon: '🎥', icon: '🎥',
mount(root, ctx) { root.appendChild(buildCameraUI(ctx)); }, // your existing builder element: () => panelEl, // your existing panel, as it is
unmount(root) { root.replaceChildren(); },
}); });
feedBack.panes.attachChip(myDialogEl, 'camera_director'); feedBack.panes.attachChip(panelEl, 'camera_director');
``` ```
`attachChip()` injects **the** standard ⇱ button — same glyph, same place, same `attachChip()` injects **the** standard pop-out chip (`⇱`) — same glyph, same
behaviour everywhere. Clicking it opens the pane in its host and **hides your place, same behaviour in every plugin. Clicking it moves your panel into a window
dialog**, leaving a "⇲ … is popped out" stub in its place. Closing the pane and leaves a "⇲ … is popped out" stub in its place; clicking the stub brings it
un-hides your dialog and restores the chip. back, to exactly the spot it left. Core owns the chip, the hiding and the stub, so
you write no show/hide logic.
**You write no show/hide logic.** Core owns it, so that every plugin's pop-out That's it. Your sliders, your presets, your tabs, your CSS, your event handlers,
behaves identically — which is the entire point. your state — all of it comes along, because none of it moved anywhere except into
a different window's document.
--- ### `element` is a function for a reason
## The one rule It is resolved at open time, not at registration. Plugins commonly build their
panel lazily on first use, or rebuild it wholesale when something changes (Camera
Director rebuilds its panel on every mode change). Asking for it when we need it
means we always move the live one.
> **`mount(root, ctx)` runs in a realm that may not have the app in it.** ### The one thing core changes about your element
Docked, your pane runs in the main window with everything present. Popped out, it `.fb-paned` is added while the pane is out, and it neutralises **placement only**:
runs in a **separate JS realm** — a different window, with no `window.highway`, no
`window.feedBack.capabilities`, no `<audio>` element, and no audio graph. Same file,
same `mount()`, different world.
So: **everything your pane touches must come through `ctx`.** A pane that reaches ```css
for a global works docked and silently dies popped out. There is no way for core to position: static; inset: auto; width: 100%;
paper over that, because a closure cannot cross a window boundary. max-width: none; max-height: none; z-index: auto; box-shadow: none;
---
## `ctx`
| | |
|---|---|
| `ctx.call(domain, command, payload)``Promise` | The capability bus. This is your only door into app services. Core builds the requester/origin/timeout envelope; you send a payload. Cross-realm calls have a 10 s deadline and reject with `PaneRpcTimeout`. |
| `ctx.on(event, fn)``unsub` | The `feedBack` bus. Handlers get a `{ detail }` object, exactly as in the main window. Only allow-listed events are mirrored (see below). |
| `ctx.subscribe(stream, fn)``unsub` | High-rate numerics: `'playhead'``{ t, duration, playing }`, `'meters'``{ master }`. |
| `ctx.playhead()``number` | The current time in seconds, smoothed. **Use this, not a stream, for per-frame drawing** — see "The clock" below. |
| `ctx.state.get(path)` / `.set(path, value)` / `.subscribe(fn)` | A dotted-path store, persisted per pane. The **main realm is authoritative**. |
| `ctx.song()` | The current `feedBack.currentSong`, or `null`. |
| `ctx.toast(opts)`, `ctx.close()` | |
| `ctx.paneId`, `ctx.host`, `ctx.isRemote` | |
**`ctx` tracks every subscription it hands you and drops them on unmount.** You
cannot leak a listener across a dock/undock cycle even if you try. Your `unmount()`
only needs to clear your own DOM.
### Events mirrored by default
`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`.
Widen with `spec.events: ['audio-mix:fader-value-changed', …]`.
**`song:position-changed` is deliberately not on the list.** It fires every 250 ms —
too coarse to animate with, too chatty to mirror across a window. Use the `playhead`
stream or `ctx.playhead()`.
---
## The 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
backgrounded while the user is looking at your pane. A pane that renders the raw
broadcast stutters at 1 fps.
`ctx.playhead()` solves this: it extrapolates between broadcasts
(`anchor + observedRate × elapsed`, capped at 2 s). The rate is *learned from the
broadcasts themselves*, so it tracks the speed slider without being told, and a dead
main window decays into a frozen clock rather than one that confidently runs away.
**Draw from `ctx.playhead()` in your own rAF loop. Subscribe to `'playhead'` only
for things that change slowly** (a time readout, a progress bar).
---
## Levels, and why they are numbers
An `AnalyserNode` **cannot cross a window boundary.** Your pane can never hold one.
So core samples the analyser in the realm that owns the audio graph and ships you
plain numbers over `ctx.subscribe('meters', …)`. The stream stays **silent** when
there is no analyser (no stems plugin) rather than reporting zeros — so "silent" and
"actually silent" stay distinguishable, and you can render an honest "unavailable"
state.
---
## Declaring a pane in `plugin.json` (preferred)
```json
"panes": [{
"id": "camera_director",
"title": "Camera Director",
"icon": "🎥",
"script": "panes/camera.js",
"defaultHost": "window",
"mirrorGlobal": "__h3dCamCtl",
"width": 380,
"height": 560
}]
``` ```
Declaring it beats calling `panes.register()` from `screen.js`, because a Your panel was almost certainly a fixed overlay pinned to a corner of the app
manifest pane is **openable from the rail and the tray without your plugin's screen (`position:fixed; top:72px; right:18px; width:288px`). Alone in its own window,
ever having been visited**. Core registers a stub and fetches the script only when every one of those is wrong — it would float 72px down from the top of a 380px
the user opens it. (A pane you can only reach by first navigating to the screen it window, still 288px wide, still casting a shadow over nothing. Colours, borders,
was meant to replace is not much of a pane.) radius, padding, fonts and your panel's own internal layout are untouched.
`script` is a relpath under your plugin's `src/`, served through the sandboxed The class is removed the moment the element goes home.
`/api/plugins/<id>/src/…` route. It sets a factory global — the same shape the viz
contract already uses: ---
## Spec
```js ```js
window.feedBackPane_camera_director = { feedBack.panes.register({
mount(root, ctx) { }, id, // required, unique
unmount(root, ctx) { }, element, // required — an Element, or a function returning one
}; title, // shown in the pane window's title bar, the dock card, the tray
``` icon, // one glyph, for the dock/tray/launcher lists
width, height, // the pane window's initial size (it remembers yours after that)
**This exact file is what a pop-out window loads in its own realm.** Write it to the defaultHost, // 'window' (default) or 'dock'
one rule above. onHost, // optional (hostId | null, el) => void — re-measure/re-anchor
---
## Driving a pane from the main realm — `panes.state(id)`
Most plugins with a pane are the **authority** over what the pane controls: they
clamp values, persist presets, emit events, and own the audio graph or the camera
rig. Such a plugin should not have core splat the pane's values somewhere — it
should *apply them itself*.
`panes.state(id)` is the main realm's handle on an open pane's store:
```js
feedBack.on('panes:opened', (e) => {
if (e.detail.id !== 'camera_director') return;
const state = feedBack.panes.state('camera_director');
// Seed it, so the pane opens showing the live camera, not defaults.
AXES.forEach((k) => state.set(k, myApi.getAxis(k)));
// …and apply whatever the pane sends back, through your own API — which
// clamps, persists, and tells the rest of your plugin.
state.subscribe((all, change) => {
if (!change) return;
myApi.setAxis(change.path, change.value);
});
}); });
``` ```
The pane stays realm-agnostic (it only ever touches `ctx.state`), and your plugin ```js
remains the single source of truth. **Every write to the store is broadcast to the feedBack.panes.attachChip(el, paneId, { header }) // → detach()
pane window**, whichever realm made it — so a value your code clamps or corrects feedBack.panes.open(id, { host }) / close(id) / detach(id) / dock(id) / focus(id)
shows up in the pane immediately, and there is exactly one way state reaches a pane. feedBack.panes.isOpen(id) / hostOf(id) / get(id) / list()
```
Guard against a write you just made coming straight back (compare against your `attachChip` puts the chip in `el.querySelector('[data-pane-header]')` if it finds
current value before applying), or a clamp will ping-pong. one, or in the `header` element you pass, or at the top of `el` otherwise.
Returns `null` when the pane is closed.
## `mirrorGlobal` — for panes that drive a plain global
The 3D highways read their free camera from `window.__h3dCamCtl` once per frame.
A camera panel in the main window just writes that object. A panel in a **pop-out
window cannot** — `window.__h3dCamCtl` there is a different object in a different
realm, and writing it moves nothing.
Declare `mirrorGlobal: '__h3dCamCtl'` and core copies your pane's state onto that
global, **in the main realm, mutating the object in place** (a renderer may be
holding the reference). You write `ctx.state.set('yaw', 0.3)`; the renderer keeps
reading the plain global it always read; `highway_3d` is not modified and does not
know panes exist.
---
## Rules that will bite you
1. **Never cache the renderer or the canvas.** `playSong()` calls `highway.stop()`
`destroy()`. Re-bind on `song:ready` / `highway:canvas-replaced` /
`highway:visibility`.
2. **Module top-level does not re-run** on screen re-entry (see
[plugin-modules.md](plugin-modules.md)). Per-visit init belongs in `mount()`.
3. **No per-frame `querySelector`.** Panes share the main thread with a 60 fps render
loop. Resolve refs in `mount()`, cache them. (See the perf rules in
[CLAUDE.md](../CLAUDE.md).)
4. **A pane with no `script` can only ever be docked.** It exists solely as a closure
in the main realm, and there is no honest way to move a closure across a window
boundary — the window host declines it and the router falls back to the dock.
5. **`localStorage` is shared with your pop-out window** (same origin). Use
`ctx.state`, which has exactly one writer.
6. **Persistence is on by default** (`persist: false` to opt out). State is keyed by
pane id.
--- ---
## Hosts ## Hosts
`panes.detach(id)` opens a pane in the best available host: `detach(id)` puts a pane in the best host available:
| host | priority | | | host | | |
|---|---|---| |---|---|---|
| `desktop` | 20 | A real Electron `BrowserWindow`. Remembered geometry, always-on-top, system tray. | | `window` | 10 | A real OS window. In the desktop app: remembered bounds, always-on-top, system tray. |
| `window` | 10 | A browser pop-up (`window.open`). No tray, no remembered bounds. | | `dock` | 0 | A card in the in-window stack. **The floor** — always available, so opening a pane can never fail. |
| `dock` | 0 | The in-window card stack. **The floor** — always available, so a pane can never fail to open. |
You do not choose; you declare `defaultHost` and the router does the rest. A pane You don't pick; you declare `defaultHost` and the router does the rest.
popped out in the desktop app comes back popped out on next launch; in a browser it
comes back **docked**, because a browser blocks `window.open()` without a user
gesture and a "pop-up blocked" toast on every page load would be worse than useless.
## API In the **desktop app** a pane you left popped out comes back popped out on next
launch. In a **browser** it comes back **docked** — a browser blocks
`window.open()` without a user gesture, so restoring it would only ever produce a
"pop-up blocked" toast. The chip pops it out again on your next click.
```js ---
feedBack.panes.register(spec) -> unregister
feedBack.panes.attachChip(el, paneId, { header }) -> detach
feedBack.panes.open(id, { host }) / close(id) / detach(id) / dock(id) / focus(id)
feedBack.panes.isOpen(id) / hostOf(id) / get(id) / list()
```
`spec`: `{ id*, title, icon, mount*, unmount, script, events[], persist, initialState, ## Things worth knowing
defaultHost, mirrorGlobal, width, height }`.
1. **Your code still runs in the main window.** The element is displayed in the
pane window, but its closures, its timers and its `document` references all
still belong to the main realm. That is exactly why everything keeps working —
but it means a `document.body.appendChild()` inside your panel (a tooltip, a
popover) lands in the **main** window, not the pane. Anchor such things to the
panel itself, not to `document.body`.
2. **Chromium throttles a backgrounded window's `requestAnimationFrame`.** While
the user is looking at your pane, the main window may be in the background —
and your rAF lives there. Event-driven panels (sliders, buttons, presets) are
unaffected. A panel that *animates* continuously may run slowly while it's the
only thing you're looking at.
3. **The element goes home exactly where it came from** — same parent, same
position among its siblings. Don't move it yourself while it's popped out.
4. **A pane the user closed with the window's X button is reaped** (a crashed
renderer never gets to say goodbye), and your element is docked back. Without
that, your panel would be stranded in a dead document with no way back.
5. **Nothing here is required.** On a host without the panes API, `feedBack.panes`
is undefined, you skip both calls, and your panel behaves exactly as it does
today.
-78
View File
@@ -277,66 +277,6 @@ def _normalize_string_list(value) -> list[str]:
return [stripped for item in value if isinstance(item, str) and (stripped := item.strip())] return [stripped for item in value if isinstance(item, str) and (stripped := item.strip())]
def _normalize_panes(value, plugin_id: str) -> list[dict]:
"""Validate a manifest's `panes` list (detachable panes — window.feedBack.panes).
Each entry: {id, title, icon?, script, defaultHost?, mirrorGlobal?, width?, height?}.
`script` is a relpath served through the sandboxed /api/plugins/<id>/src/
route, so it MUST live under the plugin's src/ tree — the same containment
rule `styles` has for assets/. A pane whose script could escape that route
would be an arbitrary-file-read dressed up as a manifest key.
A bad entry is dropped with a warning rather than failing the whole plugin:
one malformed pane should not cost the user their working plugin.
"""
if not isinstance(value, list):
return []
out: list[dict] = []
seen: set[str] = set()
for entry in value:
if not isinstance(entry, dict):
log.warning("[Plugin] %s: `panes` entry is not an object, skipping", plugin_id)
continue
pane_id = entry.get("id")
script = entry.get("script")
if not isinstance(pane_id, str) or not pane_id.strip():
log.warning("[Plugin] %s: pane is missing `id`, skipping", plugin_id)
continue
pane_id = pane_id.strip()
if not isinstance(script, str) or not script.strip():
log.warning("[Plugin] %s: pane %r is missing `script`, skipping", plugin_id, pane_id)
continue
script = script.strip().lstrip("/")
# Reject anything that could climb out of src/: absolute paths, drive
# letters, backslashes, and .. segments — the same checks settings.server_files
# applies to its relpaths.
if (
"\\" in script
or ".." in script.split("/")
or ":" in script
or not script.endswith(".js")
):
log.warning("[Plugin] %s: pane %r has an unsafe `script` %r, skipping", plugin_id, pane_id, script)
continue
if pane_id in seen:
log.warning("[Plugin] %s: duplicate pane id %r, skipping", plugin_id, pane_id)
continue
seen.add(pane_id)
pane = {
"id": pane_id,
"title": entry.get("title") if isinstance(entry.get("title"), str) else pane_id,
"icon": entry.get("icon") if isinstance(entry.get("icon"), str) else None,
"script": script,
"defaultHost": entry.get("defaultHost") if entry.get("defaultHost") in ("window", "dock") else None,
"mirrorGlobal": entry.get("mirrorGlobal") if isinstance(entry.get("mirrorGlobal"), str) else None,
"width": entry.get("width") if isinstance(entry.get("width"), int) else None,
"height": entry.get("height") if isinstance(entry.get("height"), int) else None,
}
out.append(pane)
return out
def _normalize_manifest_mapping(value) -> dict: def _normalize_manifest_mapping(value) -> dict:
return value if isinstance(value, dict) else {} return value if isinstance(value, dict) else {}
@@ -1502,14 +1442,6 @@ def load_plugins(app: FastAPI, context: dict, progress_cb=None, route_setup_fn=N
# client can build the asset URL without a second manifest read. # client can build the asset URL without a second manifest read.
"has_styles": bool(manifest.get("styles")), "has_styles": bool(manifest.get("styles")),
"styles": manifest.get("styles"), "styles": manifest.get("styles"),
# Detachable panes (window.feedBack.panes). Declaring them in the
# manifest — rather than calling panes.register() from screen.js —
# is what lets a pane be OPENED FROM THE TRAY OR THE RAIL WITHOUT THE
# PLUGIN'S SCREEN EVER HAVING BEEN VISITED: the host registers a stub
# from this projection and only fetches the pane's script when the
# user actually opens it. Validated (id + script required, script
# sandboxed under src/) so one bad entry can't take the list down.
"panes": _normalize_panes(manifest.get("panes"), plugin_id),
"standards": _normalize_string_list(manifest.get("standards")), "standards": _normalize_string_list(manifest.get("standards")),
"capabilities": _validated_capabilities, "capabilities": _validated_capabilities,
"capability_validation_warnings": _capability_validation_warnings, "capability_validation_warnings": _capability_validation_warnings,
@@ -2232,11 +2164,6 @@ def register_plugin_api(app: FastAPI):
# _nav_entry() at discovery and carried through graduation. # _nav_entry() at discovery and carried through graduation.
# The `.get()` fallbacks keep stubbed test entries (which build # The `.get()` fallbacks keep stubbed test entries (which build
# rows directly without going through _nav_entry) working. # rows directly without going through _nav_entry) working.
# Detachable panes. Key-presence check (not truthiness) like
# `capabilities` below: _nav_entry() may legitimately have
# validated the list down to empty, and an `or` would wrongly
# re-derive the unvalidated manifest value.
"panes": p["panes"] if "panes" in p else _normalize_panes((p.get("_manifest") or {}).get("panes"), p.get("id", "plugin")),
"standards": p.get("standards") or _normalize_string_list((p.get("_manifest") or {}).get("standards")), "standards": p.get("standards") or _normalize_string_list((p.get("_manifest") or {}).get("standards")),
"capabilities": p["capabilities"] if "capabilities" in p else _normalize_manifest_mapping((p.get("_manifest") or {}).get("capabilities")), "capabilities": p["capabilities"] if "capabilities" in p else _normalize_manifest_mapping((p.get("_manifest") or {}).get("capabilities")),
"capability_validation_warnings": p.get("capability_validation_warnings", []), "capability_validation_warnings": p.get("capability_validation_warnings", []),
@@ -2284,11 +2211,6 @@ def register_plugin_api(app: FastAPI):
"has_tour": e.get("has_tour", False), "has_tour": e.get("has_tour", False),
"has_styles": e.get("has_styles", False), "has_styles": e.get("has_styles", False),
"styles": e.get("styles"), "styles": e.get("styles"),
# Pending entries come from _nav_entry() too, so their panes are
# already validated. Surfacing them here is what lets a pane be
# opened while its plugin is still installing its dependencies —
# the pane's script is fetched on open, not now.
"panes": e.get("panes", []),
# Pending entries are built from _nav_entry() too, so they # Pending entries are built from _nav_entry() too, so they
# carry the same capability-pipelines.v1 metadata — surface it # carry the same capability-pipelines.v1 metadata — surface it
# so the Inspector can show still-installing plugins. # so the Inspector can show still-installing plugins.
-162
View File
@@ -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();
})();
-113
View File
@@ -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();
},
});
})();
-398
View File
@@ -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,
};
})();
-108
View File
@@ -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();
})();
+47
View File
@@ -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
View File
@@ -1,18 +1,19 @@
/* /*
* fee[dB]ack — pane dock (the in-window pane host). * fee[dB]ack — pane dock (the in-window pane host).
* *
* A right-edge stack of cards, one per open pane. Deliberately NOT a rail * A right-edge stack of cards, one per open pane. Deliberately NOT a rail popover:
* popover: the rail is exclusive (player-chrome.js openPopFor closes the last * the rail is exclusive (player-chrome.js's openPopFor closes the last one before
* one before opening the next), which is precisely why you can't watch the * opening the next), which is exactly why you cannot watch the mixer while riding
* mixer while riding the camera. Cards here coexist. * the camera. Cards here coexist.
* *
* Song-switch survival is structural, not defended. `#fb-pane-dock` is appended * As everywhere in this system, the card holds the plugin's REAL element — moved,
* to <body>, outside every `.screen`, so the per-song teardown never sees it — * not copied. The dock is a frame; the panel inside it is the panel.
* and `playSong()` ends in `showScreen('player')`, whose id === 'player' short-
* circuits the teardown branch anyway. Nothing to reset, nothing to re-mount.
* *
* Registers itself as the `dock` host at priority 0 — the floor. Whatever else * Song-switch survival is structural, not defended: #fb-pane-dock is a <body>
* exists (an OS pane window), a pane can always land here. * 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 () { (function () {
'use strict'; 'use strict';
@@ -41,11 +42,10 @@
} }
function _syncEmpty() { function _syncEmpty() {
const d = dock(); dock().classList.toggle('is-empty', cards.size === 0);
d.classList.toggle('is-empty', cards.size === 0);
} }
function mount(spec) { function place(spec, el) {
const card = document.createElement('section'); const card = document.createElement('section');
card.className = 'fb-pane-card'; card.className = 'fb-pane-card';
card.dataset.paneId = spec.id; card.dataset.paneId = spec.id;
@@ -56,8 +56,7 @@
const title = document.createElement('span'); const title = document.createElement('span');
title.className = 'fb-pane-card-title'; title.className = 'fb-pane-card-title';
// textContent, not innerHTML — a pane title can come from a plugin // textContent, not innerHTML — a pane title comes from a plugin.
// manifest, i.e. from outside core.
title.textContent = spec.icon + ' ' + spec.title; title.textContent = spec.icon + ' ' + spec.title;
const close = document.createElement('button'); const close = document.createElement('button');
@@ -72,20 +71,27 @@
head.appendChild(close); head.appendChild(close);
const body = document.createElement('div'); 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(head);
card.appendChild(body); card.appendChild(body);
dock().appendChild(card); dock().appendChild(card);
cards.set(spec.id, card); cards.set(spec.id, card);
_syncEmpty(); _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); const card = cards.get(id);
if (card) card.remove(); if (card) card.remove();
cards.delete(id); cards.delete(id);
@@ -96,13 +102,13 @@
const card = cards.get(id); const card = cards.get(id);
if (!card) return; if (!card) return;
card.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); card.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
// Re-trigger the flash even if the class is already there (repeat focus // Re-trigger the flash even if the class is still there repeat focus of
// of the same card would otherwise be a no-op animation). // the same card would otherwise be a no-op animation.
card.classList.remove('is-flash'); card.classList.remove('is-flash');
void card.offsetWidth; void card.offsetWidth;
card.classList.add('is-flash'); card.classList.add('is-flash');
setTimeout(() => card.classList.remove('is-flash'), 700); 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 });
})(); })();
-236
View File
@@ -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
View File
@@ -1,43 +1,49 @@
/* /*
* fee[dB]ack — pane manager. * 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 * A "pane" is a piece of UI a plugin already has — a mixer panel, a camera rig,
* once as `mount(root, ctx)` and mountable into any *host*: the in-window dock * a settings board — that the user can pop out into its own OS window and leave
* today, a pop-out OS window later. The manager owns which pane is open and * open: while they play, across song switches, on a second monitor, minimized to
* where; hosts own the chrome; the pane owns nothing but its own DOM. * the tray.
* *
* The problem this exists to solve: the player's rail popovers are exclusive * The whole design is one sentence: WE MOVE THE REAL ELEMENT.
* (opening one closes the last), so you cannot watch the mixer while riding the
* camera — and both vanish the moment you want to look at the highway. Panes
* are non-exclusive by construction and survive song switches, because nothing
* about them is tied to the per-song teardown.
* *
* Hosts register themselves; the manager never imports one. That is what lets * Not a copy of it, not a re-implementation of it in the pop-out window — the
* the pop-out window host drop in later without this file changing. * 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 () { (function () {
'use strict'; '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 HOSTS_KEY = 'fbPaneHosts'; // { paneId: hostId } — panes open at last unload
const STATE_KEY = (id) => 'fbPane:' + id;
// id -> normalized spec // id -> normalized spec
const specs = new Map(); const specs = new Map();
// id -> { spec, hostId, root, ctx, state } // id -> { spec, hostId, el, home: { parent, next } }
const open = new Map(); const open = new Map();
// hostId -> host provider // hostId -> host provider
const hosts = new Map(); const hosts = new Map();
// ── Persistence ────────────────────────────────────────────────────────── // ── Persistence ──────────────────────────────────────────────────────────
// localStorage is shared with any pop-out realm (same origin), so a // Only which pane was open, and where. A pane's CONTENTS are the plugin's own
// concurrent writer there would silently clobber us. The rule, enforced by // DOM and the plugin's own state — none of our business.
// this file being main-realm-only: THE MAIN REALM IS THE ONLY WRITER. A pane
// asks; the manager writes.
function _readJSON(key, fallback) { function _readJSON(key, fallback) {
try { try {
@@ -48,71 +54,48 @@
function _writeJSON(key, value) { function _writeJSON(key, value) {
try { localStorage.setItem(key, JSON.stringify(value)); } catch (e) { /* quota / private mode: non-fatal */ } try { localStorage.setItem(key, JSON.stringify(value)); } catch (e) { /* quota / private mode: non-fatal */ }
} }
function _rememberHost(id, hostId) { function _rememberHost(id, hostId) {
const map = _readJSON(HOSTS_KEY, {}); const map = _readJSON(HOSTS_KEY, {});
if (hostId) map[id] = hostId; else delete map[id]; if (hostId) map[id] = hostId; else delete map[id];
_writeJSON(HOSTS_KEY, map); _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 ───────────────────────────────────────────────────────────────── // ── Spec ─────────────────────────────────────────────────────────────────
function _normalize(spec) { function _normalize(spec) {
if (!spec || typeof spec !== 'object') throw new TypeError('panes.register: spec must be an object'); 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 (!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 { return {
id: spec.id, id: spec.id,
title: spec.title || spec.id, title: spec.title || spec.id,
icon: spec.icon || '▣', icon: spec.icon || '▣',
mount: spec.mount, // Resolved lazily: a plugin often builds its panel on first use, so the
unmount: typeof spec.unmount === 'function' ? spec.unmount : null, // element may not exist at registration time — and it may be rebuilt
// Bus events mirrored into a pop-out realm for this pane. Docked, ctx.on() // later (Camera Director rebuilds its panel on every mode change).
// reaches the real bus regardless — this list only matters once the // Asking for it at open time means we always move the live one.
// pane is in another realm, and it is declared here so it is the same element: typeof spec.element === 'function' ? spec.element : () => spec.element,
// 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
width: spec.width || 380, width: spec.width || 380,
height: spec.height || 560, 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 ───────────────────────────────────────────────────────── // ── 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); const wanted = hosts.get(preferred);
if (wanted && wanted.available() && wanted.canHost(spec)) return wanted; if (wanted && wanted.available()) return wanted;
// Fall back to the best host that IS available and WILL take this pane, // Fall back to the best available host. The dock registers at priority 0
// preferring the highest priority. The dock registers at priority 0 and // and is always available, so a pane can never fail to open.
// 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.
let best = null; let best = null;
hosts.forEach((h) => { hosts.forEach((h) => {
if (!h.available() || !h.canHost(spec)) return; if (!h.available()) return;
if (!best || h.priority > best.priority) best = h; if (!best || h.priority > best.priority) best = h;
}); });
return best; return best;
@@ -123,14 +106,101 @@
if (bus && typeof bus.emit === 'function') bus.emit(name, detail); 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) { function register(spec) {
const s = _normalize(spec); const s = _normalize(spec);
if (specs.has(s.id)) { if (specs.has(s.id)) {
// First registration wins, matching libraryCardActions.register. A // First registration wins, matching libraryCardActions.register. A
// silent overwrite would let a re-injected plugin script swap the // silent overwrite would swap the element out from under an open pane.
// mount function out from under an already-open pane.
console.warn('[panes] pane already registered, ignoring:', s.id); console.warn('[panes] pane already registered, ignoring:', s.id);
return () => {}; return () => {};
} }
@@ -138,14 +208,13 @@
_emit('panes:registered', { id: s.id, title: s.title }); _emit('panes:registered', { id: s.id, title: s.title });
// Reopen where the user left it. Deferred a tick so a plugin can call // 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 // register() and attachChip() back to back — the chip must exist before
// the pane opens or it has nothing to hide. // the pane opens, or it has nothing to hide.
// //
// A host may refuse to be auto-restored: a browser blocks window.open() // 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 // 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 // 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. // 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]; let remembered = _readJSON(HOSTS_KEY, {})[s.id];
if (remembered) { if (remembered) {
const h = hosts.get(remembered); const h = hosts.get(remembered);
@@ -162,201 +231,36 @@
_emit('panes:unregistered', { id: id }); _emit('panes:unregistered', { id: id });
} }
function openPane(id, opts) {
opts = opts || {};
const spec = specs.get(id);
if (!spec) { console.warn('[panes] open: no such pane:', id); return false; }
if (open.has(id)) { focusPane(id); return true; }
const host = _resolveHost(opts.host || spec.defaultHost, 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) { function registerHost(host) {
if (!host || !host.id) throw new TypeError('panes: host needs an id'); if (!host || !host.id) throw new TypeError('panes: host needs an id');
hosts.set(host.id, { hosts.set(host.id, {
id: host.id, id: host.id,
priority: host.priority || 0, 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, autoRestore: host.autoRestore !== false,
available: typeof host.available === 'function' ? host.available : () => true, available: typeof host.available === 'function' ? host.available : () => true,
canHost: typeof host.canHost === 'function' ? host.canHost : () => true, place: host.place,
mount: host.mount, unplace: host.unplace,
unmount: host.unmount,
focus: host.focus, focus: host.focus,
}); });
} }
const api = { const api = {
version: 1, version: 2,
register, register,
unregister, unregister,
open: openPane, open: openPane,
close: closePane, close: closePane,
move: movePane,
focus: focusPane,
detach, detach,
dock, dock,
focus: focusPane,
isOpen: (id) => open.has(id), isOpen: (id) => open.has(id),
hostOf: (id) => { const e = open.get(id); return e ? e.hostId : null; }, hostOf: (id) => { const e = open.get(id); return e ? e.hostId : null; },
get: (id) => specs.get(id) || 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 })), list: () => Array.from(specs.values()).map((s) => ({
// Host registration is host-internal, but it lives on the same object so id: s.id, title: s.title, icon: s.icon,
// a future out-of-tree host (a plugin shipping its own window shell) can open: open.has(s.id), host: (open.get(s.id) || {}).hostId || null,
// participate without a private import. })),
registerHost, 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 || {}; window.feedBack = window.feedBack || {};
-80
View File
@@ -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.
});
})();
-122
View File
@@ -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));
})();
-181
View File
@@ -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);
})();
-137
View File
@@ -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) };
})();
+125 -57
View File
@@ -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 * Opens a real OS window and MOVES THE PANE'S ELEMENT INTO IT.
* it over BroadcastChannel. Registers as the `window` host at priority 10, so it
* outranks the dock and `panes.detach()` prefers it.
* *
* This is the BROWSER implementation: a plain same-origin `window.open()`, which * The move is the whole trick, and it works because the pane window is same-origin
* is what the splitscreen follower has done for years. Electron already permits * and opener-linked: `document.adoptNode()` re-parents a live node into another
* it — main.ts's setWindowOpenHandler returns `action: 'allow'` for same-origin * window's document, and an adopted node keeps its event listeners, its closures,
* URLs, and that is load-bearing: `deny` would push the URL to the system * and every reference anything else holds to it. So the plugin's panel goes on
* browser, a different Chromium instance, where BroadcastChannel cannot reach it * running the plugin's own code in the plugin's own realm — it is just being
* and the pane would silently never sync. * *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 * That is why this file must use `window.open()` and not ask the desktop's main
* BrowserWindow, with a system tray, always-on-top, and remembered bounds). * process to make a BrowserWindow: a window we didn't open gives us no handle to
* Nothing else changes when it does — that is the point of the host registry. * 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 () { (function () {
'use strict'; 'use strict';
@@ -25,38 +32,72 @@
return; 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 const wins = new Map(); // paneId -> Window
let reaper = null; let reaper = null;
// A pane window the user closed with the OS X button never gets to say `bye` // A pane window the user closed with the OS X button gets no reliable
// reliably (a crashed renderer certainly doesn't). Poll `closed` and reap — // beforeunload (a crashed renderer certainly gets none). Poll `closed` and
// otherwise the pane stays "open" forever, its chip stays stubbed out, and // reap — otherwise the pane stays "open" forever, its chip stays stubbed out,
// the user has no way back to their dialog. Same trick splitscreen uses. // and the element it holds is stranded in a dead document with no way back.
function _startReaper() { function _startReaper() {
if (reaper != null) return; if (reaper != null) return;
reaper = setInterval(() => { reaper = setInterval(() => {
wins.forEach((w, id) => { wins.forEach((w, id) => { if (w.closed) panes.close(id); });
if (w.closed) panes.close(id); // → unmount() below clears the entry
});
if (!wins.size) { clearInterval(reaper); reaper = null; } if (!wins.size) { clearInterval(reaper); reaper = null; }
}, 500); }, 400);
} }
function mount(spec) { // Give the pane document the app's styles, so the panel looks identical.
const url = new URL(window.location.origin + '/pane'); // Cloned rather than shared: a <link> node can only live in one document, and
url.searchParams.set('pane', spec.id); // we are not about to steal the app's own stylesheet out of its head.
// The pane realm loads its mount() from this URL. Everything else it function _copyStyles(doc) {
// needs (title, state, the song) arrives in the snapshot — URL params document.querySelectorAll('link[rel="stylesheet"], style').forEach((node) => {
// are open-time only and must never be a state channel. try { doc.head.appendChild(node.cloneNode(true)); } catch (e) { /* skip a node we can't clone */ }
url.searchParams.set('script', spec.script); });
// 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, function _adopt(w, spec, el) {
'popup,width=' + spec.width + ',height=' + spec.height); 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) { if (!w) {
// Popup blocked. Bail BEFORE the manager records anything, so the // Popup blocked. Throw BEFORE the manager records anything, so the
// caller's dialog stays exactly where it was — and say so out loud // caller's panel stays exactly where it is — and say so out loud rather
// rather than appearing to do nothing. // than appearing to do nothing.
if (window.fbNotify) { if (window.fbNotify) {
window.fbNotify.show({ window.fbNotify.show({
title: 'Pop-out blocked', title: 'Pop-out blocked',
@@ -64,51 +105,78 @@
icon: '⚠️', accent: '#f59e0b', icon: '⚠️', accent: '#f59e0b',
}); });
} }
return null; throw new Error('pop-up blocked');
} }
wins.set(spec.id, w); wins.set(spec.id, w);
_startReaper(); _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); const w = wins.get(id);
wins.delete(id); wins.delete(id);
// Closing an already-closed window is a no-op, and closing one we opened // The manager adopts the element back into this document immediately after
// is always permitted (same-origin, script-opened). // this returns, so the window is empty by the time it closes.
if (w && !w.closed) { try { w.close(); } catch (e) { /* already gone */ } } if (w && !w.closed) { try { w.close(); } catch (e) { /* already gone */ } }
} }
function focus(id) { function focus(id) {
const w = wins.get(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({ panes.registerHost({
id: 'window', id: 'window',
priority: 10, priority: 10,
remote: true, autoRestore: isDesktop,
// A browser blocks window.open() outside a user gesture, so a pane place, unplace, focus,
// 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,
}); });
// The pane windows are ours; they must not outlive us. A pane window whose // Our windows; they must not outlive us. A pane window whose opener is gone
// main window is gone can never be fed again — leaving it on screen showing a // holds an element belonging to a dead document — there is nothing left to
// frozen playhead is worse than closing it. // dock it back into.
window.addEventListener('beforeunload', () => { window.addEventListener('beforeunload', () => {
wins.forEach((w) => { if (!w.closed) { try { w.close(); } catch (e) { /* ignore */ } } }); 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
View File
@@ -3,26 +3,22 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <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"> <link rel="icon" href="/static/assets/favicon.png">
<!-- Deliberately minimal. This is NOT the app shell with a flag on it: no <!-- Deliberately almost empty.
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 This document does not build a pane; it RECEIVES one. The opener moves the
milliseconds. Panes needing utility classes ship their own stylesheet via real element in here with document.adoptNode() and copies the app's
the `styles` manifest key, exactly as they must in the main window. --> 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"> <link rel="stylesheet" href="/static/panes/panes.css">
</head> </head>
<body> <body>
<header class="fb-pane-window-head"> <main id="fb-pane-root"></main>
<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>
</body> </body>
</html> </html>
+51 -118
View File
@@ -1,20 +1,43 @@
/* fee[dB]ack — detachable panes. /* fee[dB]ack — detachable panes.
* *
* Hand-authored (not Tailwind-scanned) so a runtime-installed plugin can use the * Hand-authored (not Tailwind-scanned) so a runtime-installed plugin gets the chip
* chip and the dock without shipping its own stylesheet — the same reason * and the dock without shipping its own stylesheet — the same reason .fb-selectable
* .fb-selectable is hand-authored in core CSS. * is hand-authored in core CSS.
* *
* Z-index: the dock is a child of <body>, so it is NOT on the ladder from * 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 * 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; * *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 * z-index:100` covering the viewport. A dock below 100 is invisible on the one
* player screen, which is the one screen panes exist for. * screen panes exist for. Body-level ladder: #player 100 < dock 110 < toasts 120
* * < modals 200.
* So: body-level ladder. #player 100 < dock 110 < toasts 120 < modals 200.
* A pane is persistent furniture that outranks the player, but never outranks a
* notification or an interruption.
*/ */
/* ── The 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 ────────────────────────────────────────────────────── */ /* ── The pop-out chip ────────────────────────────────────────────────────── */
.fb-pane-chip { .fb-pane-chip {
@@ -24,7 +47,6 @@
justify-content: center; justify-content: center;
width: 1.5rem; width: 1.5rem;
height: 1.5rem; height: 1.5rem;
margin-left: auto; /* right-align inside a flex header */
border: 1px solid rgba(51, 65, 85, .7); border: 1px solid rgba(51, 65, 85, .7);
border-radius: .4rem; border-radius: .4rem;
background: rgba(30, 41, 59, .8); background: rgba(30, 41, 59, .8);
@@ -39,18 +61,14 @@
border-color: #4080e0; border-color: #4080e0;
background: rgba(64, 128, 224, .18); background: rgba(64, 128, 224, .18);
} }
.fb-pane-chip:focus-visible { .fb-pane-chip:focus-visible { outline: 2px solid #4080e0; outline-offset: 1px; }
outline: 2px solid #4080e0;
outline-offset: 1px;
}
/* The dialog the chip lives in, while its pane is popped out. A dedicated class /* The panel, while its pane is popped out. A dedicated class rather than
rather than `.hidden`/[hidden]: the dialogs we attach to (the mixer popover, .hidden/[hidden]: the panels we attach to toggle those themselves, and two owners
the rail popovers) toggle those themselves, and two owners of one class is a of one class is a bug waiting for a bad day. */
bug waiting to happen. */
.fb-pane-detached { display: none !important; } .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 { .fb-pane-stub {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -64,17 +82,14 @@
cursor: pointer; cursor: pointer;
transition: background .15s, border-color .15s; transition: background .15s, border-color .15s;
} }
.fb-pane-stub:hover { .fb-pane-stub:hover { background: rgba(64, 128, 224, .18); border-color: #4080e0; }
background: rgba(64, 128, 224, .18);
border-color: #4080e0;
}
.fb-pane-stub-glyph { font-size: .85rem; } .fb-pane-stub-glyph { font-size: .85rem; }
/* ── The dock ────────────────────────────────────────────────────────────── */ /* ── The dock ────────────────────────────────────────────────────────────── */
.fb-pane-dock { .fb-pane-dock {
position: fixed; position: fixed;
top: 4.5rem; /* clear of the topbar */ top: 4.5rem;
right: 1rem; right: 1rem;
bottom: 1rem; bottom: 1rem;
z-index: 110; /* above #player (100), below toasts (120) */ z-index: 110; /* above #player (100), below toasts (120) */
@@ -85,9 +100,8 @@
gap: .6rem; gap: .6rem;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
/* The dock is a frame around cards, not a surface — pointer events belong to /* A frame around cards, not a surface — the empty space below them must never
the cards, so the empty space below them never eats a click meant for the eat a click meant for the highway. */
highway. */
pointer-events: none; pointer-events: none;
scrollbar-width: thin; scrollbar-width: thin;
} }
@@ -141,13 +155,9 @@
.fb-pane-card-btn:hover { background: rgba(51, 65, 85, .7); color: #e2e8f0; } .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-btn:focus-visible { outline: 2px solid #4080e0; outline-offset: 1px; }
.fb-pane-card-body { .fb-pane-card-body { overflow: auto; }
padding: .75rem;
font-size: .8rem;
color: #cbd5e1;
}
/* 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. */ instead of appearing to do nothing. */
.fb-pane-card.is-flash { animation: fb-pane-flash .7s ease-out; } .fb-pane-card.is-flash { animation: fb-pane-flash .7s ease-out; }
@keyframes fb-pane-flash { @keyframes fb-pane-flash {
@@ -158,9 +168,10 @@
.fb-pane-card.is-flash { animation: none; } .fb-pane-card.is-flash { animation: none; }
} }
/* ── The pop-out window (static/panes/pane.html) ─────────────────────────── */ /* ── 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 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,
html.fb-pane-window body { html.fb-pane-window body {
@@ -168,88 +179,10 @@ html.fb-pane-window body {
padding: 0; padding: 0;
height: 100%; height: 100%;
background: #0f172a; 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 { html.fb-pane-window body { display: flex; flex-direction: column; overflow: hidden; }
display: flex; #fb-pane-root {
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 {
flex: 1 1 auto; flex: 1 1 auto;
overflow-y: auto; overflow: auto;
padding: .85rem; 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); }
+4 -18
View File
@@ -1317,29 +1317,15 @@
<script defer src="/static/v3/interface-size-nudge.js"></script> <script defer src="/static/v3/interface-size-nudge.js"></script>
<script defer src="/static/v3/feedbarcade.js"></script> <script defer src="/static/v3/feedbarcade.js"></script>
<script defer src="/static/v3/player-chrome.js"></script> <script defer src="/static/v3/player-chrome.js"></script>
<!-- Detachable panes. Strict order: the bridge defines the ctx contract, the <!-- Detachable panes. The manager first; then the hosts, which register
sampler and the manager build on it, hosts register themselves with the themselves with it; then the chip and the launcher, which drive it.
manager, and the built-in panes register last (they call panes.register pane-desktop only does anything inside the desktop app. -->
+ panes.attachChip at load). Everything here runs after app.js, so
window.feedBack and the capability bus already exist. -->
<script defer src="/static/panes/pane-bridge.js"></script>
<script defer src="/static/panes/pane-streams.js"></script>
<script defer src="/static/panes/pane-manager.js"></script> <script defer src="/static/panes/pane-manager.js"></script>
<script defer src="/static/panes/pane-dock.js"></script> <script defer src="/static/panes/pane-dock.js"></script>
<script defer src="/static/panes/pane-window-host.js"></script> <script defer src="/static/panes/pane-window-host.js"></script>
<!-- Desktop host: registers above the browser pop-up host when the Electron <script defer src="/static/panes/pane-desktop.js"></script>
bridge is present (real BrowserWindow + system tray), and registers
nothing at all in a browser. -->
<script defer src="/static/panes/pane-desktop-host.js"></script>
<script defer src="/static/panes/pane-hub.js"></script>
<script defer src="/static/panes/pane-chip.js"></script> <script defer src="/static/panes/pane-chip.js"></script>
<script defer src="/static/panes/pane-mirror.js"></script>
<script defer src="/static/panes/pane-launcher.js"></script> <script defer src="/static/panes/pane-launcher.js"></script>
<script defer src="/static/panes/builtin/now-playing.js"></script>
<script defer src="/static/panes/builtin/mixer-pane.js"></script>
<!-- Last: registers panes declared in plugin manifests, so a plugin's pane is
openable from the rail or the tray without its screen ever being visited. -->
<script defer src="/static/panes/pane-plugins.js"></script>
<script> <script>
// Navbar scroll effect // Navbar scroll effect
window.addEventListener('scroll', () => { window.addEventListener('scroll', () => {