@readium/navigator 2.4.0-beta.9 → 2.5.0-beta.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 (138) hide show
  1. package/dist/ReadiumCSS-after-B_e3a-PY.js +592 -0
  2. package/dist/ReadiumCSS-after-C-T_0paD.js +530 -0
  3. package/dist/ReadiumCSS-after-lr-n3fz2.js +475 -0
  4. package/dist/ReadiumCSS-after-mXeKKPap.js +490 -0
  5. package/dist/ReadiumCSS-before-Bjd3POej.js +426 -0
  6. package/dist/ReadiumCSS-before-CfXPAGaQ.js +425 -0
  7. package/dist/ReadiumCSS-before-CrNWvuyE.js +425 -0
  8. package/dist/ReadiumCSS-before-KVen5ceo.js +425 -0
  9. package/dist/ReadiumCSS-default-BKAG5pGU.js +162 -0
  10. package/dist/ReadiumCSS-default-C63bYOYF.js +183 -0
  11. package/dist/ReadiumCSS-default-CclvbeNC.js +162 -0
  12. package/dist/ReadiumCSS-default-DnlgDaBu.js +180 -0
  13. package/dist/ReadiumCSS-ebpaj_fonts_patch-Dt2XliTg.js +82 -0
  14. package/dist/index.js +2642 -3430
  15. package/dist/index.umd.cjs +4407 -995
  16. package/package.json +2 -2
  17. package/src/audio/AudioNavigator.ts +155 -42
  18. package/src/audio/AudioPoolManager.ts +27 -14
  19. package/src/audio/engine/AudioEngine.ts +4 -3
  20. package/src/audio/engine/PreservePitchProcessor.js +166 -101
  21. package/src/audio/engine/PreservePitchWorklet.ts +2 -17
  22. package/src/audio/engine/WebAudioEngine.ts +138 -160
  23. package/src/audio/engine/index.ts +2 -2
  24. package/src/audio/index.ts +3 -3
  25. package/src/audio/preferences/AudioDefaults.ts +2 -2
  26. package/src/audio/preferences/AudioPreferences.ts +11 -11
  27. package/src/audio/preferences/AudioPreferencesEditor.ts +13 -13
  28. package/src/audio/preferences/AudioSettings.ts +3 -3
  29. package/src/audio/preferences/index.ts +4 -4
  30. package/src/audio/protection/AudioNavigatorProtector.ts +4 -4
  31. package/src/css/index.ts +1 -1
  32. package/src/epub/EpubNavigator.ts +113 -78
  33. package/src/epub/css/Properties.ts +15 -15
  34. package/src/epub/css/ReadiumCSS.ts +43 -43
  35. package/src/epub/css/index.ts +2 -2
  36. package/src/epub/frame/FrameBlobBuilder.ts +31 -31
  37. package/src/epub/frame/FrameComms.ts +1 -1
  38. package/src/epub/frame/FrameManager.ts +13 -9
  39. package/src/epub/frame/FramePoolManager.ts +13 -13
  40. package/src/epub/frame/index.ts +4 -4
  41. package/src/epub/fxl/FXLCoordinator.ts +3 -3
  42. package/src/epub/fxl/FXLFrameManager.ts +8 -8
  43. package/src/epub/fxl/FXLFramePoolManager.ts +18 -14
  44. package/src/epub/fxl/FXLPeripherals.ts +4 -4
  45. package/src/epub/fxl/index.ts +5 -5
  46. package/src/epub/helpers/scriptMode.ts +45 -0
  47. package/src/epub/index.ts +6 -5
  48. package/src/epub/preferences/EpubDefaults.ts +23 -23
  49. package/src/epub/preferences/EpubPreferences.ts +16 -16
  50. package/src/epub/preferences/EpubPreferencesEditor.ts +53 -53
  51. package/src/epub/preferences/EpubSettings.ts +101 -101
  52. package/src/epub/preferences/index.ts +4 -4
  53. package/src/helpers/index.ts +2 -2
  54. package/src/index.ts +8 -8
  55. package/src/injection/Injector.ts +42 -42
  56. package/src/injection/epubInjectables.ts +86 -17
  57. package/src/injection/index.ts +2 -2
  58. package/src/injection/webpubInjectables.ts +2 -2
  59. package/src/preferences/Configurable.ts +2 -2
  60. package/src/preferences/PreferencesEditor.ts +2 -2
  61. package/src/preferences/guards.ts +2 -2
  62. package/src/preferences/index.ts +5 -5
  63. package/src/protection/CopyProtector.ts +5 -1
  64. package/src/protection/DevToolsDetector.ts +16 -16
  65. package/src/protection/DragAndDropProtector.ts +14 -1
  66. package/src/protection/NavigatorProtector.ts +6 -6
  67. package/src/webpub/WebPubBlobBuilder.ts +1 -1
  68. package/src/webpub/WebPubFrameManager.ts +8 -8
  69. package/src/webpub/WebPubFramePoolManager.ts +7 -7
  70. package/src/webpub/WebPubNavigator.ts +27 -27
  71. package/src/webpub/css/Properties.ts +3 -3
  72. package/src/webpub/css/WebPubCSS.ts +11 -11
  73. package/src/webpub/css/index.ts +2 -2
  74. package/src/webpub/index.ts +6 -6
  75. package/src/webpub/preferences/WebPubDefaults.ts +12 -12
  76. package/src/webpub/preferences/WebPubPreferences.ts +8 -8
  77. package/src/webpub/preferences/WebPubPreferencesEditor.ts +31 -31
  78. package/src/webpub/preferences/WebPubSettings.ts +45 -45
  79. package/src/webpub/preferences/index.ts +4 -4
  80. package/types/src/audio/AudioNavigator.d.ts +34 -5
  81. package/types/src/audio/AudioPoolManager.d.ts +7 -4
  82. package/types/src/audio/engine/AudioEngine.d.ts +4 -3
  83. package/types/src/audio/engine/PreservePitchWorklet.d.ts +1 -4
  84. package/types/src/audio/engine/WebAudioEngine.d.ts +15 -9
  85. package/types/src/audio/engine/index.d.ts +2 -2
  86. package/types/src/audio/index.d.ts +3 -3
  87. package/types/src/audio/preferences/AudioPreferences.d.ts +9 -9
  88. package/types/src/audio/preferences/AudioPreferencesEditor.d.ts +4 -4
  89. package/types/src/audio/preferences/AudioSettings.d.ts +3 -3
  90. package/types/src/audio/preferences/index.d.ts +4 -4
  91. package/types/src/audio/protection/AudioNavigatorProtector.d.ts +2 -2
  92. package/types/src/css/index.d.ts +1 -1
  93. package/types/src/epub/EpubNavigator.d.ts +15 -14
  94. package/types/src/epub/css/Properties.d.ts +2 -2
  95. package/types/src/epub/css/ReadiumCSS.d.ts +3 -3
  96. package/types/src/epub/css/index.d.ts +2 -2
  97. package/types/src/epub/frame/FrameBlobBuilder.d.ts +1 -1
  98. package/types/src/epub/frame/FrameComms.d.ts +1 -1
  99. package/types/src/epub/frame/FrameManager.d.ts +3 -2
  100. package/types/src/epub/frame/FramePoolManager.d.ts +3 -3
  101. package/types/src/epub/frame/index.d.ts +4 -4
  102. package/types/src/epub/fxl/FXLFrameManager.d.ts +3 -3
  103. package/types/src/epub/fxl/FXLFramePoolManager.d.ts +5 -5
  104. package/types/src/epub/fxl/FXLPeripherals.d.ts +2 -2
  105. package/types/src/epub/fxl/index.d.ts +5 -5
  106. package/types/src/epub/helpers/scriptMode.d.ts +16 -0
  107. package/types/src/epub/index.d.ts +6 -5
  108. package/types/src/epub/preferences/EpubDefaults.d.ts +1 -1
  109. package/types/src/epub/preferences/EpubPreferences.d.ts +2 -2
  110. package/types/src/epub/preferences/EpubPreferencesEditor.d.ts +5 -5
  111. package/types/src/epub/preferences/EpubSettings.d.ts +4 -4
  112. package/types/src/epub/preferences/index.d.ts +4 -4
  113. package/types/src/helpers/index.d.ts +2 -2
  114. package/types/src/index.d.ts +8 -8
  115. package/types/src/injection/Injector.d.ts +1 -1
  116. package/types/src/injection/epubInjectables.d.ts +5 -3
  117. package/types/src/injection/index.d.ts +2 -2
  118. package/types/src/injection/webpubInjectables.d.ts +1 -1
  119. package/types/src/preferences/Configurable.d.ts +1 -1
  120. package/types/src/preferences/PreferencesEditor.d.ts +1 -1
  121. package/types/src/preferences/guards.d.ts +1 -1
  122. package/types/src/preferences/index.d.ts +5 -5
  123. package/types/src/protection/CopyProtector.d.ts +1 -0
  124. package/types/src/protection/DragAndDropProtector.d.ts +2 -0
  125. package/types/src/protection/NavigatorProtector.d.ts +1 -1
  126. package/types/src/webpub/WebPubBlobBuilder.d.ts +1 -1
  127. package/types/src/webpub/WebPubFrameManager.d.ts +2 -2
  128. package/types/src/webpub/WebPubFramePoolManager.d.ts +3 -3
  129. package/types/src/webpub/WebPubNavigator.d.ts +10 -10
  130. package/types/src/webpub/css/Properties.d.ts +2 -2
  131. package/types/src/webpub/css/WebPubCSS.d.ts +2 -2
  132. package/types/src/webpub/css/index.d.ts +2 -2
  133. package/types/src/webpub/index.d.ts +6 -6
  134. package/types/src/webpub/preferences/WebPubDefaults.d.ts +1 -1
  135. package/types/src/webpub/preferences/WebPubPreferences.d.ts +2 -2
  136. package/types/src/webpub/preferences/WebPubPreferencesEditor.d.ts +5 -5
  137. package/types/src/webpub/preferences/WebPubSettings.d.ts +4 -4
  138. package/types/src/webpub/preferences/index.d.ts +4 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@readium/navigator",
