@readium/navigator 2.4.0-alpha.8 → 2.4.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1809 -1055
- package/dist/index.umd.cjs +170 -23
- package/package.json +10 -10
- package/src/Navigator.ts +55 -1
- package/src/audio/AudioNavigator.ts +497 -0
- package/src/audio/AudioPoolManager.ts +120 -0
- package/src/audio/engine/AudioEngine.ts +26 -10
- package/src/audio/engine/PreservePitchProcessor.js +149 -0
- package/src/audio/engine/PreservePitchWorklet.ts +79 -0
- package/src/audio/engine/WebAudioEngine.ts +558 -259
- package/src/audio/index.ts +3 -1
- package/src/audio/preferences/AudioDefaults.ts +43 -0
- package/src/audio/preferences/AudioPreferences.ts +54 -0
- package/src/audio/preferences/AudioPreferencesEditor.ts +123 -0
- package/src/audio/preferences/AudioSettings.ts +36 -0
- package/src/audio/preferences/index.ts +4 -0
- package/src/epub/EpubNavigator.ts +2 -2
- package/src/epub/frame/FrameBlobBuilder.ts +33 -84
- package/src/epub/frame/FramePoolManager.ts +23 -18
- package/src/epub/fxl/FXLFrameManager.ts +4 -11
- package/src/epub/fxl/FXLFramePoolManager.ts +22 -26
- package/src/epub/preferences/EpubPreferences.ts +4 -4
- package/src/injection/Injector.ts +5 -5
- package/src/preferences/Configurable.ts +2 -3
- package/src/preferences/PreferencesEditor.ts +1 -1
- package/src/preferences/Types.ts +19 -0
- package/src/webpub/WebPubNavigator.ts +1 -2
- package/src/webpub/preferences/WebPubPreferences.ts +3 -3
- package/types/src/Navigator.d.ts +46 -0
- package/types/src/audio/AudioNavigator.d.ts +79 -0
- package/types/src/audio/AudioPoolManager.d.ts +52 -0
- package/types/src/audio/engine/AudioEngine.d.ts +21 -7
- package/types/src/audio/engine/PreservePitchWorklet.d.ts +18 -0
- package/types/src/audio/engine/WebAudioEngine.d.ts +52 -7
- package/types/src/audio/index.d.ts +2 -0
- package/types/src/audio/preferences/AudioDefaults.d.ts +21 -0
- package/types/src/audio/preferences/AudioPreferences.d.ts +23 -0
- package/types/src/audio/preferences/AudioPreferencesEditor.d.ts +19 -0
- package/types/src/audio/preferences/AudioSettings.d.ts +24 -0
- package/types/src/audio/preferences/index.d.ts +4 -0
- package/types/src/epub/EpubNavigator.d.ts +2 -2
- package/types/src/epub/frame/FrameBlobBuilder.d.ts +3 -6
- package/types/src/epub/fxl/FXLFrameManager.d.ts +0 -2
- package/types/src/epub/preferences/EpubPreferences.d.ts +2 -2
- package/types/src/preferences/Configurable.d.ts +2 -3
- package/types/src/preferences/PreferencesEditor.d.ts +1 -1
- package/types/src/preferences/Types.d.ts +3 -0
- package/types/src/webpub/WebPubNavigator.d.ts +2 -2
- package/types/src/webpub/preferences/WebPubPreferences.d.ts +2 -2
- package/LICENSE +0 -28
- package/src/divina/DivinaNavigator.ts +0 -0
- package/src/divina/index.ts +0 -0
- package/types/src/divina/DivinaNavigator.d.ts +0 -0
- package/types/src/divina/index.d.ts +0 -0
|
@@ -1,286 +1,585 @@
|
|
|
1
1
|
/* Implements the AudioEngine interface using the Web Audio API. */
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
4
|
+
AudioEngine,
|
|
5
|
+
Playback,
|
|
6
|
+
} from "./AudioEngine";
|
|
7
|
+
import { PreservePitchWorklet } from "./PreservePitchWorklet";
|
|
8
|
+
|
|
9
|
+
type EventCallback = (data: any) => void;
|
|
10
|
+
|
|
11
|
+
export class WebAudioEngine implements AudioEngine {
|
|
12
|
+
/* Defines the current playback state. */
|
|
13
|
+
public readonly playback: Playback;
|
|
14
|
+
private audioContext: AudioContext | null = null;
|
|
15
|
+
private mediaElement: HTMLAudioElement;
|
|
16
|
+
private sourceNode: MediaElementAudioSourceNode | null = null;
|
|
17
|
+
private gainNode: GainNode | null = null;
|
|
18
|
+
private listeners: { [event: string]: EventCallback[] } = {};
|
|
19
|
+
private currentPlaybackRate: number = 1;
|
|
20
|
+
private isMutedValue: boolean = false;
|
|
21
|
+
private isPlayingValue: boolean = false;
|
|
22
|
+
private isPausedValue: boolean = false;
|
|
23
|
+
private isLoadingValue: boolean = false;
|
|
24
|
+
private isLoadedValue: boolean = false;
|
|
25
|
+
private isEndedValue: boolean = false;
|
|
26
|
+
private isStoppedValue: boolean = false;
|
|
27
|
+
private worklet: PreservePitchWorklet | null = null;
|
|
28
|
+
private webAudioActive: boolean = false;
|
|
29
|
+
|
|
30
|
+
private readonly boundOnCanPlayThrough = this.onCanPlayThrough.bind(this);
|
|
31
|
+
private readonly boundOnTimeUpdate = this.onTimeUpdate.bind(this);
|
|
32
|
+
private readonly boundOnError = this.onError.bind(this);
|
|
33
|
+
private readonly boundOnEnded = this.onEnded.bind(this);
|
|
34
|
+
private readonly boundOnStalled = this.onStalled.bind(this);
|
|
35
|
+
private readonly boundOnEmptied = this.onEmptied.bind(this);
|
|
36
|
+
private readonly boundOnSuspend = this.onSuspend.bind(this);
|
|
37
|
+
private readonly boundOnWaiting = this.onWaiting.bind(this);
|
|
38
|
+
private readonly boundOnLoadedMetadata = this.onLoadedMetadata.bind(this);
|
|
39
|
+
private readonly boundOnSeeking = this.onSeeking.bind(this);
|
|
40
|
+
private readonly boundOnSeeked = this.onSeeked.bind(this);
|
|
41
|
+
private readonly boundOnPlay = this.onPlay.bind(this);
|
|
42
|
+
private readonly boundOnPlaying = this.onPlaying.bind(this);
|
|
43
|
+
private readonly boundOnPause = this.onPause.bind(this);
|
|
44
|
+
private readonly boundOnProgress = this.onProgress.bind(this);
|
|
45
|
+
|
|
46
|
+
constructor(values: { playback: Playback }) {
|
|
47
|
+
this.playback = values.playback;
|
|
48
|
+
|
|
49
|
+
// crossOrigin is set lazily in activateWebAudio() only when the worklet is needed
|
|
50
|
+
this.mediaElement = document.createElement("audio");
|
|
51
|
+
this.setVolume(this.playback.state.volume);
|
|
52
|
+
|
|
53
|
+
// Event listeners (to report the client app about some async events)
|
|
54
|
+
this.mediaElement.addEventListener("canplaythrough", this.boundOnCanPlayThrough);
|
|
55
|
+
this.mediaElement.addEventListener("timeupdate", this.boundOnTimeUpdate);
|
|
56
|
+
this.mediaElement.addEventListener("error", this.boundOnError);
|
|
57
|
+
this.mediaElement.addEventListener("ended", this.boundOnEnded);
|
|
58
|
+
this.mediaElement.addEventListener("stalled", this.boundOnStalled);
|
|
59
|
+
this.mediaElement.addEventListener("emptied", this.boundOnEmptied);
|
|
60
|
+
this.mediaElement.addEventListener("suspend", this.boundOnSuspend);
|
|
61
|
+
this.mediaElement.addEventListener("waiting", this.boundOnWaiting);
|
|
62
|
+
this.mediaElement.addEventListener("loadedmetadata", this.boundOnLoadedMetadata);
|
|
63
|
+
this.mediaElement.addEventListener("seeking", this.boundOnSeeking);
|
|
64
|
+
this.mediaElement.addEventListener("seeked", this.boundOnSeeked);
|
|
65
|
+
this.mediaElement.addEventListener("play", this.boundOnPlay);
|
|
66
|
+
this.mediaElement.addEventListener("playing", this.boundOnPlaying);
|
|
67
|
+
this.mediaElement.addEventListener("pause", this.boundOnPause);
|
|
68
|
+
this.mediaElement.addEventListener("progress", this.boundOnProgress);
|
|
69
|
+
|
|
70
|
+
//Set the start time
|
|
71
|
+
this.mediaElement.currentTime = this.playback.state.currentTime;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Adds an event listener to the audio engine.
|
|
76
|
+
* @param event - event name to be listened.
|
|
77
|
+
* @param callback - callback function to be called when the event is triggered.
|
|
78
|
+
*/
|
|
79
|
+
public on(event: string, callback: EventCallback) {
|
|
80
|
+
if (!this.listeners[event]) {
|
|
81
|
+
this.listeners[event] = [];
|
|
76
82
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
83
|
+
this.listeners[event].push(callback);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Removes an event listener from the audio engine.
|
|
88
|
+
* @param event - event name to be removed from the listeners.
|
|
89
|
+
* @param callback - callback function to be removed.
|
|
90
|
+
*/
|
|
91
|
+
public off(event: string, callback: EventCallback) {
|
|
92
|
+
if (!this.listeners[event]) return;
|
|
93
|
+
this.listeners[event] = this.listeners[event].filter(
|
|
94
|
+
(cb) => cb !== callback
|
|
95
|
+
);
|
|
96
|
+
}
|
|
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
|
+
|
|
113
|
+
// If the server doesn't honour the CORS preflight, fall back to a
|
|
114
|
+
// non-CORS load and tear down the Web Audio graph so the element
|
|
115
|
+
// is never passed to MediaElementAudioSourceNode in a tainted state.
|
|
116
|
+
const cleanup = () => {
|
|
117
|
+
this.mediaElement.removeEventListener("error", onCORSError);
|
|
118
|
+
this.mediaElement.removeEventListener("canplaythrough", onCORSSuccess);
|
|
119
|
+
};
|
|
120
|
+
const onCORSError = () => {
|
|
121
|
+
cleanup();
|
|
122
|
+
this.deactivateWebAudio();
|
|
123
|
+
this.mediaElement.removeAttribute("crossOrigin");
|
|
124
|
+
this.mediaElement.src = url;
|
|
125
|
+
this.mediaElement.load();
|
|
126
|
+
};
|
|
127
|
+
const onCORSSuccess = () => cleanup();
|
|
128
|
+
this.mediaElement.addEventListener("error", onCORSError);
|
|
129
|
+
this.mediaElement.addEventListener("canplaythrough", onCORSSuccess);
|
|
130
|
+
} else {
|
|
84
131
|
this.mediaElement.src = url;
|
|
85
132
|
this.mediaElement.load();
|
|
86
|
-
|
|
87
|
-
// Create a new source node only if it doesn't exist
|
|
88
|
-
if (!this.sourceNode) {
|
|
89
|
-
this.sourceNode = new MediaElementAudioSourceNode(this.audioContext, {
|
|
90
|
-
mediaElement: this.mediaElement,
|
|
91
|
-
});
|
|
92
|
-
this.sourceNode.connect(this.gainNode);
|
|
93
|
-
this.gainNode.connect(this.audioContext.destination);
|
|
94
|
-
}
|
|
95
133
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private deactivateWebAudio(): void {
|
|
137
|
+
if (this.worklet) {
|
|
138
|
+
this.worklet.destroy();
|
|
139
|
+
this.worklet = null;
|
|
102
140
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
// Continuously track the current time of the media element
|
|
107
|
-
// You can update UI elements or perform other tasks here
|
|
108
|
-
this.emit("timeupdate", this.mediaElement.currentTime);
|
|
141
|
+
if (this.sourceNode) {
|
|
142
|
+
this.sourceNode.disconnect();
|
|
143
|
+
this.sourceNode = null;
|
|
109
144
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
145
|
+
if (this.gainNode) {
|
|
146
|
+
this.gainNode.disconnect();
|
|
147
|
+
this.gainNode = null;
|
|
148
|
+
}
|
|
149
|
+
this.webAudioActive = false;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Sets the media element for playback.
|
|
154
|
+
* @param element The HTML audio element to use.
|
|
155
|
+
*/
|
|
156
|
+
public setMediaElement(element: HTMLAudioElement): void {
|
|
157
|
+
// Pause the outgoing element before replacing it
|
|
158
|
+
this.mediaElement.pause();
|
|
159
|
+
this.isPlayingValue = false;
|
|
160
|
+
this.isPausedValue = false;
|
|
161
|
+
|
|
162
|
+
// Disconnect old source node if it exists
|
|
163
|
+
if (this.sourceNode) {
|
|
164
|
+
this.sourceNode.disconnect();
|
|
165
|
+
this.sourceNode = null;
|
|
116
166
|
}
|
|
117
|
-
|
|
118
|
-
//
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
167
|
+
|
|
168
|
+
// Remove old event listeners from current mediaElement
|
|
169
|
+
this.mediaElement.removeEventListener("canplaythrough", this.boundOnCanPlayThrough);
|
|
170
|
+
this.mediaElement.removeEventListener("timeupdate", this.boundOnTimeUpdate);
|
|
171
|
+
this.mediaElement.removeEventListener("error", this.boundOnError);
|
|
172
|
+
this.mediaElement.removeEventListener("ended", this.boundOnEnded);
|
|
173
|
+
this.mediaElement.removeEventListener("stalled", this.boundOnStalled);
|
|
174
|
+
this.mediaElement.removeEventListener("emptied", this.boundOnEmptied);
|
|
175
|
+
this.mediaElement.removeEventListener("suspend", this.boundOnSuspend);
|
|
176
|
+
this.mediaElement.removeEventListener("waiting", this.boundOnWaiting);
|
|
177
|
+
this.mediaElement.removeEventListener("loadedmetadata", this.boundOnLoadedMetadata);
|
|
178
|
+
this.mediaElement.removeEventListener("seeking", this.boundOnSeeking);
|
|
179
|
+
this.mediaElement.removeEventListener("seeked", this.boundOnSeeked);
|
|
180
|
+
this.mediaElement.removeEventListener("play", this.boundOnPlay);
|
|
181
|
+
this.mediaElement.removeEventListener("playing", this.boundOnPlaying);
|
|
182
|
+
this.mediaElement.removeEventListener("pause", this.boundOnPause);
|
|
183
|
+
this.mediaElement.removeEventListener("progress", this.boundOnProgress);
|
|
184
|
+
|
|
185
|
+
// Set new media element
|
|
186
|
+
this.mediaElement = element;
|
|
187
|
+
|
|
188
|
+
// Add event listeners to new element
|
|
189
|
+
this.mediaElement.addEventListener("canplaythrough", this.boundOnCanPlayThrough);
|
|
190
|
+
this.mediaElement.addEventListener("timeupdate", this.boundOnTimeUpdate);
|
|
191
|
+
this.mediaElement.addEventListener("error", this.boundOnError);
|
|
192
|
+
this.mediaElement.addEventListener("ended", this.boundOnEnded);
|
|
193
|
+
this.mediaElement.addEventListener("stalled", this.boundOnStalled);
|
|
194
|
+
this.mediaElement.addEventListener("emptied", this.boundOnEmptied);
|
|
195
|
+
this.mediaElement.addEventListener("suspend", this.boundOnSuspend);
|
|
196
|
+
this.mediaElement.addEventListener("waiting", this.boundOnWaiting);
|
|
197
|
+
this.mediaElement.addEventListener("loadedmetadata", this.boundOnLoadedMetadata);
|
|
198
|
+
this.mediaElement.addEventListener("seeking", this.boundOnSeeking);
|
|
199
|
+
this.mediaElement.addEventListener("seeked", this.boundOnSeeked);
|
|
200
|
+
this.mediaElement.addEventListener("play", this.boundOnPlay);
|
|
201
|
+
this.mediaElement.addEventListener("playing", this.boundOnPlaying);
|
|
202
|
+
this.mediaElement.addEventListener("pause", this.boundOnPause);
|
|
203
|
+
this.mediaElement.addEventListener("progress", this.boundOnProgress);
|
|
204
|
+
|
|
205
|
+
// Re-apply current volume and playback rate to the new element
|
|
206
|
+
this.mediaElement.volume = this.isMutedValue ? 0 : this.playback.state.volume;
|
|
207
|
+
this.mediaElement.playbackRate = this.currentPlaybackRate;
|
|
208
|
+
|
|
209
|
+
// Check if metadata is already loaded (common with preloaded elements)
|
|
210
|
+
if (this.mediaElement.readyState >= 1) {
|
|
211
|
+
this.onLoadedMetadata(new Event('loadedmetadata'));
|
|
122
212
|
}
|
|
123
|
-
|
|
124
|
-
//
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
this.
|
|
129
|
-
this.emit("ended", null);
|
|
213
|
+
|
|
214
|
+
// Preloaded elements may have already buffered data before being swapped in,
|
|
215
|
+
// so progress events would have fired before we were listening. Emit now if
|
|
216
|
+
// seekable ranges are already available.
|
|
217
|
+
if (this.mediaElement.seekable.length > 0) {
|
|
218
|
+
this.onProgress();
|
|
130
219
|
}
|
|
131
|
-
|
|
132
|
-
//
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
220
|
+
|
|
221
|
+
// Check if the element is already loaded and trigger appropriate events
|
|
222
|
+
if (this.mediaElement.readyState >= 4) {
|
|
223
|
+
this.onCanPlayThrough();
|
|
224
|
+
} else {
|
|
225
|
+
this.isLoadingValue = true;
|
|
226
|
+
this.isLoadedValue = false;
|
|
136
227
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
_locator: Locator
|
|
144
|
-
): Promise<void> {
|
|
145
|
-
// Implementation details.
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Ensure AudioContext is running
|
|
231
|
+
private async ensureAudioContextRunning() {
|
|
232
|
+
if (!this.audioContext) {
|
|
233
|
+
this.audioContext = new AudioContext();
|
|
146
234
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
* Plays the current audio resource.
|
|
150
|
-
*/
|
|
151
|
-
public async play(): Promise<void> {
|
|
152
|
-
await this.ensureAudioContextRunning();
|
|
153
|
-
|
|
154
|
-
if (this.isPlayingValue) {
|
|
155
|
-
this.stop();
|
|
156
|
-
console.error("Audio is already playing");
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
try {
|
|
161
|
-
await this.mediaElement.play();
|
|
162
|
-
this.isPlayingValue = true;
|
|
163
|
-
this.isPausedValue = false;
|
|
164
|
-
} catch (err) {
|
|
165
|
-
console.error("error trying to play media element", err);
|
|
166
|
-
}
|
|
235
|
+
if (this.audioContext.state === "suspended") {
|
|
236
|
+
await this.audioContext.resume();
|
|
167
237
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
this.mediaElement.pause();
|
|
174
|
-
this.isPlayingValue = false;
|
|
175
|
-
this.isPausedValue = true;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private getOrCreateAudioContext(): AudioContext {
|
|
241
|
+
if (!this.audioContext) {
|
|
242
|
+
this.audioContext = new AudioContext();
|
|
176
243
|
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
244
|
+
return this.audioContext;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Event handler for timeupdate
|
|
248
|
+
private onTimeUpdate() {
|
|
249
|
+
// Continuously track the current time of the media element
|
|
250
|
+
// You can update UI elements or perform other tasks here
|
|
251
|
+
this.emit("timeupdate", this.mediaElement.currentTime);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Event handler for canplaythrough
|
|
255
|
+
private onCanPlayThrough() {
|
|
256
|
+
this.isLoadingValue = false;
|
|
257
|
+
this.isLoadedValue = true;
|
|
258
|
+
this.emit("canplaythrough", null);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Event handler for error
|
|
262
|
+
private onError() {
|
|
263
|
+
console.error("Error loading media element");
|
|
264
|
+
this.emit("error", this.mediaElement.error);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Event handle for ended
|
|
268
|
+
private onEnded() {
|
|
269
|
+
this.isPlayingValue = false;
|
|
270
|
+
this.isPausedValue = false;
|
|
271
|
+
this.isEndedValue = true;
|
|
272
|
+
this.emit("ended", null);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private onStalled(event: Event) {
|
|
276
|
+
this.emit("stalled", event);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private onEmptied(event: Event) {
|
|
280
|
+
this.emit("emptied", event);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private onSuspend(event: Event) {
|
|
284
|
+
this.emit("suspend", event);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private onWaiting(event: Event) {
|
|
288
|
+
this.emit("waiting", event);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private onLoadedMetadata(event: Event) {
|
|
292
|
+
this.emit("loadedmetadata", event);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private onSeeking(event: Event) {
|
|
296
|
+
this.emit("seeking", event);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private onSeeked(event: Event) {
|
|
300
|
+
this.emit("seeked", event);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private onPlay() {
|
|
304
|
+
this.emit("play", null);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private onPlaying() {
|
|
308
|
+
this.emit("playing", null);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private onPause() {
|
|
312
|
+
this.emit("pause", null);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private onProgress() {
|
|
316
|
+
this.emit("progress", this.mediaElement.seekable);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Used to emit some events like timeupdate or ended
|
|
320
|
+
private emit(event: string, data: any) {
|
|
321
|
+
if (!this.listeners[event]) return;
|
|
322
|
+
this.listeners[event].forEach((callback) => callback(data));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Plays the current audio resource.
|
|
327
|
+
*/
|
|
328
|
+
public async play(): Promise<void> {
|
|
329
|
+
if (this.isPlayingValue) {
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
try {
|
|
334
|
+
await this.mediaElement.play();
|
|
335
|
+
this.isPlayingValue = true;
|
|
186
336
|
this.isPausedValue = false;
|
|
337
|
+
this.isStoppedValue = false;
|
|
338
|
+
} catch (err: any) {
|
|
339
|
+
// AbortError is expected when load() interrupts a pending play() during navigation
|
|
340
|
+
if (err?.name === "AbortError") return;
|
|
341
|
+
console.error("error trying to play media element", err);
|
|
342
|
+
this.emit("error", err);
|
|
187
343
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Pauses the currently playing audio resource.
|
|
348
|
+
*/
|
|
349
|
+
public pause(): void {
|
|
350
|
+
this.mediaElement.pause();
|
|
351
|
+
this.isPlayingValue = false;
|
|
352
|
+
this.isPausedValue = true;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Stops the currently playing audio resource.
|
|
357
|
+
*/
|
|
358
|
+
public stop(): void {
|
|
359
|
+
// Stop the audio and reset the current time
|
|
360
|
+
this.mediaElement.pause();
|
|
361
|
+
this.mediaElement.currentTime = 0;
|
|
362
|
+
this.isPlayingValue = false;
|
|
363
|
+
this.isPausedValue = false;
|
|
364
|
+
this.isStoppedValue = true;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Adjusts the [volume] of the audio resource.
|
|
369
|
+
* @volume The volume to set, in the range [0, 1].
|
|
370
|
+
*/
|
|
371
|
+
public setVolume(volume: number): void {
|
|
372
|
+
if (volume < 0) {
|
|
373
|
+
this.mediaElement.volume = 0;
|
|
374
|
+
if (this.gainNode) {
|
|
195
375
|
this.gainNode.gain.value = 0;
|
|
196
|
-
this.isMutedValue = true;
|
|
197
|
-
return;
|
|
198
376
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
}
|
|
203
|
-
this.gainNode.gain.value = volume;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* Skips [seconds] either forward or backward if [seconds] is negative.
|
|
208
|
-
*/
|
|
209
|
-
public skip(seconds: number): void {
|
|
210
|
-
if (!this.mediaElement) {
|
|
211
|
-
console.error("Audio not loaded");
|
|
212
|
-
return;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
const newTime = this.mediaElement.currentTime + seconds;
|
|
216
|
-
if (newTime < 0) this.mediaElement.currentTime = 0;
|
|
217
|
-
else if (newTime > this.mediaElement.duration)
|
|
218
|
-
this.mediaElement.currentTime = this.mediaElement.duration;
|
|
219
|
-
else this.mediaElement.currentTime = newTime;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/**
|
|
223
|
-
* Returns de current time in the audio resource.
|
|
224
|
-
*/
|
|
225
|
-
public currentTime(): number {
|
|
226
|
-
return this.mediaElement.currentTime;
|
|
377
|
+
this.isMutedValue = true;
|
|
378
|
+
this.playback.state.volume = 0;
|
|
379
|
+
return;
|
|
227
380
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
*/
|
|
232
|
-
public duration(): number {
|
|
233
|
-
// Implementation details.
|
|
234
|
-
return this.mediaElement.duration;
|
|
381
|
+
if (volume > 1) {
|
|
382
|
+
this.setVolume(volume / 100);
|
|
383
|
+
return;
|
|
235
384
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
*/
|
|
240
|
-
public isPlaying(): boolean {
|
|
241
|
-
return this.isPlayingValue;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/**
|
|
245
|
-
* Returns whether the audio resource is currently paused.
|
|
246
|
-
*/
|
|
247
|
-
public isPaused(): boolean {
|
|
248
|
-
return this.isPausedValue;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
/**
|
|
252
|
-
* Returns whether the audio resource is currently stopped.
|
|
253
|
-
*/
|
|
254
|
-
public isStopped(): boolean {
|
|
255
|
-
return false;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
/**
|
|
259
|
-
* Returns whether the audio resource is currently loading.
|
|
260
|
-
*/
|
|
261
|
-
public isLoading(): boolean {
|
|
262
|
-
return this.isLoadingValue;
|
|
385
|
+
this.mediaElement.volume = volume;
|
|
386
|
+
if (this.gainNode) {
|
|
387
|
+
this.gainNode.gain.value = volume;
|
|
263
388
|
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
389
|
+
this.playback.state.volume = volume;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Skips [seconds] either forward or backward if [seconds] is negative.
|
|
394
|
+
*/
|
|
395
|
+
public skip(seconds: number): void {
|
|
396
|
+
const duration = this.mediaElement.duration;
|
|
397
|
+
if (!isFinite(duration)) return;
|
|
398
|
+
|
|
399
|
+
const newTime = this.mediaElement.currentTime + seconds;
|
|
400
|
+
if (newTime < 0) this.mediaElement.currentTime = 0;
|
|
401
|
+
else if (newTime > duration) this.mediaElement.currentTime = duration;
|
|
402
|
+
else this.mediaElement.currentTime = newTime;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Returns de current time in the audio resource.
|
|
407
|
+
*/
|
|
408
|
+
public currentTime(): number {
|
|
409
|
+
return this.mediaElement.currentTime;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Returns the duration in seconds of the current media element resource.
|
|
414
|
+
*/
|
|
415
|
+
public duration(): number {
|
|
416
|
+
// Implementation details.
|
|
417
|
+
return this.mediaElement.duration;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Returns whether the audio resource is currently playing.
|
|
422
|
+
*/
|
|
423
|
+
public isPlaying(): boolean {
|
|
424
|
+
return this.isPlayingValue;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Returns whether the audio resource is currently paused.
|
|
429
|
+
*/
|
|
430
|
+
public isPaused(): boolean {
|
|
431
|
+
return this.isPausedValue;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Returns whether the audio resource is currently stopped.
|
|
436
|
+
*/
|
|
437
|
+
public isStopped(): boolean {
|
|
438
|
+
return this.isStoppedValue;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Returns whether the audio resource is currently loading.
|
|
443
|
+
*/
|
|
444
|
+
public isLoading(): boolean {
|
|
445
|
+
return this.isLoadingValue;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Returns whether the audio resource is currently loaded.
|
|
450
|
+
*/
|
|
451
|
+
public isLoaded(): boolean {
|
|
452
|
+
return this.isLoadedValue;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Returns whether the audio resource is currently ended.
|
|
457
|
+
*/
|
|
458
|
+
public isEnded(): boolean {
|
|
459
|
+
return this.isEndedValue;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Returns whether the audio resource is currently muted.
|
|
464
|
+
*/
|
|
465
|
+
public isMuted(): boolean {
|
|
466
|
+
return this.isMutedValue;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Sets the playback rate of the audio resource with pitch preservation.
|
|
471
|
+
*/
|
|
472
|
+
public setPlaybackRate(rate: number, preservePitch: boolean): void {
|
|
473
|
+
this.currentPlaybackRate = rate;
|
|
474
|
+
this.mediaElement.playbackRate = rate;
|
|
475
|
+
if (preservePitch) {
|
|
476
|
+
if ('preservesPitch' in this.mediaElement) {
|
|
477
|
+
(this.mediaElement as any).preservesPitch = true;
|
|
478
|
+
} else {
|
|
479
|
+
// Activate Web Audio graph first, then attach the worklet
|
|
480
|
+
this.activateWebAudio().then(() => {
|
|
481
|
+
if (!this.worklet) {
|
|
482
|
+
if (this.sourceNode) {
|
|
483
|
+
this.sourceNode.disconnect();
|
|
484
|
+
this.sourceNode = null;
|
|
485
|
+
}
|
|
486
|
+
PreservePitchWorklet.createWorklet({
|
|
487
|
+
ctx: this.getOrCreateAudioContext(),
|
|
488
|
+
mediaElement: this.mediaElement,
|
|
489
|
+
pitchFactor: 1.0
|
|
490
|
+
}).then(worklet => {
|
|
491
|
+
this.worklet = worklet;
|
|
492
|
+
this.worklet.workletNode!.connect(this.gainNode!);
|
|
493
|
+
this.worklet.updatePitchFactor(1 / rate);
|
|
494
|
+
}).catch(err => {
|
|
495
|
+
console.warn("Failed to create preserve pitch worklet", err);
|
|
496
|
+
});
|
|
497
|
+
} else {
|
|
498
|
+
this.worklet.updatePitchFactor(1 / rate);
|
|
499
|
+
}
|
|
500
|
+
}).catch(err => {
|
|
501
|
+
console.warn("Web Audio unavailable, playing without pitch correction:", err);
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
} else {
|
|
505
|
+
if (this.worklet) {
|
|
506
|
+
this.worklet.destroy();
|
|
507
|
+
this.worklet = null;
|
|
508
|
+
// Worklet is gone; restore the direct source → gain path
|
|
509
|
+
if (this.webAudioActive) {
|
|
510
|
+
this.sourceNode = new MediaElementAudioSourceNode(this.getOrCreateAudioContext(), { mediaElement: this.mediaElement });
|
|
511
|
+
this.sourceNode.connect(this.gainNode!);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
270
514
|
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Activates the Web Audio graph for the current media element.
|
|
519
|
+
* Sets crossOrigin = "anonymous" and reloads so MediaElementAudioSourceNode can be used.
|
|
520
|
+
* No-ops if Web Audio is already active.
|
|
521
|
+
*/
|
|
522
|
+
private async activateWebAudio(): Promise<void> {
|
|
523
|
+
if (this.webAudioActive) return;
|
|
524
|
+
|
|
525
|
+
const src = this.mediaElement.src;
|
|
526
|
+
if (!src) return;
|
|
527
|
+
|
|
528
|
+
const currentTime = this.mediaElement.currentTime;
|
|
529
|
+
const wasPlaying = this.isPlayingValue;
|
|
530
|
+
|
|
531
|
+
if (wasPlaying) {
|
|
532
|
+
this.mediaElement.pause();
|
|
533
|
+
this.isPlayingValue = false;
|
|
277
534
|
}
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
535
|
+
|
|
536
|
+
this.mediaElement.crossOrigin = "anonymous";
|
|
537
|
+
this.mediaElement.src = src;
|
|
538
|
+
this.mediaElement.load();
|
|
539
|
+
|
|
540
|
+
await new Promise<void>((resolve, reject) => {
|
|
541
|
+
const onReady = () => {
|
|
542
|
+
this.mediaElement.removeEventListener("canplaythrough", onReady);
|
|
543
|
+
this.mediaElement.removeEventListener("error", onFail);
|
|
544
|
+
resolve();
|
|
545
|
+
};
|
|
546
|
+
const onFail = () => {
|
|
547
|
+
this.mediaElement.removeEventListener("canplaythrough", onReady);
|
|
548
|
+
this.mediaElement.removeEventListener("error", onFail);
|
|
549
|
+
reject(new Error("Audio reload with CORS failed — server may not send Access-Control-Allow-Origin"));
|
|
550
|
+
};
|
|
551
|
+
this.mediaElement.addEventListener("canplaythrough", onReady);
|
|
552
|
+
this.mediaElement.addEventListener("error", onFail);
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
this.mediaElement.currentTime = currentTime;
|
|
556
|
+
|
|
557
|
+
this.sourceNode = new MediaElementAudioSourceNode(this.getOrCreateAudioContext(), { mediaElement: this.mediaElement });
|
|
558
|
+
|
|
559
|
+
// Create gainNode lazily when Web Audio is activated
|
|
560
|
+
const audioContext = this.getOrCreateAudioContext();
|
|
561
|
+
this.gainNode = audioContext.createGain();
|
|
562
|
+
this.sourceNode.connect(this.gainNode);
|
|
563
|
+
this.gainNode.connect(audioContext.destination);
|
|
564
|
+
|
|
565
|
+
this.webAudioActive = true;
|
|
566
|
+
|
|
567
|
+
if (wasPlaying) {
|
|
568
|
+
await this.ensureAudioContextRunning();
|
|
569
|
+
await this.mediaElement.play();
|
|
570
|
+
this.isPlayingValue = true;
|
|
571
|
+
this.isPausedValue = false;
|
|
284
572
|
}
|
|
285
573
|
}
|
|
286
|
-
|
|
574
|
+
|
|
575
|
+
public get isWebAudioActive(): boolean {
|
|
576
|
+
return this.webAudioActive;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Returns the HTML media element used for playback.
|
|
581
|
+
*/
|
|
582
|
+
public getMediaElement(): HTMLMediaElement {
|
|
583
|
+
return this.mediaElement;
|
|
584
|
+
}
|
|
585
|
+
}
|