/**
* 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;
}
}
}