components_embed_dialog.js

/**
 * EmbedDialog — modal for generating a "Live Quote" embed from a transcript selection.
 *
 * Two-column layout:
 *   Left:  scrollable segment list with include/exclude toggles + options form
 *   Right: live preview that mirrors the final embed widget exactly, using the
 *          project's audio + peaks as stand-ins for the not-yet-generated clip.
 *
 * @example
 * new EmbedDialog(selTarget, project, { getToken });
 */
export class EmbedDialog {
    /**
     * @param {object} selTarget - Selection target describing which segments to embed.
     * @param {object} project - The active project.
     * @param {object} [options] - Optional configuration.
     * @param {function} [options.getToken] - Async function returning the current auth token, or null.
     * @param {object} [options.wavesurfer] - WaveSurfer instance for preview playback.
     */
    constructor(selTarget, project, { getToken, wavesurfer } = {}) {
        this._getToken = getToken ?? (() => Promise.resolve(null));
        this._project  = project;
        this._ws       = wavesurfer ?? null;

        // Normalise selTarget to a range
        if ('segIdx' in selTarget) {
            this._segIdxStart = selTarget.segIdx;
            this._segIdxEnd   = selTarget.segIdx;
        } else {
            this._segIdxStart = selTarget.segIdxStart;
            this._segIdxEnd   = selTarget.segIdxEnd;
        }
        this._charStart = selTarget.charStart;
        this._charEnd   = selTarget.charEnd;

        this._segments   = project.transcript().segments;
        this._clipStart  = this._segments[this._segIdxStart].start;
        this._clipEnd    = this._segments[this._segIdxEnd].end;
        this._wsDuration = project.waveform().duration || 0;

        // Segment include/hide map
        this._includeMap = new Map();
        for (let i = this._segIdxStart; i <= this._segIdxEnd; i++) {
            this._includeMap.set(i, true);
        }

        // Trim checkboxes
        const firstSeg = this._segments[this._segIdxStart];
        const lastSeg  = this._segments[this._segIdxEnd];
        this._canTrimStart = this._charStart > 0;
        this._canTrimEnd   = this._segIdxStart === this._segIdxEnd
            ? this._charEnd < firstSeg.text.length
            : this._charEnd < lastSeg.text.length;
        this._trimStart = this._canTrimStart;
        this._trimEnd   = this._canTrimEnd;

        // Speaker attribution default
        const speakerId = firstSeg.speaker || '';
        const speakers  = typeof project.speakers === 'function' ? project.speakers() : (project.speakers || {});
        this._speakerAttrDefault = speakers[speakerId]?.name || '';

        // Default title: "<project> - <speaker> - P<para>, Ln<line>"
        const transcriptObj = project.transcript();
        let paraNum = 1, lineNum = 1;
        for (let pi = 0; pi < transcriptObj.paragraphs.length; pi++) {
            const si = transcriptObj.paragraphs[pi].segments.indexOf(firstSeg);
            if (si !== -1) { paraNum = pi + 1; lineNum = si + 1; break; }
        }
        const speakerPart = this._speakerAttrDefault ? ` - ${this._speakerAttrDefault}` : '';
        this._defaultTitle = `${project.projectName}${speakerPart} - \u00b6${paraNum}, \u2113${lineNum}`;
        this._embedTitle   = this._defaultTitle;

        // Options
        this._options = {
            audio_format:        'mp3',
            include_waveform:    true,
            font:                'Inter',
            include_quotes:      true,
            include_title:       false,
            download_button:     false,
            speaker_attribution: null,
        };

        // Wire playback events on the shared WaveSurfer instance
        this._wsHandlers = null;
        this.#wireAudio();

        // Live refs into the current preview DOM (swapped on each rebuild)
        this._previewPlayBtn = null;
        this._previewFill    = null;
        this._previewTimeEl  = null;
        this._previewCanvas  = null;
        this._previewSegEls  = [];

        this._busy = false;
        this.#build();
    }

    // ── Audio setup ───────────────────────────────────────────────────────────

    /**
     * Registers handlers on the persistent audio element.
     * Handler bodies reference `this._preview*` fields so they always
     * target whichever DOM elements are currently live after a rebuild.
     */
    #wireAudio() {
        if (!this._ws) return;
        const ws       = this._ws;
        const clipStart = this._clipStart;
        const clipEnd   = this._clipEnd;
        const clipDur   = clipEnd - clipStart;

