@readium/navigator 2.11.1 → 2.12.1

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 (52) hide show
  1. package/dist/divina/DivinaNavigator.js +1 -1
  2. package/dist/epub/EpubNavigator.js +1 -1
  3. package/dist/epub/frame/FramePoolManager.js +1 -1
  4. package/dist/index.js +1 -1
  5. package/dist/preferences/guards.js +1 -1
  6. package/dist/readaloud/GuidedNavigationProvider.js +1 -0
  7. package/dist/readaloud/ReadAloudNavigator.js +1 -0
  8. package/dist/readaloud/ReadingUnit.js +1 -0
  9. package/dist/readaloud/SpeechProgress.js +1 -0
  10. package/dist/readaloud/preferences/ReadAloudDefaults.js +1 -0
  11. package/dist/readaloud/preferences/ReadAloudPreferences.js +1 -0
  12. package/dist/readaloud/preferences/ReadAloudPreferencesEditor.js +1 -0
  13. package/dist/readaloud/preferences/ReadAloudSettings.js +1 -0
  14. package/dist/webpub/WebPubFrameManager.js +1 -1
  15. package/dist/webpub/WebPubFramePoolManager.js +1 -1
  16. package/dist/webpub/WebPubNavigator.js +1 -1
  17. package/package.json +8 -6
  18. package/src/divina/DivinaNavigator.ts +1 -0
  19. package/src/epub/EpubNavigator.ts +127 -21
  20. package/src/epub/frame/FramePoolManager.ts +5 -0
  21. package/src/index.ts +1 -0
  22. package/src/preferences/guards.ts +11 -0
  23. package/src/readaloud/GuidedNavigationProvider.ts +87 -0
  24. package/src/readaloud/ReadAloudNavigator.ts +949 -0
  25. package/src/readaloud/ReadingUnit.ts +154 -0
  26. package/src/readaloud/SpeechProgress.ts +60 -0
  27. package/src/readaloud/index.ts +23 -0
  28. package/src/readaloud/preferences/ReadAloudDefaults.ts +24 -0
  29. package/src/readaloud/preferences/ReadAloudPreferences.ts +104 -0
  30. package/src/readaloud/preferences/ReadAloudPreferencesEditor.ts +125 -0
  31. package/src/readaloud/preferences/ReadAloudSettings.ts +44 -0
  32. package/src/readaloud/preferences/index.ts +4 -0
  33. package/src/webpub/WebPubFrameManager.ts +4 -0
  34. package/src/webpub/WebPubFramePoolManager.ts +5 -0
  35. package/src/webpub/WebPubNavigator.ts +114 -10
  36. package/types/src/epub/EpubNavigator.d.ts +26 -1
  37. package/types/src/epub/frame/FramePoolManager.d.ts +2 -0
  38. package/types/src/index.d.ts +1 -0
  39. package/types/src/preferences/guards.d.ts +1 -0
  40. package/types/src/readaloud/GuidedNavigationProvider.d.ts +28 -0
  41. package/types/src/readaloud/ReadAloudNavigator.d.ts +158 -0
  42. package/types/src/readaloud/ReadingUnit.d.ts +58 -0
  43. package/types/src/readaloud/SpeechProgress.d.ts +19 -0
  44. package/types/src/readaloud/index.d.ts +7 -0
  45. package/types/src/readaloud/preferences/ReadAloudDefaults.d.ts +11 -0
  46. package/types/src/readaloud/preferences/ReadAloudPreferences.d.ts +58 -0
  47. package/types/src/readaloud/preferences/ReadAloudPreferencesEditor.d.ts +38 -0
  48. package/types/src/readaloud/preferences/ReadAloudSettings.d.ts +26 -0
  49. package/types/src/readaloud/preferences/index.d.ts +4 -0
  50. package/types/src/webpub/WebPubFrameManager.d.ts +1 -0
  51. package/types/src/webpub/WebPubFramePoolManager.d.ts +2 -0
  52. package/types/src/webpub/WebPubNavigator.d.ts +25 -2
