@octolearn/training-ui 0.0.0-stage → 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.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +413 -3
  3. package/THIRD-PARTY-NOTICES.md +211 -0
  4. package/dist/chunks/core-rJUDTpGS.js +592 -0
  5. package/dist/chunks/define-elements-CeSpZJXj.js +6111 -0
  6. package/dist/chunks/font-data-DlFp9eSH.js +8 -0
  7. package/dist/core.js +11 -0
  8. package/dist/define.js +52 -0
  9. package/dist/index.js +50 -0
  10. package/dist/octolearn-training-ui.bundle.js +6441 -0
  11. package/dist/types/base-element.d.ts +35 -0
  12. package/dist/types/content.d.ts +51 -0
  13. package/dist/types/core.d.ts +13 -0
  14. package/dist/types/d-icons.d.ts +10 -0
  15. package/dist/types/define-elements.d.ts +18 -0
  16. package/dist/types/define.d.ts +1 -0
  17. package/dist/types/diff.d.ts +34 -0
  18. package/dist/types/elements/arithmetic-question.d.ts +31 -0
  19. package/dist/types/elements/card.d.ts +43 -0
  20. package/dist/types/elements/choice-pair.d.ts +21 -0
  21. package/dist/types/elements/dictation-question.d.ts +51 -0
  22. package/dist/types/elements/feedback-toast.d.ts +29 -0
  23. package/dist/types/elements/feedback.d.ts +87 -0
  24. package/dist/types/elements/flashcard.d.ts +46 -0
  25. package/dist/types/elements/matching-round.d.ts +61 -0
  26. package/dist/types/elements/multiple-choice.d.ts +33 -0
  27. package/dist/types/elements/picture-question.d.ts +30 -0
  28. package/dist/types/elements/progress.d.ts +24 -0
  29. package/dist/types/elements/question-answer.d.ts +20 -0
  30. package/dist/types/elements/question-parts.d.ts +88 -0
  31. package/dist/types/elements/question.d.ts +37 -0
  32. package/dist/types/elements/text-answer.d.ts +57 -0
  33. package/dist/types/elements/tile-answer.d.ts +43 -0
  34. package/dist/types/elements/today-stats.d.ts +17 -0
  35. package/dist/types/elements/trainer-overlay.d.ts +45 -0
  36. package/dist/types/elements/trainer-panel.d.ts +46 -0
  37. package/dist/types/elements/trainer.d.ts +57 -0
  38. package/dist/types/elements/true-false.d.ts +37 -0
  39. package/dist/types/events.d.ts +53 -0
  40. package/dist/types/feedback-message.d.ts +34 -0
  41. package/dist/types/font-data.d.ts +2 -0
  42. package/dist/types/fonts.d.ts +9 -0
  43. package/dist/types/i18n/strings.d.ts +233 -0
  44. package/dist/types/icons.d.ts +21 -0
  45. package/dist/types/images.d.ts +2 -0
  46. package/dist/types/index.d.ts +32 -0
  47. package/dist/types/keys.d.ts +6 -0
  48. package/dist/types/session/third-party-transport.d.ts +22 -0
  49. package/dist/types/session/training-session.d.ts +167 -0
  50. package/dist/types/session/transport.d.ts +44 -0
  51. package/dist/types/speech.d.ts +71 -0
  52. package/dist/types/styles/base.d.ts +9 -0
  53. package/dist/types/styles/d-parts.d.ts +13 -0
  54. package/dist/types/styles/tokens.d.ts +95 -0
  55. package/dist/types/viewport.d.ts +35 -0
  56. package/package.json +67 -6
