@readium/navigator 2.4.0-beta.5 → 2.4.0-beta.7

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.5",
3
+ "version": "2.4.0-beta.7",
4
4
  "type": "module",
5
5
  "description": "Next generation SDK for publications in Web Apps",
6
6
  "author": "readium",
@@ -59,6 +59,7 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
59
59
  private readonly pub: Publication;
60
60
  private positionPollInterval: ReturnType<typeof setInterval> | null = null;
61
61
  private navigationId: number = 0;
62
+ private _playIntent: boolean = false;
62
63
  private listeners: AudioNavigatorListeners;
63
64
  private currentLocation!: Locator;
64
65
 
@@ -326,6 +327,7 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
326
327
  fragments: [`t=${this.duration}`]
327
328
  }));
328
329
  this.listeners.trackEnded(this.currentLocator);
330
+ if (!this.canGoForward) return;
329
331
  await this.nextTrack();
330
332
  if (this._settings.autoPlay) this.play();
331
333
  });
@@ -431,7 +433,11 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
431
433
 
432
434
  const id = ++this.navigationId;
433
435
  const direction: "forward" | "backward" = trackIndex >= this.currentTrackIndex() ? "forward" : "backward";
434
- const wasPlaying = this.isPlaying;
436
+ // Use _playIntent rather than isPlaying — setMediaElement resets the
437
+ // engine's playing flag, so a rapid second go() would see false and
438
+ // never resume playback.
439
+ const wasPlaying = this.isPlaying || this._playIntent;
440
+ this._playIntent = wasPlaying;
435
441
 
436
442
  this.stopPositionPolling();
437
443
  this.pool.setCurrentAudio(trackIndex, direction);
@@ -449,6 +455,7 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
449
455
  }
450
456
 
451
457
  if (wasPlaying) this.play();
458
+ this._playIntent = false;
452
459
 
453
460
  cb(true);