3
- "version": "2.4.0-beta.9",
3
+ "version": "2.5.0-beta.1",
4
4
  "type": "module",
5
5
  "description": "Next generation SDK for publications in Web Apps",
6
6
  "author": "readium",
@@ -50,7 +50,7 @@
50
50
  "generate:css-selector": "node scripts/generate-css-selector.js"
51
51
  },
52
52
  "devDependencies": {
53
- "@readium/css": "^2.0.0",
53
+ "@readium/css": "^2.0.1",
54
54
  "@readium/navigator-html-injectables": "workspace:*",
55
55
  "@readium/shared": "workspace:*",
56
56
  "@types/path-browserify": "^1.0.3",
@@ -1,7 +1,7 @@
1
1
  import { Link, Locator, LocatorLocations, Publication, Timeline, TimelineItem } from "@readium/shared";
2
- import { MediaNavigator, IContentProtectionConfig, IKeyboardPeripheralsConfig } from "../Navigator";
3
- import { Configurable } from "../preferences";
4
- import { WebAudioEngine, PlaybackState } from "./engine";
2
+ import { MediaNavigator, IContentProtectionConfig, IKeyboardPeripheralsConfig } from "../Navigator.ts";
3
+ import { Configurable } from "../preferences/Configurable.ts";
4
+ import { WebAudioEngine, PlaybackState } from "./engine/index.ts";
5
5
  import {
6
6
  AudioPreferences,
7
7
  AudioDefaults,
@@ -9,12 +9,19 @@ import {
9
9
  AudioPreferencesEditor,
10
10
  IAudioPreferences,
11
11
  IAudioDefaults
12
- } from "./preferences";
13
- import { AudioPoolManager } from "./AudioPoolManager";
12
+ } from "./preferences/index.ts";
13
+ import { AudioPoolManager } from "./AudioPoolManager.ts";
14
14
  import { ContextMenuEvent, KeyboardEventData, SuspiciousActivityEvent } from "@readium/navigator-html-injectables";
15
- import { AudioNavigatorProtector } from "./protection/AudioNavigatorProtector";
16
- import { NAVIGATOR_SUSPICIOUS_ACTIVITY_EVENT } from "../protection/NavigatorProtector";
17
- import { KeyboardPeripherals, NAVIGATOR_KEYBOARD_PERIPHERAL_EVENT } from "../peripherals/KeyboardPeripherals";
15
+ import { AudioNavigatorProtector } from "./protection/AudioNavigatorProtector.ts";
16
+ import { NAVIGATOR_SUSPICIOUS_ACTIVITY_EVENT } from "../protection/NavigatorProtector.ts";
17
+ import { KeyboardPeripherals, NAVIGATOR_KEYBOARD_PERIPHERAL_EVENT } from "../peripherals/KeyboardPeripherals.ts";
18
+
19
+ export interface AudioMetadata {
20
+ duration: number;
21
+ textTracks: TextTrackList;
22
+ readyState: number;
23
+ networkState: number;
24
+ }
18
25
 
19
26
  export interface AudioNavigatorListeners {
20
27
  trackLoaded: (media: HTMLMediaElement) => void;
@@ -24,13 +31,14 @@ export interface AudioNavigatorListeners {
24
31
  trackEnded: (locator: Locator) => void;
25
32
  play: (locator: Locator) => void;
26
33
  pause: (locator: Locator) => void;
27
- metadataLoaded: (duration: number) => void;
34
+ metadataLoaded: (metadata: AudioMetadata) => void;
28
35
  stalled: (isStalled: boolean) => void;
29
36
  seeking: (isSeeking: boolean) => void;
30
37
  seekable: (seekable: TimeRanges) => void;
31
38
  contentProtection: (type: string, data: SuspiciousActivityEvent) => void;
32
39
  peripheral: (data: KeyboardEventData) => void;
33
40
  contextMenu: (data: ContextMenuEvent) => void;
41
+ remotePlaybackStateChanged: (state: RemotePlaybackState) => void;
34
42
  }
35
43
 
36
44
  const defaultListeners = (listeners: Partial<AudioNavigatorListeners>): AudioNavigatorListeners => ({
@@ -48,12 +56,18 @@ const defaultListeners = (listeners: Partial<AudioNavigatorListeners>): AudioNav
48
56
  contentProtection: listeners.contentProtection ?? (() => {}),
49
57
  peripheral: listeners.peripheral ?? (() => {}),
50
58
  contextMenu: listeners.contextMenu ?? (() => {}),
59
+ remotePlaybackStateChanged: listeners.remotePlaybackStateChanged ?? (() => {}),
51
60
  });
52
61
 
62
+ export interface IAudioContentProtectionConfig extends IContentProtectionConfig {
63
+ /** Prevents the media element from being cast to remote devices via the Remote Playback API. */
64
+ disableRemotePlayback?: boolean;
65
+ }
66
+
53
67
  export interface AudioNavigatorConfiguration {
54
68
  preferences: IAudioPreferences;
55
69
  defaults: IAudioDefaults;
56
- contentProtection?: IContentProtectionConfig;
70
+ contentProtection?: IAudioContentProtectionConfig;
57
71
  keyboardPeripherals?: IKeyboardPeripheralsConfig;
58
72
  }
59
73
 
@@ -76,6 +90,12 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
76
90
  private readonly _keyboardPeripheralsManager: KeyboardPeripherals | null = null;
77
91
  private readonly _suspiciousActivityListener: ((event: Event) => void) | null = null;
78
92
  private readonly _keyboardPeripheralListener: ((event: Event) => void) | null = null;
93
+ private readonly _contentProtection: IAudioContentProtectionConfig;
94
+ /** True while a track transition is in progress; suppresses spurious mid-navigation events. */
95
+ private _isNavigating: boolean = false;
96
+ private _isStalled: boolean = false;
97
+ private _stalledWatchdog: ReturnType<typeof setInterval> | null = null;
98
+ private _stalledCheckTime: number = 0;
79
99
 
80
100
  constructor(publication: Publication, listeners: AudioNavigatorListeners, initialPosition?: Locator, configuration: AudioNavigatorConfiguration = {
81
101
  preferences: {},
@@ -89,6 +109,10 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
89
109
  this._defaults = new AudioDefaults(configuration.defaults);
90
110
  this._settings = new AudioSettings(this._preferences, this._defaults);
91
111
 
112
+ if (publication.readingOrder.items.length === 0) {
113
+ throw new Error("AudioNavigator: publication has an empty reading order");
114
+ }
115
+
92
116
  if (initialPosition) {
93
117
  this.currentLocation = this.ensureLocatorLocations(initialPosition);
94
118
  } else {
@@ -98,7 +122,7 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
98
122
  type: firstLink.type || "audio/mpeg",
99
123
  title: firstLink.title,
100
124
  locations: new LocatorLocations({
101
- position: 0,
125
+ position: 1,
102
126
  progression: 0,
103
127
  totalProgression: 0,
104
128
  fragments: ["t=0"]
@@ -108,6 +132,9 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
108
132
 
109
133
  const initialHref = this.currentLocation.href.split("#")[0];
110
134
  const trackIndex = this.hrefToTrackIndex(initialHref);
135
+ if (trackIndex === -1) {
136
+ throw new Error(`AudioNavigator: initial href "${ initialHref }" not found in reading order`);
137
+ }
111
138
  const initialTime = this.currentLocation.locations?.time() || 0;
112
139
 
113
140
  const audioEngine = new WebAudioEngine({
@@ -121,10 +148,11 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
121
148
  }
122
149
  });
123
150
 
124
- this.pool = new AudioPoolManager(audioEngine, publication);
151
+ this.pool = new AudioPoolManager(audioEngine, publication, configuration.contentProtection);
125
152
 
126
153
  // Initialize content protection
127
154
  const contentProtection = configuration.contentProtection || {};
155
+ this._contentProtection = contentProtection;
128
156
  const keyboardPeripherals = this.mergeKeyboardPeripherals(
129
157
  contentProtection,
130
158
  configuration.keyboardPeripherals || []
@@ -158,19 +186,27 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
158
186
  }
159
187
 
160
188
  this.setupEventListeners();
161
- this.applyPreferences();
162
189
 
190
+ this._isNavigating = true;
163
191
  this.pool.setCurrentAudio(trackIndex, "forward");
164
192
 
193
+ // applyPreferences() must come after setCurrentAudio() so that the src
194
+ // is already set on the media element when setPlaybackRate() tries to
195
+ // activate the Web Audio graph for the preservePitch polyfill path.
196
+ this.applyPreferences();
197
+
165
198
  // Load and seek to initial position, then notify consumer.
166
199
  // No cancellation needed here — the constructor runs once.
167
200
  this.waitForLoadedAndSeeked(initialTime)
168
201
  .then(() => {
202
+ this._isNavigating = false;
169
203
  this.listeners.trackLoaded(this.pool.audioEngine.getMediaElement());
170
204
  this._notifyTimelineChange(this.currentLocator);
171
205
  this.listeners.positionChanged(this.currentLocator);
206
+ this._setupRemotePlayback();
172
207
  })
173
208
  .catch(() => {
209
+ this._isNavigating = false;
174
210
  // Error already forwarded via the error event listener.
175
211
  });
176
212
  }
@@ -201,6 +237,8 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
201
237
  this.pool.audioEngine.setVolume(this._settings.volume);
202
238
  this.pool.audioEngine.setPlaybackRate(this._settings.playbackRate, this._settings.preservePitch);
203
239
 
240
+ if (this.positionPollInterval !== null) this.startPositionPolling();
241
+
204
242
  if (this._settings.enableMediaSession && !this._mediaSessionEnabled) {
205
243
  this._mediaSessionEnabled = true;
206
244
  this.setupMediaSession();
@@ -223,6 +261,9 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
223
261
  if (item !== this._currentTimelineItem) {
224
262
  this._currentTimelineItem = item;
225
263
  this.listeners.timelineItemChanged(item);
264
+ if (this._settings.enableMediaSession) {
265
+ this.updateMediaSessionMetadata();
266
+ }
226
267
  }
227
268
  }
228
269
 
@@ -279,7 +320,7 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
279
320
  title: link.title,
280
321
  locations: new LocatorLocations({
281
322
  progression: duration > 0 ? timestamp / duration : 0,
282
- position: trackIndex,
323
+ position: trackIndex + 1,
283
324
  fragments: [`t=${timestamp}`]
284
325
  })
285
326
  });
@@ -338,7 +379,7 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
338
379
  this.pool.audioEngine.on("ended", async () => {
339
380
  this.stopPositionPolling();
340
381
  this.currentLocation = this.currentLocation.copyWithLocations(new LocatorLocations({
341
- position: this.currentTrackIndex(),
382
+ position: this.currentTrackIndex() + 1,
342
383
  progression: 1,
343
384
  fragments: [`t=${this.duration}`]
344
385
  }));
@@ -349,46 +390,91 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
349
390
  });
350
391
 
351
392
  this.pool.audioEngine.on("play", () => {
393
+ if (this._isNavigating) return;
352
394
  this.startPositionPolling();
353
395
  this.listeners.play(this.currentLocator);
354
396
  });
355
397
 
356
398
  this.pool.audioEngine.on("playing", () => {
357
- this.listeners.stalled(false);
399
+ if (this._isNavigating) return;
400
+ this._setStalled(false);
358
401
  });
359
402
 
360
403
  this.pool.audioEngine.on("pause", () => {
404
+ if (this._isNavigating) return;
361
405
  this.stopPositionPolling();
362
406
  this.listeners.pause(this.currentLocator);
363
407
  });
364
408
 
365
409
  this.pool.audioEngine.on("seeked", () => {
410
+ if (this._isNavigating) return;
366
411
  this.listeners.seeking(false);
367
- if (!this.isPlaying) {
368
- const currentTime = this.currentTime;
369
- const duration = this.duration;
370
- const progression = duration > 0 ? currentTime / duration : 0;
371
- this.currentLocation = this.currentLocation.copyWithLocations(new LocatorLocations({
372
- position: this.currentTrackIndex(),
373
- progression,
374
- fragments: [`t=${currentTime}`]
375
- }));
376
- this._notifyTimelineChange(this.currentLocation);
377
- this.listeners.positionChanged(this.currentLocation);
378
- }
412
+ const currentTime = this.currentTime;
413
+ const duration = this.duration;
414
+ const progression = duration > 0 ? currentTime / duration : 0;
415
+ this.currentLocation = this.currentLocation.copyWithLocations(new LocatorLocations({
416
+ position: this.currentTrackIndex() + 1,
417
+ progression,
418
+ fragments: [`t=${currentTime}`]
419
+ }));
420
+ // Always notify on seeked — don't defer to polling — so that a skip
421
+ // crossing a timeline item boundary fires timelineItemChanged immediately
422
+ // regardless of play state. _notifyTimelineChange deduplicates internally.
423
+ this._notifyTimelineChange(this.currentLocation);
424
+ this.listeners.positionChanged(this.currentLocation);
379
425
  });
380
426
 
381
- this.pool.audioEngine.on("seeking", () => this.listeners.seeking(true));
382
- this.pool.audioEngine.on("waiting", () => this.listeners.seeking(true));
383
- this.pool.audioEngine.on("stalled", () => this.listeners.stalled(true));
384
- this.pool.audioEngine.on("canplaythrough", () => this.listeners.stalled(false));
385
- this.pool.audioEngine.on("progress", (seekable: TimeRanges) => this.listeners.seekable(seekable));
386
-
427
+ this.pool.audioEngine.on("seeking", () => { if (!this._isNavigating) this.listeners.seeking(true); });
428
+ this.pool.audioEngine.on("waiting", () => { if (!this._isNavigating) this.listeners.seeking(true); });
429
+ this.pool.audioEngine.on("stalled", () => { if (!this._isNavigating) this._setStalled(true); });
430
+ this.pool.audioEngine.on("canplaythrough", () => { if (!this._isNavigating) this._setStalled(false); });
431
+ this.pool.audioEngine.on("progress", (seekable: TimeRanges) => { if (!this._isNavigating) this.listeners.seekable(seekable); });
432
+
387
433
  this.pool.audioEngine.on("loadedmetadata", () => {
388
- this.listeners.metadataLoaded(this.pool.audioEngine.duration());
434
+ const mediaElement = this.pool.audioEngine.getMediaElement();
435
+ const metadata: AudioMetadata = {
436
+ duration: this.pool.audioEngine.duration(),
437
+ textTracks: mediaElement.textTracks,
438
+ readyState: mediaElement.readyState,
439
+ networkState: mediaElement.networkState
440
+ };
441
+ this.listeners.metadataLoaded(metadata);
389
442
  });
390
443
  }
391
444
 
445
+ private _setStalled(isStalled: boolean): void {
446
+ if (this._isStalled === isStalled) return;
447
+ this._isStalled = isStalled;
448
+ this.listeners.stalled(isStalled);
449
+ if (isStalled) {
450
+ this._stalledCheckTime = this.currentTime;
451
+ this._startStalledWatchdog();
452
+ } else {
453
+ this._stopStalledWatchdog();
454
+ }
455
+ }
456
+
457
+ private _startStalledWatchdog(): void {
458
+ this._stalledWatchdog = setInterval(() => {
459
+ if (!this.isPlaying) {
460
+ this._setStalled(false);
461
+ return;
462
+ }
463
+ const t = this.currentTime;
464
+ if (t !== this._stalledCheckTime) {
465
+ this._setStalled(false);
466
+ }
467
+ this._stalledCheckTime = t;
468
+ }, 500);
469
+ }
470
+
471
+ private _stopStalledWatchdog(): void {
472
+ if (this._stalledWatchdog !== null) {
473
+ clearInterval(this._stalledWatchdog);
474
+ this._stalledWatchdog = null;
475
+ }
476
+ }
477
+
392
478
  private setupMediaSession(): void {
393
479
  if (!("mediaSession" in navigator)) return;
394
480
  navigator.mediaSession.setActionHandler("play", () => this.play());
@@ -411,7 +497,7 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
411
497
  ? this.pub.metadata.authors.items.map((a) => a.name.getTranslation()).join(", ")
412
498
  : undefined,
413
499
  album: this.pub.metadata.title.getTranslation(),
414
- artwork: cover ? [{ src: cover.href, type: cover.type }] : undefined,
500
+ artwork: cover ? [{ src: cover.toURL(this.pub.baseURL) ?? cover.href, type: cover.type }] : undefined,
415
501
  });
416
502
  }
