components_transcribe_dialog.js

import { WHISPER_MODELS, PYANNOTE_MODELS, estimateCost, estimateTime, fmtCost, fmtDur } from '../utilities/transcription_pricing.js';

/**
 * Options dialog shown before starting a Modal transcription job.
 * Displays audio stats, model selectors, speaker options, and a live cost estimate.
 */
export class TranscribeDialog {
    /**
     * @param {object} options - configuration for the dialog
     * @param {number}   options.audioDuration    - audio duration in seconds (-1 if unknown)
     * @param {boolean}  options.hasVoiceSamples  - whether any speaker has a voice sample attached
     * @param {string[]|null} [options.allowedModels] - whisper model keys allowed by the user's plan;
     *                                                   null means no restriction (show all)
     * @param {function} options.onConfirm        - called with transcription options when confirmed
     * @param {function} [options.onDismiss]      - called when the dialog is cancelled
     */
    constructor({ audioDuration, hasVoiceSamples, allowedModels = null, onConfirm, onDismiss }) {
        this._audioDuration   = audioDuration;
        this._hasVoiceSamples = hasVoiceSamples;
        this._allowedModels   = allowedModels;
        this._onConfirm       = onConfirm  ?? (() => {});
        this._onDismiss       = onDismiss  ?? (() => {});
        this.#build();
    }

    /** Builds and appends the dialog overlay to the document body. */
    #build() {
        const overlay = document.createElement('div');
        overlay.className = 'confirm-dialog-overlay';

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

        // ── Header ────────────────────────────────────────────────────────────
        const header = document.createElement('div');
        header.className = 'confirm-dialog-header';
        header.innerHTML = '<span>◎ Transcribe Audio</span>';
        modal.appendChild(header);

        // ── Body ──────────────────────────────────────────────────────────────
        const body = document.createElement('div');
        body.className = 'transcribe-dialog-body';

