@octolearn/training-ui 0.1.0 → 0.2.0

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/README.md CHANGED
@@ -255,9 +255,9 @@ shown as text, never as HTML.
255
255
  | --------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
256
256
  | `<octolearn-question>` | any question: picks the element by answer format, then learning mode | `content`, `settings`, `no-autospeak`, `no-autofocus`, `no-keys`, `revealFlashcard` |
257
257
  | `<octolearn-question-answer>` | Vokabel / question, also Stimmt das? and Karteikarte | `content`, `no-autofocus`, `no-keys`, `revealFlashcard` |
258
- | `<octolearn-dictation-question>` | Diktat: listen, "Langsam anhören", optional "Einstellungen" | `content`, `settings`, `no-autospeak`, `no-keys` |
259
- | `<octolearn-arithmetic-question>` | Rechnen ("7 · 8 =") | `content`, `no-keys` |
260
- | `<octolearn-picture-question>` | Bilderrätsel | `content`, `no-keys` |
258
+ | `<octolearn-dictation-question>` | Diktat: listen, "Langsam anhören", optional "Einstellungen" | `content`, `settings`, `no-autospeak`, `no-autofocus`, `no-keys` |
259
+ | `<octolearn-arithmetic-question>` | Rechnen ("7 · 8 =") | `content`, `no-autofocus`, `no-keys` |
260
+ | `<octolearn-picture-question>` | Bilderrätsel | `content`, `no-autofocus`, `no-keys` |
261
261
  | `<octolearn-multiple-choice>` | answer tiles, keys 1–9 | `options`, `label`, `no-keys` |
262
262
  | `<octolearn-text-answer>` | the big field, "Weiß ich nicht", Wortfragment hint | `label`, `visible-label`, `type`, `input-lang`, `hint`, `no-dont-know`, `value` |
263
263
  | `<octolearn-tile-answer>` | Buchstabensalat / Satzpuzzle | `tiles`, `separator` (`""` letters, `" "` words) |
@@ -302,8 +302,13 @@ The texts are German (`de`) or English (`en`), following the `lang` attribute of
302
302
  exported as `de` and `en`).
303
303
 
304
304
  Dictations are read aloud with the browser's speech synthesis; the voice and speed the learner chose are remembered
305
- (`getSpeechController().choose(voice, rate)`). If the browser blocks speech before the first tap, the question shows an
306
- "Abspielen" button. To use your own audio, cancel `octolearn-speak` and play `detail.text` yourself.
305
+ (`getSpeechController().choose(voice, rate)`). A word is only read by a voice of its language (`detail.lang`: the
306
+ content's `sourceLanguage`, else `de`): the learner's voice if it speaks that language, else Google Deutsch or the first
307
+ voice of it. Without one the browser gets the language alone, and the dictation shows a note that the device has no
308
+ voice for it (`getSpeechController().hasVoiceFor(lang)`). If the browser blocks speech before the first tap, the
309
+ question shows an "Abspielen" button. To use your own audio, cancel `octolearn-speak` and play `detail.text` in
310
+ `detail.lang` yourself, or pass both to `getSpeechController().speak(text, { lang, rate })`, which waits for the
311
+ browser's voice list (Chrome's first `getVoices()` is often empty).
307
312
 
308
313
  ## Theming
309
314
 
@@ -373,6 +378,17 @@ without dragging, the progress stars say "Frage 3 von 5, richtig", and the hints
373
378
  on touch screens. Right answers move on after 3 s; the timer pauses while the pointer rests on the card, and `no-auto` turns it
374
379
  off. Reduced motion is respected.
375
380
 
