Files
feedBack/static/panes/builtin/now-playing.js
T
topkoa e5cbea2e9f feat(panes): core detachable pane system + pop-out chip
The option-heavy player UIs (mixer, camera director, viz, audio routing)
all live in the rail popovers, which are exclusive: openPopFor() closes
the last one before opening the next. You cannot watch the mixer while
riding the camera, and both vanish the moment you look at the highway.

Add `window.feedBack.panes` — a core registry for live UI that is
authored once as `mount(root, ctx)` and hosted anywhere. Panes are
non-exclusive, and they survive song switches structurally: the dock is a
body child outside every .screen, so the per-song teardown never sees it.

The adoption cost for a plugin is two calls:

    feedBack.panes.register({ id, title, icon, mount, unmount });
    feedBack.panes.attachChip(myExistingDialogEl, id);

attachChip injects THE standard pop-out chip. Clicking it opens the pane
and hides the plugin's dialog, leaving a stub to bring it back. Core owns
the hide/restore, so every plugin's pop-out looks and behaves the same —
which is the point. It hides via a dedicated .fb-pane-detached class, not
.hidden/[hidden], because the dialogs we attach to already toggle those.

Everything a pane may touch arrives through `ctx` — never a global. That
is what will let the same mount() run inside a pop-out window, a separate
JS realm with no window.feedBack, no window.highway and no audio graph:

  ctx.call(domain, cmd, payload)  -> the capability bus
  ctx.on(event, fn)              -> the feedBack bus (allowlisted)
  ctx.subscribe(stream, fn)      -> playhead / meters
  ctx.state.get/set              -> persisted, main realm is the only writer
  ctx.playhead(), ctx.song(), ctx.toast(), ctx.close()

ctx tracks every subscription it hands out and drops them on unmount, so
a pane cannot leak listeners across a dock/undock cycle.

Streams exist because an AnalyserNode cannot cross a window boundary:
levels are reduced to numbers in the realm that owns the audio graph.
One shared rAF loop, refcounted against live subscriptions, dirty-checked
before fan-out, and stopped dead when the last pane closes.

Hosts register themselves with the manager rather than being imported by
it — the dock lands at priority 0 (the floor, always available), so the
OS pane window can drop in later without this code changing.

Ships two built-in panes: Now Playing (the reference pane — reads the bus,
a stream, and levels, and touches no globals) and Mixer (the same faders
as the rail, via ctx.call('audio-mix', ...), with the chip attached to the
real #mixer-control). Plus a "Panes" rail popover to open panes that have
no dialog of their own; the system tray will mirror that list.

Note the dock sits at z-index 110, not on the docs/plugin-v3-ui.md ladder
(transport 20, rail 30, popovers 40) — those live INSIDE #player's
stacking context, and #player is itself fixed at z-index 100. A dock below
100 is invisible on the one screen panes exist for. Body-level ladder:
#player 100 < dock 110 < toasts 120 < modals 200.

Pop-out windows, the system tray, manifest-declared panes and mirrorGlobal
(the window.__h3dCamCtl proxy the camera director needs) follow.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 16:54:15 -04:00

111 lines
4.4 KiB
JavaScript

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