@readium/navigator 2.4.0-beta.10 → 2.4.0-beta.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@readium/navigator",
3
- "version": "2.4.0-beta.10",
3
+ "version": "2.4.0-beta.11",
4
4
  "type": "module",
5
5
  "description": "Next generation SDK for publications in Web Apps",
6
6
  "author": "readium",
@@ -16,6 +16,13 @@ import { AudioNavigatorProtector } from "./protection/AudioNavigatorProtector";
16
16
  import { NAVIGATOR_SUSPICIOUS_ACTIVITY_EVENT } from "../protection/NavigatorProtector";
17
17
  import { KeyboardPeripherals, NAVIGATOR_KEYBOARD_PERIPHERAL_EVENT } from "../peripherals/KeyboardPeripherals";
18
18
 
19
+ export interface AudioMetadata {
20
+ duration: number;
21
+ textTracks: TextTrackList;
22
+ readyState: number;
23
+ networkState: number;
24
+ }
25
+
19
26
  export interface AudioNavigatorListeners {
20
27
  trackLoaded: (media: HTMLMediaElement) => void;
21
28
  positionChanged: (locator: Locator) => 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 => ({
@@ -50,10 +58,15 @@ const defaultListeners = (listeners: Partial<AudioNavigatorListeners>): AudioNav
50
58
  contextMenu: listeners.contextMenu ?? (() => {}),
51
59
  });
52
60
 
61
+ export interface IAudioContentProtectionConfig extends IContentProtectionConfig {
62
+ /** Prevents the media element from being cast to remote devices via the Remote Playback API. */
63
+ disableRemotePlayback?: boolean;
64
+ }
65
+
53
66
  export interface AudioNavigatorConfiguration {
54
67
  preferences: IAudioPreferences;
55
68
  defaults: IAudioDefaults;
56
- contentProtection?: IContentProtectionConfig;
69
+ contentProtection?: IAudioContentProtectionConfig;
57
70
  keyboardPeripherals?: IKeyboardPeripheralsConfig;
58
71
  }
59
72
 
@@ -76,6 +89,9 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
76
89
  private readonly _keyboardPeripheralsManager: KeyboardPeripherals | null = null;
77
90
  private readonly _suspiciousActivityListener: ((event: Event) => void) | null = null;
78
91
  private readonly _keyboardPeripheralListener: ((event: Event) => void) | null = null;
92
+ private readonly _contentProtection: IAudioContentProtectionConfig;
93
+ /** True while a track transition is in progress; suppresses spurious mid-navigation events. */
94
+ private _isNavigating: boolean = false;
79
95
 
80
96
  constructor(publication: Publication, listeners: AudioNavigatorListeners, initialPosition?: Locator, configuration: AudioNavigatorConfiguration = {
81
97
  preferences: {},
@@ -121,10 +137,11 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
121
137
  }
122
138
  });
123
139
 
124
- this.pool = new AudioPoolManager(audioEngine, publication);
140
+ this.pool = new AudioPoolManager(audioEngine, publication, configuration.contentProtection);
125
141
 
126
142
  // Initialize content protection
127
143
  const contentProtection = configuration.contentProtection || {};
144
+ this._contentProtection = contentProtection;
128
145
  const keyboardPeripherals = this.mergeKeyboardPeripherals(
129
146
  contentProtection,
130
147
  configuration.keyboardPeripherals || []
@@ -160,17 +177,21 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
160
177
  this.setupEventListeners();
161
178
  this.applyPreferences();
162
179
 
180
+ this._isNavigating = true;
163
181
  this.pool.setCurrentAudio(trackIndex, "forward");
164
182
 
165
183
  // Load and seek to initial position, then notify consumer.
166
184
  // No cancellation needed here — the constructor runs once.
167
185
  this.waitForLoadedAndSeeked(initialTime)
168
186
  .then(() => {
187
+ this._isNavigating = false;
169
188
  this.listeners.trackLoaded(this.pool.audioEngine.getMediaElement());
170
189
  this._notifyTimelineChange(this.currentLocator);
171
190
  this.listeners.positionChanged(this.currentLocator);
191
+ this._setupRemotePlayback();
172
192
  })
173
193
  .catch(() => {
194
+ this._isNavigating = false;
174
195
  // Error already forwarded via the error event listener.
175
196
  });
176
197
  }