        const onPlay  = () => {
            if (this._previewPlayBtn) this._previewPlayBtn.innerHTML = '⏸';
        };
        const onPause = () => {
            if (this._previewPlayBtn) this._previewPlayBtn.innerHTML = '▶';
        };
        const onTimeUpdate = (t) => {
            if (t >= clipEnd) {
                ws.pause();
                if (this._wsDuration > 0) ws.seekTo(clipStart / this._wsDuration);
                if (this._previewFill)   this._previewFill.style.width = '0%';
                if (this._previewTimeEl) this._previewTimeEl.textContent = '0:00';
                this._previewSegEls.forEach(el => el.classList.remove('active'));
                if (this._previewCanvas) this.#drawWaveform(this._previewCanvas, 0);
                return;
            }

            const tRel     = t - clipStart;
            const progress = tRel / clipDur;

            if (this._previewFill)   this._previewFill.style.width = (progress * 100) + '%';
            if (this._previewTimeEl) this._previewTimeEl.textContent = this.#fmtTime(tRel);

            this._previewSegEls.forEach(el => {
                const s = parseFloat(el.dataset.start);
                const e = parseFloat(el.dataset.end);
                el.classList.toggle('active', tRel >= s && tRel < e);
            });

            if (this._previewCanvas) this.#drawWaveform(this._previewCanvas, progress);
        };