454
461
  } catch (error) {
@@ -1,8 +1,11 @@
1
1
  import { Link, Publication } from "@readium/shared";
2
2
  import { WebAudioEngine } from "./engine/WebAudioEngine";
3
3
 
4
+ const UPPER_BOUNDARY = 1;
5
+ const LOWER_BOUNDARY = 1;
6
+
4
7
  export class AudioPoolManager {
5
- private preloadedElements: Map<string, HTMLAudioElement> = new Map();
8
+ private readonly pool: Map<string, HTMLAudioElement> = new Map();
6
9
  private _audioEngine: WebAudioEngine;
7
10
  private readonly _publication: Publication;
8
11
  private readonly _supportedAudioTypes: Map<string, "probably" | "maybe">;
@@ -48,102 +51,80 @@ export class AudioPoolManager {
48
51
  }
49
52
 
50
53
  /**
51
- * Sets the current audio by href, using preloaded element if available or loading otherwise,
52
- * and preloads adjacent tracks.
53
- * @param href The URL of the audio resource.
54
- * @param publication The publication containing the reading order.
55
- * @param currentIndex The current track index.
56
- * @param direction The navigation direction ('forward' or 'backward').
54
+ * Ensures an audio element exists in the pool for the given href.
55
+ * If one already exists, it is left untouched (preserving its buffered data).
57
56
  */
58
- setCurrentAudio(currentIndex: number, direction: 'forward' | 'backward'): void {
59
- const href = this.pickPlayableHref(this._publication.readingOrder.items[currentIndex]);
60
- // When Web Audio is active, preloaded elements lack crossOrigin="anonymous"
61
- // and cannot be connected to MediaElementAudioSourceNode, so bypass the pool.
62
- const preloadedElement = !this.audioEngine.isWebAudioActive ? this.get(href) : undefined;
63
- if (preloadedElement) {
64
- this.audioEngine.setMediaElement(preloadedElement);
65
- this.clear(href);
66
- } else {
67
- this.clear(href);
68
- this.audioEngine.loadAudio(href);
69
- }
70
- this.preloadAdjacent(currentIndex, direction);
71
- }
72
- preload(href: string): void {
73
- if (this.preloadedElements.has(href)) {
74
- return; // Already preloaded
57
+ private ensure(href: string): HTMLAudioElement {
58
+ let element = this.pool.get(href);
59
+ if (!element) {
60
+ element = document.createElement("audio");
61
+ 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.
64
+ if (this._audioEngine.isWebAudioActive) {
65
+ element.crossOrigin = "anonymous";
66
+ }
67
+ element.src = href;
68
+ element.load();
69
+ this.pool.set(href, element);
75
70
  }
76
-
77
- const audioElement = document.createElement("audio");
78
- audioElement.preload = "auto";
79
- audioElement.src = href;
80
- audioElement.load(); // Start buffering
81
-
82
- this.preloadedElements.set(href, audioElement);
71
+ return element;
83
72
  }
84
73
 
85
74
  /**
86
- * Retrieves a preloaded audio element by URL.
87
- * @param href The URL of the audio resource.
88
- * @returns The preloaded HTMLAudioElement, or undefined if not preloaded.
75
+ * Updates the pool around the given index: ensures elements exist within
76
+ * the LOWER_BOUNDARY and disposes those beyond the UPPER_BOUNDARY.
89
77
  */
90
- get(href: string): HTMLAudioElement | undefined {
91
- return this.preloadedElements.get(href);
92
- }
78
+ private update(currentIndex: number): void {
79
+ const items = this._publication.readingOrder.items;
80
+ const keep = new Set<string>();
93
81
 
94
- /**
95
- * Removes a preloaded element from the pool.
96
- * @param href The URL of the audio resource.
97
- */
98
- clear(href: string): void {
99
- this.preloadedElements.delete(href);
100
- }
101
-
102
- /**
103
- * Preloads the next track in the reading order.
104
- * @param publication The publication containing the reading order.
105
- * @param currentIndex The current track index.
106
- */
107
- preloadNext(currentIndex: number): void {
108
- const nextIndex = currentIndex + 1;
109
- if (nextIndex < this._publication.readingOrder.items.length) {
110
- const nextLink = this._publication.readingOrder.items[nextIndex];
111
- this.preload(this.pickPlayableHref(nextLink));
82
+ for (let j = 0; j < items.length; j++) {
83
+ const href = this.pickPlayableHref(items[j]);
84
+ if (j >= currentIndex - LOWER_BOUNDARY && j <= currentIndex + LOWER_BOUNDARY) {
85
+ this.ensure(href);
86
+ keep.add(href);
87
+ } else if (j >= currentIndex - UPPER_BOUNDARY && j <= currentIndex + UPPER_BOUNDARY) {
88
+ // Between lower and upper: keep if already loaded, don't create
89
+ if (this.pool.has(href)) {
90
+ keep.add(href);
91
+ }
92
+ }
112
93
  }
113
- }
114
94
 
115
- /**
116
- * Preloads the previous track in the reading order.
117
- * @param currentIndex The current track index.
118
- */
119
- preloadPrevious(currentIndex: number): void {
120
- const prevIndex = currentIndex - 1;
121
- if (prevIndex >= 0) {
122
- const prevLink = this._publication.readingOrder.items[prevIndex];
123
- this.preload(this.pickPlayableHref(prevLink));
95
+ // Dispose elements beyond the upper boundary
96
+ for (const [href, element] of this.pool) {
97
+ if (!keep.has(href)) {
98
+ element.removeAttribute("src");
99
+ element.load(); // release network resources
100
+ this.pool.delete(href);
101
+ }
124
102
  }
125
103
  }
126
104
 
127
105
  /**
128
- * Preloads adjacent tracks (previous and next) for smoother navigation.
129
- * @param currentIndex The current track index.
130
- * @param direction The navigation direction ('forward' or 'backward').
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.
131
108
  */
132
- preloadAdjacent(currentIndex: number, direction: 'forward' | 'backward' = 'forward'): void {
133
- if (direction === 'forward') {
134
- this.preloadNext(currentIndex);
135
- this.preloadPrevious(currentIndex);
136
- } else {
137
- this.preloadPrevious(currentIndex);
138
- this.preloadNext(currentIndex);
139
- }
109
+ setCurrentAudio(currentIndex: number, _direction: 'forward' | 'backward'): void {
110
+ const href = this.pickPlayableHref(this._publication.readingOrder.items[currentIndex]);
111
+ const element = this.ensure(href);
112
+
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);
117
+
118
+ // Manage the pool around the new position
119
+ this.update(currentIndex);
140
120
  }
141
121
 
142
- /**
143
- * Destroys the pool by stopping the engine and clearing all preloaded elements.
144
- */
145
122
  destroy(): void {
146
123
  this.audioEngine.stop();
147
- this.preloadedElements.clear();
124
+ for (const [, element] of this.pool) {
125
+ element.removeAttribute("src");
126
+ element.load();
127
+ }
128
+ this.pool.clear();
148
129
  }
149
130
  }
@@ -40,12 +40,6 @@ export interface AudioEngine {
40
40
  */
41
41
  playback: Playback;
42
42
 
43
- /**
44
- * Loads the audio resource at the given URL.
45
- * @param url The URL of the audio resource.
46
- */
47
- loadAudio(url: string): void;
48
-
49
43
  /**
50
44
  * Adds an event listener to the audio engine.
51
45
  * @param event The event name to listen.
@@ -95,47 +95,6 @@ export class WebAudioEngine implements AudioEngine {
95
95
  );
96
96
  }
97
97
 
98
- /**
99
- * Load the audio resource at the given URL.
100
- * @param url The URL of the audio resource.
101
- * */
102
- public loadAudio(url: string): void {
103
- this.isLoadingValue = true;
104
- this.isLoadedValue = false;
105
- this.isPlayingValue = false;
106
- this.isPausedValue = false;
107
-
108
- if (this.webAudioActive) {
109
- this.mediaElement.crossOrigin = "anonymous";
110
- this.mediaElement.src = url;
111
- this.mediaElement.load();
112
- this.mediaElement.playbackRate = this.currentPlaybackRate;
113
-
114
- // If the server doesn't honour the CORS preflight, fall back to a
115
- // non-CORS load and tear down the Web Audio graph so the element
116
- // is never passed to MediaElementAudioSourceNode in a tainted state.
117
- const cleanup = () => {
118
- this.mediaElement.removeEventListener("error", onCORSError);
119
- this.mediaElement.removeEventListener("canplaythrough", onCORSSuccess);
120
- };
121
- const onCORSError = () => {
122
- cleanup();
123
- this.deactivateWebAudio();
124
- this.mediaElement.removeAttribute("crossOrigin");
125
- this.mediaElement.src = url;
126
- this.mediaElement.load();
127
- this.mediaElement.playbackRate = this.currentPlaybackRate;
128
- };
129
- const onCORSSuccess = () => cleanup();
130
- this.mediaElement.addEventListener("error", onCORSError);
131
- this.mediaElement.addEventListener("canplaythrough", onCORSSuccess);
132
- } else {
133
- this.mediaElement.src = url;
134
- this.mediaElement.load();
135
- this.mediaElement.playbackRate = this.currentPlaybackRate;
136
- }
137
- }
138
-
139
98
  private deactivateWebAudio(): void {
140
99
  if (this.worklet) {
141
100
  this.worklet.destroy();
@@ -157,18 +116,7 @@ export class WebAudioEngine implements AudioEngine {
157
116
  * @param element The HTML audio element to use.
158
117
  */
159
118
  public setMediaElement(element: HTMLAudioElement): void {
160
- // Pause the outgoing element before replacing it
161
- this.mediaElement.pause();
162
- this.isPlayingValue = false;
163
- this.isPausedValue = false;
164
-
165
- // Disconnect old source node if it exists
166
- if (this.sourceNode) {
167
- this.sourceNode.disconnect();
168
- this.sourceNode = null;
169
- }
170
-
171
- // Remove old event listeners from current mediaElement
119
+ // Remove listeners BEFORE pausing so the pause doesn't leak through
172
120
  this.mediaElement.removeEventListener("canplaythrough", this.boundOnCanPlayThrough);
173
121
  this.mediaElement.removeEventListener("timeupdate", this.boundOnTimeUpdate);
174
122
  this.mediaElement.removeEventListener("error", this.boundOnError);
@@ -185,6 +133,17 @@ export class WebAudioEngine implements AudioEngine {
185
133
  this.mediaElement.removeEventListener("pause", this.boundOnPause);
186
134
  this.mediaElement.removeEventListener("progress", this.boundOnProgress);
187
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
+
188
147
  // Set new media element
189
148
  this.mediaElement = element;
190
149
 
@@ -209,6 +168,26 @@ export class WebAudioEngine implements AudioEngine {
209
168
  this.mediaElement.volume = this.isMutedValue ? 0 : this.currentVolume;
210
169
  this.mediaElement.playbackRate = this.currentPlaybackRate;
211
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
+
212
191
  // Check if metadata is already loaded (common with preloaded elements)
213
192
  if (this.mediaElement.readyState >= 1) {
214
193
  this.onLoadedMetadata(new Event('loadedmetadata'));
@@ -28,6 +28,7 @@ export declare class AudioNavigator extends MediaNavigator implements Configurab
28
28
  private readonly pub;
29
29
  private positionPollInterval;
30
30
  private navigationId;
31
+ private _playIntent;
31
32
  private listeners;
32
33
  private currentLocation;
33
34
  private _preferences;
@@ -1,7 +1,7 @@
1
1
  import { Publication } from "@readium/shared";
2
2
  import { WebAudioEngine } from "./engine/WebAudioEngine";
3
3
  export declare class AudioPoolManager {
4
- private preloadedElements;
4
+ private readonly pool;
5
5
  private _audioEngine;
6
6
  private readonly _publication;
7
7
  private readonly _supportedAudioTypes;
@@ -10,45 +10,19 @@ export declare class AudioPoolManager {
10
10
  private pickPlayableHref;
11
11
  get audioEngine(): WebAudioEngine;
12
12
  /**
13
- * Sets the current audio by href, using preloaded element if available or loading otherwise,
14
- * and preloads adjacent tracks.
15
- * @param href The URL of the audio resource.
16
- * @param publication The publication containing the reading order.
17
- * @param currentIndex The current track index.
18
- * @param direction The navigation direction ('forward' or 'backward').
13
+ * Ensures an audio element exists in the pool for the given href.
14
+ * If one already exists, it is left untouched (preserving its buffered data).
19
15
  */
20
- setCurrentAudio(currentIndex: number, direction: 'forward' | 'backward'): void;
21
- preload(href: string): void;
16
+ private ensure;
22
17
  /**
23
- * Retrieves a preloaded audio element by URL.
24
- * @param href The URL of the audio resource.
25
- * @returns The preloaded HTMLAudioElement, or undefined if not preloaded.
18
+ * Updates the pool around the given index: ensures elements exist within
19
+ * the LOWER_BOUNDARY and disposes those beyond the UPPER_BOUNDARY.
26
20
  */
27
- get(href: string): HTMLAudioElement | undefined;
21
+ private update;
28
22
  /**
29
- * Removes a preloaded element from the pool.
30
- * @param href The URL of the audio resource.
31
- */
32
- clear(href: string): void;
33
- /**
34
- * Preloads the next track in the reading order.
35
- * @param publication The publication containing the reading order.
36
- * @param currentIndex The current track index.
37
- */
38
- preloadNext(currentIndex: number): void;
39
- /**
40
- * Preloads the previous track in the reading order.
41
- * @param currentIndex The current track index.
42
- */
43
- preloadPrevious(currentIndex: number): void;
44
- /**
45
- * Preloads adjacent tracks (previous and next) for smoother navigation.
46
- * @param currentIndex The current track index.
47
- * @param direction The navigation direction ('forward' or 'backward').
48
- */
49
- preloadAdjacent(currentIndex: number, direction?: 'forward' | 'backward'): void;
50
- /**
51
- * Destroys the pool by stopping the engine and clearing all preloaded elements.
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.
52
25
  */
26
+ setCurrentAudio(currentIndex: number, _direction: 'forward' | 'backward'): void;
53
27
  destroy(): void;
54
28
  }
@@ -36,11 +36,6 @@ export interface AudioEngine {
36
36
  * The current playback state.
37
37
  */
38
38
  playback: Playback;
39
- /**
40
- * Loads the audio resource at the given URL.
41
- * @param url The URL of the audio resource.
42
- */
43
- loadAudio(url: string): void;
44
39
  /**
45
40
  * Adds an event listener to the audio engine.
46
41
  * @param event The event name to listen.
@@ -48,11 +48,6 @@ export declare class WebAudioEngine implements AudioEngine {
48
48
  * @param callback - callback function to be removed.
49
49
  */
50
50
  off(event: string, callback: EventCallback): void;
51
- /**
52
- * Load the audio resource at the given URL.
53
- * @param url The URL of the audio resource.
54
- * */
55
- loadAudio(url: string): void;
56
51
  private deactivateWebAudio;
57
52
  /**
58
53
  * Sets the media element for playback.