@readium/navigator 2.4.0-beta.8 → 2.4.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 (126) hide show
  1. package/dist/index.js +1307 -1253
  2. package/dist/index.umd.cjs +196 -133
  3. package/package.json +2 -2
  4. package/src/audio/AudioNavigator.ts +178 -52
  5. package/src/audio/AudioPoolManager.ts +27 -14
  6. package/src/audio/engine/AudioEngine.ts +4 -3
  7. package/src/audio/engine/PreservePitchProcessor.js +166 -101
  8. package/src/audio/engine/PreservePitchWorklet.ts +2 -17
  9. package/src/audio/engine/WebAudioEngine.ts +138 -160
  10. package/src/audio/engine/index.ts +2 -2
  11. package/src/audio/index.ts +3 -4
  12. package/src/audio/preferences/AudioDefaults.ts +2 -2
  13. package/src/audio/preferences/AudioPreferences.ts +11 -11
  14. package/src/audio/preferences/AudioPreferencesEditor.ts +13 -13
  15. package/src/audio/preferences/AudioSettings.ts +3 -3
  16. package/src/audio/preferences/index.ts +4 -4
  17. package/src/audio/protection/AudioNavigatorProtector.ts +4 -4
  18. package/src/css/index.ts +1 -1
  19. package/src/epub/EpubNavigator.ts +52 -52
  20. package/src/epub/css/Properties.ts +15 -15
  21. package/src/epub/css/ReadiumCSS.ts +43 -43
  22. package/src/epub/css/index.ts +2 -2
  23. package/src/epub/frame/FrameBlobBuilder.ts +10 -11
  24. package/src/epub/frame/FrameComms.ts +1 -1
  25. package/src/epub/frame/FrameManager.ts +9 -9
  26. package/src/epub/frame/FramePoolManager.ts +13 -13
  27. package/src/epub/frame/index.ts +4 -4
  28. package/src/epub/fxl/FXLCoordinator.ts +3 -3
  29. package/src/epub/fxl/FXLFrameManager.ts +8 -8
  30. package/src/epub/fxl/FXLFramePoolManager.ts +13 -13
  31. package/src/epub/fxl/FXLPeripherals.ts +4 -4
  32. package/src/epub/fxl/index.ts +5 -5
  33. package/src/epub/index.ts +5 -5
  34. package/src/epub/preferences/EpubDefaults.ts +23 -23
  35. package/src/epub/preferences/EpubPreferences.ts +16 -16
  36. package/src/epub/preferences/EpubPreferencesEditor.ts +53 -53
  37. package/src/epub/preferences/EpubSettings.ts +101 -101
  38. package/src/epub/preferences/index.ts +4 -4
  39. package/src/helpers/index.ts +2 -2
  40. package/src/index.ts +8 -9
  41. package/src/injection/Injector.ts +42 -42
  42. package/src/injection/epubInjectables.ts +8 -8
  43. package/src/injection/index.ts +2 -2
  44. package/src/injection/webpubInjectables.ts +1 -1
  45. package/src/preferences/Configurable.ts +2 -2
  46. package/src/preferences/PreferencesEditor.ts +2 -2
  47. package/src/preferences/guards.ts +2 -2
  48. package/src/preferences/index.ts +5 -5
  49. package/src/protection/CopyProtector.ts +5 -1
  50. package/src/protection/DevToolsDetector.ts +16 -16
  51. package/src/protection/DragAndDropProtector.ts +14 -1
  52. package/src/protection/NavigatorProtector.ts +6 -6
  53. package/src/webpub/WebPubBlobBuilder.ts +1 -1
  54. package/src/webpub/WebPubFrameManager.ts +8 -8
  55. package/src/webpub/WebPubFramePoolManager.ts +7 -7
  56. package/src/webpub/WebPubNavigator.ts +27 -27
  57. package/src/webpub/css/Properties.ts +3 -3
  58. package/src/webpub/css/WebPubCSS.ts +11 -11
  59. package/src/webpub/css/index.ts +2 -2
  60. package/src/webpub/index.ts +6 -6
  61. package/src/webpub/preferences/WebPubDefaults.ts +12 -12
  62. package/src/webpub/preferences/WebPubPreferences.ts +8 -8
  63. package/src/webpub/preferences/WebPubPreferencesEditor.ts +31 -31
  64. package/src/webpub/preferences/WebPubSettings.ts +45 -45
  65. package/src/webpub/preferences/index.ts +4 -4
  66. package/types/src/audio/AudioNavigator.d.ts +39 -9
  67. package/types/src/audio/AudioPoolManager.d.ts +7 -4
  68. package/types/src/audio/engine/AudioEngine.d.ts +4 -3
  69. package/types/src/audio/engine/PreservePitchWorklet.d.ts +1 -4
  70. package/types/src/audio/engine/WebAudioEngine.d.ts +15 -9
  71. package/types/src/audio/engine/index.d.ts +2 -2
  72. package/types/src/audio/index.d.ts +3 -4
  73. package/types/src/audio/preferences/AudioPreferences.d.ts +9 -9
  74. package/types/src/audio/preferences/AudioPreferencesEditor.d.ts +4 -4
  75. package/types/src/audio/preferences/AudioSettings.d.ts +3 -3
  76. package/types/src/audio/preferences/index.d.ts +4 -4
  77. package/types/src/audio/protection/AudioNavigatorProtector.d.ts +2 -2
  78. package/types/src/css/index.d.ts +1 -1
  79. package/types/src/epub/EpubNavigator.d.ts +11 -11
  80. package/types/src/epub/css/Properties.d.ts +2 -2
  81. package/types/src/epub/css/ReadiumCSS.d.ts +3 -3
  82. package/types/src/epub/css/index.d.ts +2 -2
  83. package/types/src/epub/frame/FrameBlobBuilder.d.ts +1 -1
  84. package/types/src/epub/frame/FrameComms.d.ts +1 -1
  85. package/types/src/epub/frame/FrameManager.d.ts +2 -2
  86. package/types/src/epub/frame/FramePoolManager.d.ts +3 -3
  87. package/types/src/epub/frame/index.d.ts +4 -4
  88. package/types/src/epub/fxl/FXLFrameManager.d.ts +3 -3
  89. package/types/src/epub/fxl/FXLFramePoolManager.d.ts +5 -5
  90. package/types/src/epub/fxl/FXLPeripherals.d.ts +2 -2
  91. package/types/src/epub/fxl/index.d.ts +5 -5
  92. package/types/src/epub/index.d.ts +5 -5
  93. package/types/src/epub/preferences/EpubDefaults.d.ts +1 -1
  94. package/types/src/epub/preferences/EpubPreferences.d.ts +2 -2
  95. package/types/src/epub/preferences/EpubPreferencesEditor.d.ts +5 -5
  96. package/types/src/epub/preferences/EpubSettings.d.ts +4 -4
  97. package/types/src/epub/preferences/index.d.ts +4 -4
  98. package/types/src/helpers/index.d.ts +2 -2
  99. package/types/src/index.d.ts +8 -9
  100. package/types/src/injection/Injector.d.ts +1 -1
  101. package/types/src/injection/epubInjectables.d.ts +1 -1
  102. package/types/src/injection/index.d.ts +2 -2
  103. package/types/src/preferences/Configurable.d.ts +1 -1
  104. package/types/src/preferences/PreferencesEditor.d.ts +1 -1
  105. package/types/src/preferences/guards.d.ts +1 -1
  106. package/types/src/preferences/index.d.ts +5 -5
  107. package/types/src/protection/CopyProtector.d.ts +1 -0
  108. package/types/src/protection/DragAndDropProtector.d.ts +2 -0
  109. package/types/src/protection/NavigatorProtector.d.ts +1 -1
  110. package/types/src/webpub/WebPubBlobBuilder.d.ts +1 -1
  111. package/types/src/webpub/WebPubFrameManager.d.ts +2 -2
  112. package/types/src/webpub/WebPubFramePoolManager.d.ts +3 -3
  113. package/types/src/webpub/WebPubNavigator.d.ts +10 -10
  114. package/types/src/webpub/css/Properties.d.ts +2 -2
  115. package/types/src/webpub/css/WebPubCSS.d.ts +2 -2
  116. package/types/src/webpub/css/index.d.ts +2 -2
  117. package/types/src/webpub/index.d.ts +6 -6
  118. package/types/src/webpub/preferences/WebPubDefaults.d.ts +1 -1
  119. package/types/src/webpub/preferences/WebPubPreferences.d.ts +2 -2
  120. package/types/src/webpub/preferences/WebPubPreferencesEditor.d.ts +5 -5
  121. package/types/src/webpub/preferences/WebPubSettings.d.ts +4 -4
  122. package/types/src/webpub/preferences/index.d.ts +4 -4
  123. package/src/Timeline.ts +0 -58
  124. package/src/audio/AudioTimeline.ts +0 -156
  125. package/types/src/Timeline.d.ts +0 -48
  126. package/types/src/audio/AudioTimeline.d.ts +0 -34
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@readium/navigator",
3
- "version": "2.4.0-beta.8",
3
+ "version": "2.4.0",
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
- import { Link, Locator, LocatorLocations, Publication } from "@readium/shared";
2
- import { MediaNavigator, IContentProtectionConfig, IKeyboardPeripheralsConfig } from "../Navigator";
3
- import { Configurable } from "../preferences";
4
- import { WebAudioEngine, PlaybackState } from "./engine";
1
+ import { Link, Locator, LocatorLocations, Publication, Timeline, TimelineItem } from "@readium/shared";
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,33 +9,42 @@ import {
9
9
  AudioPreferencesEditor,
10
10
  IAudioPreferences,
11
11
  IAudioDefaults
12
- } from "./preferences";
13
- import { AudioPoolManager } from "./AudioPoolManager";
14
- import { AudioTimeline } from "./AudioTimeline";
12
+ } from "./preferences/index.ts";
13
+ import { AudioPoolManager } from "./AudioPoolManager.ts";
15
14
  import { ContextMenuEvent, KeyboardEventData, SuspiciousActivityEvent } from "@readium/navigator-html-injectables";
