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.
- package/.claude/skills/get-icon/SKILL.md +144 -0
- package/.claude/skills/highlight-code/SKILL.md +62 -0
- package/.claude/skills/new-component/SKILL.md +362 -0
- package/.github/skills/get-icon/SKILL.md +4 -4
- package/.github/skills/highlight-code/SKILL.md +51 -20
- package/.github/skills/new-component/SKILL.md +183 -152
- package/AGENTS.md +2 -2
- package/dist/components/SpeechToText.js +60 -0
- package/dist/components/TextToSpeech.js +56 -0
- package/dist/components/VoiceSelector.js +49 -0
- package/dist/utils/voice.js +1 -0
- package/docs/components/accordion.html +15 -0
- package/docs/components/aside.html +15 -0
- package/docs/components/calendar.html +15 -0
- package/docs/components/card.html +15 -0
- package/docs/components/code-editor.html +15 -0
- package/docs/components/color-picker.html +15 -0
- package/docs/components/combobox.html +15 -0
- package/docs/components/content-slider.html +15 -0
- package/docs/components/context.html +15 -0
- package/docs/components/dialog.html +15 -0
- package/docs/components/dropdown.html +15 -0
- package/docs/components/filter-list.html +15 -0
- package/docs/components/focus-capture.html +15 -0
- package/docs/components/html-editor.html +15 -0
- package/docs/components/hybrid-component.html +15 -0
- package/docs/components/icon.html +15 -0
- package/docs/components/import.html +15 -0
- package/docs/components/light-component.html +15 -0
- package/docs/components/nav-spacer.html +15 -0
- package/docs/components/nav.html +15 -0
- package/docs/components/photo-viewer.html +15 -0
- package/docs/components/progress.html +15 -0
- package/docs/components/resize.html +15 -0
- package/docs/components/shadow-component.html +15 -0
- package/docs/components/show-more.html +15 -0
- package/docs/components/slider.html +15 -0
- package/docs/components/sortable.html +15 -0
- package/docs/components/speech-to-text.html +724 -0
- package/docs/components/spinner.html +15 -0
- package/docs/components/split.html +15 -0
- package/docs/components/table.html +15 -0
- package/docs/components/tableControls.html +15 -0
- package/docs/components/tableCustomFields.html +15 -0
- package/docs/components/tableFetchRecords.html +15 -0
- package/docs/components/tableFieldSortHide.html +15 -0
- package/docs/components/tablePagination.html +15 -0
- package/docs/components/tablePlaceholder.html +15 -0
- package/docs/components/tableRecordEditing.html +15 -0
- package/docs/components/tableRecordFiltering.html +15 -0
- package/docs/components/tableRecordHiding.html +15 -0
- package/docs/components/tableRecordSearching.html +15 -0
- package/docs/components/tableRecordSelection.html +15 -0
- package/docs/components/tableRowControls.html +15 -0
- package/docs/components/tableServerSync.html +15 -0
- package/docs/components/tableSorting.html +15 -0
- package/docs/components/tabs.html +15 -0
- package/docs/components/tags.html +15 -0
- package/docs/components/text-to-speech.html +718 -0
- package/docs/components/theme-select.html +15 -0
- package/docs/components/theme-switcher.html +15 -0
- package/docs/components/time.html +15 -0
- package/docs/components/timestamp.html +15 -0
- package/docs/components/toast.html +15 -0
- package/docs/components/toggle.html +15 -0
- package/docs/components/tree.html +15 -0
- package/docs/components/voice-selector.html +598 -0
- package/docs/icons/mic.svg +1 -0
- package/docs/icons/record_voice_over.svg +1 -0
- package/docs/icons/stop.svg +1 -0
- package/docs/index.html +39 -0
- package/docs/src/components/SpeechToText.js +60 -0
- package/docs/src/components/TextToSpeech.js +56 -0
- package/docs/src/components/VoiceSelector.js +49 -0
- package/docs/src/utils/voice.js +1 -0
- package/docs/utils/context.html +15 -0
- package/docs/utils/cookie.html +15 -0
- package/docs/utils/debounce.html +15 -0
- package/docs/utils/drag.html +15 -0
- package/docs/utils/elevation.html +15 -0
- package/docs/utils/formatTimestamp.html +15 -0
- package/docs/utils/object.html +15 -0
- package/docs/utils/propConverters.html +15 -0
- package/docs/utils/string.html +15 -0
- package/docs/utils/theme.html +15 -0
- package/docs/utils/toTitleCase.html +15 -0
- package/docs/utils/type.html +15 -0
- package/docs/utils/voice.html +536 -0
- package/docs/utils/wait.html +15 -0
- package/docs-src/components/speech-to-text.page.html +251 -0
- package/docs-src/components/text-to-speech.page.html +245 -0
- package/docs-src/components/voice-selector.page.html +125 -0
- package/docs-src/index.page.html +24 -0
- package/docs-src/nav.fragment.html +15 -0
- package/docs-src/utils/voice.page.html +66 -0
- package/icons/mic.svg +1 -0
- package/icons/record_voice_over.svg +1 -0
- package/icons/stop.svg +1 -0
- package/llms.txt +4 -0
- package/package.json +1 -1
- package/src/components/SpeechToText.js +252 -0
- package/src/components/TextToSpeech.js +205 -0
- package/src/components/VoiceSelector.js +271 -0
- package/src/utils/voice.js +103 -0
- package/tests/components/SpeechToText.browser-test.js +530 -0
- package/tests/components/TextToSpeech.browser-test.js +581 -0
- package/tests/components/VoiceSelector.browser-test.js +374 -0
- 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
|
+
};
|