        ws.on('play',         onPlay);
        ws.on('pause',        onPause);
        ws.on('audioprocess', onTimeUpdate);
        this._wsHandlers = { onPlay, onPause, onTimeUpdate };
    }

    // ── Waveform ──────────────────────────────────────────────────────────────

    /**
     * Extracts the waveform peak samples for the current clip range from the project.
     * @returns {number[]|null} Array of peak values, or null if unavailable.
     */
    #getClipPeaks() {
        const wf  = typeof this._project.waveform === 'function'
            ? this._project.waveform()
            : this._project.waveform;
        const all = wf?.peaks?.[0];  // peaks is [[...channel0...], ...]
        if (!all?.length) return null;
        const pps  = 140;
        const clip = Array.from(all).slice(
            Math.floor(this._clipStart * pps),
            Math.ceil(this._clipEnd   * pps),
        );
        return clip.length ? clip : null;
    }

    /**
     * Draws the clip waveform onto the given canvas element.
     * @param {HTMLCanvasElement} canvas - The canvas to draw on.
     * @param {number} [progress=0] - Playback progress fraction (0–1) for the played-colour overlay.
     */
    #drawWaveform(canvas, progress = 0) {
        const peaks = this.#getClipPeaks();
        const dpr   = window.devicePixelRatio || 1;
        const W     = canvas.width  = canvas.offsetWidth  * dpr;
        const H     = canvas.height = canvas.offsetHeight * dpr;
        if (!W || !H) return;

        const ctx = canvas.getContext('2d');
        ctx.clearRect(0, 0, W, H);

        if (!peaks) {
            ctx.fillStyle = '#e5e7eb';
            ctx.fillRect(0, H * 0.45, W, H * 0.1);
            if (progress > 0) {
                ctx.fillStyle = '#c8a84b';
                ctx.fillRect(0, H * 0.45, W * progress, H * 0.1);
            }
            return;
        }

        const barW    = W / peaks.length;
        const playedX = progress * W;
        for (let i = 0; i < peaks.length; i++) {
            const x = i * barW;
            const h = Math.max(2, peaks[i] * H * 0.85);
            ctx.fillStyle = x < playedX ? '#c8a84b' : '#d1d5db';
            ctx.fillRect(x, (H - h) / 2, Math.max(1, barW - 1), h);
        }
    }

    // ── Build ─────────────────────────────────────────────────────────────────

    /** Constructs and appends the dialog DOM to the document body. */
    #build() {
        this._scrim = document.createElement('div');
        this._scrim.className = 'embed-dialog-scrim';
        this._scrim.addEventListener('mousedown', (e) => {
            if (e.target === this._scrim) this.close();
        });

        const modal = document.createElement('div');
        modal.className = 'embed-dialog';

        const header = document.createElement('div');
        header.className = 'embed-dialog-header';
        header.textContent = 'Generate Live Quote';

        const body = document.createElement('div');
        body.className = 'embed-dialog-body';

        this._leftPanel  = this.#buildLeftPanel();
        this._rightPanel = this.#buildRightPanel();
        body.appendChild(this._leftPanel);
        body.appendChild(this._rightPanel);

        const footer = document.createElement('div');
        footer.className = 'embed-dialog-footer';

        this._cancelBtn = document.createElement('button');
        this._cancelBtn.className = 'btn btn-ghost';
        this._cancelBtn.textContent = 'Cancel';
        this._cancelBtn.addEventListener('click', () => this.close());

        this._submitBtn = document.createElement('button');
        this._submitBtn.className = 'btn btn-primary';
        this._submitBtn.textContent = 'Create Embed Code';
        this._submitBtn.addEventListener('click', () => this.#submit());

        footer.appendChild(this._cancelBtn);
        footer.appendChild(this._submitBtn);

        modal.appendChild(header);
        modal.appendChild(body);
        modal.appendChild(footer);
        this._scrim.appendChild(modal);
        document.body.appendChild(this._scrim);

        this.#updatePreview();
    }

    // ── Left panel ───────────────────────────────────────────────────────────

    /**
     * Builds the left panel containing the segment list, options, and duration bar.
     * @returns {HTMLElement}
     */
    #buildLeftPanel() {
        const panel = document.createElement('div');
        panel.className = 'embed-dialog-left';
        panel.appendChild(this.#buildSegmentList());
        panel.appendChild(this.#buildOptions());
        panel.appendChild(this.#buildDurationBar());
        return panel;
    }

    /**
     * Builds the scrollable segment list with include/exclude checkboxes and trim rows.
     * @returns {HTMLElement}
     */
    #buildSegmentList() {
        const list = document.createElement('div');
        list.className = 'embed-seg-list';

        const hint = document.createElement('p');
        hint.className   = 'embed-seg-hint';
        hint.textContent = 'Uncheck any segments to replace them with an ellipsis in the embed. Audio plays through regardless.';
        list.appendChild(hint);

        for (let i = this._segIdxStart; i <= this._segIdxEnd; i++) {
            const seg = this._segments[i];

            if (i === this._segIdxStart && this._canTrimStart) {
                list.appendChild(this.#buildTrimRow('start'));
            }

            const row = document.createElement('div');
            row.className = 'embed-seg-row';

            const checkbox = document.createElement('input');
            checkbox.type      = 'checkbox';
            checkbox.className = 'embed-seg-toggle';
            checkbox.checked   = true;
            checkbox.addEventListener('change', () => {
                this._includeMap.set(i, checkbox.checked);
                row.classList.toggle('embed-seg-row--excluded', !checkbox.checked);
                this.#updatePreview();
            });

            const textEl = document.createElement('span');
            textEl.className   = 'embed-seg-text';
            textEl.textContent = seg.text;

            const timeEl = document.createElement('span');
            timeEl.className   = 'embed-seg-time';
            timeEl.textContent = this.#fmtTime(seg.start);

            row.appendChild(checkbox);
            row.appendChild(textEl);
            row.appendChild(timeEl);
            list.appendChild(row);

            if (i === this._segIdxEnd && this._canTrimEnd) {
                list.appendChild(this.#buildTrimRow('end'));
            }
        }

        return list;
    }

    /**
     * Builds a trim checkbox row for the start or end of the selection.
     * @param {'start'|'end'} which - Whether this row controls the start or end trim.
     * @returns {HTMLElement}
     */
    #buildTrimRow(which) {
        const row = document.createElement('div');
        row.className = 'embed-trim-row';
        const label = document.createElement('label');
        const cb = document.createElement('input');
        cb.type    = 'checkbox';
        cb.checked = which === 'start' ? this._trimStart : this._trimEnd;
        cb.addEventListener('change', () => {
            if (which === 'start') this._trimStart = cb.checked;
            else                   this._trimEnd   = cb.checked;
            this.#updatePreview();
        });
        label.appendChild(cb);
        label.appendChild(document.createTextNode(
            which === 'start'
                ? 'Trim start (show \u2026 before selected text)'
                : 'Trim end (show selected text before \u2026)'
        ));
        row.appendChild(label);
        return row;
    }

    /**
     * Builds the options form section (speaker attribution, checkboxes, selects).
     * @returns {HTMLElement}
     */
    #buildOptions() {
        const wrap = document.createElement('div');
        wrap.className = 'embed-options';

        const title = document.createElement('div');
        title.className = 'embed-options-title';
        title.textContent = 'Options';
        wrap.appendChild(title);

        wrap.appendChild(this.#buildTitleRow());
        wrap.appendChild(this.#buildSpeakerAttributionRow());

        [
            this.#optionCheckbox('Include title in embed', 'include_title',
                (v) => { this._options.include_title = v; this.#updatePreview(); }),
            this.#optionCheckbox('Include quote marks', 'include_quotes',
                (v) => { this._options.include_quotes = v; this.#updatePreview(); }),
            this.#optionCheckbox('Include waveform', 'include_waveform',
                (v) => { this._options.include_waveform = v; this.#updatePreview(); }),
            this.#optionCheckbox('Download audio button', 'download_button',
                (v) => { this._options.download_button = v; this.#updatePreview(); }),
            this.#optionSelect('Font', 'font',
                ['Inter', 'IBM Plex Sans', 'IBM Plex Mono', 'Georgia'],
                (v) => { this._options.font = v; this.#updatePreview(); }),
            this.#optionSelect('Audio format', 'audio_format',
                ['mp3', 'webm'],
                (v) => { this._options.audio_format = v; }),
        ].forEach(r => wrap.appendChild(r));

        return wrap;
    }

    /**
     * Builds the quote name/title input row.
     * @returns {HTMLElement}
     */
    #buildTitleRow() {
        const wrap = document.createElement('div');
        wrap.className = 'embed-option-row embed-option-row--attribution';

        const label = document.createElement('label');
        label.className   = 'embed-attr-label';
        label.textContent = 'Quote name';

        const input = document.createElement('input');
        input.type        = 'text';
        input.className   = 'embed-attr-input';
        input.placeholder = 'Quote name';
        input.value       = this._defaultTitle;
        input.addEventListener('input', () => {
            this._embedTitle = input.value;
            this.#updatePreview();
        });

        wrap.appendChild(label);
        wrap.appendChild(input);
        return wrap;
    }

    /**
     * Builds the speaker attribution text input row.
     * @returns {HTMLElement}
     */
    #buildSpeakerAttributionRow() {
        const wrap = document.createElement('div');
        wrap.className = 'embed-option-row embed-option-row--attribution';

        const label = document.createElement('label');
        label.className = 'embed-attr-label';
        const cb = document.createElement('input');
        cb.type    = 'checkbox';
        cb.checked = false;
        label.appendChild(cb);
        label.appendChild(document.createTextNode(' Speaker attribution'));

        const input = document.createElement('input');
        input.type        = 'text';
        input.className   = 'embed-attr-input';
        input.placeholder = 'Speaker name';
        input.value       = this._speakerAttrDefault;
        input.disabled    = true;

        cb.addEventListener('change', () => {
            input.disabled = !cb.checked;
            this._options.speaker_attribution = cb.checked ? (input.value.trim() || null) : null;
            this.#updatePreview();
        });
        input.addEventListener('input', () => {
            if (cb.checked) {
                this._options.speaker_attribution = input.value.trim() || null;
                this.#updatePreview();
            }
        });

        wrap.appendChild(label);
        wrap.appendChild(input);
        return wrap;
    }

    /**
     * Builds a labelled checkbox option row.
     * @param {string} label - Display label for the option.
     * @param {string} key - Key in `this._options` to initialise from.
     * @param {function} onChange - Called with the new boolean value when the checkbox changes.
     * @returns {HTMLElement}
     */
    #optionCheckbox(label, key, onChange) {
        const row = document.createElement('div');
        row.className = 'embed-option-row';
        const lbl = document.createElement('label');
        const cb  = document.createElement('input');
        cb.type    = 'checkbox';
        cb.checked = this._options[key];
        cb.addEventListener('change', () => onChange(cb.checked));
        lbl.appendChild(cb);
        lbl.appendChild(document.createTextNode(' ' + label));
        row.appendChild(lbl);
        return row;
    }

    /**
     * Builds a labelled select option row.
     * @param {string} label - Display label for the option.
     * @param {string} key - Key in `this._options` to initialise from.
     * @param {string[]} choices - The available option values.
     * @param {function} onChange - Called with the selected string value when the select changes.
     * @returns {HTMLElement}
     */
    #optionSelect(label, key, choices, onChange) {
        const row = document.createElement('div');
        row.className = 'embed-option-row';
        const lbl = document.createElement('span');
        lbl.textContent = label;
        const sel = document.createElement('select');
        choices.forEach(c => {
            const opt = document.createElement('option');
            opt.value = c; opt.textContent = c;
            if (c === this._options[key]) opt.selected = true;
            sel.appendChild(opt);
        });
        sel.addEventListener('change', () => onChange(sel.value));
        row.appendChild(lbl);
        row.appendChild(sel);
        return row;
    }

    /**
     * Builds the clip duration indicator element.
     * @returns {HTMLElement}
     */
    #buildDurationBar() {
        this._durationEl = document.createElement('div');
        this._durationEl.className = 'embed-duration';
        this.#refreshDuration();
        return this._durationEl;
    }

    /** Updates the duration bar text and submit button state based on clip length. */
    #refreshDuration() {
        if (!this._durationEl) return;
        const dur    = this._clipEnd - this._clipStart;
        const secs   = dur.toFixed(1);
        const remain = (60 - dur).toFixed(1);
        this._durationEl.className = 'embed-duration';
        if (dur > 60) {
            this._durationEl.className += ' embed-duration--error';
            this._durationEl.textContent = `\u26a0 Clip is ${secs}s — exceeds the 60-second limit`;
            if (this._submitBtn) this._submitBtn.disabled = true;
        } else if (dur > 50) {
            this._durationEl.className += ' embed-duration--warn';
            this._durationEl.textContent = `Clip: ${secs}s (${remain}s remaining)`;
            if (this._submitBtn) this._submitBtn.disabled = false;
        } else {
            this._durationEl.textContent = `Clip: ${secs}s`;
            if (this._submitBtn) this._submitBtn.disabled = false;
        }
    }

    // ── Right panel ──────────────────────────────────────────────────────────

    /**
     * Builds the right preview panel containing the live embed preview.
     * @returns {HTMLElement}
     */
    #buildRightPanel() {
        const panel = document.createElement('div');
        panel.className = 'embed-dialog-right';

        const label = document.createElement('div');
        label.className = 'embed-preview-label';
        label.textContent = 'Preview';

        this._previewEl = document.createElement('div');
        this._previewEl.className = 'embed-preview';

        panel.appendChild(label);
        panel.appendChild(this._previewEl);
        return panel;
    }

    // ── Live preview ─────────────────────────────────────────────────────────

    /**
     * Rebuilds the full .lq-widget preview using the same DOM structure as the
     * generated embed, but wired to the project audio (seeking to the clip range)
     * and project peaks (sliced to the clip range) instead of server-generated data.
     */
    #updatePreview() {
        if (!this._previewEl) return;

        // Keep audio playing across rebuilds — just swap the DOM refs below
        const wasPlaying = this._ws?.isPlaying() ?? false;

        const config  = this.#computeSegmentsConfig();
        const options = this._options;
        this._previewEl.innerHTML = '';

        const widget = document.createElement('div');
        widget.className   = 'lq-widget';
        widget.style.fontFamily = `'${options.font}', system-ui, sans-serif`;

        // ── Transcript ──────────────────────────────────────────────────────
        const transcript = document.createElement('div');
        transcript.className = 'lq-transcript';

        if (options.include_quotes) {
            const oq = document.createElement('span');
            oq.className = 'lq-quote lq-quote-open';
            oq.textContent = '\u201c';
            transcript.appendChild(oq);
        }

        const segEls = [];
        let ellipsisPending = false;

        config.forEach(sc => {
            if (!sc.included) {
                ellipsisPending = true;
            } else {
                if (ellipsisPending) {
                    const ell = document.createElement('span');
                    ell.className = 'lq-ellipsis';
                    ell.textContent = '\u2026';
                    transcript.appendChild(ell);
                    ellipsisPending = false;
                }
                const seg  = this._segments[sc.segment_idx];
                const span = document.createElement('span');
                span.className     = 'lq-seg';
                span.dataset.start = (seg.start - this._clipStart).toFixed(3);
                span.dataset.end   = (seg.end   - this._clipStart).toFixed(3);
                span.textContent   = sc.display_text || seg.text;
                // Clicking a segment seeks to it
                span.addEventListener('click', () => {
                    if (this._ws && this._wsDuration > 0) {
                        this._ws.seekTo(seg.start / this._wsDuration);
                        if (!this._ws.isPlaying()) this._ws.play();
                    }
                });
                segEls.push(span);
                transcript.appendChild(span);
            }
        });

        if (ellipsisPending) {
            const ell = document.createElement('span');
            ell.className = 'lq-ellipsis';
            ell.textContent = '\u2026';
            transcript.appendChild(ell);
        }

        if (options.include_quotes) {
            const cq = document.createElement('span');
            cq.className = 'lq-quote lq-quote-close';
            cq.textContent = '\u201d';
            transcript.appendChild(cq);
        }

        // ── Title ────────────────────────────────────────────────────────
        if (options.include_title && this._embedTitle) {
            const titleEl = document.createElement('div');
            titleEl.className   = 'lq-title';
            titleEl.textContent = this._embedTitle;
            widget.appendChild(titleEl);
        }

        widget.appendChild(transcript);

        // ── Attribution ──────────────────────────────────────────────────
        if (options.speaker_attribution) {
            const attr = document.createElement('div');
            attr.className   = 'lq-attribution';
            attr.textContent = '\u2014 ' + options.speaker_attribution;
            widget.appendChild(attr);
        }

        // ── Waveform canvas ──────────────────────────────────────────────
        const canvas = document.createElement('canvas');
        canvas.className     = 'lq-waveform';
        canvas.style.display = options.include_waveform ? 'block' : 'none';
        canvas.style.cursor  = 'pointer';
        canvas.addEventListener('click', (e) => {
            if (!this._ws || !this._wsDuration) return;
            const r    = canvas.getBoundingClientRect();
            const frac = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width));
            this._ws.seekTo((this._clipStart + frac * (this._clipEnd - this._clipStart)) / this._wsDuration);
        });
        widget.appendChild(canvas);

        // ── Controls ─────────────────────────────────────────────────────
        const controls = document.createElement('div');
        controls.className = 'lq-controls';

        const playBtn = document.createElement('button');
        playBtn.className = 'lq-play';
        playBtn.innerHTML = wasPlaying ? '⏸' : '▶';
        playBtn.addEventListener('click', () => this.#playPause());

        const progressWrap = document.createElement('div');
        progressWrap.className = 'lq-progress-wrap';
        const fill = document.createElement('div');
        fill.className = 'lq-progress-fill';
        // Restore progress position if ws is mid-clip
        const wsT = this._ws?.getCurrentTime() ?? 0;
        if (wsT >= this._clipStart && wsT < this._clipEnd) {
            const p = (wsT - this._clipStart) / (this._clipEnd - this._clipStart);
            fill.style.width = (p * 100) + '%';
        }
        progressWrap.appendChild(fill);
        progressWrap.addEventListener('click', (e) => {
            if (!this._ws || !this._wsDuration) return;
            const r    = progressWrap.getBoundingClientRect();
            const frac = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width));
            this._ws.seekTo((this._clipStart + frac * (this._clipEnd - this._clipStart)) / this._wsDuration);
        });

        const timeEl = document.createElement('span');
        timeEl.className = 'lq-time';
        const tRel = Math.max(0, wsT - this._clipStart);
        timeEl.textContent = this.#fmtTime(
            (wsT >= this._clipStart && wsT < this._clipEnd) ? tRel : 0
        );

        if (options.download_button) {
            const dl = document.createElement('span');
            dl.className   = 'lq-download';
            dl.textContent = '\u2193';
            dl.title       = 'Available after generating embed';
            controls.appendChild(playBtn);
            controls.appendChild(progressWrap);
            controls.appendChild(timeEl);
            controls.appendChild(dl);
        } else {
            controls.appendChild(playBtn);
            controls.appendChild(progressWrap);
            controls.appendChild(timeEl);
        }

        widget.appendChild(controls);

        // ── Footer ────────────────────────────────────────────────────────
        const footer = document.createElement('div');
        footer.className = 'lq-footer';
        const link = document.createElement('a');
        link.href        = 'https://waveformstudio.app';
        link.target      = '_blank';
        link.textContent = 'Waveform Studio - Live Quote';
        footer.appendChild(link);
        widget.appendChild(footer);

        this._previewEl.appendChild(widget);

        // Swap live DOM refs so the persistent audio handlers target new elements
        this._previewPlayBtn = playBtn;
        this._previewFill    = fill;
        this._previewTimeEl  = timeEl;
        this._previewCanvas  = canvas;
        this._previewSegEls  = segEls;

        // Draw waveform after layout so canvas.offsetWidth is non-zero
        if (options.include_waveform) {
            requestAnimationFrame(() => requestAnimationFrame(() => {
                const ct = this._ws?.getCurrentTime() ?? 0;
                const p  = (ct >= this._clipStart && ct < this._clipEnd)
                    ? (ct - this._clipStart) / (this._clipEnd - this._clipStart)
                    : 0;
                this.#drawWaveform(canvas, p);
            }));
        }
    }

    // ── Segments config ───────────────────────────────────────────────────────

    /**
     * Builds the segments_config array for the API request, applying include/exclude state and trim overrides.
     * @returns {object[]} Array of segment config objects for the embed API.
     */
    #computeSegmentsConfig() {
        const config = [];
        const single = this._segIdxStart === this._segIdxEnd;

        for (let i = this._segIdxStart; i <= this._segIdxEnd; i++) {
            const seg      = this._segments[i];
            const included = this._includeMap.get(i) !== false;
            let displayText = null;

            const isFirst = i === this._segIdxStart;
            const isLast  = i === this._segIdxEnd;

            if (single) {
                const applyStart = this._trimStart && this._charStart > 0;
                const applyEnd   = this._trimEnd   && this._charEnd < seg.text.length;
                if (applyStart || applyEnd) {
                    const from = applyStart ? this._charStart : 0;
                    const to   = applyEnd   ? this._charEnd   : seg.text.length;
                    displayText = (applyStart ? '\u2026' : '') + seg.text.slice(from, to) + (applyEnd ? '\u2026' : '');
                }
            } else {
                if (isFirst && this._trimStart && this._charStart > 0) {
                    displayText = '\u2026' + seg.text.slice(this._charStart);
                } else if (isLast && this._trimEnd && this._charEnd < seg.text.length) {
                    displayText = seg.text.slice(0, this._charEnd) + '\u2026';
                }
            }

            config.push({ segment_idx: i, included, display_text: displayText });
        }

        return config;
    }

    // ── Submission ────────────────────────────────────────────────────────────

    /** Submits the embed creation request to the server and shows the resulting snippet. */
    async #submit() {
        if (this._busy) return;
        this._busy = true;
        this._submitBtn.disabled = true;
        this._submitBtn.innerHTML = '<span class="embed-spinner"></span> Generating\u2026';
        this.#clearError();

        if (this._ws?.isPlaying()) this._ws.pause();

        const body = {
            project_id:      this._project.projectId,
            segments_config: this.#computeSegmentsConfig(),
            name:            this._embedTitle,
            options:         { ...this._options, title: this._embedTitle },
        };

        try {
            const token = await this._getToken();
            const headers = { 'Content-Type': 'application/json' };
            if (token) headers['X-Auth-Token'] = token;

            const resp = await fetch('/api/embeds', { method: 'POST', headers, body: JSON.stringify(body) });
            const data = await resp.json();
            if (!resp.ok) throw new Error(data.error || `Server error ${resp.status}`);
            this.#showSnippet(data.url);
        } catch (err) {
            this.#showError(err.message || 'Failed to create embed');
            this._submitBtn.disabled = false;
            this._submitBtn.textContent = 'Create Embed Code';
            this._busy = false;
        }
    }

    /**
     * Replaces the right panel with the generated embed code snippet and copy button.
     * @param {string} embedUrl - The server-relative URL of the generated embed.
     */
    #showSnippet(embedUrl) {
        const fullUrl = `${window.location.origin}${embedUrl}`;
        const snippet = `<iframe src="${fullUrl}" width="660" height="280" frameborder="0" scrolling="no" style="border:none;max-width:100%;border-radius:12px;"></iframe>`;

        this._rightPanel.innerHTML = '';

        const wrap = document.createElement('div');
        wrap.className = 'embed-snippet-wrap';

        const label = document.createElement('div');
        label.className = 'embed-snippet-label';
        label.textContent = 'Embed Code';

        const textarea = document.createElement('textarea');
        textarea.className = 'embed-snippet-code';
        textarea.readOnly  = true;
        textarea.rows      = 4;
        textarea.value     = snippet;

        const copyBtn = document.createElement('button');
        copyBtn.className   = 'btn btn-secondary embed-copy-btn';
        copyBtn.textContent = 'Copy to clipboard';
        copyBtn.addEventListener('click', () => {
            navigator.clipboard.writeText(snippet).then(() => {
                copyBtn.textContent = 'Copied!';
                setTimeout(() => { copyBtn.textContent = 'Copy to clipboard'; }, 2000);
            });
        });

        const linkLabel = document.createElement('div');
        linkLabel.className = 'embed-snippet-label';
        linkLabel.textContent = 'Embed Link';

        const linkInput = document.createElement('input');
        linkInput.type      = 'text';
        linkInput.className = 'embed-snippet-link';
        linkInput.readOnly  = true;
        linkInput.value     = fullUrl;

        const copyLinkBtn = document.createElement('button');
        copyLinkBtn.className   = 'btn btn-secondary embed-copy-btn';
        copyLinkBtn.textContent = 'Copy link';
        copyLinkBtn.addEventListener('click', () => {
            navigator.clipboard.writeText(fullUrl).then(() => {
                copyLinkBtn.textContent = 'Copied!';
                setTimeout(() => { copyLinkBtn.textContent = 'Copy link'; }, 2000);
            });
        });

        wrap.appendChild(label);
        wrap.appendChild(textarea);
        wrap.appendChild(copyBtn);
        wrap.appendChild(linkLabel);
        wrap.appendChild(linkInput);
        wrap.appendChild(copyLinkBtn);
        this._rightPanel.appendChild(wrap);

        this._submitBtn.style.display = 'none';
        this._cancelBtn.textContent   = 'Done';

        requestAnimationFrame(() => textarea.select());
    }

    /**
     * Displays an error message in the right panel.
     * @param {string} msg - The error message to display.
     */
    #showError(msg) {
        this.#clearError();
        const err = document.createElement('div');
        err.className   = 'embed-error';
        err.textContent = '\u26a0 ' + msg;
        this._rightPanel.appendChild(err);
    }

    /** Removes any existing error elements from the right panel. */
    #clearError() {
        this._rightPanel.querySelectorAll('.embed-error').forEach(el => el.remove());
    }

    // ── Helpers ───────────────────────────────────────────────────────────────

    /** Toggles WaveSurfer playback, seeking to the clip start if the playhead is outside the clip. */
    #playPause() {
        const ws = this._ws;
        if (!ws) return;
        if (ws.isPlaying()) { ws.pause(); return; }
        const t = ws.getCurrentTime();
        if (t < this._clipStart || t >= this._clipEnd) {
            if (this._wsDuration > 0) ws.seekTo(this._clipStart / this._wsDuration);
        }
        ws.play();
    }

    /**
     * Formats a time in seconds as M:SS.
     * @param {number} secs - Time in seconds.
     * @returns {string}
     */
    #fmtTime(secs) {
        const m = Math.floor(secs / 60);
        const s = Math.floor(secs % 60);
        return m + ':' + String(s).padStart(2, '0');
    }

    /** Detaches WaveSurfer event listeners and removes the dialog from the DOM. */
    close() {
        if (this._ws && this._wsHandlers) {
            this._ws.un('play',         this._wsHandlers.onPlay);
            this._ws.un('pause',        this._wsHandlers.onPause);
            this._ws.un('audioprocess', this._wsHandlers.onTimeUpdate);
            this._wsHandlers = null;
        }
        this._scrim.remove();
    }
}