memlnaut-nisps/playground/js/ui/eoc-chain-ui.js

397 lines
13 KiB
JavaScript
Raw Normal View History

// eoc-chain-ui.js — Drawer UI for the End-of-Chain effects rack.
//
// Usage:
// import { EOCChainUI, moduleFactory } from './ui/eoc-chain-ui.js';
// EOCChainUI.init(chain, document.getElementById('eoc-drawer-body'));
//
// The drawer renders:
// 1. NISPS mode selector (Bypass / Shared / Linked / Independent)
// 2. Ordered, draggable list of active modules (bypass toggle, remove, param count)
// 3. Add-module bar (one button per available but absent module)
//
// Reacts to 'eoc:change' events on window to stay in sync with external mutations.
import { EOCModule } from '../eoc/eoc-module.js';
import { EQModule } from '../eoc/modules/eq-module.js';
import { CompressorModule } from '../eoc/modules/compressor-module.js';
import { ReverbModule } from '../eoc/modules/reverb-module.js';
import { DelayModule } from '../eoc/modules/delay-module.js';
import { SaturationModule } from '../eoc/modules/saturation-module.js';
import { MasterModule } from '../eoc/modules/master-module.js';
// ---------------------------------------------------------------------------
// Stub module factory
// ---------------------------------------------------------------------------
const STUB_DEFS = {
saturation: { displayName: 'Saturation', paramMeta: [] },
eq: { displayName: 'EQ', paramMeta: [] },
compressor: { displayName: 'Compressor', paramMeta: [] },
reverb: { displayName: 'Reverb', paramMeta: [] },
delay: { displayName: 'Delay', paramMeta: [] },
master: { displayName: 'Master Bus', paramMeta: [] },
};
// Canonical slot order (matches EOCChain's DEFAULT_ORDER)
const MODULE_ORDER = ['saturation', 'eq', 'compressor', 'reverb', 'delay', 'master'];
/**
* Build a minimal EOCModule stub for the given id.
* Exported so a-app.js can replace it with real implementations later.
*
* @param {string} id one of saturation | eq | compressor | reverb | delay | master
* @returns {EOCModule}
*/
export function moduleFactory(id) {
// Real Faust WASM implementations
switch (id) {
case 'eq': return new EQModule();
case 'compressor': return new CompressorModule();
case 'reverb': return new ReverbModule();
case 'delay': return new DelayModule();
case 'saturation': return new SaturationModule();
case 'master': return new MasterModule();
default: break;
}
const def = STUB_DEFS[id];
if (!def) throw new Error(`moduleFactory: unknown module id '${id}'`);
// Build stub class inline, scoped to the specific id/def
class StubModule extends EOCModule {
get id() { return id; }
get displayName() { return def.displayName; }
get paramMeta() { return def.paramMeta; }
async init(audioCtx) {
// Minimal super call to satisfy the base class contract without
// requiring a real AudioContext in stub/test scenarios
if (audioCtx && typeof audioCtx.createGain === 'function') {
await super.init(audioCtx);
this._finishInit();
} else {
this._initialized = true;
}
}
}
return new StubModule();
}
// ---------------------------------------------------------------------------
// EOCChainUI
// ---------------------------------------------------------------------------
export const EOCChainUI = {
/**
* @type {import('../eoc/eoc-chain.js').EOCChain|null}
*/
_chain: null,
/** @type {HTMLElement|null} */
_container: null,
/** Dragged module id during HTML5 DnD */
_dragId: null,
/**
* Initialise the UI.
*
* @param {import('../eoc/eoc-chain.js').EOCChain} chain
* @param {HTMLElement} containerEl
*/
init(chain, containerEl) {
this._chain = chain;
this._container = containerEl;
// Listen for external chain changes
window.addEventListener('eoc:change', () => this._render());
this._render();
},
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
_render() {
if (!this._chain || !this._container) return;
const c = this._container;
c.innerHTML = '';
c.appendChild(this._buildNispsBar());
c.appendChild(this._buildModuleList());
c.appendChild(this._buildAddBar());
},
// NISPS mode selector bar
_buildNispsBar() {
const modes = [
{ id: 'bypass', label: 'Bypass' },
{ id: 'shared', label: 'Shared' },
{ id: 'linked', label: 'Linked' },
{ id: 'independent', label: 'Indep' },
];
const bar = document.createElement('div');
bar.className = 'eoc-nisps-bar';
const label = document.createElement('span');
label.className = 'eoc-section-label';
label.textContent = 'NISPS';
bar.appendChild(label);
const toggle = document.createElement('div');
toggle.className = 'pill-toggle pill-toggle-sm eoc-nisps-toggle';
modes.forEach(({ id, label: lbl }) => {
const btn = document.createElement('button');
btn.className = 'pill-opt' + (this._chain.nispsMode === id ? ' active' : '');
btn.textContent = lbl;
btn.addEventListener('click', () => {
this._chain.nispsMode = id;
// re-render is triggered by eoc:change event from chain
});
toggle.appendChild(btn);
});
bar.appendChild(toggle);
return bar;
},
// Ordered list of module rows
_buildModuleList() {
const list = document.createElement('div');
list.className = 'eoc-module-list';
const modules = this._chain.modules;
if (modules.length === 0) {
const empty = document.createElement('div');
empty.className = 'eoc-empty-hint';
empty.textContent = 'No modules — add one below';
list.appendChild(empty);
return list;
}
modules.forEach((mod, idx) => {
const row = this._buildModuleRow(mod, idx, modules.length, list);
list.appendChild(row);
});
return list;
},
_buildModuleRow(mod, idx, totalCount, listEl) {
const row = document.createElement('div');
row.className = 'eoc-module-row';
row.dataset.id = mod.id;
row.draggable = true;
// --- Drag handle ---
const handle = document.createElement('span');
handle.className = 'eoc-drag-handle';
handle.title = 'Drag to reorder';
handle.textContent = '⠿';
row.appendChild(handle);
// --- Module name ---
const name = document.createElement('span');
name.className = 'eoc-module-name';
name.textContent = mod.displayName;
row.appendChild(name);
// --- Param count badge ---
const badge = document.createElement('span');
badge.className = 'eoc-param-badge';
badge.textContent = mod.paramCount === 1
? '1 param'
: `${mod.paramCount} params`;
row.appendChild(badge);
// --- Bypass toggle ---
const toggleLabel = document.createElement('label');
toggleLabel.className = 'eoc-bypass-toggle';
toggleLabel.title = mod.enabled ? 'Active — click to bypass' : 'Bypassed — click to enable';
const toggleInput = document.createElement('input');
toggleInput.type = 'checkbox';
toggleInput.checked = mod.enabled;
toggleInput.addEventListener('change', () => {
mod.enabled = toggleInput.checked;
// _applyBypass wires audio graph internally; dispatch change so UI refreshes
window.dispatchEvent(new CustomEvent('eoc:change', {
detail: { reason: 'bypass-toggled', chain: this._chain }
}));
});
const toggleSlider = document.createElement('span');
toggleSlider.className = 'eoc-bypass-slider';
toggleLabel.appendChild(toggleInput);
toggleLabel.appendChild(toggleSlider);
row.appendChild(toggleLabel);
// --- Remove button ---
const removeBtn = document.createElement('button');
removeBtn.className = 'eoc-remove-btn';
removeBtn.title = `Remove ${mod.displayName}`;
removeBtn.textContent = '×';
removeBtn.addEventListener('click', () => {
this._chain.removeModule(mod.id);
});
row.appendChild(removeBtn);
// --- Manual param sliders (shown in bypass mode, or when module has params) ---
if (mod.paramCount > 0) {
const paramsSection = this._buildParamSliders(mod);
row.appendChild(paramsSection);
}
// --- HTML5 Drag-and-drop ---
row.addEventListener('dragstart', (e) => {
this._dragId = mod.id;
row.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', mod.id);
});
row.addEventListener('dragend', () => {
this._dragId = null;
row.classList.remove('dragging');
// Clean up all drop indicators
listEl.querySelectorAll('.eoc-drop-indicator').forEach(el => el.remove());
});
row.addEventListener('dragover', (e) => {
if (!this._dragId || this._dragId === mod.id) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
// Show drop-indicator above or below based on cursor position
const rect = row.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
const insertBefore = e.clientY < midY;
// Remove all existing indicators then place one
listEl.querySelectorAll('.eoc-drop-indicator').forEach(el => el.remove());
const indicator = document.createElement('div');
indicator.className = 'eoc-drop-indicator';
if (insertBefore) {
listEl.insertBefore(indicator, row);
} else {
row.insertAdjacentElement('afterend', indicator);
}
});
row.addEventListener('drop', (e) => {
if (!this._dragId || this._dragId === mod.id) return;
e.preventDefault();
// Determine new position
const rect = row.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
const insertBefore = e.clientY < midY;
const allRows = [...listEl.querySelectorAll('.eoc-module-row')];
const targetIdx = allRows.findIndex(r => r.dataset.id === mod.id);
// After moving the dragged element out, the target index needs adjustment
const dragIdx = allRows.findIndex(r => r.dataset.id === this._dragId);
let newPos = insertBefore ? targetIdx : targetIdx + 1;
if (dragIdx < targetIdx) newPos -= 1; // account for removal shifting indices
this._chain.moveModule(this._dragId, Math.max(0, newPos));
// eoc:change will re-render
});
return row;
},
// Per-module param sliders (collapsible, shown only when paramCount > 0)
_buildParamSliders(mod) {
const section = document.createElement('div');
section.className = 'eoc-params-section';
// Toggle button (chevron + label)
const toggleBtn = document.createElement('button');
toggleBtn.className = 'eoc-params-toggle';
const isManual = this._chain.nispsMode === 'bypass';
toggleBtn.textContent = isManual ? '▶ params (manual)' : '▶ params';
toggleBtn.title = 'Expand to show parameter sliders';
const sliderList = document.createElement('div');
sliderList.className = 'eoc-params-list eoc-params-collapsed';
toggleBtn.addEventListener('click', () => {
const collapsed = sliderList.classList.toggle('eoc-params-collapsed');
toggleBtn.textContent = collapsed
? (isManual ? '▶ params (manual)' : '▶ params')
: (isManual ? '▼ params (manual)' : '▼ params');
});
// One slider per param
mod.paramMeta.forEach((meta, i) => {
const row = document.createElement('div');
row.className = 'eoc-param-row';
const label = document.createElement('label');
label.className = 'eoc-param-label';
label.textContent = meta.name ?? meta.id ?? `Param ${i}`;
const slider = document.createElement('input');
slider.type = 'range';
slider.min = '0';
slider.max = '1';
slider.step = '0.001';
slider.value = String(mod.getCurrentParamValue(i));
slider.className = 'eoc-param-slider';
slider.addEventListener('input', () => {
mod.setParam(i, parseFloat(slider.value));
});
row.appendChild(label);
row.appendChild(slider);
sliderList.appendChild(row);
});
section.appendChild(toggleBtn);
section.appendChild(sliderList);
return section;
},
// Add-module bar
_buildAddBar() {
const bar = document.createElement('div');
bar.className = 'eoc-add-bar';
const activeIds = new Set(this._chain.modules.map(m => m.id));
const available = MODULE_ORDER.filter(id => !activeIds.has(id));
if (available.length === 0) {
const hint = document.createElement('span');
hint.className = 'eoc-add-hint';
hint.textContent = 'All modules added';
bar.appendChild(hint);
return bar;
}
available.forEach(id => {
const def = STUB_DEFS[id];
if (!def) return;
const btn = document.createElement('button');
btn.className = 'action-btn eoc-add-btn';
btn.textContent = `+ ${def.displayName}`;
btn.title = `Add ${def.displayName} to chain`;
btn.addEventListener('click', () => {
this._chain.addModule(moduleFactory(id));
// eoc:change re-renders
});
bar.appendChild(btn);
});
return bar;
},
};