16
- import { AudioNavigatorProtector } from "./protection/AudioNavigatorProtector";
17
- import { NAVIGATOR_SUSPICIOUS_ACTIVITY_EVENT } from "../protection/NavigatorProtector";
18
- 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
+ }
19
25
 
20
26
  export interface AudioNavigatorListeners {
21
27
  trackLoaded: (media: HTMLMediaElement) => void;
22
28
  positionChanged: (locator: Locator) => void;
29
+ timelineItemChanged: (item: TimelineItem | undefined) => void;
23
30
  error: (error: any, locator: Locator) => void;
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 => ({
37
45
  trackLoaded: listeners.trackLoaded ?? (() => {}),
38
46
  positionChanged: listeners.positionChanged ?? (() => {}),
47
+ timelineItemChanged: listeners.timelineItemChanged ?? (() => {}),
39
48
  error: listeners.error ?? (() => {}),
40
49
  trackEnded: listeners.trackEnded ?? (() => {}),
41
50
  play: listeners.play ?? (() => {}),
@@ -47,12 +56,18 @@ const defaultListeners = (listeners: Partial<AudioNavigatorListeners>): AudioNav
47
56
  contentProtection: listeners.contentProtection ?? (() => {}),
48
57
  peripheral: listeners.peripheral ?? (() => {}),
49
58
  contextMenu: listeners.contextMenu ?? (() => {}),
59
+ remotePlaybackStateChanged: listeners.remotePlaybackStateChanged ?? (() => {}),
50
60
  });
51
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
+
52
67
  export interface AudioNavigatorConfiguration {
53
68
  preferences: IAudioPreferences;
54
69
  defaults: IAudioDefaults;
55
- contentProtection?: IContentProtectionConfig;
70
+ contentProtection?: IAudioContentProtectionConfig;
56
71
  keyboardPeripherals?: IKeyboardPeripheralsConfig;
57
72
  }
58
73
 
@@ -71,10 +86,16 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
71
86
  private _mediaSessionEnabled: boolean = false;
72
87
  private pool: AudioPoolManager;
73
88
  private readonly _navigatorProtector: AudioNavigatorProtector | null = null;
74
- private readonly _timeline: AudioTimeline;
89
+ private _currentTimelineItem: TimelineItem | undefined;
75
90
  private readonly _keyboardPeripheralsManager: KeyboardPeripherals | null = null;
76
91
  private readonly _suspiciousActivityListener: ((event: Event) => void) | null = null;
77
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;
78
99
 
79
100
  constructor(publication: Publication, listeners: AudioNavigatorListeners, initialPosition?: Locator, configuration: AudioNavigatorConfiguration = {
80
101
  preferences: {},
@@ -82,13 +103,16 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
82
103
  }) {
83
104
  super();
84
105
  this.pub = publication;
85
- this._timeline = new AudioTimeline(publication);
86
106
  this.listeners = defaultListeners(listeners);
87
107
 
88
108
  this._preferences = new AudioPreferences(configuration.preferences);
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
- this._timeline.update(this.currentLocator);
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();
@@ -214,8 +252,19 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
214
252
  return this.pub;
215
253
  }
216
254
 
217
- get timeline(): AudioTimeline {
218
- return this._timeline;
255
+ get timeline(): Timeline {
256
+ return this.pub.timeline;
257
+ }
258
+
259
+ private _notifyTimelineChange(locator: Locator): void {
260
+ const item = this.pub.timeline.locate(locator);
261
+ if (item !== this._currentTimelineItem) {
262
+ this._currentTimelineItem = item;
263
+ this.listeners.timelineItemChanged(item);
264
+ if (this._settings.enableMediaSession) {
265
+ this.updateMediaSessionMetadata();
266
+ }
267
+ }
219
268
  }
220
269
 
221
270
  private ensureLocatorLocations(locator: Locator): Locator {
@@ -271,7 +320,7 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
271
320
  title: link.title,
272
321
  locations: new LocatorLocations({
273
322
  progression: duration > 0 ? timestamp / duration : 0,
274
- position: trackIndex,
323
+ position: trackIndex + 1,
275
324
  fragments: [`t=${timestamp}`]
276
325
  })
277
326
  });
@@ -330,7 +379,7 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
330
379
  this.pool.audioEngine.on("ended", async () => {
331
380
  this.stopPositionPolling();
332
381
  this.currentLocation = this.currentLocation.copyWithLocations(new LocatorLocations({
333
- position: this.currentTrackIndex(),
382
+ position: this.currentTrackIndex() + 1,
334
383
  progression: 1,
335
384
  fragments: [`t=${this.duration}`]
336
385
  }));
@@ -341,45 +390,91 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
341
390
  });
342
391
 
343
392
  this.pool.audioEngine.on("play", () => {
393
+ if (this._isNavigating) return;
344
394
  this.startPositionPolling();
345
395
  this.listeners.play(this.currentLocator);
346
396
  });
347
397
 
348
398
  this.pool.audioEngine.on("playing", () => {
349
- this.listeners.stalled(false);
399
+ if (this._isNavigating) return;
400
+ this._setStalled(false);
350
401
  });
351
402
 
352
403
  this.pool.audioEngine.on("pause", () => {
404
+ if (this._isNavigating) return;
353
405
  this.stopPositionPolling();
354
406
  this.listeners.pause(this.currentLocator);
355
407
  });
356
408
 
357
409
  this.pool.audioEngine.on("seeked", () => {
410
+ if (this._isNavigating) return;
358
411
  this.listeners.seeking(false);
359
- if (!this.isPlaying) {
360
- const currentTime = this.currentTime;
361
- const duration = this.duration;
362
- const progression = duration > 0 ? currentTime / duration : 0;
363
- this.currentLocation = this.currentLocation.copyWithLocations(new LocatorLocations({
364
- position: this.currentTrackIndex(),
365
- progression,
366
- fragments: [`t=${currentTime}`]
367
- }));
368
- this._timeline.update(this.currentLocation);
369
- this.listeners.positionChanged(this.currentLocation);
370
- }
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);
371
425
  });
