@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.
Files changed (54) hide show
  1. package/dist/index.js +1809 -1055
  2. package/dist/index.umd.cjs +170 -23
  3. package/package.json +10 -10
  4. package/src/Navigator.ts +55 -1
  5. package/src/audio/AudioNavigator.ts +497 -0
  6. package/src/audio/AudioPoolManager.ts +120 -0
  7. package/src/audio/engine/AudioEngine.ts +26 -10
  8. package/src/audio/engine/PreservePitchProcessor.js +149 -0
  9. package/src/audio/engine/PreservePitchWorklet.ts +79 -0
  10. package/src/audio/engine/WebAudioEngine.ts +558 -259
  11. package/src/audio/index.ts +3 -1
  12. package/src/audio/preferences/AudioDefaults.ts +43 -0
  13. package/src/audio/preferences/AudioPreferences.ts +54 -0
  14. package/src/audio/preferences/AudioPreferencesEditor.ts +123 -0
  15. package/src/audio/preferences/AudioSettings.ts +36 -0
  16. package/src/audio/preferences/index.ts +4 -0
  17. package/src/epub/EpubNavigator.ts +2 -2
  18. package/src/epub/frame/FrameBlobBuilder.ts +33 -84
  19. package/src/epub/frame/FramePoolManager.ts +23 -18
  20. package/src/epub/fxl/FXLFrameManager.ts +4 -11
  21. package/src/epub/fxl/FXLFramePoolManager.ts +22 -26
  22. package/src/epub/preferences/EpubPreferences.ts +4 -4
  23. package/src/injection/Injector.ts +5 -5
  24. package/src/preferences/Configurable.ts +2 -3
  25. package/src/preferences/PreferencesEditor.ts +1 -1
  26. package/src/preferences/Types.ts +19 -0
  27. package/src/webpub/WebPubNavigator.ts +1 -2
  28. package/src/webpub/preferences/WebPubPreferences.ts +3 -3
  29. package/types/src/Navigator.d.ts +46 -0
  30. package/types/src/audio/AudioNavigator.d.ts +79 -0
  31. package/types/src/audio/AudioPoolManager.d.ts +52 -0
  32. package/types/src/audio/engine/AudioEngine.d.ts +21 -7
  33. package/types/src/audio/engine/PreservePitchWorklet.d.ts +18 -0
  34. package/types/src/audio/engine/WebAudioEngine.d.ts +52 -7
  35. package/types/src/audio/index.d.ts +2 -0
  36. package/types/src/audio/preferences/AudioDefaults.d.ts +21 -0
  37. package/types/src/audio/preferences/AudioPreferences.d.ts +23 -0
  38. package/types/src/audio/preferences/AudioPreferencesEditor.d.ts +19 -0
  39. package/types/src/audio/preferences/AudioSettings.d.ts +24 -0
  40. package/types/src/audio/preferences/index.d.ts +4 -0
  41. package/types/src/epub/EpubNavigator.d.ts +2 -2
  42. package/types/src/epub/frame/FrameBlobBuilder.d.ts +3 -6
  43. package/types/src/epub/fxl/FXLFrameManager.d.ts +0 -2
  44. package/types/src/epub/preferences/EpubPreferences.d.ts +2 -2
  45. package/types/src/preferences/Configurable.d.ts +2 -3
  46. package/types/src/preferences/PreferencesEditor.d.ts +1 -1
  47. package/types/src/preferences/Types.d.ts +3 -0
  48. package/types/src/webpub/WebPubNavigator.d.ts +2 -2
  49. package/types/src/webpub/preferences/WebPubPreferences.d.ts +2 -2
  50. package/LICENSE +0 -28
  51. package/src/divina/DivinaNavigator.ts +0 -0
  52. package/src/divina/index.ts +0 -0
  53. package/types/src/divina/DivinaNavigator.d.ts +0 -0
  54. 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
