transcription_manager.js


/**
 * Manages parallel transcription jobs. Each job is keyed by project ID and
 * persists independently of which project is open in the workspace, so the
 * SSE connection stays alive while the user navigates between projects.
 */
export class TranscriptionManager {
    #active = new Map();  // projectId → { fraction, status, startTime, abortController, server, isDownloading }
    #onUpdate;
    #onResult;
    #downloadModal = null;

    /**
     * @param {object} callbacks - event hooks for job state changes and completion
     * @param {function} callbacks.onUpdate - called with projectId whenever a job's state changes
     * @param {function} callbacks.onResult - async (projectId, server) => void, called on success
     */
    constructor({ onUpdate, onResult }) {
        this.#onUpdate = onUpdate ?? (() => {});
        this.#onResult = onResult ?? (() => Promise.resolve());
    }

    /**
     * @param {string} projectId - the project to check
     * @returns {boolean}
     */
    isActive(projectId) {
        return this.#active.has(projectId);
    }

    /**
     * @param {string} projectId - project ID
     * @returns {{ fraction: number, status: string, startTime: number } | null}
     */
    getState(projectId) {
        return this.#active.get(projectId) ?? null;
    }

    /**
     * Starts a transcription job for the given project. No-ops if already running.
     * @param {string} projectId - the project to transcribe
     * @param {object} server - Server instance
     * @param {object} opts - Transcription options (modelSize, speakerCount, etc.)
     */
    async start(projectId, server, opts) {
        if (this.#active.has(projectId)) return;

        const abortController = new AbortController();
        const state = {
            fraction: 0,
            status: 'Starting transcription…',
            startTime: Date.now(),
            abortController,
            server,
            isDownloading: false,
        };
        this.#active.set(projectId, state);
        this.#onUpdate(projectId);

        let succeeded = false;
        try {
            await server.transcribeProject(projectId, opts, (event) => {
                if (event.type === 'downloading_models') {
                    state.isDownloading = true;
                    state.status = event.message;
                    this.#showDownloadModal(event.message);
                } else if (event.type === 'models_ready') {
                    state.isDownloading = false;
                    this.#hideDownloadModal();
                } else if (event.type === 'status') {
                    if (state.isDownloading) {
                        state.isDownloading = false;
                        this.#hideDownloadModal();
                    }
                    state.status = event.message;
                } else if (event.type === 'progress') {
                    state.fraction = event.fraction;
                } else if (event.type === 'result') {
                    succeeded = true;
                    state.fraction = 1;
                    state.status = 'Done!';
                } else if (event.type === 'error') {
                    state.isDownloading = false;
                    this.#hideDownloadModal();
                    state.status = `Error: ${event.message}`;
                }
                this.#onUpdate(projectId);
            }, abortController.signal);
        } catch (e) {
            this.#hideDownloadModal();
            if (!abortController.signal.aborted) {
                state.status = `Error: ${e.message}`;
                this.#onUpdate(projectId);
            }
        } finally {
            if (succeeded) {
                this.#onUpdate(projectId);
                await this.#onResult(projectId, server);
                setTimeout(() => {
                    this.#active.delete(projectId);
                    this.#onUpdate(projectId);
                }, 2000);
            } else {
                this.#active.delete(projectId);
                this.#onUpdate(projectId);
            }
        }
    }

    /**
     * Reconnects to a transcription that was already running before the page loaded.
     * Polls the server for progress until the job completes, then calls onResult.
     * No-ops if a job for this project is already tracked locally.
     * @param {string} projectId - the project to reconnect to
     * @param {object} server - Server instance
     * @param {{ fraction: number, status: string, elapsed_secs: number }} initialState - last known state from the server
     */
    async reconnect(projectId, server, initialState) {
        if (this.#active.has(projectId)) return;

        const state = {
            fraction: initialState.fraction ?? 0,
            status: initialState.status ?? 'Transcribing…',
            startTime: Date.now() - (initialState.elapsed_secs ?? 0) * 1000,
            abortController: new AbortController(),
            server,
        };
        this.#active.set(projectId, state);
        this.#onUpdate(projectId);

        while (this.#active.has(projectId)) {
            await new Promise(r => setTimeout(r, 2000));
            if (!this.#active.has(projectId)) break;

            let resp;
            try {
                resp = await server.getTranscriptionStatus(projectId);
            } catch {
                break;
            }

            if (!resp.active) {
                this.#active.delete(projectId);
                this.#onUpdate(projectId);
                await this.#onResult(projectId, server);
                break;
            }

            state.fraction = resp.fraction ?? state.fraction;
            state.status = resp.status ?? state.status;
            this.#onUpdate(projectId);
        }
    }

    /**
     * @param {string} projectId - the project whose transcription to cancel
     */
    cancel(projectId) {
        const state = this.#active.get(projectId);
        if (state) {
            state.abortController.abort();
            this.#active.delete(projectId);
            this.#hideDownloadModal();
            this.#onUpdate(projectId);
        }
    }

    /**
     * Shows a blocking "Downloading Models" overlay modal.
     * @param {string} message - Text displayed inside the modal body.
     */
    #showDownloadModal(message) {
        if (this.#downloadModal) {
            this.#downloadModal.querySelector('.tx-dl-message').textContent = message;
            return;
        }
        const overlay = document.createElement('div');
        overlay.className = 'tx-dl-overlay';
        overlay.innerHTML = `
            <div class="tx-dl-modal">
                <div class="tx-dl-title">Downloading Models</div>
                <div class="tx-dl-message">${message}</div>
                <div class="tx-dl-bar-wrap"><div class="tx-dl-bar"></div></div>
                <div class="tx-dl-note">First-time setup only — models will be cached for future use</div>
            </div>`;
        document.body.appendChild(overlay);
        this.#downloadModal = overlay;
    }

    /** Removes the downloading modal overlay. */
    #hideDownloadModal() {
        if (this.#downloadModal) {
            this.#downloadModal.remove();
            this.#downloadModal = null;
        }
    }
}