@@ -349,20 +370,24 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
349
370
  });
350
371
 
351
372
  this.pool.audioEngine.on("play", () => {
373
+ if (this._isNavigating) return;
352
374
  this.startPositionPolling();
353
375
  this.listeners.play(this.currentLocator);
354
376
  });
355
377
 
356
378
  this.pool.audioEngine.on("playing", () => {
379
+ if (this._isNavigating) return;
357
380
  this.listeners.stalled(false);
358
381
  });
359
382
 
360
383
  this.pool.audioEngine.on("pause", () => {
384
+ if (this._isNavigating) return;
361
385
  this.stopPositionPolling();
362
386
  this.listeners.pause(this.currentLocator);
363
387
  });
364
388
 
365
389
  this.pool.audioEngine.on("seeked", () => {
390
+ if (this._isNavigating) return;
366
391
  this.listeners.seeking(false);
367
392
  if (!this.isPlaying) {
368
393
  const currentTime = this.currentTime;
@@ -378,14 +403,21 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
378
403
  }
379
404
  });
380
405
 
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
-
406
+ this.pool.audioEngine.on("seeking", () => { if (!this._isNavigating) this.listeners.seeking(true); });
407
+ this.pool.audioEngine.on("waiting", () => { if (!this._isNavigating) this.listeners.seeking(true); });
408
+ this.pool.audioEngine.on("stalled", () => { if (!this._isNavigating) this.listeners.stalled(true); });
409
+ this.pool.audioEngine.on("canplaythrough", () => { if (!this._isNavigating) this.listeners.stalled(false); });
410
+ this.pool.audioEngine.on("progress", (seekable: TimeRanges) => { if (!this._isNavigating) this.listeners.seekable(seekable); });
411
+
387
412
  this.pool.audioEngine.on("loadedmetadata", () => {
388
- this.listeners.metadataLoaded(this.pool.audioEngine.duration());
413
+ const mediaElement = this.pool.audioEngine.getMediaElement();
414
+ const metadata: AudioMetadata = {
415
+ duration: this.pool.audioEngine.duration(),
416
+ textTracks: mediaElement.textTracks,
417
+ readyState: mediaElement.readyState,
418
+ networkState: mediaElement.networkState
419
+ };
420
+ this.listeners.metadataLoaded(metadata);
389
421
  });
390
422
  }
391
423
 
@@ -452,17 +484,16 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
452
484
 
453
485
  const id = ++this.navigationId;
454
486
  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.
458
487
  const wasPlaying = this.isPlaying || this._playIntent;
459
488
  this._playIntent = wasPlaying;
460
489
 
490
+ this._isNavigating = true;
461
491
  this.stopPositionPolling();
462
492
  this.pool.setCurrentAudio(trackIndex, direction);
463
493
  this.currentLocation = locator.copyWithLocations(locator.locations);
464
494
 
465
495
  await this.waitForLoadedAndSeeked(time, id);
496
+ this._isNavigating = false;
466
497
 
467
498
  if (id !== this.navigationId) {
468
499
  cb(false);
@@ -481,6 +512,7 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
481
512
 
482
513
  cb(true);
483
514
  } catch (error) {
515
+ this._isNavigating = false;
484
516
  console.error("Failed to go to locator:", error);
485
517
  cb(false);
486
518
  } finally {
@@ -574,6 +606,28 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
574
606
  return this.currentTrackIndex() < this.pub.readingOrder.items.length - 1;
575
607
  }
576
608
 
