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