@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/dist/index.js +400 -443
- package/dist/index.umd.cjs +18 -18
- package/package.json +1 -1
- package/src/audio/AudioNavigator.ts +8 -1
- package/src/audio/AudioPoolManager.ts +61 -80
- package/src/audio/engine/AudioEngine.ts +0 -6
- package/src/audio/engine/WebAudioEngine.ts +32 -53
- package/types/src/audio/AudioNavigator.d.ts +1 -0
- package/types/src/audio/AudioPoolManager.d.ts +10 -36
- package/types/src/audio/engine/AudioEngine.d.ts +0 -5
- package/types/src/audio/engine/WebAudioEngine.d.ts +0 -5
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
*
|
|
52
|
-
*
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
this.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
*
|
|
87
|
-
*
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
78
|
+
private update(currentIndex: number): void {
|
|
79
|
+
const items = this._publication.readingOrder.items;
|
|
80
|
+
const keep = new Set<string>();
|
|
93
81
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
*
|
|
129
|
-
*
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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.
|
|
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
|
-
//
|
|
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
|
|
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
|
-
*
|
|
14
|
-
*
|
|
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
|
-
|
|
21
|
-
preload(href: string): void;
|
|
16
|
+
private ensure;
|
|
22
17
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
-
|
|
21
|
+
private update;
|
|
28
22
|
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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.
|