wiseguru_ReWrite-Voice-Notes/src/ui/quick-record.ts
WiseGuru dddfb0cf12 Mobile textfield troubleshooting and remove Web Speech
Web Speech had all kinds of bugs, not worth troubleshooting.
2026-05-27 20:24:33 -07:00

316 lines
8.9 KiB
TypeScript

import { Notice } from 'obsidian';
import type ReWritePlugin from '../main';
import { PipelineSource, PipelineStage, runPipeline } from '../pipeline';
import { isMediaRecorderAvailable, resolveActiveProfile } from '../platform';
import { Recorder } from '../recorder';
import { NoteTemplate } from '../types';
import { isProfileConfigured } from './setup-card';
import { ReWriteModal } from './modal';
export class QuickRecordController {
private recorder: Recorder | null = null;
private timerHandle: number | null = null;
private floater: QuickRecordFloater | null = null;
private settled = false;
private template: NoteTemplate;
constructor(
private readonly plugin: ReWritePlugin,
template: NoteTemplate,
private readonly onDispose: () => void,
) {
this.template = template;
}
async begin(): Promise<void> {
this.recorder = new Recorder();
await this.recorder.start(this.plugin.settings.recordingFormat);
this.floater = new QuickRecordFloater({
onStop: () => {
void this.finish();
},
onCancel: () => this.cancel(),
getTemplates: () => this.plugin.templates,
getActiveTemplateId: () => this.template.id,
onPickTemplate: (t) => {
this.template = t;
this.floater?.setTemplateName(t.name);
},
initialTemplateName: this.template.name,
});
this.timerHandle = window.setInterval(() => {
const ms = this.recorder?.getElapsedMs() ?? 0;
this.floater?.setTime(formatDuration(ms));
}, 250);
}
cancel(): void {
if (this.settled) return;
this.settled = true;
this.stopTimer();
this.recorder?.cancel();
this.recorder = null;
this.floater?.dispose();
this.floater = null;
this.onDispose();
}
async finish(): Promise<void> {
if (this.settled) return;
this.settled = true;
this.stopTimer();
this.floater?.setBusy('Processing...');
try {
const source = await this.endCapture();
const { profile } = resolveActiveProfile(this.plugin.settings);
await runPipeline({
app: this.plugin.app,
settings: this.plugin.settings,
host: this.plugin,
profile,
template: this.template,
source,
onStage: (stage) => this.floater?.setBusy(stageLabel(stage)),
});
this.plugin.settings.lastUsedTemplateId = this.template.id;
await this.plugin.saveSettings();
new Notice('ReWrite complete.');
this.floater?.dispose();
this.floater = null;
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
new Notice(`ReWrite quick record failed: ${msg}`);
this.floater?.dispose();
this.floater = null;
new ReWriteModal(this.plugin.app, this.plugin).open();
} finally {
this.onDispose();
}
}
private async endCapture(): Promise<PipelineSource> {
if (!this.recorder) throw new Error('No active recording.');
const result = await this.recorder.stop();
this.recorder = null;
return { kind: 'audio', audio: result.blob, durationMs: result.durationMs };
}
private stopTimer(): void {
if (this.timerHandle !== null) {
window.clearInterval(this.timerHandle);
this.timerHandle = null;
}
}
}
export async function startQuickRecord(
plugin: ReWritePlugin,
onDispose: () => void,
): Promise<QuickRecordController | null> {
const settings = plugin.settings;
const { profile } = resolveActiveProfile(settings);
if (plugin.encryptionStatus.locked) {
new Notice('ReWrite: API keys are locked. Unlock to record.');
plugin.promptUnlock();
return null;
}
if (profile.transcriptionProvider === 'none') {
new Notice('ReWrite: transcription is disabled for this profile. Opening the modal so you can paste text instead.');
new ReWriteModal(plugin.app, plugin).open();
return null;
}
if (!isProfileConfigured(profile)) {
new Notice('ReWrite: profile is not configured. Finish setup to use quick record.');
new ReWriteModal(plugin.app, plugin).open();
return null;
}
const template = pickQuickRecordTemplate(plugin);
if (!template) {
new Notice('ReWrite: add a template before using quick record.');
new ReWriteModal(plugin.app, plugin).open();
return null;
}
if (!isMediaRecorderAvailable()) {
new Notice('Audio recording is not supported in this environment. Opening the modal instead.');
new ReWriteModal(plugin.app, plugin).open();
return null;
}
const controller = new QuickRecordController(plugin, template, onDispose);
try {
await controller.begin();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
new Notice(`ReWrite quick record could not start: ${msg}`);
onDispose();
return null;
}
return controller;
}
function pickQuickRecordTemplate(plugin: ReWritePlugin): NoteTemplate | undefined {
const s = plugin.settings;
const templates = plugin.templates;
return (
templates.find((t) => t.id === s.defaultTemplateId)
?? templates.find((t) => t.id === s.lastUsedTemplateId)
?? templates[0]
);
}
interface QuickRecordFloaterOptions {
onStop: () => void;
onCancel: () => void;
getTemplates: () => NoteTemplate[];
getActiveTemplateId: () => string;
onPickTemplate: (t: NoteTemplate) => void;
initialTemplateName: string;
}
class QuickRecordFloater {
private readonly el: HTMLElement;
private readonly timerEl: HTMLElement;
private readonly templateBtn: HTMLButtonElement;
private readonly templateLabel: HTMLElement;
private popover: HTMLElement | null = null;
private outsideClickHandler: ((e: MouseEvent) => void) | null = null;
private keyHandler: ((e: KeyboardEvent) => void) | null = null;
private busy = false;
constructor(private readonly options: QuickRecordFloaterOptions) {
this.el = document.body.createDiv({ cls: 'rewrite-quick-floater' });
this.el.createSpan({ cls: 'rewrite-quick-dot' });
this.el.createSpan({ cls: 'rewrite-quick-label', text: 'Recording' });
this.timerEl = this.el.createSpan({ cls: 'rewrite-quick-timer', text: '0:00' });
this.templateBtn = this.el.createEl('button', {
cls: 'rewrite-quick-template',
});
this.templateLabel = this.templateBtn.createSpan({
cls: 'rewrite-quick-template-label',
text: options.initialTemplateName,
});
this.templateBtn.title = 'Change template';
this.templateBtn.addEventListener('click', (e) => {
if (this.busy) return;
e.stopPropagation();
this.togglePopover();
});
const stopBtn = this.el.createEl('button', {
text: 'Stop',
cls: 'mod-cta rewrite-quick-stop',
});
stopBtn.addEventListener('click', () => {
if (this.busy) return;
options.onStop();
});
const cancelBtn = this.el.createEl('button', {
text: 'Cancel',
cls: 'rewrite-quick-cancel',
});
cancelBtn.addEventListener('click', () => {
if (this.busy) return;
options.onCancel();
});
}
setTime(label: string): void {
if (!this.busy) this.timerEl.setText(label);
}
setBusy(label: string): void {
this.busy = true;
this.el.addClass('is-busy');
this.timerEl.setText(label);
this.templateBtn.disabled = true;
this.closePopover();
}
setTemplateName(name: string): void {
this.templateLabel.setText(name);
}
dispose(): void {
this.closePopover();
this.el.remove();
}
private togglePopover(): void {
if (this.popover) {
this.closePopover();
return;
}
this.openPopover();
}
private openPopover(): void {
const templates = this.options.getTemplates();
if (templates.length === 0) return;
const activeId = this.options.getActiveTemplateId();
const popover = this.el.createDiv({ cls: 'rewrite-quick-popover' });
for (const t of templates) {
const item = popover.createEl('button', {
cls: 'rewrite-quick-popover-item',
text: t.name,
});
if (t.id === activeId) item.addClass('is-active');
item.addEventListener('click', (e) => {
e.stopPropagation();
this.options.onPickTemplate(t);
this.closePopover();
});
}
this.popover = popover;
this.outsideClickHandler = (e: MouseEvent) => {
if (!this.popover) return;
const target = e.target as Node | null;
if (target && (this.popover.contains(target) || this.templateBtn.contains(target))) return;
this.closePopover();
};
this.keyHandler = (e: KeyboardEvent) => {
if (e.key === 'Escape') this.closePopover();
};
document.addEventListener('click', this.outsideClickHandler, true);
document.addEventListener('keydown', this.keyHandler, true);
}
private closePopover(): void {
if (this.outsideClickHandler) {
document.removeEventListener('click', this.outsideClickHandler, true);
this.outsideClickHandler = null;
}
if (this.keyHandler) {
document.removeEventListener('keydown', this.keyHandler, true);
this.keyHandler = null;
}
this.popover?.remove();
this.popover = null;
}
}
function stageLabel(stage: PipelineStage): string {
switch (stage) {
case 'persist-audio':
return 'Saving audio...';
case 'transcribe':
return 'Transcribing...';
case 'cleanup':
return 'Cleaning up...';
case 'insert':
return 'Inserting...';
}
}
function formatDuration(ms: number): string {
const total = Math.max(0, Math.floor(ms / 1000));
const minutes = Math.floor(total / 60);
const seconds = total % 60;
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}