@@ -0,0 +1,592 @@
1
+ const VOICE_STORAGE_KEY = "octolearn.training.voice";
2
+ const RATE_STORAGE_KEY = "octolearn.training.rate";
3
+ const MIN_RATE = 0.5;
4
+ const MAX_RATE = 1.5;
5
+ const VOICE_WAIT_MS = 1e3;
6
+ class SpeechController {
7
+ constructor(options = {}) {
8
+ this.voices = [];
9
+ this.selectedVoice = null;
10
+ this.pending = null;
11
+ this.requestId = 0;
12
+ this.connections = 0;
13
+ this.handleVoicesChanged = () => {
14
+ if (this.pending) {
15
+ this.flushWhenVoicesKnown();
16
+ }
17
+ };
18
+ this.preferredVoices = options.preferredVoices ?? ["Google Deutsch"];
19
+ this.preferredLanguage = (options.preferredLanguage ?? "de").toLowerCase();
20
+ this.rate = readStoredRate();
21
+ }
22
+ static get supported() {
23
+ return typeof speechSynthesis !== "undefined" && typeof SpeechSynthesisUtterance !== "undefined";
24
+ }
25
+ /** Starts listening for the browser's voice list. Call disconnect() as often as connect(). */
26
+ connect() {
27
+ if (!SpeechController.supported) {
28
+ return;
29
+ }
30
+ if (this.connections++ === 0) {
31
+ speechSynthesis.addEventListener("voiceschanged", this.handleVoicesChanged);
32
+ this.refreshVoices();
33
+ }
34
+ }
35
+ disconnect() {
36
+ if (!SpeechController.supported || this.connections === 0) {
37
+ return;
38
+ }
39
+ if (--this.connections === 0) {
40
+ speechSynthesis.removeEventListener("voiceschanged", this.handleVoicesChanged);
41
+ }
42
+ }
43
+ speak(input, options = {}) {
44
+ const text = (input ?? "").trim();
45
+ if (text === "" || !SpeechController.supported) {
46
+ return "unavailable";
47
+ }
48
+ this.pending = { text, options };
49
+ if ("userActivation" in navigator && !navigator.userActivation.hasBeenActive) {
50
+ return "blocked";
51
+ }
52
+ this.flushWhenVoicesKnown();
53
+ return "spoken";
54
+ }
55
+ /** Speaks the text that was blocked; call it from a click. */
56
+ playPending() {
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;
66
+ }
67
+ return this.voiceFor(lang) !== void 0;
68
+ }
69
+ stop() {
70
+ this.pending = null;
71
+ this.requestId++;
72
+ this.stopWaitingForVoices();
73
+ if (SpeechController.supported) {
74
+ speechSynthesis.cancel();
75
+ }
76
+ }
77
+ /** Remembers the voice (by name; the voice objects change when the browser reloads its list) and the speed. */
78
+ choose(voice, rate = this.rate) {
79
+ this.selectedVoice = voice;
80
+ this.rate = clampRate(rate);
81
+ try {
82
+ localStorage.setItem(VOICE_STORAGE_KEY, voice?.name ?? "");
83
+ localStorage.setItem(RATE_STORAGE_KEY, String(this.rate));
84
+ } catch {
85
+ }
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. */
106
+ refreshVoices() {
107
+ const available = speechSynthesis.getVoices();
108
+ if (available.length > 0) {
109
+ this.voices = available;
110
+ this.selectedVoice = this.preferredVoice(available);
111
+ }
112
+ return this.voices.length > 0;
113
+ }
114
+ /** A voice that speaks the language: the learner's, else a preferred one, else the first; never one of another language. */
115
+ voiceFor(lang) {
116
+ const wanted = primaryLanguage(lang);
117
+ const speaks = (voice) => !!voice && primaryLanguage(voice.lang) === wanted;
118
+ if (speaks(this.selectedVoice)) {
119
+ return this.selectedVoice;
120
+ }
121
+ return this.preferredVoices.map((name) => this.voices.find((voice) => voice.name === name)).find(speaks) ?? this.voices.find(speaks);
122
+ }
123
+ preferredVoice(available) {
124
+ const chosen = this.selectedVoice?.name ?? readStoredVoiceName();
125
+ const byName = (name) => name ? available.find((voice) => voice.name === name) : void 0;
126
+ return byName(chosen) ?? this.preferredVoices.map(byName).find((voice) => voice !== void 0) ?? available.find((voice) => voice.lang.toLowerCase().startsWith(this.preferredLanguage)) ?? available[0];
127
+ }
128
+ flushPending() {
129
+ if (!this.pending) {
130
+ return;
131
+ }
132
+ const requestId = ++this.requestId;
133
+ const { text, options } = this.pending;
134
+ this.pending = null;
135
+ const lang = options.lang ?? this.preferredLanguage;
136
+ const voice = this.voiceFor(lang);
137
+ const speakLatest = () => {
138
+ if (requestId !== this.requestId) {
139
+ return;
140
+ }
141
+ const utterance = new SpeechSynthesisUtterance(text);
142
+ if (voice) {
143
+ utterance.voice = voice;
144
+ }
145
+ utterance.lang = voice?.lang ?? lang;
146
+ utterance.rate = options.rate ?? this.rate;
147
+ speechSynthesis.speak(utterance);
148
+ };
149
+ if (!speechSynthesis.speaking && !speechSynthesis.pending) {
150
+ speakLatest();
151
+ return;
152
+ }
153
+ speechSynthesis.cancel();
154
+ setTimeout(speakLatest, 0);
155
+ }
156
+ }
157
+ function primaryLanguage(lang) {
158
+ return lang.trim().toLowerCase().split(/[-_]/)[0];
159
+ }
160
+ function clampRate(rate) {
161
+ return Number.isFinite(rate) ? Math.min(MAX_RATE, Math.max(MIN_RATE, rate)) : 1;
162
+ }
163
+ function readStoredRate() {
164
+ try {
165
+ const rate = Number.parseFloat(localStorage.getItem(RATE_STORAGE_KEY) ?? "");
166
+ return Number.isFinite(rate) && rate >= MIN_RATE && rate <= MAX_RATE ? rate : 1;
167
+ } catch {
168
+ return 1;
169
+ }
170
+ }
171
+ function readStoredVoiceName() {
172
+ try {
173
+ const name = localStorage.getItem(VOICE_STORAGE_KEY);
174
+ return name === null || name === "" ? void 0 : name;
175
+ } catch {
176
+ return void 0;
177
+ }
178
+ }
179
+ let defaultController;
180
+ function getSpeechController() {
181
+ defaultController ??= new SpeechController();
182
+ return defaultController;
183
+ }
184
+ const ANSWER_EVENT = "octolearn-answer";
185
+ const DONT_KNOW_ANSWER = "?";
186
+ class TransportError extends Error {
187
+ constructor(status, message, retryAfterSeconds) {
188
+ super(message);
189
+ this.status = status;
190
+ this.retryAfterSeconds = retryAfterSeconds;
191
+ this.name = "TransportError";
192
+ }
193
+ }
194
+ function createThirdPartyTransport(options) {
195
+ const base = options.baseUrl.replace(/\/+$/, "");
196
+ const fetchFn = options.fetch ?? ((url, init) => fetch(url, init));
197
+ async function request(path, init = {}) {
198
+ const headers = { Accept: "application/json", ...await options.headers?.(), ...init.headers };
199
+ const response = await fetchFn(`${base}${path}`, { ...init, headers });
200
+ if (!response.ok) {
201
+ const retryAfter = Number.parseInt(response.headers.get("X-Rate-Limit-Retry-After-Seconds") ?? "", 10);
202
+ throw new TransportError(
203
+ response.status,
204
+ `octolearn API: ${init.method ?? "GET"} ${path} failed with ${response.status}`,
205
+ Number.isFinite(retryAfter) ? retryAfter : void 0
206
+ );
207
+ }
208
+ return await response.json();
209
+ }
210
+ return {
211
+ nextQuestions() {
212
+ const params = new URLSearchParams({ numberOfNeededQuestions: String(options.batchSize ?? 5) });
213
+ if (options.queryFormats?.length) {
214
+ params.set("filterQueryFormat", options.queryFormats.join(","));
215
+ }
216
+ if (options.learningModes?.length) {
217
+ params.set("filterLearningMode", options.learningModes.join(","));
218
+ }
219
+ return request(`/third-party/next-relevant-questions?${params.toString()}`).then((list) => list ?? []);
220
+ },
221
+ submitAnswer(content, answer) {
222
+ return request("/third-party", {
223
+ method: "PUT",
224
+ headers: { "Content-Type": "application/json" },
225
+ body: JSON.stringify({ frontendContent: content, answer })
226
+ });
227
+ },
228
+ todaysStatistic() {
229
+ return request("/third-party/get-todays-statistic");
230
+ }
231
+ };
232
+ }
233
+ class TrainingSession extends EventTarget {
234
+ constructor(transport, options = {}) {
235
+ super();
236
+ this.transport = transport;
237
+ this.correct = 0;
238
+ this.wrong = 0;
239
+ this.practising = false;
240
+ this.streak = 0;
241
+ this.feedback = null;
242
+ this.round = null;
243
+ this.nextRound = null;
244
+ this.roundRequested = false;
245
+ this.answersSinceRound = 0;
246
+ this.roundResults = [];
247
+ this.queue = [];
248
+ this.buffer = null;
249
+ this.started = false;
250
+ this.loading = false;
251
+ this.loadFailed = false;
252
+ this.prefetching = false;
253
+ this.prefetchFailed = false;
254
+ this.finished = false;
255
+ this.submitting = false;
256
+ this.recentlyAnswered = [];
257
+ this.prefetchAt = options.prefetchAt ?? 2;
258
+ this.recentMemory = options.recentMemory ?? 3;
259
+ this.holdForFeedback = options.holdForFeedback ?? false;
260
+ this.roundSize = Math.max(1, options.roundSize ?? 5);
261
+ this.matchingEvery = Math.max(1, options.matchingEvery ?? 8);
262
+ }
263
+ get state() {
264
+ if (this.loadFailed) {
265
+ return "error";
266
+ }
267
+ if (this.feedback) {
268
+ return "feedback";
269
+ }
270
+ if (this.round) {
271
+ return "pairs";
272
+ }
273
+ if (this.loading) {
274
+ return "loading";
275
+ }
276
+ if (this.queue.length > 0) {
277
+ return this.dueDone ? "due-done" : "question";
278
+ }
279
+ if (!this.started) {
280
+ return "idle";
281
+ }
282
+ if (this.finished) {
283
+ return "done";
284
+ }
285
+ return this.prefetchFailed && !this.prefetching ? "error" : "loading";
286
+ }
287
+ /** The question to show. */
288
+ get current() {
289
+ return this.queue[0] ?? null;
290
+ }
291
+ /** The questions waiting, the current one first. */
292
+ get upcoming() {
293
+ return this.queue;
294
+ }
295
+ /** Only practice is left (nothing due) and the learner hasn't chosen to go on. */
296
+ get dueDone() {
297
+ return !this.practising && this.queue.length > 0 && !this.queue.some((content) => content.learningLevelShallBeChanged);
298
+ }
299
+ /** The stars: the current question of the round and the results so far. A new round starts after `roundSize` answers. */
300
+ get roundProgress() {
301
+ if (this.feedback) {
302
+ return { current: this.roundResults.length, total: this.roundSize, results: this.roundResults };
303
+ }
304
+ if (this.roundResults.length >= this.roundSize) {
305
+ return { current: 1, total: this.roundSize, results: [] };
306
+ }
307
+ return { current: this.roundResults.length + 1, total: this.roundSize, results: this.roundResults };
308
+ }
309
+ /** An answer is being sent; further answers are ignored until it is evaluated. */
310
+ get busy() {
311
+ return this.submitting;
312
+ }
313
+ /** Loads the first questions and today's counts. */
314
+ async start() {
315
+ this.started = true;
316
+ await Promise.all([this.load(), this.loadStatistic(), this.requestRound()]);
317
+ }
318
+ /** Asks for questions again, e.g. after an error ("Erneut versuchen"). */
319
+ async reload() {
320
+ this.started = true;
321
+ await this.load();
322
+ }
323
+ startPractising() {
324
+ this.practising = true;
325
+ this.changed();
326
+ }
327
+ /** With `holdForFeedback`: leaves the feedback and shows the next question ("Weiter"). */
328
+ continue() {
329
+ if (!this.feedback) {
330
+ return;
331
+ }
332
+ this.feedback = null;
333
+ this.changed();
334
+ }
335
+ /**
336
+ * Sends the answer to the current question and moves on to the next one (with `holdForFeedback`: to the feedback).
337
+ * Returns null when there is no question, the answer is blank or another answer is still being sent. Pass
338
+ * `{ dontKnow: true }` for "Weiß ich nicht" (the `octolearn-answer` event's detail).
339
+ */
340
+ async answer(text, details = {}) {
341
+ const content = this.queue[0];
342
+ const answer = text.trim();
343
+ if (!content || answer === "" || this.submitting || this.feedback || this.round) {
344
+ return null;
345
+ }
346
+ this.submitting = true;
347
+ this.changed();
348
+ if (content.id !== void 0) {
349
+ this.rememberAnswered(content.id);
350
+ }
351
+ let evaluation = null;
352
+ try {
353
+ evaluation = await this.transport.submitAnswer(content, answer);
354
+ } catch {
355
+ evaluation = null;
356
+ }
357
+ const correct = evaluation?.isCorrect === true;
358
+ if (correct) {
359
+ this.correct++;
360
+ this.streak++;
361
+ } else {
362
+ this.wrong++;
363
+ this.streak = 0;
364
+ }
365
+ this.submitting = false;
366
+ if (this.roundResults.length >= this.roundSize) {
367
+ this.roundResults = [];
368
+ }
369
+ this.roundResults = [...this.roundResults, correct ? "right" : "wrong"];
370
+ const counts = content.learningLevelShallBeChanged !== false;
371
+ const multipleChoice = content.queryFormat === "MultipleChoice";
372
+ const outcome = {
373
+ content,
374
+ answer,
375
+ evaluation,
376
+ correct,
377
+ feedback: {
378
+ result: correct ? "correct" : "wrong",
379
+ answer,
380
+ correctAnswer: correct ? void 0 : evaluation?.correctAnswer,
381
+ typo: correct && evaluation?.acceptedWithTypo === true,
382
+ note: correct && evaluation?.acceptedWithTypo ? evaluation.feedbackMessage : void 0,
383
+ dontKnow: details.dontKnow === true,
384
+ unchecked: evaluation === null,
385
+ // a typed answer moves the learning level two steps, every other format one (OC-120); practice moves nothing
386
+ points: correct && counts ? content.queryFormat === "TextInput" ? 2 : 1 : 0,
387
+ streak: this.streak,
388
+ options: multipleChoice ? content.options ?? null : null,
389
+ word: content.learningMode === "Dictation" || content.isItVocabulary === true,
390
+ listenable: content.learningMode === "Dictation" && !multipleChoice,
391
+ queryFormat: content.queryFormat
392
+ }
393
+ };
394
+ if (this.holdForFeedback) {
395
+ this.feedback = outcome;
396
+ }
397
+ this.advance();
398
+ this.answersSinceRound++;
399
+ if (this.nextRound) {
400
+ this.round = this.nextRound;
401
+ this.nextRound = null;
402
+ } else if (this.answersSinceRound >= this.matchingEvery) {
403
+ void this.requestRound();
404
+ }
405
+ this.dispatchEvent(new CustomEvent("answered", { detail: outcome }));
406
+ this.changed();
407
+ return outcome;
408
+ }
409
+ /**
410
+ * Checks one pair of the current round, like an answer to that question; counts it and fires `pair-checked`. Null when
411
+ * the pair is not part of the round or could not be checked (the pair then stays open).
412
+ */
413
+ async checkPair(item, answer) {
414
+ if (!this.round?.items.some((roundItem) => roundItem.id === item.id)) {
415
+ return null;
416
+ }
417
+ let evaluation;
418
+ try {
419
+ evaluation = await this.transport.submitAnswer(
420
+ { id: item.id, learningMode: item.learningMode, learningLevelShallBeChanged: item.learningLevelShallBeChanged },
421
+ answer
422
+ );
423
+ } catch {
424
+ return null;
425
+ }
426
+ const correct = evaluation.isCorrect;
427
+ this.rememberAnswered(item.id);
428
+ if (correct) {
429
+ this.correct++;
430
+ this.streak++;
431
+ } else {
432
+ this.wrong++;
433
+ this.streak = 0;
434
+ }
435
+ this.dispatchEvent(new CustomEvent("pair-checked", { detail: { item, answer, evaluation, correct } }));
436
+ this.changed();
437
+ return evaluation;
438
+ }
439
+ /** After a round ("Weiter"): the single questions go on; the ones the round just asked are dropped from the queue. */
440
+ finishRound() {
441
+ if (!this.round) {
442
+ return;
443
+ }
444
+ const asked = new Set(this.round.items.map((item) => item.id));
445
+ const notAsked = (content) => content.id === void 0 || !asked.has(content.id);
446
+ this.round = null;
447
+ this.answersSinceRound = 0;
448
+ this.queue = this.queue.filter(notAsked);
449
+ if (this.buffer) {
450
+ this.buffer = this.buffer.filter(notAsked);
451
+ }
452
+ if (this.queue.length === 0) {
453
+ if (this.buffer === null && !this.prefetching) {
454
+ void this.prefetch();
455
+ } else {
456
+ this.drainBuffer();
457
+ }
458
+ }
459
+ this.changed();
460
+ }
461
+ /** The answer of a Karteikarte (for `<octolearn-flashcard>`.reveal); rejects when the transport can't. */
462
+ revealFlashcard(content = this.current) {
463
+ if (!content || !this.transport.revealFlashcard) {
464
+ return Promise.reject(new Error("This transport cannot reveal flashcards."));
465
+ }
466
+ return this.transport.revealFlashcard(content);
467
+ }
468
+ /**
469
+ * Asks for a round of "Paare finden" (OC-125). Before the first answer it is shown at once; later it waits until the
470
+ * question on screen is answered. A round without items, or an error, just means single questions go on.
471
+ */
472
+ async requestRound() {
473
+ if (!this.transport.matchingRound || this.roundRequested || this.round || this.nextRound) {
474
+ return;
475
+ }
476
+ this.roundRequested = true;
477
+ try {
478
+ const round = await this.transport.matchingRound();
479
+ if (round.items.length === 0) {
480
+ this.answersSinceRound = 0;
481
+ } else if (this.answersSinceRound === 0 && !this.feedback) {
482
+ this.round = round;
483
+ } else {
484
+ this.nextRound = round;
485
+ }
486
+ } catch {
487
+ this.answersSinceRound = 0;
488
+ } finally {
489
+ this.roundRequested = false;
490
+ this.changed();
491
+ }
492
+ }
493
+ async load() {
494
+ this.loading = true;
495
+ this.loadFailed = false;
496
+ this.changed();
497
+ try {
498
+ const batch = await this.transport.nextQuestions();
499
+ this.queue = this.preferNotRecentlyAnswered(batch);
500
+ this.buffer = null;
501
+ this.prefetchFailed = false;
502
+ this.finished = this.queue.length === 0;
503
+ } catch {
504
+ this.loadFailed = true;
505
+ } finally {
506
+ this.loading = false;
507
+ this.changed();
508
+ }
509
+ }
510
+ async loadStatistic() {
511
+ if (!this.transport.todaysStatistic) {
512
+ return;
513
+ }
514
+ try {
515
+ const statistic = await this.transport.todaysStatistic();
516
+ this.correct = statistic.correctAnswerCount ?? this.correct;
517
+ this.wrong = statistic.wrongAnswerCount ?? this.wrong;
518
+ this.changed();
519
+ } catch {
520
+ }
521
+ }
522
+ advance() {
523
+ this.queue.shift();
524
+ if (this.queue.length <= this.prefetchAt && !this.prefetching && this.buffer === null) {
525
+ void this.prefetch();
526
+ }
527
+ if (this.queue.length === 0) {
528
+ this.drainBuffer();
529
+ }
530
+ }
531
+ async prefetch() {
532
+ this.prefetching = true;
533
+ this.prefetchFailed = false;
534
+ const onScreenAtStart = new Set(this.queue.map((content) => content.id));
535
+ let droppedOnScreen = false;
536
+ try {
537
+ const received = await this.transport.nextQuestions();
538
+ const notOnScreen = received.filter((content) => !onScreenAtStart.has(content.id) && !this.queue.some((open) => open.id === content.id));
539
+ droppedOnScreen = notOnScreen.length < received.length;
540
+ this.buffer = notOnScreen.length === 0 && droppedOnScreen ? null : this.preferNotRecentlyAnswered(notOnScreen);
541
+ } catch {
542
+ this.prefetchFailed = true;
543
+ this.buffer = null;
544
+ }
545
+ this.prefetching = false;
546
+ if (this.buffer === null && droppedOnScreen) {
547
+ if (this.queue.length === 0) {
548
+ void this.prefetch();
549
+ }
550
+ } else if (this.queue.length === 0) {
551
+ this.drainBuffer();
552
+ }
553
+ this.changed();
554
+ }
555
+ drainBuffer() {
556
+ if (this.buffer === null) {
557
+ return;
558
+ }
559
+ if (this.buffer.length > 0) {
560
+ this.queue = this.buffer;
561
+ } else {
562
+ this.finished = true;
563
+ }
564
+ this.buffer = null;
565
+ }
566
+ rememberAnswered(id) {
567
+ this.recentlyAnswered = [...this.recentlyAnswered.filter((known) => known !== id), id].slice(-this.recentMemory);
568
+ }
569
+ /**
570
+ * Puts what was answered a moment ago behind the other questions of the batch (OC-224). It is not left out: a wrongly
571
+ * answered question comes back in the same session on purpose, and a batch that lost its only due question would say
572
+ * "done" while that question is still waiting (OC-251). A lone question may come back.
573
+ */
574
+ preferNotRecentlyAnswered(batch) {
575
+ const isRecent = (content) => content.id !== void 0 && this.recentlyAnswered.includes(content.id);
576
+ return [...batch.filter((content) => !isRecent(content)), ...batch.filter(isRecent)];
577
+ }
578
+ changed() {
579
+ this.dispatchEvent(new Event("change"));
580
+ }
581
+ }
582
+ export {
583
+ ANSWER_EVENT as A,
584
+ DONT_KNOW_ANSWER as D,
585
+ MAX_RATE as M,
586
+ SpeechController as S,
587
+ TrainingSession as T,
588
+ MIN_RATE as a,
589
+ TransportError as b,
590
+ createThirdPartyTransport as c,
591
+ getSpeechController as g
592
+ };