mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-19 06:52:38 +00:00
A pane can now leave the main window entirely. Same `mount(root, ctx)`,
same file, different JS realm — which is what the ctx-only contract in the
previous commit was for.
## A purpose-built document, not the app shell with a flag on it
`GET /pane` serves static/panes/pane.html: the bridge, the runtime, and
the pane's own script. No highway, no library, no v3 shell, no <audio>,
no Tailwind.
The splitscreen follower takes the other road — it reloads the whole app
at `/?ssFollower=1` and hides what it doesn't want — and pays for it with
an anti-flash block that must run before any script parses (index.html),
bail-outs in app.js and shell.js, and ~40 lines of CSS hiding core
elements by id. It loads the entire app to throw it away. A pane window
has nothing to throw away, so it boots in milliseconds and there is
nothing to flash.
The cost is that `window.feedBack` in a pane realm is a deliberate,
documented SUBSET. The runtime installs exactly what a pane is promised —
`panes.register`, and the no-op chip/dock calls a shared script may make
at load — so a pane reaching for something it was never given fails
loudly at authoring time instead of subtly at runtime.
## The channel
BroadcastChannel('feedback-panes'), same origin. This works only because
Electron's setWindowOpenHandler returns `action: 'allow'` for same-origin
URLs: `deny` would push the window to the system browser, a different
Chromium instance, where BroadcastChannel cannot reach it and the pane
would silently never sync. That flag is load-bearing.
hello -> snapshot resync-on-open, always. The snapshot is the only way
the pane realm learns anything.
state main is authoritative. A pane's write is a REQUEST;
main applies it and echoes to every realm, so a
losing write self-corrects instead of splitting brain.
rpc / rpc:reply ctx.call() -> the capability bus, with a 10s deadline.
Without one, a main window that died mid-call leaves
the pane's promise pending forever.
event allowlisted bus events, JSON-safe. A CustomEvent
carrying a DOM node (highway:canvas-replaced does)
would throw on postMessage and take the channel down
for everyone, so detail is round-tripped through JSON.
stream one coalesced message per pane per frame, OVERWRITING
anything not yet flushed. Queueing would build a
backlog: Chromium throttles a backgrounded window, and
the main window is exactly what's backgrounded while
the user looks at the pane.
sub / unsub refcounts the main-realm sampler.
bye both directions.
## The follower clock
The pane extrapolates between broadcasts: anchor + observedRate * elapsed,
capped at 2s. observedRate is learned from the broadcasts themselves
(dt/dwall) so it tracks the speed slider without being told about it, and
seeks/pauses are excluded from the fit — a jump is not a tempo. Capping it
means a dead main window decays into a frozen clock rather than one that
confidently runs away. This is splitscreen's hard-won trick, generalized:
panes just call ctx.playhead().
## Failure modes, all of them
- Main window closes -> `bye {main-closed}` and the pane says so plainly,
rather than showing a frozen playhead that looks live. The host also
closes its windows outright; a pane that cannot be fed should not be on
screen.
- Pane window X'd or crashed -> a `closed` poll reaps it (a crashed
renderer never sends `bye`), the pane closes, and the chip's dialog comes
back. Without this the user's dialog stays hidden with no way back.
- Popup blocked -> a toast, and we bail BEFORE the manager records
anything, so the caller's dialog stays exactly where it was.
- Nobody answers `hello` in 5s -> the window says so instead of spinning.
- A pane with no `script` is a closure in this realm and cannot honestly
cross a window boundary. The window host declines it (canHost) and the
router falls back to the dock.
- A browser blocks window.open() outside a user gesture, so a popped-out
pane cannot be auto-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 next click. (autoRestore: false. The desktop host will
set it true.)
Hosts may now declare `remote: true`, meaning the pane's mount() runs in
another realm: the manager then owns only the authoritative state store and
never calls mount() itself. That is the seam the Electron BrowserWindow +
tray host drops into next, with no change here.
Verified: popped Now Playing and Mixer into real windows. The pane realm has
no window.highway, no capability bus and no <audio>, yet the Mixer renders
its faders via ctx.call('audio-mix','list-faders') across the channel — and
dragging that fader IN THE PANE WINDOW moved the main window's song volume
to 55 and persisted it. Closing the pane window un-hid the mixer dialog,
removed the stub and restored the chip, while the other pane window stayed
open.
Signed-off-by: topkoa <topkoa@gmail.com>
114 lines
4.6 KiB
JavaScript
114 lines
4.6 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: '🎵',
|
|
// 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();
|
|
},
|
|
});
|
|
})();
|