372
426
 
373
- this.pool.audioEngine.on("seeking", () => this.listeners.seeking(true));
374
- this.pool.audioEngine.on("waiting", () => this.listeners.seeking(true));
375
- this.pool.audioEngine.on("stalled", () => this.listeners.stalled(true));
376
- this.pool.audioEngine.on("progress", (seekable: TimeRanges) => this.listeners.seekable(seekable));
377
-
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
+
378
433
  this.pool.audioEngine.on("loadedmetadata", () => {
379
- 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);
380
442
  });
381
443
  }
382
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
+
383
478
  private setupMediaSession(): void {
384
479
  if (!("mediaSession" in navigator)) return;
385
480
  navigator.mediaSession.setActionHandler("play", () => this.play());
@@ -402,7 +497,7 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
402
497
  ? this.pub.metadata.authors.items.map((a) => a.name.getTranslation()).join(", ")
403
498
  : undefined,
404
499
  album: this.pub.metadata.title.getTranslation(),
405
- artwork: cover ? [{ src: cover.href, type: cover.type }] : undefined,
500
+ artwork: cover ? [{ src: cover.toURL(this.pub.baseURL) ?? cover.href, type: cover.type }] : undefined,
406
501
  });
407
502
  }
408
503
 
@@ -413,11 +508,11 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
413
508
  const duration = this.duration;