609
+ /**
610
+ * The RemotePlayback object for the primary media element.
611
+ * Because the element is never swapped, this reference is stable for the
612
+ * lifetime of the navigator — host apps can store it and call `.prompt()`,
613
+ * `.watchAvailability()`, etc. directly.
614
+ */
615
+ get remotePlayback(): RemotePlayback {
616
+ return this.pool.audioEngine.getMediaElement().remote;
617
+ }
618
+
619
+ /** Wires up the optional remotePlaybackStateChanged listener. Called once after initial load. */
620
+ private _setupRemotePlayback(): void {
621
+ if (this._contentProtection.disableRemotePlayback) {
622
+ return;
623
+ }
624
+ const remote = this.remotePlayback;
625
+ if (!remote) return;
626
+ remote.onconnecting = () => this.listeners.remotePlaybackStateChanged?.("connecting");
627
+ remote.onconnect = () => this.listeners.remotePlaybackStateChanged?.("connected");
628
+ remote.ondisconnect = () => this.listeners.remotePlaybackStateChanged?.("disconnected");
629
+ }
630
+
577
631
  private destroyMediaSession(): void {
578
632
  if (!("mediaSession" in navigator)) return;
579
633
  navigator.mediaSession.metadata = null;
@@ -1,5 +1,6 @@
1
1
  import { Link, Publication } from "@readium/shared";
2
2
  import { WebAudioEngine } from "./engine/WebAudioEngine";
3
+ import type { IAudioContentProtectionConfig } from "./AudioNavigator";
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"> {
@@ -59,8 +64,8 @@ export class AudioPoolManager {
59
64
  if (!element) {
60
65
  element = document.createElement("audio");
61
66
  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.
67
+ // Match the primary element's CORS mode so cached responses
68
+ // are reusable when changeSrc() loads this href on it.
64
69
  if (this._audioEngine.isWebAudioActive) {
65
70
  element.crossOrigin = "anonymous";
66
71
  }
@@ -74,12 +79,14 @@ export class AudioPoolManager {
74
79
  /**
75
80
  * Updates the pool around the given index: ensures elements exist within
76
81
  * the LOWER_BOUNDARY and disposes those beyond the UPPER_BOUNDARY.
82
+ * The current track is excluded — the primary engine element represents it.
77
83
  */
78
84
  private update(currentIndex: number): void {
79
85
  const items = this._publication.readingOrder.items;
80
86
  const keep = new Set<string>();
81
87
 
82
88
  for (let j = 0; j < items.length; j++) {
89
+ if (j === currentIndex) continue; // primary element handles the current track
83
90
  const href = this.pickPlayableHref(items[j]);
84
91
  if (j >= currentIndex - LOWER_BOUNDARY && j <= currentIndex + LOWER_BOUNDARY) {
85
92
  this.ensure(href);
@@ -103,17 +110,21 @@ export class AudioPoolManager {
103
110
  }
104
111
 
105
112
  /**
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.
113
+ * Sets the current audio for playback at the given track index by changing
114
+ * the src on the persistent primary element. This preserves the RemotePlayback
115
+ * session and any Web Audio graph connections across track changes.
108
116
  */
109
117
  setCurrentAudio(currentIndex: number, _direction: 'forward' | 'backward'): void {
110
118
  const href = this.pickPlayableHref(this._publication.readingOrder.items[currentIndex]);
111
- const element = this.ensure(href);
119
+ this.audioEngine.changeSrc(href);
112
120
 
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);
121
+ // Discard any pool entry for this href — the primary element owns it now
122
+ if (this.pool.has(href)) {
123
+ const existing = this.pool.get(href)!;
124
+ existing.removeAttribute("src");
125
+ existing.load();
126
+ this.pool.delete(href);
127
+ }
117
128
 
118
129
  // Manage the pool around the new position
119
130
  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.
@@ -16,8 +16,6 @@ export class WebAudioEngine implements AudioEngine {
16
16
  private sourceNode: MediaElementAudioSourceNode | null = null;
17
17
  private gainNode: GainNode | null = null;
18
18
  private listeners: { [event: string]: EventCallback[] } = {};
19
- private currentVolume: number = 1;
20
- private currentPlaybackRate: number = 1;
21
19
  private isMutedValue: boolean = false;
22
20
  private isPlayingValue: boolean = false;
23
21
  private isPausedValue: boolean = false;
@@ -95,120 +93,6 @@ export class WebAudioEngine implements AudioEngine {
95
93
  );
96
94
  }
97
95
 
98
- private deactivateWebAudio(): void {
99
- if (this.worklet) {
100
- this.worklet.destroy();
101
- this.worklet = null;
102
- }
103
- if (this.sourceNode) {
104
- this.sourceNode.disconnect();
105
- this.sourceNode = null;
106
- }
107
- if (this.gainNode) {
108
- this.gainNode.disconnect();
109
- this.gainNode = null;
110
- }
111
- this.webAudioActive = false;
112
- }
113
-
114
- /**
115
- * Sets the media element for playback.
116
- * @param element The HTML audio element to use.
117
- */
118
- public setMediaElement(element: HTMLAudioElement): void {
119
- // Remove listeners BEFORE pausing so the pause doesn't leak through
120
- this.mediaElement.removeEventListener("canplaythrough", this.boundOnCanPlayThrough);
121
- this.mediaElement.removeEventListener("timeupdate", this.boundOnTimeUpdate);
122
- this.mediaElement.removeEventListener("error", this.boundOnError);
123
- this.mediaElement.removeEventListener("ended", this.boundOnEnded);
124
- this.mediaElement.removeEventListener("stalled", this.boundOnStalled);
125
- this.mediaElement.removeEventListener("emptied", this.boundOnEmptied);
126
- this.mediaElement.removeEventListener("suspend", this.boundOnSuspend);
127
- this.mediaElement.removeEventListener("waiting", this.boundOnWaiting);
128
- this.mediaElement.removeEventListener("loadedmetadata", this.boundOnLoadedMetadata);
129
- this.mediaElement.removeEventListener("seeking", this.boundOnSeeking);
130
- this.mediaElement.removeEventListener("seeked", this.boundOnSeeked);
131
- this.mediaElement.removeEventListener("play", this.boundOnPlay);
132
- this.mediaElement.removeEventListener("playing", this.boundOnPlaying);
133
- this.mediaElement.removeEventListener("pause", this.boundOnPause);
134
- this.mediaElement.removeEventListener("progress", this.boundOnProgress);
135
-
136
- // Now safe to pause the outgoing element
137
- this.mediaElement.pause();
138
- this.isPlayingValue = false;
139
- this.isPausedValue = false;
140
-
141
- // Disconnect old source node if it exists
142
- if (this.sourceNode) {
143
- this.sourceNode.disconnect();
144
- this.sourceNode = null;
145
- }
146
-
147
- // Set new media element
148
- this.mediaElement = element;
149
-
150
- // Add event listeners to new element
151
- this.mediaElement.addEventListener("canplaythrough", this.boundOnCanPlayThrough);
152
- this.mediaElement.addEventListener("timeupdate", this.boundOnTimeUpdate);
153
- this.mediaElement.addEventListener("error", this.boundOnError);
154
- this.mediaElement.addEventListener("ended", this.boundOnEnded);
155
- this.mediaElement.addEventListener("stalled", this.boundOnStalled);
156
- this.mediaElement.addEventListener("emptied", this.boundOnEmptied);
157
- this.mediaElement.addEventListener("suspend", this.boundOnSuspend);
158
- this.mediaElement.addEventListener("waiting", this.boundOnWaiting);
159
- this.mediaElement.addEventListener("loadedmetadata", this.boundOnLoadedMetadata);
160
- this.mediaElement.addEventListener("seeking", this.boundOnSeeking);
161
- this.mediaElement.addEventListener("seeked", this.boundOnSeeked);
162
- this.mediaElement.addEventListener("play", this.boundOnPlay);
163
- this.mediaElement.addEventListener("playing", this.boundOnPlaying);
164
- this.mediaElement.addEventListener("pause", this.boundOnPause);
165
- this.mediaElement.addEventListener("progress", this.boundOnProgress);
166
-
167
- // Re-apply current volume and playback rate to the new element
168
- this.mediaElement.volume = this.isMutedValue ? 0 : this.currentVolume;
169
- this.mediaElement.playbackRate = this.currentPlaybackRate;
170
-
171
- // Reconnect the Web Audio graph to the new element
172
- if (this.webAudioActive) {
173
- try {
174
- const ctx = this.getOrCreateAudioContext();
175
- this.sourceNode = new MediaElementAudioSourceNode(ctx, { mediaElement: this.mediaElement });
176
- if (!this.gainNode) {
177
- this.gainNode = ctx.createGain();
178
- this.gainNode.connect(ctx.destination);
179
- }
180
- if (this.worklet?.workletNode) {
181
- this.sourceNode.connect(this.worklet.workletNode);
182
- } else {
183
- this.sourceNode.connect(this.gainNode);
184
- }
185
- } catch {
186
- // CORS failed on this element — deactivate Web Audio gracefully
187
- this.deactivateWebAudio();
188
- }
189
- }
190
-
191
- // Check if metadata is already loaded (common with preloaded elements)
192
- if (this.mediaElement.readyState >= 1) {
193
- this.onLoadedMetadata(new Event('loadedmetadata'));
194
- }
195
-
196
- // Preloaded elements may have already buffered data before being swapped in,
197
- // so progress events would have fired before we were listening. Emit now if
198
- // seekable ranges are already available.
199
- if (this.mediaElement.seekable.length > 0) {
200
- this.onProgress();
201
- }
202
-
203
- // Check if the element is already loaded and trigger appropriate events
204
- if (this.mediaElement.readyState >= 4) {
205
- this.onCanPlayThrough();
206
- } else {
207
- this.isLoadingValue = true;
208
- this.isLoadedValue = false;
209
- }
210
- }
211
-
212
96
  // Ensure AudioContext is running
213
97
  private async ensureAudioContextRunning() {
214
98
  if (!this.audioContext) {
@@ -352,7 +236,6 @@ export class WebAudioEngine implements AudioEngine {
352
236
  */
353
237
  public setVolume(volume: number): void {
354
238
  if (volume < 0) {
355
- this.currentVolume = 0;
356
239
  this.mediaElement.volume = 0;
357
240
  if (this.gainNode) {
358
241
  this.gainNode.gain.value = 0;
@@ -364,7 +247,6 @@ export class WebAudioEngine implements AudioEngine {
364
247
  this.setVolume(volume / 100);
365
248
  return;
366
249
  }
367
- this.currentVolume = volume;
368
250
  this.mediaElement.volume = volume;
369
251
  if (this.gainNode) {
370
252
  this.gainNode.gain.value = volume;
@@ -452,7 +334,6 @@ export class WebAudioEngine implements AudioEngine {
452
334
  * Sets the playback rate of the audio resource with pitch preservation.
453
335
  */
454
336
  public setPlaybackRate(rate: number, preservePitch: boolean): void {
455
- this.currentPlaybackRate = rate;
456
337
  this.mediaElement.playbackRate = rate;
457
338
  if (preservePitch) {
458
339
  if ('preservesPitch' in this.mediaElement) {
@@ -558,6 +439,27 @@ export class WebAudioEngine implements AudioEngine {
558
439
  return this.webAudioActive;
559
440
  }
560
441
 
442
+ /**
443
+ * Changes the src of the primary media element without swapping the element.
444
+ * Preserves the RemotePlayback session and all attached event listeners.
445
+ */
446
+ public changeSrc(href: string): void {
447
+ if (this.mediaElement.src === href) {
448
+ return;
449
+ }
450
+ this.mediaElement.pause();
451
+ this.isPlayingValue = false;
452
+ this.isPausedValue = false;
453
+ this.isLoadedValue = false;
454
+ this.isLoadingValue = true;
455
+ this.isEndedValue = false;
456
+ if (this.webAudioActive) {
457
+ this.mediaElement.crossOrigin = "anonymous";
458
+ }
459
+ this.mediaElement.src = href;
460
+ this.mediaElement.load();
461
+ }
462
+
561
463
  /**
562
464
  * Returns the HTML media element used for playback.
563
465
  */
@@ -3,6 +3,12 @@ import { MediaNavigator, IContentProtectionConfig, IKeyboardPeripheralsConfig }
3
3
  import { Configurable } from "../preferences";
4
4
  import { AudioPreferences, AudioSettings, AudioPreferencesEditor, IAudioPreferences, IAudioDefaults } from "./preferences";
5
5
  import { ContextMenuEvent, KeyboardEventData, SuspiciousActivityEvent } from "@readium/navigator-html-injectables";
6
+ export interface AudioMetadata {
7
+ duration: number;
8
+ textTracks: TextTrackList;
9
+ readyState: number;
10
+ networkState: number;
11
+ }
6
12
  export interface AudioNavigatorListeners {
7
13
  trackLoaded: (media: HTMLMediaElement) => void;
8
14
  positionChanged: (locator: Locator) => void;
@@ -11,18 +17,23 @@ export interface AudioNavigatorListeners {
11
17
  trackEnded: (locator: Locator) => void;
12
18
  play: (locator: Locator) => void;
13
19
  pause: (locator: Locator) => void;
14
- metadataLoaded: (duration: number) => void;
20
+ metadataLoaded: (metadata: AudioMetadata) => void;
15
21
  stalled: (isStalled: boolean) => void;
16
22
  seeking: (isSeeking: boolean) => void;
17
23
  seekable: (seekable: TimeRanges) => void;
18
24
  contentProtection: (type: string, data: SuspiciousActivityEvent) => void;
19
25
  peripheral: (data: KeyboardEventData) => void;
20
26
  contextMenu: (data: ContextMenuEvent) => void;
27
+ remotePlaybackStateChanged?: (state: RemotePlaybackState) => void;
28
+ }
29
+ export interface IAudioContentProtectionConfig extends IContentProtectionConfig {
30
+ /** Prevents the media element from being cast to remote devices via the Remote Playback API. */
31
+ disableRemotePlayback?: boolean;
21
32
  }
22
33
  export interface AudioNavigatorConfiguration {
23
34
  preferences: IAudioPreferences;
24
35
  defaults: IAudioDefaults;
25
- contentProtection?: IContentProtectionConfig;
36
+ contentProtection?: IAudioContentProtectionConfig;
26
37
  keyboardPeripherals?: IKeyboardPeripheralsConfig;
27
38
  }
28
39
  export declare class AudioNavigator extends MediaNavigator implements Configurable<AudioSettings, AudioPreferences> {
@@ -43,6 +54,9 @@ export declare class AudioNavigator extends MediaNavigator implements Configurab
43
54
  private readonly _keyboardPeripheralsManager;
44
55
  private readonly _suspiciousActivityListener;
45
56
  private readonly _keyboardPeripheralListener;
57
+ private readonly _contentProtection;
58
+ /** True while a track transition is in progress; suppresses spurious mid-navigation events. */
59
+ private _isNavigating;
46
60
  constructor(publication: Publication, listeners: AudioNavigatorListeners, initialPosition?: Locator, configuration?: AudioNavigatorConfiguration);
47
61
  get settings(): AudioSettings;
48
62
  get preferencesEditor(): AudioPreferencesEditor;
@@ -90,6 +104,15 @@ export declare class AudioNavigator extends MediaNavigator implements Configurab
90
104
  get isTrackEnd(): boolean;
91
105
  get canGoBackward(): boolean;
92
106
  get canGoForward(): boolean;
107
+ /**
108
+ * The RemotePlayback object for the primary media element.
109
+ * Because the element is never swapped, this reference is stable for the
110
+ * lifetime of the navigator — host apps can store it and call `.prompt()`,
111
+ * `.watchAvailability()`, etc. directly.
112
+ */
113
+ get remotePlayback(): RemotePlayback;
114
+ /** Wires up the optional remotePlaybackStateChanged listener. Called once after initial load. */
115
+ private _setupRemotePlayback;
93
116
  private destroyMediaSession;
94
117
  destroy(): void;
95
118
  }
@@ -1,11 +1,12 @@
1
1
  import { Publication } from "@readium/shared";
2
2
  import { WebAudioEngine } from "./engine/WebAudioEngine";
3
+ import type { IAudioContentProtectionConfig } from "./AudioNavigator";
3
4
  export declare class AudioPoolManager {
4
5
  private readonly pool;
5
6
  private _audioEngine;
6
7
  private readonly _publication;
7
8
  private readonly _supportedAudioTypes;
8
- constructor(audioEngine: WebAudioEngine, publication: Publication);
9
+ constructor(audioEngine: WebAudioEngine, publication: Publication, contentProtection?: IAudioContentProtectionConfig);
9
10
  private detectSupportedAudioTypes;
10
11
  private pickPlayableHref;
11
12
  get audioEngine(): WebAudioEngine;
@@ -17,11 +18,13 @@ export declare class AudioPoolManager {
17
18
  /**
18
19
  * Updates the pool around the given index: ensures elements exist within
19
20
  * the LOWER_BOUNDARY and disposes those beyond the UPPER_BOUNDARY.
21
+ * The current track is excluded — the primary engine element represents it.
20
22
  */
21
23
  private update;
22
24
  /**
23
- * Sets the current audio for playback at the given track index.
24
- * The element is always sourced from the pool never loaded ad-hoc on the engine.
25
+ * Sets the current audio for playback at the given track index by changing
26
+ * the src on the persistent primary element. This preserves the RemotePlayback
27
+ * session and any Web Audio graph connections across track changes.
25
28
  */
26
29
  setCurrentAudio(currentIndex: number, _direction: 'forward' | 'backward'): void;
27
30
  destroy(): void;
@@ -49,10 +49,11 @@ export interface AudioEngine {
49
49
  */
50
50
  off(event: string, callback: (data: any) => void): void;
51
51
  /**
52
- * Sets the media element for playback, enabling use of preloaded elements from the pool.
53
- * @param element The HTML audio element to use for playback.
52
+ * Changes the src of the primary media element without swapping it,
53
+ * preserving the RemotePlayback session and all attached event listeners.
54
+ * @param href The URL of the new audio resource.
54
55
  */
55
- setMediaElement(element: HTMLAudioElement): void;
56
+ changeSrc(href: string): void;
56
57
  /**
57
58
  * Plays the current audio resource.
58
59
  */
@@ -7,8 +7,6 @@ export declare class WebAudioEngine implements AudioEngine {
7
7
  private sourceNode;
8
8
  private gainNode;
9
9
  private listeners;
10
- private currentVolume;
11
- private currentPlaybackRate;
12
10
  private isMutedValue;
13
11
  private isPlayingValue;
14
12
  private isPausedValue;
@@ -48,12 +46,6 @@ export declare class WebAudioEngine implements AudioEngine {
48
46
  * @param callback - callback function to be removed.
49
47
  */
50
48
  off(event: string, callback: EventCallback): void;
51
- private deactivateWebAudio;
52
- /**
53
- * Sets the media element for playback.
54
- * @param element The HTML audio element to use.
55
- */
56
- setMediaElement(element: HTMLAudioElement): void;
57
49
  private ensureAudioContextRunning;
58
50
  private getOrCreateAudioContext;
59
51
  private onTimeUpdate;
@@ -140,6 +132,11 @@ export declare class WebAudioEngine implements AudioEngine {
140
132
  */
141
133
  private activateWebAudio;
142
134
  get isWebAudioActive(): boolean;
135
+ /**
136
+ * Changes the src of the primary media element without swapping the element.
137
+ * Preserves the RemotePlayback session and all attached event listeners.
138
+ */
139
+ changeSrc(href: string): void;
143
140
  /**
144
141
  * Returns the HTML media element used for playback.
145
142
  */