kempo-ui 0.4.3 → 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/.claude/skills/get-icon/SKILL.md +144 -0
  2. package/.claude/skills/highlight-code/SKILL.md +62 -0
  3. package/.claude/skills/new-component/SKILL.md +362 -0
  4. package/.github/skills/get-icon/SKILL.md +4 -4
  5. package/.github/skills/highlight-code/SKILL.md +51 -20
  6. package/.github/skills/new-component/SKILL.md +183 -152
  7. package/AGENTS.md +2 -2
  8. package/dist/components/SpeechToText.js +60 -0
  9. package/dist/components/TextToSpeech.js +56 -0
  10. package/dist/components/VoiceSelector.js +49 -0
  11. package/dist/utils/voice.js +1 -0
  12. package/docs/components/accordion.html +15 -0
  13. package/docs/components/aside.html +15 -0
  14. package/docs/components/calendar.html +15 -0
  15. package/docs/components/card.html +15 -0
  16. package/docs/components/code-editor.html +15 -0
  17. package/docs/components/color-picker.html +15 -0
  18. package/docs/components/combobox.html +15 -0
  19. package/docs/components/content-slider.html +15 -0
  20. package/docs/components/context.html +15 -0
  21. package/docs/components/dialog.html +15 -0
  22. package/docs/components/dropdown.html +15 -0
  23. package/docs/components/filter-list.html +15 -0
  24. package/docs/components/focus-capture.html +15 -0
  25. package/docs/components/html-editor.html +15 -0
  26. package/docs/components/hybrid-component.html +15 -0
  27. package/docs/components/icon.html +15 -0
  28. package/docs/components/import.html +15 -0
  29. package/docs/components/light-component.html +15 -0
  30. package/docs/components/nav-spacer.html +15 -0
  31. package/docs/components/nav.html +15 -0
  32. package/docs/components/photo-viewer.html +15 -0
  33. package/docs/components/progress.html +15 -0
  34. package/docs/components/resize.html +15 -0
  35. package/docs/components/shadow-component.html +15 -0
  36. package/docs/components/show-more.html +15 -0
  37. package/docs/components/slider.html +15 -0
  38. package/docs/components/sortable.html +15 -0
  39. package/docs/components/speech-to-text.html +724 -0
  40. package/docs/components/spinner.html +15 -0
  41. package/docs/components/split.html +15 -0
  42. package/docs/components/table.html +15 -0
  43. package/docs/components/tableControls.html +15 -0
  44. package/docs/components/tableCustomFields.html +15 -0
  45. package/docs/components/tableFetchRecords.html +15 -0
  46. package/docs/components/tableFieldSortHide.html +15 -0
  47. package/docs/components/tablePagination.html +15 -0
  48. package/docs/components/tablePlaceholder.html +15 -0
  49. package/docs/components/tableRecordEditing.html +15 -0
  50. package/docs/components/tableRecordFiltering.html +15 -0
  51. package/docs/components/tableRecordHiding.html +15 -0
  52. package/docs/components/tableRecordSearching.html +15 -0
  53. package/docs/components/tableRecordSelection.html +15 -0
  54. package/docs/components/tableRowControls.html +15 -0
  55. package/docs/components/tableServerSync.html +15 -0
  56. package/docs/components/tableSorting.html +15 -0
  57. package/docs/components/tabs.html +15 -0
  58. package/docs/components/tags.html +15 -0
  59. package/docs/components/text-to-speech.html +718 -0
  60. package/docs/components/theme-select.html +15 -0
  61. package/docs/components/theme-switcher.html +15 -0
  62. package/docs/components/time.html +15 -0
  63. package/docs/components/timestamp.html +15 -0
  64. package/docs/components/toast.html +15 -0
  65. package/docs/components/toggle.html +15 -0
  66. package/docs/components/tree.html +15 -0
  67. package/docs/components/voice-selector.html +598 -0
  68. package/docs/icons/mic.svg +1 -0
  69. package/docs/icons/record_voice_over.svg +1 -0
  70. package/docs/icons/stop.svg +1 -0
  71. package/docs/index.html +39 -0
  72. package/docs/src/components/SpeechToText.js +60 -0
  73. package/docs/src/components/TextToSpeech.js +56 -0
  74. package/docs/src/components/VoiceSelector.js +49 -0
  75. package/docs/src/utils/voice.js +1 -0
  76. package/docs/utils/context.html +15 -0
  77. package/docs/utils/cookie.html +15 -0
  78. package/docs/utils/debounce.html +15 -0
  79. package/docs/utils/drag.html +15 -0
  80. package/docs/utils/elevation.html +15 -0
  81. package/docs/utils/formatTimestamp.html +15 -0
  82. package/docs/utils/object.html +15 -0
  83. package/docs/utils/propConverters.html +15 -0
  84. package/docs/utils/string.html +15 -0
  85. package/docs/utils/theme.html +15 -0
  86. package/docs/utils/toTitleCase.html +15 -0
  87. package/docs/utils/type.html +15 -0
  88. package/docs/utils/voice.html +536 -0
  89. package/docs/utils/wait.html +15 -0
  90. package/docs-src/components/speech-to-text.page.html +251 -0
  91. package/docs-src/components/text-to-speech.page.html +245 -0
  92. package/docs-src/components/voice-selector.page.html +125 -0
  93. package/docs-src/index.page.html +24 -0
  94. package/docs-src/nav.fragment.html +15 -0
  95. package/docs-src/utils/voice.page.html +66 -0
  96. package/icons/mic.svg +1 -0
  97. package/icons/record_voice_over.svg +1 -0
  98. package/icons/stop.svg +1 -0
  99. package/llms.txt +4 -0
  100. package/package.json +1 -1
  101. package/src/components/SpeechToText.js +252 -0
  102. package/src/components/TextToSpeech.js +205 -0
  103. package/src/components/VoiceSelector.js +271 -0
  104. package/src/utils/voice.js +103 -0
  105. package/tests/components/SpeechToText.browser-test.js +530 -0
  106. package/tests/components/TextToSpeech.browser-test.js +581 -0
  107. package/tests/components/VoiceSelector.browser-test.js +374 -0
  108. package/tests/utils/voice.browser-test.js +128 -0