381
+ ## Phones and the on-screen keyboard
382
+
383
+ When a phone's keyboard opens it covers about half of the screen. The elements notice this through
384
+ `window.visualViewport` (CSS can't: the layout viewport keeps its size) and set the reflected attribute `short` on
385
+ themselves while less than 520 px (`SHORT_VIEWPORT_PX`) are visible. In that state the card hides its header and Inkie
386
+ (the label and stars stay for screen readers), texts, pictures and buttons get smaller, the answer field carries a
387
+ round send button instead of "Fertig", and "Weiß ich nicht" becomes a small text button. A question whose field has the
388
+ focus then scrolls its card to the top of the visible part, so the word or picture stays above the field. When the
389
+ keyboard closes, everything is back as before. The page can follow the same state, e.g. with
390
+ `.layout:has(octolearn-trainer[short])` or `getViewportWatcher().subscribe(short => …)`.
391
+
376
392
  ## Browser support, CSP and size
377
393
 
378
394
  Current Chrome, Edge, Firefox and Safari (custom elements, shadow DOM, container queries: Chrome 105+, Firefox 110+,
@@ -2,6 +2,7 @@ const VOICE_STORAGE_KEY = "octolearn.training.voice";
2
2
  const RATE_STORAGE_KEY = "octolearn.training.rate";
3
3
  const MIN_RATE = 0.5;
4
4
  const MAX_RATE = 1.5;
5
+ const VOICE_WAIT_MS = 1e3;
5
6
  class SpeechController {
6
7
  constructor(options = {}) {
7
8
  this.voices = [];
@@ -10,8 +11,8 @@ class SpeechController {
10
11
  this.requestId = 0;
11
12
  this.connections = 0;
12
13
  this.handleVoicesChanged = () => {
13
- if (this.refreshVoices()) {
14
- this.flushPending();
14
+ if (this.pending) {
15
+ this.flushWhenVoicesKnown();
15
16
  }
16
17
  };
17
18
  this.preferredVoices = options.preferredVoices ?? ["Google Deutsch"];
@@ -48,20 +49,27 @@ class SpeechController {
48
49
  if ("userActivation" in navigator && !navigator.userActivation.hasBeenActive) {
49
50
  return "blocked";
50
51
  }
51
- if (this.refreshVoices()) {
52
- this.flushPending();
53
- }
52
+ this.flushWhenVoicesKnown();
54
53
  return "spoken";
55
54
  }
56
55
  /** Speaks the text that was blocked; call it from a click. */
57
56
  playPending() {
58
- if (this.refreshVoices()) {
59
- this.flushPending();
57
+ this.flushWhenVoicesKnown();
58
+ }
59
+ /**
60
+ * Whether the browser has a voice for the language (default: `preferredLanguage`); `undefined` while its voice list
61
+ * is still loading or without speech synthesis.
62
+ */
63
+ hasVoiceFor(lang = this.preferredLanguage) {
64
+ if (!SpeechController.supported || !this.refreshVoices()) {
65
+ return void 0;
60
66
  }
67
+ return this.voiceFor(lang) !== void 0;
61
68
  }
62
69
  stop() {
63
70
  this.pending = null;
64
71
  this.requestId++;
72
+ this.stopWaitingForVoices();
65
73
  if (SpeechController.supported) {
66
74
  speechSynthesis.cancel();
67
75
  }
@@ -76,21 +84,41 @@ class SpeechController {
76
84
  } catch {
77
85
  }
78
86
  }
87
+ /** Speaks the pending text now if the voice list is known, else on voiceschanged or after VOICE_WAIT_MS. */
88
+ flushWhenVoicesKnown() {
89
+ this.stopWaitingForVoices();
90
+ if (this.refreshVoices()) {
91
+ this.flushPending();
92
+ return;
93
+ }
94
+ this.voiceWait = setTimeout(() => {
95
+ this.voiceWait = void 0;
96
+ this.flushPending();
97
+ }, VOICE_WAIT_MS);
98
+ }
99
+ stopWaitingForVoices() {
100
+ if (this.voiceWait !== void 0) {
101
+ clearTimeout(this.voiceWait);
102
+ this.voiceWait = void 0;
103
+ }
104
+ }
105
+ /** Reads the browser's voice list; false while it is still empty. */
79
106
  refreshVoices() {
80
107
  const available = speechSynthesis.getVoices();
81
- if (available.length === 0) {
82
- return this.selectedVoice !== null;
108
+ if (available.length > 0) {
109
+ this.voices = available;
110
+ this.selectedVoice = this.preferredVoice(available);
83
111
  }
84
- this.voices = available;
85
- this.selectedVoice = this.preferredVoice(available);
86
- return this.selectedVoice !== null;
112
+ return this.voices.length > 0;
87
113
  }
114
+ /** A voice that speaks the language: the learner's, else a preferred one, else the first; never one of another language. */
88
115
  voiceFor(lang) {
89
- const wanted = lang.toLowerCase().split(/[-_]/)[0];
90
- if (this.selectedVoice?.lang.toLowerCase().startsWith(wanted)) {
116
+ const wanted = primaryLanguage(lang);
117
+ const speaks = (voice) => !!voice && primaryLanguage(voice.lang) === wanted;
118
+ if (speaks(this.selectedVoice)) {
91
119
  return this.selectedVoice;
92
120
  }
93
- return this.voices.find((voice) => voice.lang.toLowerCase().startsWith(wanted));
121
+ return this.preferredVoices.map((name) => this.voices.find((voice) => voice.name === name)).find(speaks) ?? this.voices.find(speaks);
94
122
  }
95
123
  preferredVoice(available) {
96
124
  const chosen = this.selectedVoice?.name ?? readStoredVoiceName();
@@ -104,7 +132,8 @@ class SpeechController {
104
132
  const requestId = ++this.requestId;
105
133
  const { text, options } = this.pending;
106
134
  this.pending = null;
107
- const voice = (options.lang ? this.voiceFor(options.lang) : void 0) ?? this.selectedVoice;
135
+ const lang = options.lang ?? this.preferredLanguage;
136
+ const voice = this.voiceFor(lang);
108
137
  const speakLatest = () => {
109
138
  if (requestId !== this.requestId) {
110
139
  return;
@@ -113,9 +142,7 @@ class SpeechController {
113
142
  if (voice) {
114
143
  utterance.voice = voice;
115
144
  }
116
- if (options.lang) {
117
- utterance.lang = options.lang;
118
- }
145
+ utterance.lang = voice?.lang ?? lang;
119
146
  utterance.rate = options.rate ?? this.rate;
120
147
  speechSynthesis.speak(utterance);
121
148
  };
@@ -127,6 +154,9 @@ class SpeechController {
127
154
  setTimeout(speakLatest, 0);
128
155
  }
129
156
  }
157
+ function primaryLanguage(lang) {
158
+ return lang.trim().toLowerCase().split(/[-_]/)[0];
159
+ }
130
160
  function clampRate(rate) {
131
161
  return Number.isFinite(rate) ? Math.min(MAX_RATE, Math.max(MIN_RATE, rate)) : 1;
132
162
  }