        // Audio stats
        body.appendChild(this.#buildStats());

        // Options grid
        const grid = document.createElement('div');
        grid.className = 'transcribe-dialog-grid';
        const availableWhisperModels = this._allowedModels
            ? WHISPER_MODELS.filter(m => this._allowedModels.includes(m.key))
            : WHISPER_MODELS;
        const defaultWhisper = availableWhisperModels.some(m => m.key === 'medium')
            ? 'medium'
            : availableWhisperModels.at(-1)?.key ?? 'medium';
        grid.appendChild(this.#makeSelectRow('Whisper model',      'td-whisper',
            availableWhisperModels.map(m => ({ value: m.key, label: m.label })), defaultWhisper));
        grid.appendChild(this.#makeSelectRow('Diarization model',  'td-pyannote',
            PYANNOTE_MODELS.map(m => ({ value: m.key, label: m.label })), PYANNOTE_MODELS[0].key));
        grid.appendChild(this.#makeSpeakerCountRow());
        grid.appendChild(this.#makeVoiceSamplesRow());
        body.appendChild(grid);

        // Cost estimate
        body.appendChild(this.#buildEstimate());

        // Actions
        body.appendChild(this.#buildActions(overlay, modal));

        modal.appendChild(body);
        overlay.appendChild(modal);

        // Dismiss on backdrop click (only when mousedown also started on the overlay)
        let _mouseDownOnOverlay = false;
        overlay.addEventListener('mousedown', (e) => { _mouseDownOnOverlay = e.target === overlay; });
        overlay.addEventListener('click', (e) => {
            if (e.target === overlay && _mouseDownOnOverlay) { overlay.remove(); this._onDismiss(); }
        });

        // Wire live estimate updates
        const update = () => this.#updateEstimate(modal);
        modal.querySelector('#td-whisper').addEventListener('change', update);
        modal.querySelector('#td-pyannote').addEventListener('change', update);
        this.#updateEstimate(modal);

        document.body.appendChild(overlay);
    }

    /** @returns {HTMLElement} audio stats section showing duration. */
    #buildStats() {
        const section = document.createElement('div');
        section.className = 'transcribe-dialog-stats';
        const dur = this._audioDuration > 0 ? fmtDur(this._audioDuration) : '—';
        section.innerHTML = `
            <div class="transcribe-stat">
                <span class="transcribe-stat-label">audio duration</span>
                <span class="transcribe-stat-value">${dur}</span>
            </div>`;
        return section;
    }

    /** @returns {HTMLElement} cost/time estimate section with placeholder spans. */
    #buildEstimate() {
        const section = document.createElement('div');
        section.className = 'transcribe-dialog-estimate';
        section.innerHTML = `
            <div class="transcribe-estimate-row">
                <span class="transcribe-estimate-label">est. cost</span>
                <span class="transcribe-estimate-value" id="td-est-cost">—</span>
            </div>
            <div class="transcribe-estimate-row">
                <span class="transcribe-estimate-label">est. time</span>
                <span class="transcribe-estimate-value" id="td-est-time">—</span>
            </div>
            <div class="transcribe-estimate-note">Estimates assume A10G GPU. Actual costs vary.</div>`;
        return section;
    }

    /**
     * @param {HTMLElement} overlay - the backdrop element to remove on action
     * @param {HTMLElement} modal - the modal element used to read form values
     * @returns {HTMLElement} actions row with Cancel and Confirm buttons
     */
    #buildActions(overlay, modal) {
        const actions = document.createElement('div');
        actions.className = 'transcribe-dialog-actions';

        const cancelBtn = document.createElement('button');
        cancelBtn.className = 'sample-btn';
        cancelBtn.textContent = 'Cancel';
        cancelBtn.addEventListener('click', () => { overlay.remove(); this._onDismiss(); });
        actions.appendChild(cancelBtn);

        const confirmBtn = document.createElement('button');
        confirmBtn.className = 'sample-btn transcribe-dialog-confirm-btn';
        confirmBtn.textContent = '◎ Start Transcription';
        confirmBtn.addEventListener('click', () => {
            overlay.remove();
            const speakerRaw = parseInt(modal.querySelector('#td-speaker-count').value, 10);
            this._onConfirm({
                modelSize:           modal.querySelector('#td-whisper').value,
                pyannoteModel:       modal.querySelector('#td-pyannote').value,
                speakerCount:        speakerRaw > 0 ? speakerRaw : null,
                includeVoiceSamples: modal.querySelector('#td-voice-samples').checked,
            });
        });
        actions.appendChild(confirmBtn);

        return actions;
    }

    /**
     * @param {string} labelText - visible label for the row
     * @param {string} id - id attribute for the select element
     * @param {Array<{value: string, label: string}>} options - select options
     * @param {string} defaultValue - value to pre-select
     * @returns {HTMLElement} a label+select row element
     */
    #makeSelectRow(labelText, id, options, defaultValue) {
        const row = document.createElement('div');
        row.className = 'transcribe-dialog-row';
        const opts = options.map(o =>
            `<option value="${o.value}"${o.value === defaultValue ? ' selected' : ''}>${o.label}</option>`
        ).join('');
        row.innerHTML = `
            <label class="transcribe-dialog-label" for="${id}">${labelText}</label>
            <select id="${id}" class="transcribe-dialog-select">${opts}</select>`;
        return row;
    }

    /** @returns {HTMLElement} row with a numeric input for estimated speaker count. */
    #makeSpeakerCountRow() {
        const row = document.createElement('div');
        row.className = 'transcribe-dialog-row';
        row.innerHTML = `
            <label class="transcribe-dialog-label" for="td-speaker-count">Est. speakers</label>
            <div class="transcribe-dialog-control">
                <input type="number" id="td-speaker-count" class="transcribe-dialog-number-input"
                    min="0" max="50" step="1" value="0" />
                <span class="transcribe-dialog-hint">0 = auto-detect</span>
            </div>`;
        return row;
    }

    /** @returns {HTMLElement} row with a checkbox to include recorded voice samples. */
    #makeVoiceSamplesRow() {
        const row = document.createElement('div');
        row.className = 'transcribe-dialog-row';
        const disabled = !this._hasVoiceSamples;
        const hint = disabled ? 'No speaker samples recorded' : 'Guide diarization using recorded samples';
        row.innerHTML = `
            <label class="transcribe-dialog-label" for="td-voice-samples">Voice samples</label>
            <div class="transcribe-dialog-control">
                <input type="checkbox" id="td-voice-samples" ${disabled ? 'disabled' : ''} />
                <span class="transcribe-dialog-hint">${hint}</span>
            </div>`;
        return row;
    }

    /**
     * Reads the current model selections and updates the cost/time estimate display.
     * @param {HTMLElement} modal - the modal element containing the form inputs
     */
    #updateEstimate(modal) {
        const wModel = modal.querySelector('#td-whisper').value;
        const pModel = modal.querySelector('#td-pyannote').value;
        const dur    = this._audioDuration;
        if (dur > 0) {
            modal.querySelector('#td-est-cost').textContent = fmtCost(estimateCost(dur, wModel, pModel));
            modal.querySelector('#td-est-time').textContent = fmtDur(estimateTime(dur, wModel, pModel));
        }
    }
}