@@ -0,0 +1,205 @@
1
+ import { html, css } from '../lit-all.min.js';
2
+ import ShadowComponent from './ShadowComponent.js';
3
+ import { getVoice as getPreferredVoice } from '../utils/voice.js';
4
+ import './Icon.js';
5
+
6
+ export default class TextToSpeech extends ShadowComponent {
7
+ static properties = {
8
+ text: { type: String, reflect: true },
9
+ voice: { type: String, reflect: true },
10
+ language: { type: String, reflect: true },
11
+ rate: { type: Number, reflect: true },
12
+ pitch: { type: Number, reflect: true },
13
+ volume: { type: Number, reflect: true },
14
+ disabled: { type: Boolean, reflect: true },
15
+ speaking: { type: Boolean, reflect: true }
16
+ };
17
+
18
+ #unsupported = false;
19
+ #utterance = null;
20
+
21
+ /*
22
+ Lifecycle Callbacks
23
+ */
24
+ constructor() {
25
+ super();
26
+ this.text = '';
27
+ this.voice = '';
28
+ this.language = '';
29
+ this.rate = 1;
30
+ this.pitch = 1;
31
+ this.volume = 1;
32
+ this.disabled = false;
33
+ this.speaking = false;
34
+ }
35
+
36
+ connectedCallback() {
37
+ super.connectedCallback();
38
+ if(typeof window === 'undefined' || !window.speechSynthesis || typeof window.SpeechSynthesisUtterance !== 'function'){
39
+ this.#unsupported = true;
40
+ }
41
+ }
42
+
43
+ disconnectedCallback() {
44
+ super.disconnectedCallback();
45
+ this.stop();
46
+ }
47
+
48
+ /*
49
+ Utility
50
+ */
51
+ get effectiveText() {
52
+ if(this.text) return this.text;
53
+ return this.textContent.trim();
54
+ }
55
+
56
+ resolveVoice = () => {
57
+ if(this.#unsupported) return null;
58
+ // Use the explicit `voice` attribute first; if none is set, fall back to
59
+ // the user's saved preference from the voice utility (so a site-wide
60
+ // VoiceSelector can drive every TextToSpeech that hasn't opted out).
61
+ const source = this.voice || getPreferredVoice() || '';
62
+ if(!source) return null;
63
+ const voices = window.speechSynthesis.getVoices();
64
+ const candidates = source.split(',').map(s => s.trim()).filter(Boolean);
65
+ for(const name of candidates){
66
+ const match = voices.find(v => v.name === name);
67
+ if(match) return match;
68
+ }
69
+ return null;
70
+ };
71
+
72
+ /*
73
+ Public Methods
74
+ */
75
+ speak = (override) => {
76
+ if(this.disabled || this.#unsupported) return;
77
+ const text = (override !== undefined ? String(override) : this.effectiveText).trim();
78
+ if(!text) return;
79
+ // Cancel any in-flight speech (ours or otherwise) before queuing this one
80
+ window.speechSynthesis.cancel();
81
+ const utterance = new window.SpeechSynthesisUtterance(text);
82
+ utterance.rate = this.rate;
83
+ utterance.pitch = this.pitch;
84
+ utterance.volume = this.volume;
85
+ if(this.language) utterance.lang = this.language;
86
+ const voice = this.resolveVoice();
87
+ if(voice) utterance.voice = voice;
88
+ utterance.onstart = this.handleStart;
89
+ utterance.onend = this.handleEnd;
90
+ utterance.onerror = this.handleError;
91
+ this.#utterance = utterance;
92
+ window.speechSynthesis.speak(utterance);
93
+ };
94
+
95
+ stop = () => {
96
+ if(this.#unsupported) return;
97
+ window.speechSynthesis.cancel();
98
+ };
99
+
100
+ toggle = () => {
101
+ if(this.speaking) this.stop();
102
+ else this.speak();
103
+ };
104
+
105
+ /*
106
+ Event Handlers
107
+ */
108
+ handleStart = () => {
109
+ this.speaking = true;
110
+ this.dispatchEvent(new CustomEvent('start', { bubbles: true }));
111
+ };
112
+
113
+ handleEnd = () => {
114
+ this.speaking = false;
115
+ this.#utterance = null;
116
+ this.dispatchEvent(new CustomEvent('end', { bubbles: true }));
117
+ };
118
+
119
+ handleError = (e) => {
120
+ this.speaking = false;
121
+ this.#utterance = null;
122
+ this.dispatchEvent(new CustomEvent('error', {
123
+ detail: { error: e.error || 'unknown' },
124
+ bubbles: true
125
+ }));
126
+ };
127
+
128
+ handleClick = () => {
129
+ if(this.disabled) return;
130
+ this.toggle();
131
+ };
132
+
133
+ /*
134
+ Rendering
135
+ */
136
+ render() {
137
+ const supported = !this.#unsupported;
138
+ const label = !supported
139
+ ? 'Text-to-speech not supported in this browser'
140
+ : this.speaking ? 'Stop speaking' : 'Speak';
141
+ return html`
142
+ <button
143
+ type="button"
144
+ class="no-btn btn"
145
+ ?disabled=${this.disabled || !supported}
146
+ @click=${this.handleClick}
147
+ aria-label=${label}
148
+ title=${label}
149
+ >
150
+ <k-icon name=${this.speaking ? 'stop' : 'record_voice_over'}></k-icon>
151
+ </button>
152
+ <slot style="display:none"></slot>
153
+ `;
154
+ }
155
+
156
+ /*
157
+ Styles
158
+ */
159
+ static styles = css`
160
+ :host {
161
+ --btn_size: 2.5rem;
162
+ --btn_bg: var(--c_bg);
163
+ --btn_bg__speaking: var(--c_primary);
164
+ --btn_tc__speaking: white;
165
+ display: inline-block;
166
+ }
167
+ :host([disabled]) {
168
+ opacity: 0.5;
169
+ pointer-events: none;
170
+ }
171
+ .btn {
172
+ display: inline-flex;
173
+ align-items: center;
174
+ justify-content: center;
175
+ width: var(--btn_size);
176
+ height: var(--btn_size);
177
+ padding: 0;
178
+ border: 1px solid var(--c_border);
179
+ border-radius: 50%;
180
+ background: var(--btn_bg);
181
+ color: var(--tc);
182
+ cursor: pointer;
183
+ transition: background var(--animation_ms), color var(--animation_ms), border-color var(--animation_ms);
184
+ }
185
+ .btn:hover:not(:disabled) {
186
+ background: var(--c_bg__alt);
187
+ }
188
+ .btn:focus-visible {
189
+ outline: none;
190
+ box-shadow: var(--focus_shadow);
191
+ }
192
+ :host([speaking]) .btn,
193
+ :host([speaking]) .btn:hover,
194
+ :host([speaking]) .btn:focus-visible {
195
+ background: var(--btn_bg__speaking);
196
+ color: var(--btn_tc__speaking);
197
+ border-color: transparent;
198
+ }
199
+ .btn:disabled {
200
+ cursor: not-allowed;
201
+ }
202
+ `;
203
+ }
204
+
205
+ customElements.define('k-text-to-speech', TextToSpeech);
@@ -0,0 +1,271 @@
1
+ import { html, css } from '../lit-all.min.js';
2
+ import ShadowComponent from './ShadowComponent.js';
3
+ import {
4
+ getVoice,
5
+ setVoice,
6
+ subscribeToVoice,
7
+ subscribeToAvailableVoices
8
+ } from '../utils/voice.js';
9
+
10
+ const primaryLang = (tag) => (tag || '').split('-')[0].toLowerCase();
11
+
12
+ const browserLanguage = () => {
13
+ if(typeof navigator === 'undefined') return 'en';
14
+ return primaryLang(navigator.language || 'en') || 'en';
15
+ };
16
+
17
+ let displayNames = null;
18
+ try {
19
+ if(typeof Intl !== 'undefined' && Intl.DisplayNames){
20
+ const locale = (typeof navigator !== 'undefined' && navigator.language) || 'en';
21
+ displayNames = new Intl.DisplayNames([locale], { type: 'language' });
22
+ }
23
+ } catch(e) {}
24
+
25
+ // Cache for native-name lookups (Intl.DisplayNames in the language's own locale)
26
+ // so we don't construct a new instance per option per render.
27
+ const nativeNameCache = new Map();
28
+ const getNativeName = (code) => {
29
+ if(nativeNameCache.has(code)) return nativeNameCache.get(code);
30
+ let native = '';
31
+ try {
32
+ if(typeof Intl !== 'undefined' && Intl.DisplayNames){
33
+ const dn = new Intl.DisplayNames([code], { type: 'language' });
34
+ native = dn.of(code) || '';
35
+ }
36
+ } catch(e) {}
37
+ // Capitalize the first character so dropdown labels look consistent —
38
+ // Intl.DisplayNames returns "español"/"français" lowercase, which looks
39
+ // off next to the capitalized localized name.
40
+ if(native){
41
+ native = native.charAt(0).toUpperCase() + native.slice(1);
42
+ }
43
+ nativeNameCache.set(code, native);
44
+ return native;
45
+ };
46
+
47
+ // Render a language code as "Localized (Native)" — e.g. "Spanish (Español)",
48
+ // "Japanese (日本語)". The native name is a deliberate accessibility escape
49
+ // hatch: if the user accidentally has the page in a language they can't read,
50
+ // they can still recognize their own language by its native name. When the
51
+ // localized and native names match (user already in that language) we skip
52
+ // the duplicate.
53
+ const formatLanguage = (code) => {
54
+ let localized = code;
55
+ if(displayNames){
56
+ try {
57
+ localized = displayNames.of(code) || code;
58
+ } catch(e) {}
59
+ }
60
+ const native = getNativeName(code);
61
+ if(native && native.toLowerCase() !== localized.toLowerCase()){
62
+ return `${localized} (${native})`;
63
+ }
64
+ return localized;
65
+ };
66
+
67
+ // Strip the parenthetical "(language (region))" that OS-level voices often
68
+ // append to their name (e.g. "Eddy (German (Germany))" → "Eddy"). The actual
69
+ // `value` we submit to SpeechSynthesis still uses the full name.
70
+ const displayVoiceName = (name) => {
71
+ const cleaned = name.split('(')[0].trim();
72
+ return cleaned || name;
73
+ };
74
+
75
+ export default class VoiceSelector extends ShadowComponent {
76
+ static properties = {
77
+ language: { type: String, reflect: true },
78
+ placeholder: { type: String, reflect: true },
79
+ disabled: { type: Boolean, reflect: true },
80
+ voices: { state: true },
81
+ selected: { state: true },
82
+ currentLang: { state: true }
83
+ };
84
+
85
+ #unsubVoices = null;
86
+ #unsubSelected = null;
87
+ #unsupported = false;
88
+ #langInitialized = false;
89
+
90
+ /*
91
+ Lifecycle Callbacks
92
+ */
93
+ constructor() {
94
+ super();
95
+ this.language = '';
96
+ this.placeholder = 'Browser default';
97
+ this.disabled = false;
98
+ this.voices = [];
99
+ this.selected = '';
100
+ this.currentLang = '';
101
+ }
102
+
103
+ connectedCallback() {
104
+ super.connectedCallback();
105
+ if(typeof window === 'undefined' || !window.speechSynthesis){
106
+ this.#unsupported = true;
107
+ return;
108
+ }
109
+ this.#unsubVoices = subscribeToAvailableVoices(list => {
110
+ this.voices = list;
111
+ this.#initLanguageOnce();
112
+ });
113
+ this.#unsubSelected = subscribeToVoice(name => {
114
+ this.selected = name || '';
115
+ // Keep the language dropdown in sync with externally-set voices
116
+ if(name){
117
+ const match = this.voices.find(v => v.name === name);
118
+ if(match && match.lang){
119
+ this.currentLang = primaryLang(match.lang);
120
+ }
121
+ }
122
+ });
123
+ }
124
+
125
+ disconnectedCallback() {
126
+ super.disconnectedCallback();
127
+ if(this.#unsubVoices) this.#unsubVoices();
128
+ if(this.#unsubSelected) this.#unsubSelected();
129
+ this.#unsubVoices = null;
130
+ this.#unsubSelected = null;
131
+ }
132
+
133
+ /*
134
+ Utility
135
+ */
136
+ #initLanguageOnce = () => {
137
+ if(this.#langInitialized || this.voices.length === 0) return;
138
+ this.#langInitialized = true;
139
+ // Priority: existing voice's language > attribute > navigator > first available
140
+ const fromVoice = this.selected
141
+ ? this.voices.find(v => v.name === this.selected)
142
+ : null;
143
+ if(fromVoice && fromVoice.lang){
144
+ this.currentLang = primaryLang(fromVoice.lang);
145
+ return;
146
+ }
147
+ if(this.language){
148
+ this.currentLang = primaryLang(this.language);
149
+ return;
150
+ }
151
+ const navLang = browserLanguage();
152
+ const langs = this.availableLanguages;
153
+ if(langs.includes(navLang)){
154
+ this.currentLang = navLang;
155
+ } else if(langs.length > 0){
156
+ this.currentLang = langs[0];
157
+ }
158
+ };
159
+
160
+ get availableLanguages() {
161
+ const langs = new Set();
162
+ for(const v of this.voices){
163
+ if(v.lang) langs.add(primaryLang(v.lang));
164
+ }
165
+ return [...langs].sort();
166
+ }
167
+
168
+ get voicesForCurrentLanguage() {
169
+ if(!this.currentLang) return this.voices;
170
+ return this.voices.filter(v => primaryLang(v.lang) === this.currentLang);
171
+ }
172
+
173
+ /*
174
+ Event Handlers
175
+ */
176
+ handleLanguageChange = (e) => {
177
+ if(this.disabled) return;
178
+ this.currentLang = e.target.value;
179
+ // If the saved voice no longer exists in this language, clear it
180
+ const stillValid = this.voicesForCurrentLanguage.some(v => v.name === this.selected);
181
+ if(!stillValid){
182
+ setVoice('');
183
+ }
184
+ };
185
+
186
+ handleVoiceChange = (e) => {
187
+ if(this.disabled) return;
188
+ const value = e.target.value;
189
+ setVoice(value);
190
+ this.dispatchEvent(new CustomEvent('change', {
191
+ detail: { voice: value, language: this.currentLang },
192
+ bubbles: true
193
+ }));
194
+ };
195
+
196
+ /*
197
+ Public Methods
198
+ */
199
+ refresh = () => {
200
+ if(this.#unsupported) return;
201
+ this.voices = window.speechSynthesis.getVoices();
202
+ this.#initLanguageOnce();
203
+ };
204
+
205
+ /*
206
+ Rendering
207
+ */
208
+ render() {
209
+ if(this.#unsupported){
210
+ return html`
211
+ <select disabled aria-label="Voice selector (unsupported)">
212
+ <option>Speech synthesis not supported</option>
213
+ </select>
214
+ `;
215
+ }
216
+ const langs = this.availableLanguages;
217
+ const voicesInLang = this.voicesForCurrentLanguage;
218
+ return html`
219
+ <div class="row">
220
+ <div class="col" style="min-width: 10rem; flex: 0 0 auto">
221
+ <select
222
+ class="lang"
223
+ aria-label="Language"
224
+ ?disabled=${this.disabled || langs.length === 0}
225
+ .value=${this.currentLang}
226
+ @change=${this.handleLanguageChange}
227
+ >
228
+ ${langs.map(code => html`
229
+ <option value=${code} ?selected=${code === this.currentLang}>${formatLanguage(code)}</option>
230
+ `)}
231
+ </select>
232
+ </div>
233
+ <div class="col">
234
+ <select
235
+ class="voice"
236
+ aria-label="Voice"
237
+ ?disabled=${this.disabled}
238
+ .value=${this.selected}
239
+ @change=${this.handleVoiceChange}
240
+ >
241
+ <option value="">${this.placeholder}</option>
242
+ ${voicesInLang.map(v => html`
243
+ <option
244
+ value=${v.name}
245
+ ?selected=${v.name === this.selected}
246
+ >${displayVoiceName(v.name)}</option>
247
+ `)}
248
+ </select>
249
+ </div>
250
+ </div>
251
+ `;
252
+ }
253
+
254
+ /*
255
+ Styles
256
+ */
257
+ static styles = css`
258
+ :host {
259
+ display: block;
260
+ }
261
+ :host([disabled]) {
262
+ opacity: 0.5;
263
+ pointer-events: none;
264
+ }
265
+ select {
266
+ width: 100%;
267
+ }
268
+ `;
269
+ }
270
+
271
+ customElements.define('k-voice-selector', VoiceSelector);
@@ -0,0 +1,103 @@
1
+ /*
2
+ Voice Utility
3
+ - Manage the user's preferred speech-synthesis voice
4
+ - Subscribe to voice changes
5
+ - Persist voice to localStorage
6
+ - Sync across tabs via the storage event
7
+ */
8
+
9
+ import createContext from './context.js';
10
+
11
+ const STORAGE_KEY = 'k-voice';
12
+
13
+ const getInitialVoice = () => {
14
+ try {
15
+ const stored = localStorage.getItem(STORAGE_KEY);
16
+ return stored || '';
17
+ } catch(e){
18
+ return '';
19
+ }
20
+ };
21
+
22
+ const voiceContext = createContext('voice', getInitialVoice());
23
+
24
+ const persist = voice => {
25
+ try {
26
+ if(voice) localStorage.setItem(STORAGE_KEY, voice);
27
+ else localStorage.removeItem(STORAGE_KEY);
28
+ } catch(e) {}
29
+ };
30
+
31
+ voiceContext.subscribe(persist);
32
+
33
+ if(typeof window !== 'undefined'){
34
+ window.addEventListener('storage', event => {
35
+ if(event.key === STORAGE_KEY){
36
+ voiceContext.set(event.newValue || '');
37
+ }
38
+ });
39
+ }
40
+
41
+ export const setVoice = name => voiceContext.set(name || '');
42
+
43
+ export const getVoice = () => voiceContext.get();
44
+
45
+ export const subscribeToVoice = callback => voiceContext.subscribe(callback);
46
+
47
+ /*
48
+ Helpers for enumerating available voices from the browser.
49
+ getVoices() may return [] on the first call; the browser fires a
50
+ `voiceschanged` event when the list is ready. waitForVoices() returns a
51
+ promise that resolves once the list is populated (or after a small timeout
52
+ if the browser never fires the event).
53
+ */
54
+ export const getAvailableVoices = () => {
55
+ if(typeof window === 'undefined' || !window.speechSynthesis) return [];
56
+ return window.speechSynthesis.getVoices();
57
+ };
58
+
59
+ export const waitForVoices = (timeoutMs = 2000) => {
60
+ if(typeof window === 'undefined' || !window.speechSynthesis){
61
+ return Promise.resolve([]);
62
+ }
63
+ const synth = window.speechSynthesis;
64
+ const initial = synth.getVoices();
65
+ if(initial.length > 0) return Promise.resolve(initial);
66
+ return new Promise(resolve => {
67
+ let settled = false;
68
+ const handler = () => {
69
+ if(settled) return;
70
+ settled = true;
71
+ synth.removeEventListener('voiceschanged', handler);
72
+ resolve(synth.getVoices());
73
+ };
74
+ synth.addEventListener('voiceschanged', handler);
75
+ setTimeout(() => {
76
+ if(settled) return;
77
+ settled = true;
78
+ synth.removeEventListener('voiceschanged', handler);
79
+ resolve(synth.getVoices());
80
+ }, timeoutMs);
81
+ });
82
+ };
83
+
84
+ export const subscribeToAvailableVoices = callback => {
85
+ if(typeof window === 'undefined' || !window.speechSynthesis){
86
+ callback([]);
87
+ return () => {};
88
+ }
89
+ const synth = window.speechSynthesis;
90
+ const fire = () => callback(synth.getVoices());
91
+ synth.addEventListener('voiceschanged', fire);
92
+ fire();
93
+ return () => synth.removeEventListener('voiceschanged', fire);
94
+ };
95
+
96
+ export default {
97
+ get: getVoice,
98
+ set: setVoice,
99
+ subscribe: subscribeToVoice,
100
+ getAvailable: getAvailableVoices,
101
+ waitForVoices,
102
+ subscribeAvailable: subscribeToAvailableVoices
103
+ };