@@ -0,0 +1,949 @@
1
+ import { Layout, Locator, Profile, getCssSelector } from "@readium/shared";
2
+ import { FrameClickEvent, TextLayout, TextLineStarts } from "@readium/navigator-html-injectables";
3
+ import {
4
+ createLocator,
5
+ filterByLanguages,
6
+ LocatorOptions,
7
+ ReadiumSpeechNavigator,
8
+ ReadiumSpeechPlaybackEngine,
9
+ ReadiumSpeechPlaybackEvent,
10
+ ReadiumSpeechPlaybackState,
11
+ ReadiumSpeechUtterance,
12
+ ReadiumSpeechVoice,
13
+ resolveUtteranceLocate,
14
+ sortVoicesByRegions,
15
+ SpeechPreferences,
16
+ WebSpeechEngine,
17
+ WebSpeechVoiceManager,
18
+ } from "@readium/speech";
19
+ import { Navigator, VisualNavigatorViewport } from "../Navigator.ts";
20
+ import { Decoration, DecorableNavigator } from "../decorations/index.ts";
21
+ import { GuidedNavigationProvider, PublicationGuidedNavigationProvider } from "./GuidedNavigationProvider.ts";
22
+ import { GuidedNavigationPool, ReadingUnit, ReadingUnits } from "./ReadingUnit.ts";
23
+ import { SpeechProgress } from "./SpeechProgress.ts";
24
+ import {
25
+ IReadAloudDefaults,
26
+ IReadAloudPreferences,
27
+ ReadAloudAutoPause,
28
+ ReadAloudDecorationStyle,
29
+ ReadAloudDefaults,
30
+ ReadAloudPreferences,
31
+ ReadAloudPreferencesEditor,
32
+ ReadAloudSettings,
33
+ } from "./preferences/index.ts";
34
+
35
+ export type ReadAloudState = ReadiumSpeechPlaybackState;
36
+
37
+ export interface ReadAloudVoicesOptions {
38
+ /** BCP 47 tags, matched by base language. */
39
+ languages?: string[];
40
+ }
41
+
42
+ export interface ReadAloudUtterance {
43
+ text: string;
44
+ /** One locator per piece of the utterance, each in its own resource. */
45
+ locators: Locator[];
46
+ }
47
+
48
+ export interface ReadAloudListeners {
49
+ stateChanged?: (state: ReadAloudState) => void;
50
+ utteranceChanged?: (utterance: ReadAloudUtterance) => void;
51
+ wordChanged?: (locator: Locator, word: string) => void;
52
+ error?: (error: unknown) => void;
53
+ }
54
+
55
+ export interface ReadAloudConfiguration {
56
+ /** Defaults to a `WebSpeechEngine`. */
57
+ engine?: ReadiumSpeechPlaybackEngine;
58
+ provider?: GuidedNavigationProvider;
59
+ preferences?: IReadAloudPreferences;
60
+ defaults?: IReadAloudDefaults;
61
+ /**
62
+ * Whether the reader can move away while reading aloud, which then stops following until back.
63
+ * Otherwise navigating in the content is locked while playing. Defaults to true.
64
+ */
65
+ detachable?: boolean;
66
+ }
67
+
68
+ interface TurningPosition {
69
+ unit: ReadingUnit;
70
+ index: number;
71
+ }
72
+
73
+ interface PausedPosition {
74
+ unit: ReadingUnit;
75
+ index: number;
76
+ /** Whether the utterance at `index` is the current one yet, rather than the one before it. */
77
+ current: boolean;
78
+ }
79
+
80
+ interface SpokenPoint {
81
+ /** Index of the piece among the watched ones. */
82
+ piece: number;
83
+ /** Offset in the piece's text, counting each run of whitespace as one character. */
84
+ offset: number;
85
+ }
86
+
87
+ /**
88
+ * A `WebSpeechEngine` whose voices, and default voice, are those of the publication's languages.
89
+ * Its default voice sends word boundaries when one can.
90
+ */
91
+ class PublicationWebSpeechEngine extends WebSpeechEngine {
92
+ constructor(private readonly languages?: string[]) {
93
+ super();
94
+ }
95
+
96
+ override async initialize(): Promise<boolean> {
97
+ const languages = this.languages?.length ? this.languages : undefined;
98
+ if (!await super.initialize({ languages })) return false;
99
+ if (this.getCurrentVoice()) return true;
100
+ const voices = (await this.getAvailableVoices()).filter(voice => voice.controls?.boundary !== false);
101
+ const manager = await WebSpeechVoiceManager.initialize({ languages });
102
+ const voice = await manager.getDefaultVoice(languages ?? [...(navigator.languages ?? ["en"])], voices);
103
+ if (voice) await this.setVoice(voice);
104
+ return true;
105
+ }
106
+ }
107
+
108
+ const UTTERANCE_GROUP = "readaloud-utterance";
109
+ const WORD_GROUP = "readaloud-word";
110
+ const GO_TIMEOUT = 1000;
111
+ // Subpixel scrolling and rounding of the reported line starts.
112
+ const LAYOUT_TOLERANCE = 1;
113
+
114
+ /**
115
+ * Reads a publication aloud with a speech engine, alongside the navigator displaying it.
116
+ * The spoken utterance and word are highlighted, and the navigator follows them.
117
+ */
118
+ export class ReadAloudNavigator {
119
+ private readonly speech: ReadiumSpeechNavigator;
120
+ private readonly pool: GuidedNavigationPool;
121
+ private readonly units: ReadingUnits;
122
+ private readonly unsubscribers: (() => void)[] = [];
123
+ private readonly _defaults: ReadAloudDefaults;
124
+ private _preferences: ReadAloudPreferences;
125
+ private _settings: ReadAloudSettings;
126
+ private _preferencesEditor: ReadAloudPreferencesEditor | null = null;
127
+ private utteranceLocators: Locator[] = [];
128
+ private wordLocator?: Locator;
129
+ // Where playback resumes after pausing at a page or spread, which speech knows nothing about.
130
+ private pausedBefore?: PausedPosition;
131
+ // Speech stopped while following the next utterance, to tell whether that reaches new columns.
132
+ private turning?: TurningPosition;
133
+ private followedHref?: string;
134
+ // A move is under way, so layout reports until its acknowledgement show it, not the reader's.
135
+ private following?: ReturnType<typeof setTimeout>;
136
+ // The reader moved away from the spoken text, which isn't followed until back in view.
137
+ private detached = false;
138
+ private navigationLocked = false;
139
+ // The resource the spoken text was last shown in, to tell when the reader moves to another.
140
+ private shownHref?: string;
141
+ // The utterance pieces whose layout the navigator reports, and the latest report.
142
+ private watchedPieces = new Map<LocatorOptions, number>();
143
+ private unwatchLayout?: () => void;
144
+ private lines: (TextLineStarts | null)[] = [];
145
+ private viewport?: TextLayout["viewport"];
146
+ // Voices without word boundaries turn the page when the spoken text is timed to reach the next one.
147
+ private readonly progress = new SpeechProgress();
148
+ private breakTimer?: ReturnType<typeof setTimeout>;
149
+ private unit?: ReadingUnit;
150
+ private loading = false;
151
+ // Whether the unit being loaded waits paused once ready, rather than speaking.
152
+ private loadPaused = false;
153
+ private loadToken = 0;
154
+ // Settles the pending wait for speech's "ready", which never comes once stopped or destroyed.
155
+ private settleReady?: () => void;
156
+ private lastState: ReadAloudState = "idle";
157
+
158
+ constructor(
159
+ private readonly navigator: Navigator & Partial<DecorableNavigator> & {
160
+ readonly viewport?: VisualNavigatorViewport;
161
+ readonly layout?: Layout;
162
+ watchTextLayout?(locators: Locator[], cb: (layout: TextLayout) => void): () => void;
163
+ findPointedPiece?(event: FrameClickEvent, pieces: Locator[]): Promise<Locator | undefined>;
164
+ lockNavigation?(locked: boolean): void;
165
+ },
166
+ private readonly listeners: ReadAloudListeners = {},
167
+ configuration: ReadAloudConfiguration = {}
168
+ ) {
169
+ this.detachable = configuration.detachable ?? true;
170
+ this.pool = new GuidedNavigationPool(navigator.publication, configuration.provider ?? new PublicationGuidedNavigationProvider(navigator.publication));
171
+ this.units = new ReadingUnits(navigator);
172
+ this._preferences = new ReadAloudPreferences(configuration.preferences);
173
+ this._defaults = new ReadAloudDefaults(configuration.defaults);
174
+ this.speech = new ReadiumSpeechNavigator(configuration.engine ?? new PublicationWebSpeechEngine(navigator.publication.metadata.languages), {
175
+ preferences: ReadAloudNavigator.speechPreferences(this._preferences),
176
+ defaults: ReadAloudNavigator.speechPreferences({ ...this._defaults.speech, autoPause: this._defaults.autoPause }),
177
+ });
178
+ this._settings = new ReadAloudSettings(this.speech.settings, this._preferences, this._defaults);
179
+ this.speech.setSpeakInContentLanguage(this._settings.speakInContentLanguage);
180
+ this.listen();
181
+ }
182
+
183
+ /** Whether the reader can move away while reading aloud. Otherwise the app keeps its own navigation from moving while playing. */
184
+ readonly detachable: boolean;
185
+
186
+ get state(): ReadAloudState {
187
+ if (this.loading) return "loading";
188
+ if (this.turning) return "playing";
189
+ return this.pausedBefore ? "paused" : this.speech.getState();
190
+ }
191
+
192
+ /**
193
+ * Starts reading from `from`, or resumes when paused.
194
+ * Without `from`, an idle reader starts from the navigator's current position.
195
+ */
196
+ async play(from?: Locator): Promise<void> {
197
+ if (!from && this.loading) {
198
+ this.loadPaused = false;
199
+ return;
200
+ }
201
+ if (!from && this.turning) return;
202
+ if (!from && this.pausedBefore) {
203
+ await this.resumePaused(this.pausedBefore);
204
+ return;
205
+ }
206
+ const state = this.state;
207
+ if (!from && (state === "paused" || state === "ready")) {
208
+ this.speech.play();
209
+ return;
210
+ }
211
+ if (!from && state === "playing") return;
212
+ this.attach();
213
+ await this.start(from ?? this.navigator.currentLocator);
214
+ }
215
+
216
+ /**
217
+ * Starts reading from the utterance under a click or tap in a displayed resource, loading that resource when needed.
218
+ * Resolves false when the press isn't on content read aloud, or is on an interactive element, so the caller handles it.
219
+ */
220
+ async readFromPointer(event: FrameClickEvent): Promise<boolean> {
221
+ if (event.interactiveElement || !event.onContent || !this.navigator.findPointedPiece) return false;
222
+ const unit = this.unit;
223
+ const index = unit ? await this.pointedIndex(event, this.speech.getContentQueue()) : -1;
224
+ if (unit && index >= 0) {
225
+ this.attach();
226
+ return this.loadUnit(() => unit, () => index);
227
+ }
228
+ const href = this.navigator.currentLocator.href.split("#")[0];
229
+ // Content of the loaded unit that no utterance covers isn't read aloud.
230
+ if (unit?.links.some(link => link.href.split("#")[0] === href)) return false;
231
+ this.attach();
232
+ return this.loadUnit(() => this.units.around(href), async queue => {
233
+ const index = await this.pointedIndex(event, queue);
234
+ return index >= 0 ? index : this.startIndex(queue, this.navigator.currentLocator);
235
+ });
236
+ }
237
+
238
+ pause(): void {
239
+ if (this.loading) this.loadPaused = true;
240
+ else if (this.turning) this.pauseTurning();
241
+ else if (!this.pausedBefore) this.speech.pause();
242
+ }
243
+
244
+ stop(): void {
245
+ this.loadToken++;
246
+ this.settleReady?.();
247
+ this.attach();
248
+ this.pausedBefore = undefined;
249
+ this.turning = undefined;
250
+ this.setLoading(false);
251
+ this.speech.stop();
252
+ }
253
+
254
+ /** Moves to the next utterance, continuing into the next reading unit at the end of this one. */
255
+ async next(): Promise<boolean> {
256
+ this.attach();
257
+ if (this.turning) this.pauseTurning();
258
+ if (this.pausedBefore) return this.movePaused(this.pausedBefore, 1);
259
+ if (this.speech.next()) return true;
260
+ if (!this.unit || !this.units.linkAfter(this.unit)) return false;
261
+ return this.loadUnit(() => this.units.after(this.unit!), () => 0, this.staysPaused());
262
+ }
263
+
264
+ /** Moves to the previous utterance, continuing into the previous reading unit at the start of this one. */
265
+ async previous(): Promise<boolean> {
266
+ this.attach();
267
+ if (this.turning) this.pauseTurning();
268
+ if (this.pausedBefore) return this.movePaused(this.pausedBefore, -1);
269
+ if (this.speech.previous()) return true;
270
+ if (!this.unit || !this.units.linkBefore(this.unit)) return false;
271
+ return this.loadUnit(() => this.units.before(this.unit!), queue => queue.length - 1, this.staysPaused());
272
+ }
273
+
274
+ /**
275
+ * The available voices, only those of `languages` when given,
276
+ * ranked the way the default voice is picked: preferred region first, then quality.
277
+ */
278
+ async getVoices(options: ReadAloudVoicesOptions = {}): Promise<ReadiumSpeechVoice[]> {
279
+ const voices = await this.speech.getVoices();
280
+ const languages = options.languages?.length ? options.languages : undefined;
281
+ const filtered = languages ? filterByLanguages(voices, languages) : voices;
282
+ const publicationLanguages = this.navigator.publication.metadata.languages;
283
+ const preferred = languages
284
+ ?? (publicationLanguages?.length ? publicationLanguages : [...(navigator.languages ?? ["en"])]);
285
+ return sortVoicesByRegions(preferred, filtered);
286
+ }
287
+
288
+ setVoice(voice: ReadiumSpeechVoice | string): void {
289
+ this.speech.setVoice(voice);
290
+ }
291
+
292
+ getCurrentVoice(): ReadiumSpeechVoice | null {
293
+ return this.speech.getCurrentVoice();
294
+ }
295
+
296
+ get settings(): Readonly<ReadAloudSettings> {
297
+ return Object.freeze({ ...this._settings });
298
+ }
299
+
300
+ get preferencesEditor(): ReadAloudPreferencesEditor {
301
+ if (this._preferencesEditor === null) {
302
+ this._preferencesEditor = new ReadAloudPreferencesEditor(this._preferences, this.settings, this.speech.settings, this.layout());
303
+ }
304
+ return this._preferencesEditor;
305
+ }
306
+
307
+ async submitPreferences(preferences: ReadAloudPreferences): Promise<void> {
308
+ this._preferences = this._preferences.merging(preferences);
309
+ const old = this.speech.getContentQueue();
310
+ await this.speech.submitPreferences(ReadAloudNavigator.speechPreferences(this._preferences));
311
+ this._settings = new ReadAloudSettings(this.speech.settings, this._preferences, this._defaults);
312
+ this.speech.setSpeakInContentLanguage(this._settings.speakInContentLanguage);
313
+ // Speech keeps its place in a rebuilt queue only while playing or paused itself, not while stopped here.
314
+ const held = this.pausedBefore ?? this.turning;
315
+ if (held) {
316
+ held.index = this.indexAfterRebuild(old, held.index);
317
+ if (this.pausedBefore?.current) this.notifyUtterance(this.speech.getContentQueue()[held.index]);
318
+ }
319
+ // Segmentation may have split the queue into other pieces.
320
+ if (this.unit) this.watchLayout(this.speech.getContentQueue());
321
+ if (this._preferencesEditor !== null) {
322
+ this._preferencesEditor = new ReadAloudPreferencesEditor(this._preferences, this.settings, this.speech.settings, this.layout());
323
+ }
324
+ this.decorate();
325
+ }
326
+
327
+ async destroy(): Promise<void> {
328
+ this.loadToken++;
329
+ this.settleReady?.();
330
+ this.pausedBefore = undefined;
331
+ this.turning = undefined;
332
+ this.unsubscribers.forEach(unsubscribe => unsubscribe());
333
+ this.unsubscribers.length = 0;
334
+ this.clearBreak();
335
+ clearTimeout(this.following);
336
+ this.following = undefined;
337
+ this.unwatchLayout?.();
338
+ this.unwatchLayout = undefined;
339
+ this.clearHighlights();
340
+ this.lockNavigation(false);
341
+ await this.speech.destroy();
342
+ }
343
+
344
+ private listen() {
345
+ const on = (type: ReadiumSpeechPlaybackEvent["type"], handler: (event: ReadiumSpeechPlaybackEvent) => void) => {
346
+ this.unsubscribers.push(this.speech.on(type, event => {
347
+ handler(event);
348
+ this.notifyState();
349
+ }));
350
+ };
351
+ on("start", () => {
352
+ const utterance = this.speech.getCurrentContent();
353
+ if (utterance) this.progress.start(utterance);
354
+ this.notifyUtterance();
355
+ this.armBreak();
356
+ });
357
+ on("skip", () => {
358
+ this.clearBreak();
359
+ this.progress.reset();
360
+ this.notifyUtterance();
361
+ });
362
+ on("boundary", event => {
363
+ // Voices listed without boundaries may still send them, but unreliably (e.g. out of order after a pause).
364
+ if (!this.sendsBoundaries()) return;
365
+ // Some voices report zero-length boundaries, which would highlight the whole element.
366
+ if (event.detail?.name !== "word" || !event.detail.locate || !event.detail.word?.trim()) return;
367
+ const locate = event.detail.locate as LocatorOptions;
368
+ const locator = this.locatorFor(locate);
369
+ if (!locator) return;
370
+ this.wordLocator = locator;
371
+ this.decorate();
372
+ const word = this.wordPoint(locate);
373
+ if (word && this.isHidden(word)) this.follow(locator);
374
+ this.listeners.wordChanged?.(locator, event.detail.word ?? "");
375
+ });
376
+ on("pause", () => {
377
+ this.clearBreak();
378
+ this.progress.pause();
379
+ });
380
+ on("resume", () => {
381
+ this.progress.resume();
382
+ this.armBreak();
383
+ });
384
+ on("end", () => {
385
+ this.clearBreak();
386
+ this.progress.end(this.speech.getCurrentContent(), this.voiceName(), this.speech.settings.rate);
387
+ const state = this.speech.getState();
388
+ // Speech goes idle once the last utterance of the queue has been spoken.
389
+ if (state === "idle") {
390
+ const unit = this.unit!;
391
+ const link = this.units.linkAfter(unit);
392
+ if (!link) this.clearHighlights();
393
+ else void this.loadUnit(() => this.units.after(unit), () => 0, this.pausesBeforeUnit(link.href));
394
+ return;
395
+ }
396
+ // Paused by speech's own auto-pause otherwise.
397
+ if (state !== "playing") return;
398
+ const queue = this.speech.getContentQueue();
399
+ const index = queue.indexOf(this.speech.getCurrentContent()!);
400
+ if (index < 0 || index + 1 >= queue.length) return;
401
+ if (this.pausesBetween(queue[index], queue[index + 1])) {
402
+ // Stopping, not pausing, cancels the next utterance without a paused engine to resume.
403
+ this.pausedBefore = { unit: this.unit!, index: index + 1, current: true };
404
+ this.speech.stop();
405
+ this.notifyUtterance(queue[index + 1]);
406
+ } else if (this.layout() === Layout.reflowable && this.pausesAtPages()) {
407
+ this.turnTo(index + 1);
408
+ }
409
+ });
410
+ on("error", event => {
411
+ this.clearBreak();
412
+ this.progress.reset();
413
+ this.clearHighlights();
414
+ this.listeners.error?.(event.detail ?? event);
415
+ });
416
+ on("stop", () => {
417
+ this.clearBreak();
418
+ this.progress.reset();
419
+ if (!this.pausedBefore && !this.turning) this.clearHighlights();
420
+ });
421
+ for (const type of ["idle", "loading", "ready"] as const) on(type, () => {});
422
+ }
423
+
424
+ private async start(from: Locator): Promise<void> {
425
+ await this.loadUnit(() => this.units.around(from.href.split("#")[0]), queue => this.startIndex(queue, from));
426
+ }
427
+
428
+ private async resumePaused(paused: PausedPosition): Promise<void> {
429
+ if (paused.unit !== this.unit) {
430
+ await this.loadUnit(() => paused.unit, () => paused.index);
431
+ return;
432
+ }
433
+ // A queue rebuilt by submitted preferences may still be loading, and speech can't play its first utterance until ready.
434
+ if (this.speech.getState() === "loading" && !await this.speechReady()) return;
435
+ const resumed = this.pausedBefore ?? paused;
436
+ this.pausedBefore = undefined;
437
+ this.speakFrom(resumed.index);
438
+ this.notifyState();
439
+ }
440
+
441
+ // Waits as a load would, so pausing, playing or stopping meanwhile applies. Resolves whether to speak then.
442
+ private async speechReady(): Promise<boolean> {
443
+ const token = ++this.loadToken;
444
+ this.loadPaused = false;
445
+ this.setLoading(true);
446
+ await this.untilReady();
447
+ if (token !== this.loadToken) return false;
448
+ this.setLoading(false);
449
+ return !this.loadPaused;
450
+ }
451
+
452
+ // Speech's next "ready", or stopping or destroying, which also settles a previous wait.
453
+ private untilReady(): Promise<void> {
454
+ this.settleReady?.();
455
+ return new Promise<void>(resolve => {
456
+ const settle = () => {
457
+ off();
458
+ if (this.settleReady === settle) this.settleReady = undefined;
459
+ resolve();
460
+ };
461
+ const off = this.speech.on("ready", settle);
462
+ this.settleReady = settle;
463
+ });
464
+ }
465
+
466
+ // Moves the position playback resumes at by `offset` utterances, staying paused.
467
+ private async movePaused(paused: PausedPosition, offset: number): Promise<boolean> {
468
+ const target = (paused.current ? paused.index : paused.index - 1) + offset;
469
+ if (paused.unit === this.unit) {
470
+ const queue = this.speech.getContentQueue();
471
+ if (target >= 0 && target < queue.length) {
472
+ this.pausedBefore = { unit: paused.unit, index: target, current: true };
473
+ this.notifyUtterance(queue[target]);
474
+ return true;
475
+ }
476
+ if (target >= queue.length) {
477
+ if (!this.units.linkAfter(paused.unit)) return false;
478
+ return this.loadUnit(() => this.units.after(paused.unit), () => target - queue.length, true);
479
+ }
480
+ }
481
+ if (target >= 0) return this.loadUnit(() => paused.unit, () => target, true);
482
+ if (!this.units.linkBefore(paused.unit)) return false;
483
+ return this.loadUnit(() => this.units.before(paused.unit), queue => queue.length + target, true);
484
+ }
485
+
486
+ // `find` gives the unit once speech is stopped, as finding it may move the navigator.
487
+ private async loadUnit(find: () => ReadingUnit | undefined, indexIn: (queue: ReadiumSpeechUtterance[]) => number | Promise<number>, paused = false): Promise<boolean> {
488
+ const token = ++this.loadToken;
489
+ this.loadPaused = paused;
490
+ this.setLoading(true);
491
+ this.pausedBefore = undefined;
492
+ this.turning = undefined;
493
+ this.speech.stop();
494
+ try {
495
+ const unit = find();
496
+ if (!unit) {
497
+ this.setLoading(false);
498
+ return false;
499
+ }
500
+ const { guided, failures } = await this.pool.stitch(unit);
501
+ if (token !== this.loadToken) return false;
502
+ failures.forEach(failure => this.listeners.error?.(failure.error));
503
+
504
+ const ready = this.untilReady();
505
+ await this.speech.loadGndContent(guided);
506
+ if (token !== this.loadToken) return false;
507
+ this.unit = unit;
508
+
509
+ const queue = this.speech.getContentQueue();
510
+ if (queue.length === 0) {
511
+ this.setLoading(false);
512
+ // Nothing to read in this unit (e.g. only images): continue with the next one.
513
+ if (this.units.linkAfter(unit)) return this.loadUnit(() => this.units.after(unit), () => 0, this.loadPaused);
514
+ return false;
515
+ }
516
+ await ready;
517
+ if (token !== this.loadToken) return false;
518
+
519
+ const start = await indexIn(queue);
520
+ if (token !== this.loadToken) return false;
521
+ this.setLoading(false);
522
+ const loaded = this.speech.getContentQueue();
523
+ this.watchLayout(loaded);
524
+ const index = Math.max(Math.min(this.indexAfterRebuild(queue, Math.max(start, 0)), loaded.length - 1), 0);
525
+ if (this.loadPaused) {
526
+ this.pausedBefore = { unit, index, current: true };
527
+ this.notifyUtterance(loaded[index]);
528
+ this.notifyState();
529
+ } else {
530
+ this.speakFrom(index);
531
+ }
532
+ return true;
533
+ } catch (error) {
534
+ if (token === this.loadToken) this.setLoading(false);
535
+ this.listeners.error?.(error);
536
+ return false;
537
+ }
538
+ }
539
+
540
+ // The first utterance in `from`'s resource, refined by its CSS selector, then by its text, when they match one.
541
+ private startIndex(queue: ReadiumSpeechUtterance[], from: Locator): number {
542
+ const href = from.href.split("#")[0];
543
+ const selector = from.locations ? getCssSelector(from.locations) : undefined;
544
+ const quote = from.text?.highlight?.trim().slice(0, 40);
545
+ let first = -1;
546
+ let byText = -1;
547
+ for (let i = 0; i < queue.length; i++) {
548
+ const pieces = this.piecesOf(queue[i]).filter(piece => piece.href?.split("#")[0] === href);
549
+ if (pieces.length === 0) continue;
550
+ if (first === -1) first = i;
551
+ if (selector && pieces.some(piece => piece.cssSelector === selector)) return i;
552
+ if (byText === -1 && quote && (queue[i].plain ?? "").includes(quote)) byText = i;
553
+ }
554
+ return byText !== -1 ? byText : Math.max(first, 0);
555
+ }
556
+
557
+ // The index of `old[index]` in speech's queue, which submitted preferences may have rebuilt since `old`.
558
+ private indexAfterRebuild(old: ReadiumSpeechUtterance[], index: number): number {
559
+ const queue = this.speech.getContentQueue();
560
+ if (queue.length === old.length && queue.every((utterance, i) => utterance === old[i])) return index;
561
+ const piece = old[index] && this.piecesOf(old[index])[0];
562
+ const locator = piece && this.locatorFor(piece);
563
+ return locator ? this.startIndex(queue, locator) : 0;
564
+ }
565
+
566
+ // The index in `queue` of the utterance under the press, or -1.
567
+ private async pointedIndex(event: FrameClickEvent, queue: ReadiumSpeechUtterance[]): Promise<number> {
568
+ const pieces = queue.flatMap((utterance, index) => this.piecesOf(utterance).map(piece => ({ index, locator: this.locatorFor(piece) })))
569
+ .filter((piece): piece is { index: number; locator: Locator } => piece.locator !== undefined);
570
+ const found = await this.navigator.findPointedPiece?.(event, pieces.map(piece => piece.locator));
571
+ return pieces.find(piece => piece.locator === found)?.index ?? -1;
572
+ }
573
+
574
+ private piecesOf(utterance: ReadiumSpeechUtterance): LocatorOptions[] {
575
+ return resolveUtteranceLocate(utterance, this.speech.settings.segmentation);
576
+ }
577
+
578
+ // Speech can't speak the utterance it's already at, so the first one is played instead.
579
+ private speakFrom(index: number) {
580
+ if (index === 0) this.speech.play();
581
+ else this.speech.jumpTo(index, true);
582
+ }
583
+
584
+ private pausesAtPages(): boolean {
585
+ const autoPause = this._settings.autoPause;
586
+ switch (this.layout()) {
587
+ case Layout.fixed: return autoPause === ReadAloudAutoPause.page || autoPause === ReadAloudAutoPause.spread;
588
+ case Layout.reflowable: return autoPause === ReadAloudAutoPause.page;
589
+ default: return false;
590
+ }
591
+ }
592
+
593
+ // Columns aren't told apart by hrefs, so the next utterance is followed and the viewport compared.
594
+ private turnTo(index: number) {
595
+ const next = this.speech.getContentQueue()[index];
596
+ const locator = this.piecesOf(next).map(piece => this.locatorFor(piece)).find(locator => locator !== undefined);
597
+ // A hidden document doesn't turn pages until shown again, nor does a reader looking elsewhere.
598
+ if (!locator || document.hidden || this.isDetached()) return;
599
+ const turning = { unit: this.unit!, index };
600
+ this.turning = turning;
601
+ this.speech.stop();
602
+ const before = this.navigator.viewport?.progressions.get(locator.href);
603
+ const start = before?.start;
604
+ const end = before?.end;
605
+ const settle = (turned: boolean) => {
606
+ clearTimeout(timeout);
607
+ if (this.turning !== turning) return;
608
+ this.turning = undefined;
609
+ if (turned) {
610
+ this.pausedBefore = { ...turning, current: true };
611
+ this.notifyUtterance(this.speech.getContentQueue()[turning.index]);
612
+ } else {
613
+ this.pausedBefore = { ...turning, current: true };
614
+ void this.resumePaused(this.pausedBefore);
615
+ }
616
+ this.notifyState();
617
+ };
618
+ // Expired comms callbacks are dropped, never called.
619
+ const timeout = setTimeout(() => settle(false), GO_TIMEOUT);
620
+ this.followedHref = locator.href;
621
+ this.navigator.go(locator, false, ok => {
622
+ const after = this.navigator.viewport?.progressions.get(locator.href);
623
+ settle(ok && after !== undefined && (after.start !== start || after.end !== end));
624
+ });
625
+ }
626
+
627
+ // Moving into another unit keeps playback paused, as moving within one does.
628
+ private staysPaused(): boolean {
629
+ return this.loading ? this.loadPaused : this.speech.getState() === "paused";
630
+ }
631
+
632
+ private pauseTurning() {
633
+ this.pausedBefore = { ...this.turning!, current: false };
634
+ this.turning = undefined;
635
+ this.notifyState();
636
+ }
637
+
638
+ // The next unit starts on another resource, which may still be in the displayed spread.
639
+ private pausesBeforeUnit(href: string): boolean {
640
+ if (!this.pausesAtPages()) return false;
641
+ const displayed = this.navigator.viewport?.readingOrder ?? [];
642
+ if (this._settings.autoPause !== ReadAloudAutoPause.spread || displayed.length === 0) return true;
643
+ return !displayed.includes(href);
644
+ }
645
+
646
+ // Whether `next` starts on another page than `ended` ends on, or outside the displayed spread.
647
+ private pausesBetween(ended: ReadiumSpeechUtterance, next: ReadiumSpeechUtterance | undefined): boolean {
648
+ if (!next || !this.pausesAtPages()) return false;
649
+ const start = this.hrefsOf(next)[0];
650
+ if (!start) return false;
651
+ const displayed = this.navigator.viewport?.readingOrder ?? [];
652
+ if (this._settings.autoPause === ReadAloudAutoPause.spread && displayed.length > 0) {
653
+ return !displayed.includes(start);
654
+ }
655
+ return start !== this.hrefsOf(ended).at(-1);
656
+ }
657
+
658
+ private hrefsOf(utterance: ReadiumSpeechUtterance): string[] {
659
+ return this.piecesOf(utterance)
660
+ .map(piece => piece.href && this.navigator.publication.readingOrder.findWithHref(piece.href.split("#")[0])?.href)
661
+ .filter((href): href is string => !!href);
662
+ }
663
+
664
+ private notifyUtterance(utterance = this.speech.getCurrentContent()) {
665
+ if (!utterance) return;
666
+ const locators = this.piecesOf(utterance)
667
+ .map(piece => this.locatorFor(piece))
668
+ .filter((locator): locator is Locator => locator !== undefined);
669
+ this.utteranceLocators = locators;
670
+ this.wordLocator = undefined;
671
+ this.decorate();
672
+ if (locators.length > 0) {
673
+ const start = this.utteranceStart(utterance);
674
+ if (!start || this.isHidden(start) !== false) this.follow(locators[0]);
675
+ } else {
676
+ // Following a whole page again would reset its scroll position.
677
+ const page = this.pageLocator();
678
+ if (page) {
679
+ locators.push(page);
680
+ if (page.href !== this.followedHref) this.follow(page);
681
+ }
682
+ }
683
+ this.listeners.utteranceChanged?.({ text: utterance.plain ?? "", locators });
684
+ }
685
+
686
+ // Image pages (Divina) are read one at a time, and their utterances carry no location of their own.
687
+ private pageLocator(): Locator | undefined {
688
+ const links = this.unit?.links;
689
+ return links?.length === 1 && links[0].mediaType.isBitmap ? links[0].locator : undefined;
690
+ }
691
+
692
+ private decorate() {
693
+ this.applyDecorations(UTTERANCE_GROUP, this.utteranceLocators, this._settings.utteranceStyle);
694
+ this.applyDecorations(WORD_GROUP, this.wordLocator ? [this.wordLocator] : [], this._settings.wordStyle);
695
+ }
696
+
697
+ private applyDecorations(group: string, locators: Locator[], style: ReadAloudDecorationStyle) {
698
+ const decorations: Decoration[] = style
699
+ ? locators.map((locator, i) => ({ id: `${group}-${i}`, locator, style }))
700
+ : [];
701
+ this.navigator.applyDecorations?.(decorations, group);
702
+ }
703
+
704
+ private clearHighlights() {
705
+ this.utteranceLocators = [];
706
+ this.wordLocator = undefined;
707
+ this.decorate();
708
+ this.followedHref = undefined;
709
+ }
710
+
711
+ private follow(locator: Locator) {
712
+ // A fixed-layout page doesn't scroll, and going to one of the displayed spread can display another.
713
+ if (this.units.displays(locator.href) || this.following !== undefined || this.isDetached()) return;
714
+ this.followedHref = locator.href;
715
+ const done = () => {
716
+ if (this.following !== timeout) return;
717
+ clearTimeout(timeout);
718
+ this.following = undefined;
719
+ };
720
+ // Expired comms callbacks are dropped, never called.
721
+ const timeout = setTimeout(done, GO_TIMEOUT);
722
+ this.following = timeout;
723
+ this.navigator.go(locator, false, ok => {
724
+ if (ok && this.following === timeout) this.shownHref = locator.href.split("#")[0];
725
+ done();
726
+ });
727
+ }
728
+
729
+ private attach() {
730
+ this.detached = false;
731
+ this.shownHref = undefined;
732
+ }
733
+
734
+ // Layout reports stop once the reader moves to another resource, so that is told from the navigator.
735
+ private isDetached(): boolean {
736
+ const href = this.navigator.currentLocator.href.split("#")[0];
737
+ if (this.detachable && this.shownHref && this.shownHref !== href && this.layout() !== Layout.fixed) this.detached = true;
738
+ return this.detached;
739
+ }
740
+
741
+ // Watches where the pieces of `queue` are laid out, when the navigator reports it.
742
+ private watchLayout(queue: ReadiumSpeechUtterance[]) {
743
+ this.unwatchLayout?.();
744
+ this.unwatchLayout = undefined;
745
+ this.watchedPieces.clear();
746
+ this.lines = [];
747
+ this.viewport = undefined;
748
+ if (!this.navigator.watchTextLayout || this.layout() === Layout.fixed) return;
749
+ const locators: Locator[] = [];
750
+ for (const piece of queue.flatMap(utterance => this.piecesOf(utterance))) {
751
+ const locator = this.locatorFor(piece);
752
+ if (!locator || this.watchedPieces.has(piece)) continue;
753
+ this.watchedPieces.set(piece, locators.length);
754
+ locators.push(locator);
755
+ }
756
+ if (locators.length === 0) return;
757
+ this.unwatchLayout = this.navigator.watchTextLayout(locators, layout => this.laidOut(layout));
758
+ }
759
+
760
+ private laidOut(layout: TextLayout) {
761
+ this.shownHref = this.navigator.currentLocator.href.split("#")[0];
762
+ this.viewport = layout.viewport;
763
+ if (layout.pieces) this.lines = layout.pieces;
764
+ const spoken = this.spokenPoint();
765
+ const hidden = spoken && this.isHidden(spoken);
766
+ if (hidden === false) {
767
+ this.detached = false;
768
+ } else if (hidden && layout.pieces) {
769
+ // Laid out again, so the spoken text may have moved out of view.
770
+ const locator = this.pointLocator(spoken);
771
+ if (locator) this.follow(locator);
772
+ } else if (hidden && this.detachable && this.following === undefined && !this.turning) {
773
+ // Only scrolling reports the viewport alone, and this one isn't a move made here.
774
+ this.detached = true;
775
+ }
776
+ if (this.breakTimer !== undefined || this.progress.speaking) this.armBreak();
777
+ }
778
+
779
+ // Whether `point` is out of view, or undefined when its layout isn't known.
780
+ private isHidden(point: SpokenPoint): boolean | undefined {
781
+ const start = this.lineStart(point);
782
+ if (start === undefined || !this.viewport) return undefined;
783
+ const { pos, size } = this.viewport;
784
+ return start < pos - LAYOUT_TOLERANCE || start >= pos + size - LAYOUT_TOLERANCE;
785
+ }
786
+
787
+ private lineStart(point: SpokenPoint): number | undefined {
788
+ const lines = this.lines[point.piece];
789
+ if (!lines || lines.offsets.length === 0) return undefined;
790
+ let i = 0;
791
+ while (i + 1 < lines.offsets.length && lines.offsets[i + 1] <= point.offset) i++;
792
+ return lines.starts[i];
793
+ }
794
+
795
+ // The spoken word, or the timed position for voices without word boundaries, or the start of the utterance.
796
+ private spokenPoint(): SpokenPoint | undefined {
797
+ const utterance = this.speech.getCurrentContent();
798
+ if (!utterance) return undefined;
799
+ if (this.wordLocator?.text && this.sendsBoundaries()) {
800
+ const word = this.wordPoint({ cssSelector: getCssSelector(this.wordLocator.locations), text: this.wordLocator.text });
801
+ if (word) return word;
802
+ }
803
+ if (!this.sendsBoundaries() && this.progress.speaking) {
804
+ const timed = this.pointAt(utterance, this.progress.elapsed() * this.speed());
805
+ if (timed) return timed;
806
+ }
807
+ return this.utteranceStart(utterance);
808
+ }
809
+
810
+ private utteranceStart(utterance: ReadiumSpeechUtterance): SpokenPoint | undefined {
811
+ const index = this.watchedPieces.get(this.piecesOf(utterance)[0]);
812
+ return index === undefined ? undefined : { piece: index, offset: 0 };
813
+ }
814
+
815
+ // A word comes as its piece's locate with the text split around it.
816
+ private wordPoint(locate: LocatorOptions): SpokenPoint | undefined {
817
+ const utterance = this.speech.getCurrentContent();
818
+ const text = locate.text;
819
+ if (!utterance || !text) return undefined;
820
+ const whole = (text.before ?? "") + (text.highlight ?? "") + (text.after ?? "");
821
+ const piece = this.piecesOf(utterance).find(piece => piece.cssSelector === locate.cssSelector && piece.text?.highlight === whole);
822
+ const index = piece && this.watchedPieces.get(piece);
823
+ return index === undefined ? undefined : { piece: index, offset: ReadAloudNavigator.collapsed(text.before ?? "").length };
824
+ }
825
+
826
+ // The point `chars` characters into `utterance`.
827
+ private pointAt(utterance: ReadiumSpeechUtterance, chars: number): SpokenPoint | undefined {
828
+ let rest = chars;
829
+ for (const piece of this.piecesOf(utterance)) {
830
+ const index = this.watchedPieces.get(piece);
831
+ const length = ReadAloudNavigator.collapsed(piece.text?.highlight ?? "").length;
832
+ if (index !== undefined && rest < length) return { piece: index, offset: Math.max(0, Math.floor(rest)) };
833
+ rest -= length;
834
+ }
835
+ return undefined;
836
+ }
837
+
838
+ // A locator for the character at `point`, which going to shows the line or column it starts.
839
+ private pointLocator(point: SpokenPoint): Locator | undefined {
840
+ const piece = [...this.watchedPieces].find(([, index]) => index === point.piece)?.[0];
841
+ const highlight = piece?.text?.highlight;
842
+ if (!piece || !highlight) return undefined;
843
+ const at = ReadAloudNavigator.rawOffset(highlight, point.offset);
844
+ return this.locatorFor({
845
+ ...piece,
846
+ domRange: undefined,
847
+ text: { before: highlight.slice(0, at), highlight: highlight.slice(at, at + 1), after: highlight.slice(at + 1) },
848
+ });
849
+ }
850
+
851
+ // Times the turn to the next line or column out of view, for voices without word boundaries.
852
+ private armBreak() {
853
+ this.clearBreak();
854
+ const utterance = this.speech.getCurrentContent();
855
+ if (!utterance || this.detached || this.sendsBoundaries() || !this.progress.speaking || !this.viewport) return;
856
+ const end = this.viewport.pos + this.viewport.size - LAYOUT_TOLERANCE;
857
+ let before = 0;
858
+ for (const piece of this.piecesOf(utterance)) {
859
+ const index = this.watchedPieces.get(piece);
860
+ const lines = index === undefined ? null : this.lines[index];
861
+ const ahead = lines?.starts.findIndex(start => start >= end) ?? -1;
862
+ if (index !== undefined && lines && ahead >= 0) {
863
+ const point = { piece: index, offset: lines.offsets[ahead] };
864
+ const delay = (before + point.offset) / this.speed() - this.progress.elapsed();
865
+ this.breakTimer = setTimeout(() => {
866
+ this.breakTimer = undefined;
867
+ const locator = this.pointLocator(point);
868
+ if (locator) this.follow(locator);
869
+ }, Math.max(0, delay * 1000));
870
+ return;
871
+ }
872
+ before += ReadAloudNavigator.collapsed(piece.text?.highlight ?? "").length;
873
+ }
874
+ }
875
+
876
+ private clearBreak() {
877
+ clearTimeout(this.breakTimer);
878
+ this.breakTimer = undefined;
879
+ }
880
+
881
+ private sendsBoundaries(): boolean {
882
+ return this.speech.getCurrentVoice()?.controls?.boundary !== false;
883
+ }
884
+
885
+ private voiceName(): string {
886
+ return this.speech.getCurrentVoice()?.name ?? "";
887
+ }
888
+
889
+ private speed(): number {
890
+ return this.progress.speed(this.voiceName(), this.speech.settings.rate);
891
+ }
892
+
893
+ // The frame counts offsets with each run of whitespace as one character.
894
+ private static collapsed(text: string): string {
895
+ return text.replace(/\s+/g, " ");
896
+ }
897
+
898
+ // The offset in `text` of the character `collapsed` characters in, once whitespace runs are collapsed.
899
+ private static rawOffset(text: string, collapsed: number): number {
900
+ let count = 0;
901
+ for (let i = 0; i < text.length; i++) {
902
+ if (/\s/.test(text[i]) && i > 0 && /\s/.test(text[i - 1])) continue;
903
+ if (count === collapsed) return i;
904
+ count++;
905
+ }
906
+ return text.length;
907
+ }
908
+
909
+ private locatorFor(locate: LocatorOptions): Locator | undefined {
910
+ if (!locate.href) return undefined;
911
+ const link = this.navigator.publication.readingOrder.findWithHref(locate.href.split("#")[0]);
912
+ if (!link) return undefined;
913
+ const { text, locations } = createLocator(locate, { location: { href: link.href } } as Window);
914
+ return new Locator({ href: link.href, type: link.type ?? "text/html", text, locations });
915
+ }
916
+
917
+ // Navigators without a layout of their own (WebPub) scroll.
918
+ private layout(): Layout {
919
+ const metadata = this.navigator.publication.metadata;
920
+ if (metadata.conformsTo?.includes(Profile.DIVINA) || metadata.effectiveLayout === Layout.fixed) return Layout.fixed;
921
+ return this.navigator.layout === Layout.reflowable ? Layout.reflowable : Layout.scrolled;
922
+ }
923
+
924
+ // Speech knows nothing of pages and spreads, so it never auto-pauses at them itself.
925
+ private static speechPreferences(preferences: IReadAloudPreferences): SpeechPreferences {
926
+ const { autoPause, speakInContentLanguage, utteranceStyle, wordStyle, ...speech } = preferences;
927
+ const ownScope = autoPause === ReadAloudAutoPause.page || autoPause === ReadAloudAutoPause.spread;
928
+ return new SpeechPreferences({ ...speech, autoPause: ownScope ? ReadAloudAutoPause.none : autoPause });
929
+ }
930
+
931
+ private setLoading(loading: boolean) {
932
+ this.loading = loading;
933
+ this.notifyState();
934
+ }
935
+
936
+ private lockNavigation(locked: boolean) {
937
+ if (locked === this.navigationLocked) return;
938
+ this.navigationLocked = locked;
939
+ this.navigator.lockNavigation?.(locked);
940
+ }
941
+
942
+ private notifyState() {
943
+ const state = this.state;
944
+ this.lockNavigation(!this.detachable && (state === "playing" || state === "loading"));
945
+ if (state === this.lastState) return;
946
+ this.lastState = state;
947
+ this.listeners.stateChanged?.(state);
948
+ }
949
+ }