diff --git a/static/panes/builtin/mixer-pane.js b/static/panes/builtin/mixer-pane.js new file mode 100644 index 0000000..f531be7 --- /dev/null +++ b/static/panes/builtin/mixer-pane.js @@ -0,0 +1,161 @@ +/* + * fee[dB]ack — Mixer pane (built-in). + * + * The same faders as the rail's mixer popover, in a pane that stays open. + * + * Two reasons this exists rather than "pop out the existing popover": + * + * 1. It is the second half of the chip proof. This file attaches the standard + * ⇱ chip to the EXISTING `#mixer-control` in the rail — real core UI we + * already own — so popping out hides the rail mixer and leaves the stub in + * its place. No new dialog was invented to demo the flow. + * 2. It talks to the `audio-mix` capability bus through `ctx.call()` and to + * nothing else. That is what makes it realm-portable: the rail popover + * reaches into `window.feedBack.capabilities` directly and could never + * survive a move into a pop-out window; this can. + * + * The mixer registry deliberately stores specs, not values — each fader's owner + * persists its own. So this pane persists nothing either (`persist: false`); it + * reads the live values every time it opens. + */ +(function () { + 'use strict'; + + const panes = window.feedBack && window.feedBack.panes; + if (!panes) return; + + function fmt(v, unit) { + const s = v === Math.round(v) ? v.toFixed(0) : v.toFixed(2); + return unit ? s + unit : s; + } + + function strip(fader, ctx) { + const min = Number(fader.min), max = Number(fader.max); + let cur = Number(fader.currentValue); + if (!Number.isFinite(cur)) cur = Number(fader.defaultValue) || 0; + cur = Math.min(max, Math.max(min, cur)); + const available = fader.availability === 'available' && fader.userAdjustable !== false; + + const wrap = document.createElement('div'); + wrap.className = 'fb-pane-row'; + + const label = document.createElement('span'); + label.className = 'fb-pane-key'; + label.textContent = fader.label || fader.faderLabel || fader.faderId || fader.id; + + const slider = document.createElement('input'); + slider.type = 'range'; + slider.className = 'accent-accent slider-input'; + slider.min = String(min); + slider.max = String(max); + slider.step = String(fader.step); + slider.value = String(cur); + slider.disabled = !available; + slider.setAttribute('aria-label', label.textContent + ' volume'); + + const value = document.createElement('span'); + value.className = 'fb-pane-val is-num'; + value.textContent = available ? fmt(cur, fader.unit) : 'Unavailable'; + + // A drag fires `input` faster than the capability round-trip resolves, so + // responses can land out of order. Only the newest write may paint. + let seq = 0; + slider.addEventListener('input', () => { + if (slider.disabled) return; + const mine = ++seq; + const requested = parseFloat(slider.value); + ctx.call('audio-mix', 'set-fader-value', { + participantId: fader.participantId, + faderId: fader.faderId || fader.id, + value: Number.isFinite(requested) ? requested : cur, + }).then((result) => { + if (mine !== seq) return; + const payload = (result && result.payload) || {}; + const committed = Number(payload.committedValue); + if (Number.isFinite(committed)) { + cur = Math.min(max, Math.max(min, committed)); + slider.value = String(cur); + } + value.textContent = (result && result.outcome === 'handled') ? fmt(cur, fader.unit) : 'Failed'; + }).catch(() => { + if (mine !== seq) return; + slider.value = String(cur); + value.textContent = 'Failed'; + }); + }); + + wrap.appendChild(label); + wrap.appendChild(slider); + wrap.appendChild(value); + return wrap; + } + + panes.register({ + id: 'core_mixer', + title: 'Mixer', + icon: '🎚', + persist: false, + // Faders come and go with the song (a song with no stems unregisters + // them), so the pane has to hear about it. These ride on top of the + // default event allowlist. + events: [ + 'audio-mix:participant-registered', + 'audio-mix:participant-removed', + 'audio-mix:fader-unavailable', + ], + + mount(root, ctx) { + const list = document.createElement('div'); + root.appendChild(list); + + let renderSeq = 0; + function render() { + const mine = ++renderSeq; + ctx.call('audio-mix', 'list-faders', {}).then((result) => { + if (mine !== renderSeq) return; // a newer render superseded us + const faders = (result && result.payload && Array.isArray(result.payload.faders)) + ? result.payload.faders : []; + list.replaceChildren(); + if (!faders.length) { + const empty = document.createElement('div'); + empty.className = 'fb-pane-dim'; + empty.textContent = 'No audio sources.'; + list.appendChild(empty); + return; + } + faders.forEach((f) => list.appendChild(strip(f, ctx))); + }).catch((err) => { + if (mine !== renderSeq) return; + console.error('[panes] mixer: list-faders failed', err); + list.replaceChildren(); + const oops = document.createElement('div'); + oops.className = 'fb-pane-dim'; + oops.textContent = 'Mixer unavailable.'; + list.appendChild(oops); + }); + } + + render(); + ctx.on('audio-mix:participant-registered', render); + ctx.on('audio-mix:participant-removed', render); + ctx.on('audio-mix:fader-unavailable', render); + // A new song brings a new set of stems. + ctx.on('song:ready', render); + }, + + unmount(root) { + root.replaceChildren(); + }, + }); + + // The chip, on the real rail mixer. Hiding #mixer-control (button + popover) + // rather than #mixer-popover alone means the rail doesn't keep offering a + // "Mixer ▾" button that opens an empty popover while the pane owns the faders. + function _attach() { + const el = document.getElementById('mixer-control'); + if (!el) return; + panes.attachChip(el, 'core_mixer'); + } + if (document.readyState !== 'complete') document.addEventListener('DOMContentLoaded', _attach); + else _attach(); +})(); diff --git a/static/panes/builtin/now-playing.js b/static/panes/builtin/now-playing.js new file mode 100644 index 0000000..cbf7d64 --- /dev/null +++ b/static/panes/builtin/now-playing.js @@ -0,0 +1,110 @@ +/* + * fee[dB]ack — "Now Playing" pane (built-in). + * + * The reference pane, and the one that proves the contract: it reads song + * metadata off the mirrored event bus, a playhead off a stream, and audio levels + * off a stream that only exists when the stems plugin does. It touches + * `window.feedBack`, `window.highway` and the audio graph exactly zero times — + * everything comes through `ctx` — which is what will let it run unchanged + * inside a pop-out window, where none of those globals exist. + * + * Read this before writing a pane of your own. + */ +(function () { + 'use strict'; + + const panes = window.feedBack && window.feedBack.panes; + if (!panes) return; + + function row(parent, key) { + const r = document.createElement('div'); + r.className = 'fb-pane-row'; + const k = document.createElement('span'); + k.className = 'fb-pane-key'; + k.textContent = key; + const v = document.createElement('span'); + v.className = 'fb-pane-val'; + r.appendChild(k); + r.appendChild(v); + parent.appendChild(r); + return v; + } + + function fmtTime(s) { + if (!Number.isFinite(s) || s < 0) s = 0; + const m = Math.floor(s / 60); + return m + ':' + String(Math.floor(s % 60)).padStart(2, '0'); + } + + panes.register({ + id: 'now_playing', + title: 'Now Playing', + icon: '🎵', + // Levels are transient; nothing here is worth remembering across a reload. + persist: false, + + mount(root, ctx) { + const title = row(root, 'Song'); + const artist = row(root, 'Artist'); + const arr = row(root, 'Arrangement'); + const tuning = row(root, 'Tuning'); + + const timeVal = row(root, 'Position'); + timeVal.classList.add('is-num'); + const bar = document.createElement('div'); + bar.className = 'fb-pane-bar'; + const fill = document.createElement('div'); + fill.className = 'fb-pane-bar-fill'; + bar.appendChild(fill); + root.appendChild(bar); + + const levelLabel = document.createElement('div'); + levelLabel.className = 'fb-pane-dim'; + levelLabel.style.marginTop = '.6rem'; + levelLabel.textContent = 'Level — no stems plugin'; + const level = document.createElement('div'); + level.className = 'fb-pane-bar'; + const levelFill = document.createElement('div'); + levelFill.className = 'fb-pane-bar-fill is-level'; + level.appendChild(levelFill); + root.appendChild(levelLabel); + root.appendChild(level); + + function renderSong() { + const s = ctx.song(); + title.textContent = (s && s.title) || '—'; + artist.textContent = (s && s.artist) || '—'; + arr.textContent = (s && (s.arrangementSmartName || s.arrangement)) || '—'; + tuning.textContent = (s && Array.isArray(s.tuning)) ? s.tuning.join(' ') : '—'; + } + + renderSong(); + // The mirrored bus. `song:loaded` also fires on an arrangement switch, + // which is exactly when the arrangement/tuning rows go stale. + ctx.on('song:loaded', renderSong); + + // Streams, not events: the playhead moves every frame, and + // `song:position-changed` is throttled to 250 ms — too coarse for a + // bar, and too chatty to mirror across a window boundary. + ctx.subscribe('playhead', (p) => { + timeVal.textContent = fmtTime(p.t) + ' / ' + fmtTime(p.duration); + const frac = p.duration > 0 ? Math.min(1, Math.max(0, p.t / p.duration)) : 0; + fill.style.transform = 'scaleX(' + frac.toFixed(4) + ')'; + }); + + // The meters stream stays silent when no analyser exists rather than + // reporting zeros — so a silent stream and real silence are + // distinguishable, and this label is honest either way. + ctx.subscribe('meters', (m) => { + levelLabel.textContent = 'Level'; + levelFill.style.transform = 'scaleX(' + Math.min(1, m.master * 3).toFixed(3) + ')'; + }); + }, + + unmount(root) { + // ctx tears down every subscription it handed out; the pane only owns + // its DOM. + root.replaceChildren(); + }, + }); +})(); diff --git a/static/panes/pane-bridge.js b/static/panes/pane-bridge.js new file mode 100644 index 0000000..f0c4381 --- /dev/null +++ b/static/panes/pane-bridge.js @@ -0,0 +1,236 @@ +/* + * fee[dB]ack — pane bridge. + * + * The transport + context layer for detachable panes. Zero DOM, zero UI. + * + * A pane is authored ONCE, as `mount(root, ctx)`, and must run unchanged in two + * places: docked in the main window, or inside a pop-out window (a separate JS + * realm where `window.feedBack`, `window.highway` and the audio graph do not + * exist). Everything a pane is allowed to touch therefore arrives through `ctx` + * — never through globals. That is the whole point of this file: it is the only + * seam between "pane code" and "which realm am I in". + * + * Two transports implement that seam: + * + * LocalTransport — main realm. Calls straight through to the capability bus, + * the feedBack event bus, and the stream sampler. + * RemoteTransport — pane realm (added with the pop-out window). Same methods, + * marshalled over BroadcastChannel. + * + * A pane cannot tell them apart, and must not try. + * + * Exposes `window.__fbPaneBridge` (host-internal — panes never touch it). + */ +(function () { + 'use strict'; + + // Bumped only on a breaking envelope change. The pane realm refuses to talk + // to a main realm with a different major, rather than half-working. + const PROTOCOL_VERSION = 1; + const CHANNEL_NAME = 'feedback-panes'; + + // Bus events mirrored into a pane realm by default. Deliberately an + // allowlist, not a firehose: `song:position-changed` fires every 250ms and + // `capability:event` fires constantly, and neither belongs on a + // cross-window channel — position rides the `playhead` stream instead. + // A pane widens this with `spec.events: [...]`. + const DEFAULT_EVENTS = [ + 'song:loading', 'song:loaded', 'song:ready', + 'song:play', 'song:pause', 'song:ended', 'song:stop', 'song:seek', + 'song:arrangement-changed', + 'screen:changed', 'theme:changed', 'library:changed', + 'highway:canvas-replaced', 'highway:visibility', + ]; + + // ── State store ────────────────────────────────────────────────────────── + // A dotted-path key/value tree, one per pane. In the main realm this is the + // authoritative copy; a pane realm holds a replica and its writes are + // requests (see RemoteTransport). Subscribers get (snapshot, change). + + function _split(path) { + if (typeof path !== 'string' || !path) throw new TypeError('pane state: path must be a non-empty string'); + return path.split('.'); + } + + function createStateStore(initial) { + let data = (initial && typeof initial === 'object') ? JSON.parse(JSON.stringify(initial)) : {}; + const subs = new Set(); + + function get(path) { + if (path == null) return data; + let node = data; + for (const k of _split(path)) { + if (node == null || typeof node !== 'object') return undefined; + node = node[k]; + } + return node; + } + + function set(path, value) { + const keys = _split(path); + let node = data; + for (let i = 0; i < keys.length - 1; i++) { + const k = keys[i]; + // Walk-and-create. A non-object on the way down is replaced — + // the writer's shape wins over a stale scalar. + if (node[k] == null || typeof node[k] !== 'object') node[k] = {}; + node = node[k]; + } + const last = keys[keys.length - 1]; + if (node[last] === value) return false; // no-op writes don't notify + node[last] = value; + const change = { path: path, value: value }; + subs.forEach((fn) => { try { fn(data, change); } catch (e) { console.error('[panes] state subscriber threw', e); } }); + return true; + } + + // Bulk replace, used on snapshot/resync. Notifies once with a null change. + function replace(next) { + data = (next && typeof next === 'object') ? next : {}; + subs.forEach((fn) => { try { fn(data, null); } catch (e) { console.error('[panes] state subscriber threw', e); } }); + } + + function subscribe(fn) { + subs.add(fn); + return () => subs.delete(fn); + } + + return { get, set, replace, subscribe, all: () => data }; + } + + // ── Local transport (main realm) ───────────────────────────────────────── + + const CALL_TIMEOUT_MS = 2100; // matches core's own audio-mix calls + + function createLocalTransport(paneId) { + return { + kind: 'local', + + // Route to the capability bus. Panes get exactly this, and nothing + // else, as their door into app services — so a pane written against + // ctx.call() keeps working when it moves realms. + // + // A pane passes a plain PAYLOAD; the requester/origin/timeout + // envelope is core's to build. That keeps the pane-side call + // identical in both realms, where the remote transport has to + // reconstruct the envelope on this side of the channel anyway. + call(domain, command, payload) { + const caps = window.feedBack && window.feedBack.capabilities; + if (!caps || typeof caps.command !== 'function') { + return Promise.reject(new Error('pane ctx.call: capability bus unavailable')); + } + return caps.command(domain, command, { + requester: 'pane.' + paneId, + origin: 'pane', + payload: payload || {}, + timeoutMs: CALL_TIMEOUT_MS, + }); + }, + + on(name, fn) { + const bus = window.feedBack; + if (!bus || typeof bus.on !== 'function') return () => {}; + bus.on(name, fn); + return () => bus.off(name, fn); + }, + + subscribe(stream, fn) { + const s = window.__fbPaneStreams; + if (!s) return () => {}; + return s.subscribe(stream, fn); + }, + + // In the main realm the clock needs no interpolation — the highway's + // own time IS the source of truth. (The pane realm has to + // extrapolate; see RemoteTransport, added with the pop-out window.) + playhead() { + const hw = window.highway; + const t = (hw && typeof hw.getTime === 'function') ? hw.getTime() : NaN; + return Number.isFinite(t) ? t : 0; + }, + + song() { + return (window.feedBack && window.feedBack.currentSong) || null; + }, + + toast(opts) { + if (window.fbNotify && typeof window.fbNotify.show === 'function') window.fbNotify.show(opts || {}); + }, + }; + } + + // ── ctx ────────────────────────────────────────────────────────────────── + // What a pane's mount() actually receives. Every subscription it hands out + // is tracked, so unmount() can drop them all — a pane physically cannot + // leak a listener across a dock/undock cycle, which is the failure mode + // that would otherwise show up as duplicate handlers after three song + // switches. + + function createCtx(opts) { + const paneId = opts.paneId; + const transport = opts.transport; + const state = opts.state; + const disposers = []; + let disposed = false; + + function track(unsub) { + if (typeof unsub !== 'function') return () => {}; + // Late subscriptions (a pane calling ctx.on() from a setTimeout that + // outlived unmount) are torn down immediately rather than silently + // registered against a dead pane. + if (disposed) { try { unsub(); } catch (e) { /* already gone */ } return () => {}; } + disposers.push(unsub); + return () => { + const i = disposers.indexOf(unsub); + if (i >= 0) disposers.splice(i, 1); + try { unsub(); } catch (e) { /* already gone */ } + }; + } + + const ctx = { + paneId: paneId, + host: opts.host, // 'dock' | 'shared' | 'pane:' + isRemote: transport.kind !== 'local', + + state: { + get: (path) => state.get(path), + set: (path, value) => state.set(path, value), + all: () => state.all(), + subscribe: (fn) => track(state.subscribe(fn)), + }, + + call: (domain, command, args) => transport.call(domain, command, args), + on: (name, fn) => track(transport.on(name, fn)), + subscribe: (stream, fn) => track(transport.subscribe(stream, fn)), + playhead: () => transport.playhead(), + song: () => transport.song(), + toast: (o) => transport.toast(o), + + // Ask the host to put this pane away. The pane does not know or care + // whether that means closing a dock card or an OS window. + close: () => { if (typeof opts.onClose === 'function') opts.onClose(); }, + }; + + ctx._dispose = function () { + if (disposed) return; + disposed = true; + // Copy-then-clear: a disposer that removes itself from the list + // (the closure returned by track) would otherwise skip its neighbour. + const list = disposers.slice(); + disposers.length = 0; + list.forEach((fn) => { try { fn(); } catch (e) { console.error('[panes] disposer threw', e); } }); + }; + + return ctx; + } + + window.__fbPaneBridge = { + PROTOCOL_VERSION, + CHANNEL_NAME, + DEFAULT_EVENTS, + CALL_TIMEOUT_MS, + createStateStore, + createLocalTransport, + createCtx, + }; +})(); diff --git a/static/panes/pane-chip.js b/static/panes/pane-chip.js new file mode 100644 index 0000000..2e6024e --- /dev/null +++ b/static/panes/pane-chip.js @@ -0,0 +1,135 @@ +/* + * fee[dB]ack — the pop-out chip. + * + * One affordance, core-owned, identical everywhere: the small ⇱ button a plugin + * drops into a dialog it already has. + * + * feedBack.panes.register({ id: 'camera_director', title: 'Camera', mount, unmount }); + * feedBack.panes.attachChip(myDialogEl, 'camera_director'); + * + * That is the entire adoption cost. Clicking the chip opens the pane in its host + * and hides `myDialogEl`; a stub takes its place so the user can find it again; + * closing the pane un-hides the dialog and restores the chip. The plugin writes + * no show/hide logic — if it did, every plugin would invent a slightly different + * one, which is exactly the inconsistency this exists to prevent. + * + * Hiding uses `.fb-pane-detached`, NOT the `hidden` class or `[hidden]`, because + * the dialogs being hidden here already toggle those themselves (the core mixer + * popover, every rail popover). Two owners of one class is a bug waiting for a + * bad day; a dedicated class composes cleanly with whatever the dialog does. + */ +(function () { + 'use strict'; + + const panes = window.feedBack && window.feedBack.panes; + if (!panes || typeof panes.register !== 'function') { + console.error('[panes] pane-manager.js must load before pane-chip.js'); + return; + } + + // paneId -> { el, chip, stub, spec } + const attached = new Map(); + + function _makeChip(spec) { + const b = document.createElement('button'); + b.type = 'button'; + b.className = 'fb-pane-chip'; + b.title = 'Pop out'; + b.setAttribute('aria-label', 'Pop out ' + spec.title); + b.textContent = '⇱'; + b.addEventListener('click', (e) => { + // Rail popovers close on any document click that lands outside them + // (player-chrome.js). Without this the popover would close under the + // chip mid-click, which reads as the button not working. + e.stopPropagation(); + e.preventDefault(); + panes.detach(spec.id); + }); + return b; + } + + function _makeStub(spec) { + const s = document.createElement('button'); + s.type = 'button'; + s.className = 'fb-pane-stub'; + s.setAttribute('aria-label', 'Bring ' + spec.title + ' back'); + s.title = 'Bring it back'; + const glyph = document.createElement('span'); + glyph.className = 'fb-pane-stub-glyph'; + glyph.textContent = '⇲'; + const label = document.createElement('span'); + label.textContent = spec.title + ' is popped out'; + s.appendChild(glyph); + s.appendChild(label); + s.addEventListener('click', (e) => { + e.stopPropagation(); + e.preventDefault(); + panes.close(spec.id); + }); + return s; + } + + function _onOpened(rec) { + rec.el.classList.add('fb-pane-detached'); + if (!rec.stub.isConnected) rec.el.parentNode.insertBefore(rec.stub, rec.el); + } + + function _onClosed(rec) { + rec.el.classList.remove('fb-pane-detached'); + rec.stub.remove(); + } + + /** + * attachChip(el, paneId, opts) + * + * `el` — the dialog to hide when the pane pops out. The chip is injected + * into `el.querySelector('[data-pane-header]')` when present, else + * prepended to `el` itself. + * `opts` — { header: Element } to place the chip somewhere specific. + * + * Returns a detach function that removes the chip and stub and restores the + * dialog — call it if your plugin tears its dialog down. + */ + function attachChip(el, paneId, opts) { + opts = opts || {}; + if (!(el instanceof Element)) throw new TypeError('panes.attachChip: el must be an Element'); + const spec = panes.get(paneId); + if (!spec) { console.warn('[panes] attachChip: register the pane first:', paneId); return () => {}; } + if (attached.has(paneId)) { console.warn('[panes] attachChip: already attached:', paneId); return () => {}; } + + const chip = _makeChip(spec); + const stub = _makeStub(spec); + const host = opts.header || el.querySelector('[data-pane-header]') || el; + if (host === el) host.insertBefore(chip, host.firstChild); + else host.appendChild(chip); + + const rec = { el, chip, stub, spec }; + attached.set(paneId, rec); + + // Reconcile immediately: register() reopens a pane the user left open at + // last unload, and that can land before (or after) attachChip runs. + if (panes.isOpen(paneId)) _onOpened(rec); + + return () => { + if (attached.get(paneId) !== rec) return; + attached.delete(paneId); + chip.remove(); + _onClosed(rec); + }; + } + + // One pair of bus listeners for every chip, rather than one pair per chip. + const bus = window.feedBack; + if (bus && typeof bus.on === 'function') { + bus.on('panes:opened', (e) => { + const rec = attached.get(e.detail && e.detail.id); + if (rec) _onOpened(rec); + }); + bus.on('panes:closed', (e) => { + const rec = attached.get(e.detail && e.detail.id); + if (rec) _onClosed(rec); + }); + } + + window.feedBack.panes.attachChip = attachChip; +})(); diff --git a/static/panes/pane-dock.js b/static/panes/pane-dock.js new file mode 100644 index 0000000..b9b7e59 --- /dev/null +++ b/static/panes/pane-dock.js @@ -0,0 +1,108 @@ +/* + * fee[dB]ack — pane dock (the in-window pane host). + * + * A right-edge stack of cards, one per open pane. Deliberately NOT a rail + * popover: the rail is exclusive (player-chrome.js openPopFor closes the last + * one before opening the next), which is precisely why you can't watch the + * mixer while riding the camera. Cards here coexist. + * + * Song-switch survival is structural, not defended. `#fb-pane-dock` is appended + * to , outside every `.screen`, so the per-song teardown never sees it — + * and `playSong()` ends in `showScreen('player')`, whose id === 'player' short- + * circuits the teardown branch anyway. Nothing to reset, nothing to re-mount. + * + * Registers itself as the `dock` host at priority 0 — the floor. Whatever else + * exists (an OS pane window), a pane can always land here. + */ +(function () { + 'use strict'; + + const panes = window.feedBack && window.feedBack.panes; + if (!panes || typeof panes.registerHost !== 'function') { + console.error('[panes] pane-manager.js must load before pane-dock.js'); + return; + } + + let dockEl = null; + const cards = new Map(); // paneId -> card element + + function dock() { + if (dockEl && dockEl.isConnected) return dockEl; + dockEl = document.getElementById('fb-pane-dock'); + if (!dockEl) { + dockEl = document.createElement('div'); + dockEl.id = 'fb-pane-dock'; + dockEl.className = 'fb-pane-dock'; + dockEl.setAttribute('role', 'region'); + dockEl.setAttribute('aria-label', 'Panes'); + document.body.appendChild(dockEl); + } + return dockEl; + } + + function _syncEmpty() { + const d = dock(); + d.classList.toggle('is-empty', cards.size === 0); + } + + function mount(spec) { + const card = document.createElement('section'); + card.className = 'fb-pane-card'; + card.dataset.paneId = spec.id; + card.setAttribute('aria-label', spec.title); + + const head = document.createElement('header'); + head.className = 'fb-pane-card-head'; + + const title = document.createElement('span'); + title.className = 'fb-pane-card-title'; + // textContent, not innerHTML — a pane title can come from a plugin + // manifest, i.e. from outside core. + title.textContent = spec.icon + ' ' + spec.title; + + const close = document.createElement('button'); + close.type = 'button'; + close.className = 'fb-pane-card-btn'; + close.setAttribute('aria-label', 'Close ' + spec.title); + close.title = 'Close'; + close.textContent = '✕'; + close.addEventListener('click', () => panes.close(spec.id)); + + head.appendChild(title); + head.appendChild(close); + + const body = document.createElement('div'); + body.className = 'fb-pane-card-body fb-selectable'; + + card.appendChild(head); + card.appendChild(body); + dock().appendChild(card); + cards.set(spec.id, card); + _syncEmpty(); + + // The pane mounts into the body, never the card — so it cannot reach + // (or accidentally destroy) the chrome that owns its close button. + return body; + } + + function unmount(id) { + const card = cards.get(id); + if (card) card.remove(); + cards.delete(id); + _syncEmpty(); + } + + function focus(id) { + const card = cards.get(id); + if (!card) return; + card.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + // Re-trigger the flash even if the class is already there (repeat focus + // of the same card would otherwise be a no-op animation). + card.classList.remove('is-flash'); + void card.offsetWidth; + card.classList.add('is-flash'); + setTimeout(() => card.classList.remove('is-flash'), 700); + } + + panes.registerHost({ id: 'dock', priority: 0, available: () => !!document.body, mount, unmount, focus }); +})(); diff --git a/static/panes/pane-launcher.js b/static/panes/pane-launcher.js new file mode 100644 index 0000000..e740c52 --- /dev/null +++ b/static/panes/pane-launcher.js @@ -0,0 +1,61 @@ +/* + * fee[dB]ack — pane launcher (the "Panes" rail popover). + * + * A chip only works for a pane that already has a dialog to hide. Panes with no + * dialog — a readout, a plugin's optional extra — need somewhere to be opened + * from, so every registered pane gets one: a checkbox list in the rail. + * + * The rail popover is the right home for this precisely because it IS exclusive + * and transient. It's a menu, not a workspace; the panes it opens are the + * workspace, and they persist. + * + * Populated from the registry, so a plugin that calls panes.register() appears + * here with no further work. (The system tray will mirror this list.) + */ +(function () { + 'use strict'; + + const panes = window.feedBack && window.feedBack.panes; + const bus = window.feedBack; + if (!panes || !bus || typeof bus.on !== 'function') return; + + let listEl = null; + + function render() { + if (!listEl || !listEl.isConnected) listEl = document.getElementById('v3-rail-panes-list'); + if (!listEl) return; + const all = panes.list(); + listEl.replaceChildren(); + + if (!all.length) { + const empty = document.createElement('div'); + empty.className = 'v3-pop-empty'; + empty.textContent = 'No panes available.'; + listEl.appendChild(empty); + return; + } + + all.forEach((p) => { + const b = document.createElement('button'); + b.type = 'button'; + b.className = 'v3-pop-btn'; + b.setAttribute('aria-pressed', p.open ? 'true' : 'false'); + b.textContent = (p.open ? '● ' : '○ ') + p.icon + ' ' + p.title; + b.addEventListener('click', (e) => { + e.stopPropagation(); + if (panes.isOpen(p.id)) panes.close(p.id); else panes.detach(p.id); + }); + listEl.appendChild(b); + }); + } + + // The registry changes when plugins load and when panes open/close. Render is + // cheap and rare (never on a playback path), so just re-run it. + bus.on('panes:registered', render); + bus.on('panes:unregistered', render); + bus.on('panes:opened', render); + bus.on('panes:closed', render); + + if (document.readyState !== 'complete') document.addEventListener('DOMContentLoaded', render); + else render(); +})(); diff --git a/static/panes/pane-manager.js b/static/panes/pane-manager.js new file mode 100644 index 0000000..451b459 --- /dev/null +++ b/static/panes/pane-manager.js @@ -0,0 +1,288 @@ +/* + * fee[dB]ack — pane manager. + * + * The registry and host router behind `window.feedBack.panes`. Main realm only. + * + * A "pane" is a piece of live UI — a mixer, a camera rig, a readout — authored + * once as `mount(root, ctx)` and mountable into any *host*: the in-window dock + * today, a pop-out OS window later. The manager owns which pane is open and + * where; hosts own the chrome; the pane owns nothing but its own DOM. + * + * The problem this exists to solve: the player's rail popovers are exclusive + * (opening one closes the last), so you cannot watch the mixer while riding the + * camera — and both vanish the moment you want to look at the highway. Panes + * are non-exclusive by construction and survive song switches, because nothing + * about them is tied to the per-song teardown. + * + * Hosts register themselves; the manager never imports one. That is what lets + * the pop-out window host drop in later without this file changing. + */ +(function () { + 'use strict'; + + const B = window.__fbPaneBridge; + if (!B) { console.error('[panes] pane-bridge.js must load before pane-manager.js'); return; } + + const HOSTS_KEY = 'fbPaneHosts'; // { paneId: hostId } — panes open at last unload + const STATE_KEY = (id) => 'fbPane:' + id; + + // id -> normalized spec + const specs = new Map(); + // id -> { spec, hostId, root, ctx, state } + const open = new Map(); + // hostId -> host provider + const hosts = new Map(); + + // ── Persistence ────────────────────────────────────────────────────────── + // localStorage is shared with any pop-out realm (same origin), so a + // concurrent writer there would silently clobber us. The rule, enforced by + // this file being main-realm-only: THE MAIN REALM IS THE ONLY WRITER. A pane + // asks; the manager writes. + + function _readJSON(key, fallback) { + try { + const raw = localStorage.getItem(key); + return raw ? JSON.parse(raw) : fallback; + } catch (e) { return fallback; } // private mode / corrupt value + } + function _writeJSON(key, value) { + try { localStorage.setItem(key, JSON.stringify(value)); } catch (e) { /* quota / private mode: non-fatal */ } + } + + function _rememberHost(id, hostId) { + const map = _readJSON(HOSTS_KEY, {}); + if (hostId) map[id] = hostId; else delete map[id]; + _writeJSON(HOSTS_KEY, map); + } + + // Pane state is saved on a trailing debounce — a fader drag writes on every + // input event, and localStorage is synchronous. + const _saveTimers = new Map(); + function _scheduleSave(id, state) { + clearTimeout(_saveTimers.get(id)); + _saveTimers.set(id, setTimeout(() => { + _saveTimers.delete(id); + _writeJSON(STATE_KEY(id), state.all()); + }, 250)); + } + + // ── Spec ───────────────────────────────────────────────────────────────── + + function _normalize(spec) { + if (!spec || typeof spec !== 'object') throw new TypeError('panes.register: spec must be an object'); + if (!spec.id || typeof spec.id !== 'string') throw new TypeError('panes.register: spec.id is required'); + if (typeof spec.mount !== 'function') throw new TypeError('panes.register(' + spec.id + '): spec.mount is required'); + return { + id: spec.id, + title: spec.title || spec.id, + icon: spec.icon || '▣', + mount: spec.mount, + unmount: typeof spec.unmount === 'function' ? spec.unmount : null, + // Bus events mirrored into a pop-out realm for this pane. Docked, ctx.on() + // reaches the real bus regardless — this list only matters once the + // pane is in another realm, and it is declared here so it is the same + // list in both. + events: Array.isArray(spec.events) ? B.DEFAULT_EVENTS.concat(spec.events) : B.DEFAULT_EVENTS, + persist: spec.persist !== false, // default on; opt out with `persist: false` + initialState: spec.initialState || {}, + defaultHost: spec.defaultHost || 'window', + mirrorGlobal: spec.mirrorGlobal || null, // honoured by pane-mirror.js + width: spec.width || 380, + height: spec.height || 560, + }; + } + + // ── Host routing ───────────────────────────────────────────────────────── + // A host provider is `{ id, priority, available(), mount(spec) -> Element, + // unmount(id), focus(id) }`. Higher priority wins when a pane asks for a + // host it can't have. + + function _resolveHost(preferred) { + const wanted = hosts.get(preferred); + if (wanted && wanted.available()) return wanted; + // Fall back to the best host that IS available, preferring the highest + // priority. The dock registers at priority 0, so it is always the floor — + // a pane can never fail to open just because no window host exists. + let best = null; + hosts.forEach((h) => { + if (!h.available()) return; + if (!best || h.priority > best.priority) best = h; + }); + return best; + } + + function _emit(name, detail) { + const bus = window.feedBack; + if (bus && typeof bus.emit === 'function') bus.emit(name, detail); + } + + // ── Public API ─────────────────────────────────────────────────────────── + + function register(spec) { + const s = _normalize(spec); + if (specs.has(s.id)) { + // First registration wins, matching libraryCardActions.register. A + // silent overwrite would let a re-injected plugin script swap the + // mount function out from under an already-open pane. + console.warn('[panes] pane already registered, ignoring:', s.id); + return () => {}; + } + specs.set(s.id, s); + _emit('panes:registered', { id: s.id, title: s.title }); + + // Reopen where the user left it. Deferred a tick so a plugin can call + // register() and attachChip() back-to-back — the chip must exist before + // the pane opens or it has nothing to hide. + const remembered = _readJSON(HOSTS_KEY, {})[s.id]; + if (remembered) setTimeout(() => { if (specs.has(s.id) && !open.has(s.id)) openPane(s.id, { host: remembered, remember: false }); }, 0); + + return () => unregister(s.id); + } + + function unregister(id) { + if (open.has(id)) closePane(id, { remember: false }); + specs.delete(id); + _emit('panes:unregistered', { id: id }); + } + + function openPane(id, opts) { + opts = opts || {}; + const spec = specs.get(id); + if (!spec) { console.warn('[panes] open: no such pane:', id); return false; } + if (open.has(id)) { focusPane(id); return true; } + + const host = _resolveHost(opts.host || spec.defaultHost); + if (!host) { console.error('[panes] open: no host available for', id); return false; } + + const state = B.createStateStore(spec.persist ? _readJSON(STATE_KEY(id), spec.initialState) : spec.initialState); + if (spec.persist) state.subscribe(() => _scheduleSave(id, state)); + + let root; + try { + root = host.mount(spec); + } catch (e) { + console.error('[panes] host', host.id, 'failed to mount', id, e); + return false; + } + + const ctx = B.createCtx({ + paneId: id, + host: host.id, + transport: B.createLocalTransport(id), + state: state, + onClose: () => closePane(id), + }); + + const entry = { spec, hostId: host.id, root, ctx, state }; + open.set(id, entry); + + try { + spec.mount(root, ctx); + } catch (e) { + // A pane that throws in mount() must not leave a half-open shell + // behind — tear the whole thing back down and tell the user, rather + // than leaving an empty card they can't explain. + console.error('[panes] pane threw in mount():', id, e); + closePane(id, { remember: false }); + if (window.fbNotify) window.fbNotify.show({ title: spec.title, message: 'Failed to open.', icon: '⚠️', accent: '#f59e0b' }); + return false; + } + + if (opts.remember !== false) _rememberHost(id, host.id); + _emit('panes:opened', { id: id, host: host.id }); + return true; + } + + function closePane(id, opts) { + opts = opts || {}; + const entry = open.get(id); + if (!entry) return false; + open.delete(id); + + // Order matters: the pane tears down its own DOM/listeners first, then + // ctx drops everything it handed out, then the host removes the shell. + // Reversing any of these hands the pane a root that has already been + // detached, or leaks the subscriptions its unmount() assumed it kept. + try { if (entry.spec.unmount) entry.spec.unmount(entry.root, entry.ctx); } + catch (e) { console.error('[panes] pane threw in unmount():', id, e); } + try { entry.ctx._dispose(); } catch (e) { console.error('[panes] ctx dispose threw:', id, e); } + + const host = hosts.get(entry.hostId); + try { if (host) host.unmount(id); } catch (e) { console.error('[panes] host', entry.hostId, 'threw in unmount:', id, e); } + + // Flush any pending debounced state write — closing must not lose the + // last fader nudge. + if (entry.spec.persist) { + clearTimeout(_saveTimers.get(id)); + _saveTimers.delete(id); + _writeJSON(STATE_KEY(id), entry.state.all()); + } + + if (opts.remember !== false) _rememberHost(id, null); + _emit('panes:closed', { id: id, host: entry.hostId }); + return true; + } + + // Move an open pane to a different host without losing its state: the pane's + // DOM is rebuilt (mount runs again against the new root) but its state store + // is the persisted one, so a fader sits where the user left it. + function movePane(id, hostId) { + const wasOpen = open.has(id); + if (wasOpen) closePane(id, { remember: false }); + return openPane(id, { host: hostId }); + } + + function focusPane(id) { + const entry = open.get(id); + if (!entry) return false; + const host = hosts.get(entry.hostId); + if (host && typeof host.focus === 'function') host.focus(id); + return true; + } + + // `detach` is what the pop-out chip calls: put this pane wherever a pane + // most wants to live. Today the dock is usually the only host; once the + // window host registers, it outranks the dock and the same call opens an OS + // window instead. The chip never changes. + function detach(id) { + const spec = specs.get(id); + return openPane(id, { host: (spec && spec.defaultHost) || 'window' }); + } + + function dock(id) { return movePane(id, 'dock'); } + + function registerHost(host) { + if (!host || !host.id) throw new TypeError('panes: host needs an id'); + hosts.set(host.id, { + id: host.id, + priority: host.priority || 0, + available: typeof host.available === 'function' ? host.available : () => true, + mount: host.mount, + unmount: host.unmount, + focus: host.focus, + }); + } + + const api = { + version: 1, + register, + unregister, + open: openPane, + close: closePane, + move: movePane, + focus: focusPane, + detach, + dock, + isOpen: (id) => open.has(id), + hostOf: (id) => { const e = open.get(id); return e ? e.hostId : null; }, + get: (id) => specs.get(id) || null, + list: () => Array.from(specs.values()).map((s) => ({ id: s.id, title: s.title, icon: s.icon, open: open.has(s.id), host: (open.get(s.id) || {}).hostId || null })), + // Host registration is host-internal, but it lives on the same object so + // a future out-of-tree host (a plugin shipping its own window shell) can + // participate without a private import. + registerHost, + }; + + window.feedBack = window.feedBack || {}; + window.feedBack.panes = Object.assign(window.feedBack.panes || {}, api); +})(); diff --git a/static/panes/pane-streams.js b/static/panes/pane-streams.js new file mode 100644 index 0000000..b4a67c1 --- /dev/null +++ b/static/panes/pane-streams.js @@ -0,0 +1,137 @@ +/* + * fee[dB]ack — pane streams. + * + * The main realm's sampler for high-rate numeric data a pane wants to display: + * the playhead, and audio levels. + * + * Why a sampler rather than letting panes read the sources directly: + * + * 1. An AnalyserNode cannot cross a window boundary. A popped-out pane can + * never hold one. So levels must be reduced to plain numbers HERE, in the + * realm that owns the audio graph, and shipped as numbers. Making the + * docked path work the same way is what keeps one `mount()` valid in both + * realms. + * 2. Per the plugin performance rules, playback-tied loops must stop when + * nothing is looking at them. One shared rAF loop, reference-counted + * against live subscriptions, is strictly cheaper than N plugin loops — + * and it stops dead when the last pane closes. + * + * Exposes `window.__fbPaneStreams` (host-internal; panes reach this through + * ctx.subscribe()). + */ +(function () { + 'use strict'; + + // Sources are sampled every frame; a source that returns `undefined` is + // simply unavailable right now (no stems plugin, no song loaded) and its + // subscribers are not called at all — better than feeding them zeros they'd + // render as a real silent signal. + const SOURCES = { + // { t, duration, playing } — the transport position. + playhead() { + const hw = window.highway; + if (!hw || typeof hw.getTime !== 'function') return undefined; + const t = hw.getTime(); + if (!Number.isFinite(t)) return undefined; + const info = (typeof hw.getSongInfo === 'function' && hw.getSongInfo()) || {}; + const bus = window.feedBack; + return { + t: t, + duration: Number.isFinite(info.duration) ? info.duration : 0, + playing: !!(bus && bus.isPlaying), + }; + }, + + // { master } — 0..1 RMS of the master bus. + // + // Read from the stems plugin's analyser when present. It mutes the core + //