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;';
    }
}