417
503
 
@@ -422,7 +508,7 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
422
508
  const duration = this.duration;
423
509
  const progression = duration > 0 ? currentTime / duration : 0;
424
510
  this.currentLocation = this.currentLocation.copyWithLocations(new LocatorLocations({
425
- position: this.currentTrackIndex(),
511
+ position: this.currentTrackIndex() + 1,
426
512
  progression,
427
513
  fragments: [`t=${currentTime}`]
428
514
  }));
@@ -451,25 +537,27 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
451
537
  }
452
538
 
453
539
  const id = ++this.navigationId;
454
- const direction: "forward" | "backward" = trackIndex >= this.currentTrackIndex() ? "forward" : "backward";
455
- // Use _playIntent rather than isPlaying setMediaElement resets the
456
- // engine's playing flag, so a rapid second go() would see false and
457
- // never resume playback.
540
+ const previousTrackIndex = this.currentTrackIndex();
541
+ const direction: "forward" | "backward" = trackIndex >= previousTrackIndex ? "forward" : "backward";
458
542
  const wasPlaying = this.isPlaying || this._playIntent;
459
543
  this._playIntent = wasPlaying;
460
544
 
545
+ this._isNavigating = true;
461
546
  this.stopPositionPolling();
462
547
  this.pool.setCurrentAudio(trackIndex, direction);
