components_apply_effect_dialog.js
/**
* Modal dialog for applying an effect chain to a transcript selection.
*
* Shows a chain picker, then dynamically renders that chain's controls
* (sliders for float/int, checkbox for bool) and preset buttons.
* A Preview button fetches and plays the processed audio without committing.
*
* @example
* new ApplyEffectDialog(chains, {
* fetchPreview: ({ chainId, controls }) => server.previewEffect(...),
* onApply: ({ chainId, controls }) => { ... },
* onDismiss: () => { ... },
* });
*
* // Edit mode — pre-select chain and populate existing control values:
* new ApplyEffectDialog(chains, {
* title: 'Edit Effect',
* initialChainId: 'voice_changer',
* initialControls: { pitch: -4, formant_compensation: 2 },
* ...
* });
*/
export class ApplyEffectDialog {
/**
* @param {Array<object>} chains - Effect chain metadata from GET /api/effect-chains.
* @param {object} callbacks - Lifecycle callbacks for the dialog.
* @param {function} callbacks.fetchPreview - async ({ chainId, controls }) => Blob
* @param {function} callbacks.onApply - Called with { chainId, controls }.
* @param {function} [callbacks.onDismiss] - Called when the dialog is dismissed without applying.
* @param {string} [callbacks.title] - Dialog header text (default: "Apply Effect")
* @param {string} [callbacks.initialChainId] - Pre-select this chain on open.
* @param {object} [callbacks.initialControls] - Override control defaults when pre-selecting.
*/
constructor(chains, { fetchPreview, onApply, onDismiss, title, initialChainId, initialControls } = {}) {
this._chains = chains;
this._fetchPreview = fetchPreview ?? null;
this._onApply = onApply ?? (() => {});
this._onDismiss = onDismiss ?? (() => {});
this._title = title ?? 'Apply Effect';
this._initialChainId = initialChainId ?? null;
this._initialControls = initialControls ?? null;
this._controlValues = {};
this._audio = null; // current HTMLAudioElement
this._previewUrl = null; // current blob URL
this.#buildDialog();
}
/** Removes the dialog from the DOM and stops any in-progress preview. */
close() {
this.#stopAudio();
this._overlay?.remove();
}
// ── Private ────────────────────────────────────────────────────────────────
/** Constructs and appends the dialog overlay and modal to the document body. */
#buildDialog() {
const overlay = document.createElement('div');
overlay.className = 'confirm-dialog-overlay';
this._overlay = overlay;
const modal = document.createElement('div');
modal.className = 'confirm-dialog-modal';
modal.style.cssText = 'max-height:none;width:400px;padding:0;';
const header = document.createElement('div');
header.className = 'confirm-dialog-header';
header.innerHTML = `<span>${this._title}</span>`;
modal.appendChild(header);
const body = document.createElement('div');
body.style.cssText = 'padding:1rem 1.25rem;display:flex;flex-direction:column;gap:0.85rem;';
// ── Chain picker ───────────────────────────────────────────────────────
const pickerLabel = document.createElement('label');
pickerLabel.style.cssText = this.#labelStyle();
pickerLabel.textContent = 'Effect';
const picker = document.createElement('select');
picker.style.cssText = 'background:var(--surface);border:1px solid var(--border);border-radius:2px;' +
'color:var(--text);font-family:var(--font-mono);font-size:var(--fs-caption);' +
'padding:0.3rem 0.5rem;width:100%;outline:none;cursor:pointer;';
const placeholder = document.createElement('option');
placeholder.value = '';
placeholder.textContent = '— choose an effect —';
placeholder.disabled = true;
placeholder.selected = true;
picker.appendChild(placeholder);
for (const chain of this._chains) {
const opt = document.createElement('option');
opt.value = chain.id;
opt.textContent = chain.name;
picker.appendChild(opt);
}
pickerLabel.appendChild(picker);
body.appendChild(pickerLabel);
// ── Controls area ──────────────────────────────────────────────────────
const controlsArea = document.createElement('div');
controlsArea.style.cssText = 'display:flex;flex-direction:column;gap:0.75rem;';
body.appendChild(controlsArea);
// ── Actions ────────────────────────────────────────────────────────────
const actions = document.createElement('div');
actions.style.cssText = 'display:flex;gap:0.5rem;align-items:center;margin-top:0.25rem;';
// Preview — left-aligned, pushed right by margin-right:auto on the right group
const previewBtn = document.createElement('button');
previewBtn.className = 'sample-btn';
previewBtn.textContent = 'Preview';
previewBtn.disabled = true;
previewBtn.style.cssText = 'padding:0.35rem 0.75rem;font-size:var(--fs-label);margin-right:auto;';
previewBtn.addEventListener('click', () => this.#handlePreview(picker.value, previewBtn));
actions.appendChild(previewBtn);
const cancelBtn = document.createElement('button');
cancelBtn.className = 'sample-btn';
cancelBtn.textContent = 'Cancel';
cancelBtn.style.cssText = 'padding:0.35rem 0.75rem;font-size:var(--fs-label);';
cancelBtn.addEventListener('click', () => { this.close(); this._onDismiss(); });
actions.appendChild(cancelBtn);
const applyBtn = document.createElement('button');
applyBtn.className = 'sample-btn';
applyBtn.textContent = 'Apply';
applyBtn.disabled = true;
applyBtn.style.cssText = 'padding:0.35rem 0.75rem;font-size:var(--fs-label);' +
'border-color:var(--accent2-border);color:var(--accent2);';
applyBtn.onmouseenter = () => {
if (!applyBtn.disabled) {
applyBtn.style.background = 'var(--accent2-faint)';
applyBtn.style.borderColor = 'var(--accent2-glow)';
}
};
applyBtn.onmouseleave = () => {
applyBtn.style.background = '';
applyBtn.style.borderColor = 'var(--accent2-border)';
};
applyBtn.addEventListener('click', () => {
if (!picker.value) return;
this.close();
this._onApply({ chainId: picker.value, controls: { ...this._controlValues } });
});
actions.appendChild(applyBtn);
picker.addEventListener('change', () => {
this.#stopAudio();
const chain = this._chains.find(c => c.id === picker.value);
if (!chain) return;
this._controlValues = Object.fromEntries(
(chain.controls ?? []).map(c => [c.id, c.default])
);
this.#renderControls(controlsArea, chain);
previewBtn.disabled = false;
applyBtn.disabled = false;
});
body.appendChild(actions);
modal.appendChild(body);
overlay.appendChild(modal);
let _mouseDownOnOverlay = false;
overlay.addEventListener('mousedown', (e) => { _mouseDownOnOverlay = e.target === overlay; });
overlay.addEventListener('click', (e) => {
if (e.target === overlay && _mouseDownOnOverlay) { this.close(); this._onDismiss(); }
});
document.body.appendChild(overlay);
const autoSelectId = this._initialChainId
?? (this._chains.length === 1 ? this._chains[0].id : null);
if (autoSelectId) {
picker.value = autoSelectId;
picker.dispatchEvent(new Event('change'));
if (this._initialControls) {
Object.assign(this._controlValues, this._initialControls);
const chain = this._chains.find(c => c.id === autoSelectId);
if (chain) this.#renderControls(controlsArea, chain);
}
}
}
/**
* Handles the preview button: fetches and plays (or stops) the effect preview audio.
* @param {string} chainId - ID of the selected effect chain.
* @param {HTMLElement} btn - The preview button element.
*/
async #handlePreview(chainId, btn) {
if (!chainId || !this._fetchPreview) return;
// If already playing, stop
if (this._audio && !this._audio.paused) {
this.#stopAudio();
btn.textContent = 'Preview';
return;
}
btn.textContent = 'Loading…';
btn.disabled = true;
try {
const blob = await this._fetchPreview({ chainId, controls: { ...this._controlValues } });
this.#stopAudio();
const url = URL.createObjectURL(blob);
const audio = new Audio(url);
this._audio = audio;
this._previewUrl = url;
audio.addEventListener('ended', () => {
btn.textContent = 'Preview';
btn.disabled = false;
this.#stopAudio();
});
audio.addEventListener('error', () => {
btn.textContent = 'Preview';
btn.disabled = false;
});
await audio.play();
btn.textContent = 'Stop';
btn.disabled = false;
} catch (e) {
console.error('Effect preview failed:', e);
btn.textContent = 'Preview';
btn.disabled = false;
}
}
/** Pauses and releases any in-progress preview audio and revokes its blob URL. */
#stopAudio() {
if (this._audio) {
this._audio.pause();
this._audio = null;
}
if (this._previewUrl) {
URL.revokeObjectURL(this._previewUrl);
this._previewUrl = null;
}
}
/**
* Renders preset buttons and control sliders for a chain into the given container.
* @param {HTMLElement} container - Element to render controls into.
* @param {object} chain - Effect chain metadata object.
*/
#renderControls(container, chain) {
container.innerHTML = '';
// ── Presets ────────────────────────────────────────────────────────────
const presets = chain.presets ?? [];
if (presets.length) {
const presetRow = document.createElement('div');
presetRow.style.cssText = 'display:flex;flex-wrap:wrap;gap:0.4rem;align-items:center;';
const presetLabel = document.createElement('span');
presetLabel.style.cssText = 'font-family:var(--font-mono);font-size:var(--fs-hint);' +
'color:var(--muted);letter-spacing:0.04em;margin-right:0.15rem;';
presetLabel.textContent = 'Presets';
presetRow.appendChild(presetLabel);
for (const preset of presets) {
const btn = document.createElement('button');
btn.className = 'sample-btn';
btn.textContent = preset.label;
btn.style.cssText = 'padding:0.2rem 0.55rem;font-size:var(--fs-hint);';
btn.addEventListener('click', () => {
for (const [id, val] of Object.entries(preset.controls ?? {})) {
this._controlValues[id] = val;
}
this.#stopAudio();
this.#renderControls(container, chain);
});
presetRow.appendChild(btn);
}
container.appendChild(presetRow);
}
// ── Controls ───────────────────────────────────────────────────────────
for (const ctrl of chain.controls ?? []) {
container.appendChild(this.#makeControlRow(ctrl));
}
}
/**
* Builds a labeled slider row for a single effect control.
* @param {object} ctrl - Control descriptor from the chain metadata.
* @returns {HTMLElement} The label element containing the slider.
*/
#makeControlRow(ctrl) {
const row = document.createElement('label');
row.style.cssText = this.#labelStyle();
const header = document.createElement('div');
header.style.cssText = 'display:flex;justify-content:space-between;align-items:baseline;';
const nameSpan = document.createElement('span');
nameSpan.textContent = ctrl.label;
const valueDisplay = document.createElement('span');
valueDisplay.style.cssText = 'color:var(--text);min-width:3em;text-align:right;';
header.appendChild(nameSpan);
header.appendChild(valueDisplay);
row.appendChild(header);
if (ctrl.type === 'bool') {
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = !!this._controlValues[ctrl.id];
checkbox.style.cssText = 'margin-top:0.25rem;accent-color:var(--accent2);';
valueDisplay.textContent = checkbox.checked ? 'on' : 'off';
checkbox.addEventListener('change', () => {
this._controlValues[ctrl.id] = checkbox.checked;
valueDisplay.textContent = checkbox.checked ? 'on' : 'off';
this.#stopAudio();
});
row.appendChild(checkbox);
} else {
const slider = document.createElement('input');
slider.type = 'range';
slider.min = ctrl.min ?? 0;
slider.max = ctrl.max ?? 1;
slider.step = ctrl.type === 'int' ? 1 : 0.01;
slider.value = this._controlValues[ctrl.id] ?? ctrl.default;
slider.style.cssText = 'width:100%;accent-color:var(--accent2);margin-top:0.2rem;';
const fmt = (v) => ctrl.unit ? `${parseFloat(v).toFixed(2)} ${ctrl.unit}`
: parseFloat(v).toFixed(ctrl.type === 'int' ? 0 : 2);
valueDisplay.textContent = fmt(slider.value);
slider.addEventListener('input', () => {
const v = ctrl.type === 'int' ? parseInt(slider.value, 10) : parseFloat(slider.value);
this._controlValues[ctrl.id] = v;
valueDisplay.textContent = fmt(slider.value);
this.#stopAudio();
});
row.appendChild(slider);
}
return row;
}
/**
* Returns the shared inline CSS string for control label rows.
* @returns {string} CSS text for a label row element.
*/
#labelStyle() {
return 'display:flex;flex-direction:column;gap:0.3rem;font-family:var(--font-mono);' +
'font-size:var(--fs-caption);color:var(--muted);letter-spacing:0.04em;';
}
}