| 1 | import { |
| 2 | VoiceInputController, |
| 3 | voiceInputBlocksSubmission, |
| 4 | type VoiceDraftSnapshot, |
| 5 | type VoiceInputControllerDependencies, |
| 6 | } from "@t3tools/client-runtime/voice-input"; |
| 7 | |
| 8 | export type VoiceInputTarget = { |
| 9 | readonly ownerKey: string; |
| 10 | /** Names the draft being dictated into while its composer is off screen. */ |
| 11 | readonly label?: string; |
| 12 | readonly readDraft: () => VoiceDraftSnapshot | null; |
| 13 | readonly commitDraft: VoiceInputControllerDependencies["commitDraft"]; |
| 14 | readonly subscribe: () => () => void; |
| 15 | }; |
| 16 | |
| 17 | export function createVoiceInputTarget( |
| 18 | ownerKey: string, |
| 19 | readText: () => string | null, |
| 20 | commitDraft: VoiceInputTarget["commitDraft"], |
| 21 | selection: VoiceDraftSnapshot["selection"], |
| 22 | subscribeToChanges: (onChange: () => void) => () => void, |
| 23 | ): VoiceInputTarget { |
| 24 | let revision = 0; |
| 25 | return { |
| 26 | ownerKey, |
| 27 | readDraft: () => { |
| 28 | const text = readText(); |
| 29 | if (text === null) return null; |
| 30 | return { ownerKey, text, selection, revision }; |
| 31 | }, |
| 32 | commitDraft, |
| 33 | subscribe: () => { |
| 34 | let previousText = readText(); |
| 35 | return subscribeToChanges(() => { |
| 36 | const text = readText(); |
| 37 | if (text !== previousText) { |
| 38 | previousText = text; |
| 39 | revision += 1; |
| 40 | } |
| 41 | }); |
| 42 | }, |
| 43 | }; |
| 44 | } |
| 45 | |
| 46 | export class VoiceInputSession { |
| 47 | readonly controller: VoiceInputController; |
| 48 | private target: VoiceInputTarget | null = null; |
| 49 | private unsubscribe: (() => void) | null = null; |
| 50 | |
| 51 | constructor(dependencies: Omit<VoiceInputControllerDependencies, "readDraft" | "commitDraft">) { |
| 52 | this.controller = new VoiceInputController({ |
| 53 | ...dependencies, |
| 54 | onStateChange: (state) => { |
| 55 | if (!voiceInputBlocksSubmission(state)) { |
| 56 | this.unsubscribe?.(); |
| 57 | this.unsubscribe = null; |
| 58 | } |
| 59 | dependencies.onStateChange(state); |
| 60 | }, |
| 61 | readDraft: () => this.target?.readDraft() ?? null, |
| 62 | commitDraft: (text, selection) => this.target?.commitDraft(text, selection), |
| 63 | }); |
| 64 | } |
| 65 | |
| 66 | get ownerKey(): string | null { |
| 67 | return this.target?.ownerKey ?? null; |
| 68 | } |
| 69 | |
| 70 | get label(): string | null { |
| 71 | return this.target?.label ?? null; |
| 72 | } |
| 73 | |
| 74 | cancel(ownerKey: string | null): void { |
| 75 | if (ownerKey !== null && this.ownerKey === ownerKey) this.controller.cancel(); |
| 76 | } |
| 77 | |
| 78 | retry(): Promise<void> { |
| 79 | return this.target ? this.start(this.target) : Promise.resolve(); |
| 80 | } |
| 81 | |
| 82 | start(target: VoiceInputTarget): Promise<void> { |
| 83 | if (voiceInputBlocksSubmission(this.controller.currentState)) return Promise.resolve(); |
| 84 | this.target = target; |
| 85 | this.unsubscribe = target.subscribe(); |
| 86 | return this.controller.start(); |
| 87 | } |
| 88 | } |