- AudioEngine,
5
- Playback,
6
- } from "./AudioEngine";
7
-
8
- import { Publication } from "@readium/shared";
9
- import { Locator } from "@readium/shared";
10
-
11
- type EventCallback = (data: any) => void;
12
-
13
- export class WebAudioEngine implements AudioEngine {
14
- /* Defines the current playback state. */
15
- public readonly playback: Playback;
16
- private audioContext: AudioContext;
17
- private mediaElement: HTMLAudioElement;
18
- private sourceNode: MediaElementAudioSourceNode | null = null;
19
- private gainNode: GainNode;
20
- private listeners: { [event: string]: EventCallback[] } = {};
21
- private isMutedValue: boolean = false;
22
- private isPlayingValue: boolean = false;
23
- private isPausedValue: boolean = false;
24
- private isLoadingValue: boolean = false;
25
- private isLoadedValue: boolean = false;
26
- private isEndedValue: boolean = false;
27
-
28
- constructor(values: { playback: Playback; audioContext: AudioContext }) {
29
- this.playback = values.playback;
30
- this.audioContext = values.audioContext;
31
- this.gainNode = this.audioContext?.createGain();
32
- this.setVolume(this.playback.state.volume); // Default initial volume
33
-
34
- // Create an HTML audio element
35
- this.mediaElement = document.createElement("audio");
36
- this.mediaElement.crossOrigin = "use-credentials"; // Optional: Handle cross-origin audio files
37
-
38
- // Event listeners (to report the client app about some async events)
39
- this.mediaElement.addEventListener(
40
- "canplaythrough",
41
- this.onCanPlayThrough.bind(this)
42
- );
43
- this.mediaElement.addEventListener(
44
- "timeupdate",
45
- this.onTimeUpdate.bind(this)
46
- );
47
- this.mediaElement.addEventListener("error", this.onError.bind(this));
48
- this.mediaElement.addEventListener("ended", this.onEnded.bind(this));
49
-
50
- //Set the start time
51
- this.mediaElement.currentTime = this.playback.state.currentTime;
52
- }
53
-
54
- /**
55
- * Adds an event listener to the audio engine.
56
- * @param event - event name to be listened.
57
- * @param callback - callback function to be called when the event is triggered.
58
- */
59
- public on(event: string, callback: EventCallback) {
60
- if (!this.listeners[event]) {
61
- this.listeners[event] = [];
62
- }
63
- this.listeners[event].push(callback);
64
- }
65
-
66
- /**
67
- * Removes an event listener from the audio engine.
68
- * @param event - event name to be removed from the listeners.
69
- * @param callback - callback function to be removed.
70
- */
71
- public off(event: string, callback: EventCallback) {
72
- if (!this.listeners[event]) return;
73
- this.listeners[event] = this.listeners[event].filter(
74
- (cb) => cb !== callback
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
- * Load the audio resource at the given URL.
80
- * @param url The URL of the audio resource.
81
- * */
82
- public loadAudio(url: string): void {
83
- this.isLoadingValue = true;
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
- // Ensure AudioContext is running
98
- private async ensureAudioContextRunning() {
99
- if (this.audioContext.state === "suspended") {
100
- await this.audioContext.resume();
101
- }
134
+ }
135
+
136
+ private deactivateWebAudio(): void {
137
+ if (this.worklet) {
138
+ this.worklet.destroy();
139
+ this.worklet = null;
102
140
  }
103
-
104
- // Event handler for timeupdate
105
- private onTimeUpdate() {
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
- // Event handler for canplaythrough
112
- private onCanPlayThrough() {
113
- this.isLoadingValue = false;
114
- this.isLoadedValue = true;
115
- this.emit("canplaythrough", null);
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
- // Event handler for error
119
- private onError() {
120
- console.error("Error loading media element");
121
- this.emit("error", this.mediaElement.error);
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
- // Event handle for ended
125
- private onEnded() {
126
- this.isPlayingValue = false;
127
- this.isPausedValue = false;
128
- this.isEndedValue = true;
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
- // Used to emit some events like timeupdate or ended
133
- private emit(event: string, data: any) {
134
- if (!this.listeners[event]) return;
135
- this.listeners[event].forEach((callback) => callback(data));
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
- * Plays the audio resource at the given locator.
140
- */
141
- public async playLocator(
142
- _publication: Publication,
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
- * Pauses the currently playing audio resource.
171
- */
172
- public pause(): void {
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
- * Stops the currently playing audio resource.
180
- */
181
- public stop(): void {
182
- // Stop the audio and reset the current time
183
- this.mediaElement.pause();
184
- this.mediaElement.currentTime = 0;
185
- this.isPlayingValue = false;
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
- * Adjusts the [volume] of the audio resource.
191
- * @volume The volume to set, in the range [0, 1].
192
- */
193
- public setVolume(volume: number): void {
194
- if (volume < 0) {
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
- if (volume > 1) {
200
- this.setVolume(volume / 100);
201
- return;
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
- * Returns the duration in seconds of the current media element resource.
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
- * Returns whether the audio resource is currently playing.
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
- * Returns whether the audio resource is currently loaded.
267
- */
268
- public isLoaded(): boolean {
269
- return this.isLoadedValue;
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
- * Returns whether the audio resource is currently ended.
274
- */
275
- public isEnded(): boolean {
276
- return this.isEndedValue;
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
- * Returns whether the audio resource is currently muted.
281
- */
282
- public isMuted(): boolean {
283
- return this.isMutedValue;
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
+ }