463
548
  this.currentLocation = locator.copyWithLocations(locator.locations);
464
549
 
465
550
  await this.waitForLoadedAndSeeked(time, id);
551
+ this._isNavigating = false;
466
552
 
467
553
  if (id !== this.navigationId) {
468
554
  cb(false);
469
555
  return;
470
556
  }
471
557
 
472
- this.listeners.trackLoaded(this.pool.audioEngine.getMediaElement());
558
+ if (trackIndex !== previousTrackIndex) {
559
+ this.listeners.trackLoaded(this.pool.audioEngine.getMediaElement());
560
+ }
473
561
  this._notifyTimelineChange(this.currentLocator);
474
562
  this.listeners.positionChanged(this.currentLocator);
475
563
 
@@ -481,6 +569,7 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
481
569
 
482
570
  cb(true);
483
571
  } catch (error) {
572
+ this._isNavigating = false;
484
573
  console.error("Failed to go to locator:", error);
485
574
  cb(false);
486
575
  } finally {
@@ -574,6 +663,29 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
574
663
  return this.currentTrackIndex() < this.pub.readingOrder.items.length - 1;
575
664
  }
576
665
 
666
+ /**
667
+ * The RemotePlayback object for the primary media element.
668
+ * Because the element is never swapped, this reference is stable for the
669
+ * lifetime of the navigator — host apps can store it and call `.prompt()`,
670
+ * `.watchAvailability()`, etc. directly.
671
+ */
672
+ get remotePlayback(): RemotePlayback | undefined {
673
+ const el = this.pool.audioEngine.getMediaElement();
674
+ return "remote" in el ? el.remote : undefined;
675
+ }
676
+
677
+ /** Wires up the optional remotePlaybackStateChanged listener. Called once after initial load. */
678
+ private _setupRemotePlayback(): void {
679
+ if (this._contentProtection.disableRemotePlayback) {
680
+ return;
681
+ }
682
+ const remote = this.remotePlayback;
683
+ if (!remote) return;
684
+ remote.onconnecting = () => this.listeners.remotePlaybackStateChanged("connecting");
685
+ remote.onconnect = () => this.listeners.remotePlaybackStateChanged("connected");
686
+ remote.ondisconnect = () => this.listeners.remotePlaybackStateChanged("disconnected");
687
+ }
688
+
577
689
  private destroyMediaSession(): void {
578
690
  if (!("mediaSession" in navigator)) return;
579
691
  navigator.mediaSession.metadata = null;
@@ -587,6 +699,7 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
587
699
 
588
700
  destroy(): void {
589
701
  this.stopPositionPolling();
702
+ this._stopStalledWatchdog();
590
703
  this.destroyMediaSession();
591
704
  if (this._suspiciousActivityListener) {
592
705
  window.removeEventListener(NAVIGATOR_SUSPICIOUS_ACTIVITY_EVENT, this._suspiciousActivityListener);
@@ -1,5 +1,6 @@
1
1
  import { Link, Publication } from "@readium/shared";
2
- import { WebAudioEngine } from "./engine/WebAudioEngine";
2
+ import { WebAudioEngine } from "./engine/WebAudioEngine.ts";
3
+ import type { IAudioContentProtectionConfig } from "./AudioNavigator.ts";
3
4
 
4
5
  const UPPER_BOUNDARY = 1;
5
6
  const LOWER_BOUNDARY = 1;
@@ -10,10 +11,14 @@ export class AudioPoolManager {
10
11
  private readonly _publication: Publication;
11
12
  private readonly _supportedAudioTypes: Map<string, "probably" | "maybe">;
12
13
 
13
- constructor(audioEngine: WebAudioEngine, publication: Publication) {
14
+ constructor(audioEngine: WebAudioEngine, publication: Publication, contentProtection: IAudioContentProtectionConfig = {}) {
14
15
  this._audioEngine = audioEngine;
15
16
  this._publication = publication;
16
17
  this._supportedAudioTypes = this.detectSupportedAudioTypes();
18
+
19
+ if (contentProtection.disableRemotePlayback) {
20
+ this._audioEngine.getMediaElement().disableRemotePlayback = true;
21
+ }
17
22
  }
18
23
 
19
24
  private detectSupportedAudioTypes(): Map<string, "probably" | "maybe"> {
@@ -34,16 +39,18 @@ export class AudioPoolManager {
34
39
  }
35
40
 
36
41
  private pickPlayableHref(link: Link): string {
42
+ const base = this._publication.baseURL;
37
43
  const candidates = [link, ...(link.alternates?.items ?? [])];
38
44
  let best: { href: string; confidence: "probably" | "maybe" } | undefined;
39
45
  for (const candidate of candidates) {
40
46
  if (!candidate.type) continue;
41
47
  const confidence = this._supportedAudioTypes.get(candidate.type);
42
48
  if (!confidence) continue;
43
- if (confidence === "probably") return candidate.href;
44
- if (!best) best = { href: candidate.href, confidence };
49
+ const href = candidate.toURL(base) ?? candidate.href;
50
+ if (confidence === "probably") return href;
51
+ if (!best) best = { href, confidence };
45
52
  }
46
- return best?.href ?? link.href;
53
+ return best?.href ?? (link.toURL(base) ?? link.href);
47
54
  }
48
55
 
49
56
  get audioEngine(): WebAudioEngine {
@@ -59,8 +66,8 @@ export class AudioPoolManager {
59
66
  if (!element) {
60
67
  element = document.createElement("audio");
61
68
  element.preload = "auto";
62
- // When Web Audio is active CORS already succeeded, so preload
63
- // with crossOrigin to avoid a destructive reload at swap time.
69
+ // Match the primary element's CORS mode so cached responses
70
+ // are reusable when changeSrc() loads this href on it.
64
71
  if (this._audioEngine.isWebAudioActive) {
65
72
  element.crossOrigin = "anonymous";
66
73
  }
@@ -74,12 +81,14 @@ export class AudioPoolManager {
74
81
  /**
75
82
  * Updates the pool around the given index: ensures elements exist within
76
83
  * the LOWER_BOUNDARY and disposes those beyond the UPPER_BOUNDARY.
84
+ * The current track is excluded — the primary engine element represents it.
77
85
  */
78
86
  private update(currentIndex: number): void {
79
87
  const items = this._publication.readingOrder.items;
80
88
  const keep = new Set<string>();
81
89
 
82
90
  for (let j = 0; j < items.length; j++) {
91
+ if (j === currentIndex) continue; // primary element handles the current track
83
92
  const href = this.pickPlayableHref(items[j]);
84
93
  if (j >= currentIndex - LOWER_BOUNDARY && j <= currentIndex + LOWER_BOUNDARY) {
85
94
  this.ensure(href);
@@ -103,17 +112,21 @@ export class AudioPoolManager {
103
112
  }
104
113
 
105
114
  /**
106
- * Sets the current audio for playback at the given track index.
107
- * The element is always sourced from the pool never loaded ad-hoc on the engine.
115
+ * Sets the current audio for playback at the given track index by changing
116
+ * the src on the persistent primary element. This preserves the RemotePlayback
117
+ * session and any Web Audio graph connections across track changes.
108
118
  */
109
119
  setCurrentAudio(currentIndex: number, _direction: 'forward' | 'backward'): void {
110
120
  const href = this.pickPlayableHref(this._publication.readingOrder.items[currentIndex]);
111
- const element = this.ensure(href);
121
+ this.audioEngine.changeSrc(href);
112
122
 
113
- this.audioEngine.setMediaElement(element);
114
-
115
- // Remove from pool so the engine fully owns it and we don't dispose it
116
- this.pool.delete(href);
123
+ // Discard any pool entry for this href — the primary element owns it now
124
+ if (this.pool.has(href)) {
125
+ const existing = this.pool.get(href)!;
126
+ existing.removeAttribute("src");
127
+ existing.load();
128
+ this.pool.delete(href);
129
+ }
117
130
 
118
131
  // Manage the pool around the new position
119
132
  this.update(currentIndex);
@@ -55,10 +55,11 @@ export interface AudioEngine {
55
55
  off(event: string, callback: (data: any) => void): void;
56
56
 
57
57
  /**
58
- * Sets the media element for playback, enabling use of preloaded elements from the pool.
59
- * @param element The HTML audio element to use for playback.
58
+ * Changes the src of the primary media element without swapping it,
59
+ * preserving the RemotePlayback session and all attached event listeners.
60
+ * @param href The URL of the new audio resource.
60
61
  */
61
- setMediaElement(element: HTMLAudioElement): void;
62
+ changeSrc(href: string): void;
62
63
 
63
64
  /**
64
65
  * Plays the current audio resource.