414
509
  const progression = duration > 0 ? currentTime / duration : 0;
415
510
  this.currentLocation = this.currentLocation.copyWithLocations(new LocatorLocations({
416
- position: this.currentTrackIndex(),
511
+ position: this.currentTrackIndex() + 1,
417
512
  progression,
418
513
  fragments: [`t=${currentTime}`]
419
514
  }));
420
- this._timeline.update(this.currentLocation);
515
+ this._notifyTimelineChange(this.currentLocation);
421
516
  this.listeners.positionChanged(this.currentLocation);
422
517
  }, this._settings.pollInterval);
423
518
  }
@@ -442,23 +537,28 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
442
537
  }
443
538
 
444
539
  const id = ++this.navigationId;
445
- const direction: "forward" | "backward" = trackIndex >= this.currentTrackIndex() ? "forward" : "backward";
446
- // Use _playIntent rather than isPlaying setMediaElement resets the
447
- // engine's playing flag, so a rapid second go() would see false and
448
- // never resume playback.
540
+ const previousTrackIndex = this.currentTrackIndex();
541
+ const direction: "forward" | "backward" = trackIndex >= previousTrackIndex ? "forward" : "backward";
449
542
  const wasPlaying = this.isPlaying || this._playIntent;
450
543
  this._playIntent = wasPlaying;
451
544
 
545
+ this._isNavigating = true;
452
546
  this.stopPositionPolling();
453
547
  this.pool.setCurrentAudio(trackIndex, direction);
454
548
  this.currentLocation = locator.copyWithLocations(locator.locations);
455
549
 
456
550
  await this.waitForLoadedAndSeeked(time, id);
551
+ this._isNavigating = false;
457
552
 
458
- if (id !== this.navigationId) return;
553
+ if (id !== this.navigationId) {
554
+ cb(false);
555
+ return;
556
+ }
459
557
 
460
- this.listeners.trackLoaded(this.pool.audioEngine.getMediaElement());
461
- this._timeline.update(this.currentLocator);
558
+ if (trackIndex !== previousTrackIndex) {
559
+ this.listeners.trackLoaded(this.pool.audioEngine.getMediaElement());
560
+ }
561
+ this._notifyTimelineChange(this.currentLocator);
462
562
  this.listeners.positionChanged(this.currentLocator);
463
563
 
464
564
  if (this._settings.enableMediaSession) {
@@ -466,12 +566,14 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
466
566
  }
467
567
 
468
568
  if (wasPlaying) this.play();
469
- this._playIntent = false;
470
569
 
471
570
  cb(true);
472
571
  } catch (error) {
572
+ this._isNavigating = false;
473
573
  console.error("Failed to go to locator:", error);
474
574
  cb(false);
575
+ } finally {
576
+ this._playIntent = false;
475
577
  }
476
578
  }
477
579
 
@@ -561,6 +663,29 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
561
663
  return this.currentTrackIndex() < this.pub.readingOrder.items.length - 1;
562
664
  }
563
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
+
564
689
  private destroyMediaSession(): void {
565
690
  if (!("mediaSession" in navigator)) return;
566
691
  navigator.mediaSession.metadata = null;
@@ -574,6 +699,7 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
574
699
 
575
700
  destroy(): void {
576
701
  this.stopPositionPolling();
702
+ this._stopStalledWatchdog();
577
703
  this.destroyMediaSession();
578
704
  if (this._suspiciousActivityListener) {
579
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.