@readium/navigator 2.4.0-beta.1 → 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 +771 -663
- package/dist/index.umd.cjs +22 -22
- package/package.json +1 -1
- package/src/audio/AudioNavigator.ts +181 -23
- package/src/audio/AudioPoolManager.ts +103 -82
- package/src/audio/engine/AudioEngine.ts +4 -14
- package/src/audio/engine/WebAudioEngine.ts +21 -137
- package/src/audio/protection/AudioNavigatorProtector.ts +38 -0
- package/src/epub/frame/FrameManager.ts +1 -1
- package/src/epub/fxl/FXLFrameManager.ts +1 -1
- package/src/preferences/Types.ts +2 -2
- package/src/protection/CopyProtector.ts +22 -0
- package/src/protection/DevToolsDetector.ts +1 -0
- package/src/protection/DragAndDropProtector.ts +34 -0
- package/src/protection/NavigatorProtector.ts +3 -3
- package/src/webpub/WebPubFrameManager.ts +1 -1
- package/src/webpub/WebPubNavigator.ts +6 -2
- package/types/src/audio/AudioNavigator.d.ts +42 -3
- package/types/src/audio/AudioPoolManager.d.ts +18 -39
- package/types/src/audio/engine/AudioEngine.d.ts +4 -12
- package/types/src/audio/engine/WebAudioEngine.d.ts +5 -12
- package/types/src/audio/protection/AudioNavigatorProtector.d.ts +8 -0
- package/types/src/protection/CopyProtector.d.ts +8 -0
- package/types/src/protection/DragAndDropProtector.d.ts +10 -0
- package/types/src/protection/NavigatorProtector.d.ts +1 -1
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Link, Locator, LocatorLocations, Publication } from "@readium/shared";
|
|
2
|
-
import { MediaNavigator } from "../Navigator";
|
|
1
|
+
import { Link, Locator, LocatorLocations, Publication, Timeline, TimelineItem } from "@readium/shared";
|
|
2
|
+
import { MediaNavigator, IContentProtectionConfig, IKeyboardPeripheralsConfig } from "../Navigator";
|
|
3
3
|
import { Configurable } from "../preferences";
|
|
4
4
|
import { WebAudioEngine, PlaybackState } from "./engine";
|
|
5
5
|
import {
|
|
@@ -11,29 +11,70 @@ import {
|
|
|
11
11
|
IAudioDefaults
|
|
12
12
|
} from "./preferences";
|
|
13
13
|
import { AudioPoolManager } from "./AudioPoolManager";
|
|
14
|
+
import { ContextMenuEvent, KeyboardEventData, SuspiciousActivityEvent } from "@readium/navigator-html-injectables";
|
|
15
|
+
import { AudioNavigatorProtector } from "./protection/AudioNavigatorProtector";
|
|
16
|
+
import { NAVIGATOR_SUSPICIOUS_ACTIVITY_EVENT } from "../protection/NavigatorProtector";
|
|
17
|
+
import { KeyboardPeripherals, NAVIGATOR_KEYBOARD_PERIPHERAL_EVENT } from "../peripherals/KeyboardPeripherals";
|
|
18
|
+
|
|
19
|
+
export interface AudioMetadata {
|
|
20
|
+
duration: number;
|
|
21
|
+
textTracks: TextTrackList;
|
|
22
|
+
readyState: number;
|
|
23
|
+
networkState: number;
|
|
24
|
+
}
|
|
14
25
|
|
|
15
26
|
export interface AudioNavigatorListeners {
|
|
16
27
|
trackLoaded: (media: HTMLMediaElement) => void;
|
|
17
28
|
positionChanged: (locator: Locator) => void;
|
|
29
|
+
timelineItemChanged: (item: TimelineItem | undefined) => void;
|
|
18
30
|
error: (error: any, locator: Locator) => void;
|
|
19
31
|
trackEnded: (locator: Locator) => void;
|
|
20
32
|
play: (locator: Locator) => void;
|
|
21
33
|
pause: (locator: Locator) => void;
|
|
22
|
-
metadataLoaded: (
|
|
34
|
+
metadataLoaded: (metadata: AudioMetadata) => void;
|
|
23
35
|
stalled: (isStalled: boolean) => void;
|
|
24
36
|
seeking: (isSeeking: boolean) => void;
|
|
25
37
|
seekable: (seekable: TimeRanges) => void;
|
|
38
|
+
contentProtection: (type: string, data: SuspiciousActivityEvent) => void;
|
|
39
|
+
peripheral: (data: KeyboardEventData) => void;
|
|
40
|
+
contextMenu: (data: ContextMenuEvent) => void;
|
|
41
|
+
remotePlaybackStateChanged?: (state: RemotePlaybackState) => void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const defaultListeners = (listeners: Partial<AudioNavigatorListeners>): AudioNavigatorListeners => ({
|
|
45
|
+
trackLoaded: listeners.trackLoaded ?? (() => {}),
|
|
46
|
+
positionChanged: listeners.positionChanged ?? (() => {}),
|
|
47
|
+
timelineItemChanged: listeners.timelineItemChanged ?? (() => {}),
|
|
48
|
+
error: listeners.error ?? (() => {}),
|
|
49
|
+
trackEnded: listeners.trackEnded ?? (() => {}),
|
|
50
|
+
play: listeners.play ?? (() => {}),
|
|
51
|
+
pause: listeners.pause ?? (() => {}),
|
|
52
|
+
metadataLoaded: listeners.metadataLoaded ?? (() => {}),
|
|
53
|
+
stalled: listeners.stalled ?? (() => {}),
|
|
54
|
+
seeking: listeners.seeking ?? (() => {}),
|
|
55
|
+
seekable: listeners.seekable ?? (() => {}),
|
|
56
|
+
contentProtection: listeners.contentProtection ?? (() => {}),
|
|
57
|
+
peripheral: listeners.peripheral ?? (() => {}),
|
|
58
|
+
contextMenu: listeners.contextMenu ?? (() => {}),
|
|
59
|
+
});
|
|
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;
|
|
26
64
|
}
|
|
27
65
|
|
|
28
66
|
export interface AudioNavigatorConfiguration {
|
|
29
67
|
preferences: IAudioPreferences;
|
|
30
68
|
defaults: IAudioDefaults;
|
|
69
|
+
contentProtection?: IAudioContentProtectionConfig;
|
|
70
|
+
keyboardPeripherals?: IKeyboardPeripheralsConfig;
|
|
31
71
|
}
|
|
32
72
|
|
|
33
73
|
export class AudioNavigator extends MediaNavigator implements Configurable<AudioSettings, AudioPreferences> {
|
|
34
74
|
private readonly pub: Publication;
|
|
35
75
|
private positionPollInterval: ReturnType<typeof setInterval> | null = null;
|
|
36
76
|
private navigationId: number = 0;
|
|
77
|
+
private _playIntent: boolean = false;
|
|
37
78
|
private listeners: AudioNavigatorListeners;
|
|
38
79
|
private currentLocation!: Locator;
|
|
39
80
|
|
|
@@ -41,7 +82,16 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
41
82
|
private _defaults: AudioDefaults;
|
|
42
83
|
private _settings: AudioSettings;
|
|
43
84
|
private _preferencesEditor: AudioPreferencesEditor | null = null;
|
|
85
|
+
private _mediaSessionEnabled: boolean = false;
|
|
44
86
|
private pool: AudioPoolManager;
|
|
87
|
+
private readonly _navigatorProtector: AudioNavigatorProtector | null = null;
|
|
88
|
+
private _currentTimelineItem: TimelineItem | undefined;
|
|
89
|
+
private readonly _keyboardPeripheralsManager: KeyboardPeripherals | null = null;
|
|
90
|
+
private readonly _suspiciousActivityListener: ((event: Event) => void) | null = null;
|
|
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;
|
|
45
95
|
|
|
46
96
|
constructor(publication: Publication, listeners: AudioNavigatorListeners, initialPosition?: Locator, configuration: AudioNavigatorConfiguration = {
|
|
47
97
|
preferences: {},
|
|
@@ -49,11 +99,11 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
49
99
|
}) {
|
|
50
100
|
super();
|
|
51
101
|
this.pub = publication;
|
|
102
|
+
this.listeners = defaultListeners(listeners);
|
|
52
103
|
|
|
53
104
|
this._preferences = new AudioPreferences(configuration.preferences);
|
|
54
105
|
this._defaults = new AudioDefaults(configuration.defaults);
|
|
55
106
|
this._settings = new AudioSettings(this._preferences, this._defaults);
|
|
56
|
-
this.listeners = listeners;
|
|
57
107
|
|
|
58
108
|
if (initialPosition) {
|
|
59
109
|
this.currentLocation = this.ensureLocatorLocations(initialPosition);
|
|
@@ -81,30 +131,67 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
81
131
|
state: {
|
|
82
132
|
currentTime: initialTime,
|
|
83
133
|
duration: 0,
|
|
84
|
-
volume: this._settings.volume
|
|
85
134
|
} as PlaybackState,
|
|
86
135
|
playWhenReady: false,
|
|
87
136
|
index: trackIndex
|
|
88
137
|
}
|
|
89
138
|
});
|
|
90
139
|
|
|
91
|
-
this.pool = new AudioPoolManager(audioEngine);
|
|
92
|
-
|
|
140
|
+
this.pool = new AudioPoolManager(audioEngine, publication, configuration.contentProtection);
|
|
141
|
+
|
|
142
|
+
// Initialize content protection
|
|
143
|
+
const contentProtection = configuration.contentProtection || {};
|
|
144
|
+
this._contentProtection = contentProtection;
|
|
145
|
+
const keyboardPeripherals = this.mergeKeyboardPeripherals(
|
|
146
|
+
contentProtection,
|
|
147
|
+
configuration.keyboardPeripherals || []
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
if (contentProtection.disableContextMenu ||
|
|
151
|
+
contentProtection.checkAutomation ||
|
|
152
|
+
contentProtection.checkIFrameEmbedding ||
|
|
153
|
+
contentProtection.monitorDevTools ||
|
|
154
|
+
contentProtection.protectPrinting?.disable ||
|
|
155
|
+
contentProtection.disableDragAndDrop ||
|
|
156
|
+
contentProtection.protectCopy) {
|
|
157
|
+
this._navigatorProtector = new AudioNavigatorProtector(contentProtection);
|
|
158
|
+
this._suspiciousActivityListener = (event: Event) => {
|
|
159
|
+
const { type, ...detail } = (event as CustomEvent).detail;
|
|
160
|
+
if (type === "context_menu") {
|
|
161
|
+
this.listeners.contextMenu(detail as ContextMenuEvent);
|
|
162
|
+
} else {
|
|
163
|
+
this.listeners.contentProtection(type, detail as SuspiciousActivityEvent);
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
window.addEventListener(NAVIGATOR_SUSPICIOUS_ACTIVITY_EVENT, this._suspiciousActivityListener);
|
|
167
|
+
}
|
|
93
168
|
|
|
94
|
-
if (
|
|
95
|
-
this.
|
|
169
|
+
if (keyboardPeripherals.length > 0) {
|
|
170
|
+
this._keyboardPeripheralsManager = new KeyboardPeripherals({ keyboardPeripherals });
|
|
171
|
+
this._keyboardPeripheralListener = (event: Event) => {
|
|
172
|
+
this.listeners.peripheral((event as CustomEvent).detail);
|
|
173
|
+
};
|
|
174
|
+
window.addEventListener(NAVIGATOR_KEYBOARD_PERIPHERAL_EVENT, this._keyboardPeripheralListener);
|
|
96
175
|
}
|
|
97
176
|
|
|
98
|
-
this.
|
|
177
|
+
this.setupEventListeners();
|
|
178
|
+
this.applyPreferences();
|
|
179
|
+
|
|
180
|
+
this._isNavigating = true;
|
|
181
|
+
this.pool.setCurrentAudio(trackIndex, "forward");
|
|
99
182
|
|
|
100
183
|
// Load and seek to initial position, then notify consumer.
|
|
101
184
|
// No cancellation needed here — the constructor runs once.
|
|
102
185
|
this.waitForLoadedAndSeeked(initialTime)
|
|
103
186
|
.then(() => {
|
|
187
|
+
this._isNavigating = false;
|
|
104
188
|
this.listeners.trackLoaded(this.pool.audioEngine.getMediaElement());
|
|
189
|
+
this._notifyTimelineChange(this.currentLocator);
|
|
105
190
|
this.listeners.positionChanged(this.currentLocator);
|
|
191
|
+
this._setupRemotePlayback();
|
|
106
192
|
})
|
|
107
193
|
.catch(() => {
|
|
194
|
+
this._isNavigating = false;
|
|
108
195
|
// Error already forwarded via the error event listener.
|
|
109
196
|
});
|
|
110
197
|
}
|
|
@@ -126,7 +213,6 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
126
213
|
}
|
|
127
214
|
|
|
128
215
|
private applyPreferences(): void {
|
|
129
|
-
const oldSettings = this._settings;
|
|
130
216
|
this._settings = new AudioSettings(this._preferences, this._defaults);
|
|
131
217
|
|
|
132
218
|
if (this._preferencesEditor !== null) {
|
|
@@ -136,9 +222,11 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
136
222
|
this.pool.audioEngine.setVolume(this._settings.volume);
|
|
137
223
|
this.pool.audioEngine.setPlaybackRate(this._settings.playbackRate, this._settings.preservePitch);
|
|
138
224
|
|
|
139
|
-
if (this._settings.enableMediaSession && !
|
|
225
|
+
if (this._settings.enableMediaSession && !this._mediaSessionEnabled) {
|
|
226
|
+
this._mediaSessionEnabled = true;
|
|
140
227
|
this.setupMediaSession();
|
|
141
|
-
} else if (!this._settings.enableMediaSession &&
|
|
228
|
+
} else if (!this._settings.enableMediaSession && this._mediaSessionEnabled) {
|
|
229
|
+
this._mediaSessionEnabled = false;
|
|
142
230
|
this.destroyMediaSession();
|
|
143
231
|
}
|
|
144
232
|
}
|
|
@@ -147,6 +235,18 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
147
235
|
return this.pub;
|
|
148
236
|
}
|
|
149
237
|
|
|
238
|
+
get timeline(): Timeline {
|
|
239
|
+
return this.pub.timeline;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private _notifyTimelineChange(locator: Locator): void {
|
|
243
|
+
const item = this.pub.timeline.locate(locator);
|
|
244
|
+
if (item !== this._currentTimelineItem) {
|
|
245
|
+
this._currentTimelineItem = item;
|
|
246
|
+
this.listeners.timelineItemChanged(item);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
150
250
|
private ensureLocatorLocations(locator: Locator): Locator {
|
|
151
251
|
return new Locator({
|
|
152
252
|
...locator,
|
|
@@ -264,25 +364,30 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
264
364
|
fragments: [`t=${this.duration}`]
|
|
265
365
|
}));
|
|
266
366
|
this.listeners.trackEnded(this.currentLocator);
|
|
367
|
+
if (!this.canGoForward) return;
|
|
267
368
|
await this.nextTrack();
|
|
268
369
|
if (this._settings.autoPlay) this.play();
|
|
269
370
|
});
|
|
270
371
|
|
|
271
372
|
this.pool.audioEngine.on("play", () => {
|
|
373
|
+
if (this._isNavigating) return;
|
|
272
374
|
this.startPositionPolling();
|
|
273
375
|
this.listeners.play(this.currentLocator);
|
|
274
376
|
});
|
|
275
377
|
|
|
276
378
|
this.pool.audioEngine.on("playing", () => {
|
|
379
|
+
if (this._isNavigating) return;
|
|
277
380
|
this.listeners.stalled(false);
|
|
278
381
|
});
|
|
279
382
|
|
|
280
383
|
this.pool.audioEngine.on("pause", () => {
|
|
384
|
+
if (this._isNavigating) return;
|
|
281
385
|
this.stopPositionPolling();
|
|
282
386
|
this.listeners.pause(this.currentLocator);
|
|
283
387
|
});
|
|
284
388
|
|
|
285
389
|
this.pool.audioEngine.on("seeked", () => {
|
|
390
|
+
if (this._isNavigating) return;
|
|
286
391
|
this.listeners.seeking(false);
|
|
287
392
|
if (!this.isPlaying) {
|
|
288
393
|
const currentTime = this.currentTime;
|
|
@@ -293,17 +398,26 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
293
398
|
progression,
|
|
294
399
|
fragments: [`t=${currentTime}`]
|
|
295
400
|
}));
|
|
401
|
+
this._notifyTimelineChange(this.currentLocation);
|
|
296
402
|
this.listeners.positionChanged(this.currentLocation);
|
|
297
403
|
}
|
|
298
404
|
});
|
|
299
405
|
|
|
300
|
-
this.pool.audioEngine.on("seeking", () => this.listeners.seeking(true));
|
|
301
|
-
this.pool.audioEngine.on("waiting", () => this.listeners.seeking(true));
|
|
302
|
-
this.pool.audioEngine.on("stalled", () => this.listeners.stalled(true));
|
|
303
|
-
this.pool.audioEngine.on("
|
|
304
|
-
|
|
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
|
+
|
|
305
412
|
this.pool.audioEngine.on("loadedmetadata", () => {
|
|
306
|
-
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);
|
|
307
421
|
});
|
|
308
422
|
}
|
|
309
423
|
|
|
@@ -322,12 +436,14 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
322
436
|
if (!("mediaSession" in navigator)) return;
|
|
323
437
|
const trackIndex = this.currentTrackIndex();
|
|
324
438
|
const track = this.pub.readingOrder.items[trackIndex];
|
|
439
|
+
const cover = this.pub.getCover();
|
|
325
440
|
navigator.mediaSession.metadata = new MediaMetadata({
|
|
326
441
|
title: track?.title || `Track ${trackIndex + 1}`,
|
|
327
442
|
artist: this.pub.metadata.authors
|
|
328
443
|
? this.pub.metadata.authors.items.map((a) => a.name.getTranslation()).join(", ")
|
|
329
444
|
: undefined,
|
|
330
445
|
album: this.pub.metadata.title.getTranslation(),
|
|
446
|
+
artwork: cover ? [{ src: cover.href, type: cover.type }] : undefined,
|
|
331
447
|
});
|
|
332
448
|
}
|
|
333
449
|
|
|
@@ -342,6 +458,7 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
342
458
|
progression,
|
|
343
459
|
fragments: [`t=${currentTime}`]
|
|
344
460
|
}));
|
|
461
|
+
this._notifyTimelineChange(this.currentLocation);
|
|
345
462
|
this.listeners.positionChanged(this.currentLocation);
|
|
346
463
|
}, this._settings.pollInterval);
|
|
347
464
|
}
|
|
@@ -367,17 +484,24 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
367
484
|
|
|
368
485
|
const id = ++this.navigationId;
|
|
369
486
|
const direction: "forward" | "backward" = trackIndex >= this.currentTrackIndex() ? "forward" : "backward";
|
|
370
|
-
const wasPlaying = this.isPlaying;
|
|
487
|
+
const wasPlaying = this.isPlaying || this._playIntent;
|
|
488
|
+
this._playIntent = wasPlaying;
|
|
371
489
|
|
|
490
|
+
this._isNavigating = true;
|
|
372
491
|
this.stopPositionPolling();
|
|
373
|
-
this.pool.setCurrentAudio(
|
|
492
|
+
this.pool.setCurrentAudio(trackIndex, direction);
|
|
374
493
|
this.currentLocation = locator.copyWithLocations(locator.locations);
|
|
375
494
|
|
|
376
495
|
await this.waitForLoadedAndSeeked(time, id);
|
|
496
|
+
this._isNavigating = false;
|
|
377
497
|
|
|
378
|
-
if (id !== this.navigationId)
|
|
498
|
+
if (id !== this.navigationId) {
|
|
499
|
+
cb(false);
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
379
502
|
|
|
380
503
|
this.listeners.trackLoaded(this.pool.audioEngine.getMediaElement());
|
|
504
|
+
this._notifyTimelineChange(this.currentLocator);
|
|
381
505
|
this.listeners.positionChanged(this.currentLocator);
|
|
382
506
|
|
|
383
507
|
if (this._settings.enableMediaSession) {
|
|
@@ -388,8 +512,11 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
388
512
|
|
|
389
513
|
cb(true);
|
|
390
514
|
} catch (error) {
|
|
515
|
+
this._isNavigating = false;
|
|
391
516
|
console.error("Failed to go to locator:", error);
|
|
392
517
|
cb(false);
|
|
518
|
+
} finally {
|
|
519
|
+
this._playIntent = false;
|
|
393
520
|
}
|
|
394
521
|
}
|
|
395
522
|
|
|
@@ -399,7 +526,8 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
399
526
|
cb(false);
|
|
400
527
|
return;
|
|
401
528
|
}
|
|
402
|
-
const
|
|
529
|
+
const time = link.locator.locations?.time() ?? 0;
|
|
530
|
+
const locator = this.createLocator(trackIndex, time);
|
|
403
531
|
await this.go(locator, _animated, cb);
|
|
404
532
|
}
|
|
405
533
|
|
|
@@ -478,6 +606,28 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
478
606
|
return this.currentTrackIndex() < this.pub.readingOrder.items.length - 1;
|
|
479
607
|
}
|
|
480
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
|
+
|
|
481
631
|
private destroyMediaSession(): void {
|
|
482
632
|
if (!("mediaSession" in navigator)) return;
|
|
483
633
|
navigator.mediaSession.metadata = null;
|
|
@@ -492,6 +642,14 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
492
642
|
destroy(): void {
|
|
493
643
|
this.stopPositionPolling();
|
|
494
644
|
this.destroyMediaSession();
|
|
645
|
+
if (this._suspiciousActivityListener) {
|
|
646
|
+
window.removeEventListener(NAVIGATOR_SUSPICIOUS_ACTIVITY_EVENT, this._suspiciousActivityListener);
|
|
647
|
+
}
|
|
648
|
+
if (this._keyboardPeripheralListener) {
|
|
649
|
+
window.removeEventListener(NAVIGATOR_KEYBOARD_PERIPHERAL_EVENT, this._keyboardPeripheralListener);
|
|
650
|
+
}
|
|
651
|
+
this._navigatorProtector?.destroy();
|
|
652
|
+
this._keyboardPeripheralsManager?.destroy();
|
|
495
653
|
this.pool.destroy();
|
|
496
654
|
}
|
|
497
655
|
}
|
|
@@ -1,120 +1,141 @@
|
|
|
1
|
-
import { Publication } from "@readium/shared";
|
|
1
|
+
import { Link, Publication } from "@readium/shared";
|
|
2
2
|
import { WebAudioEngine } from "./engine/WebAudioEngine";
|
|
3
|
+
import type { IAudioContentProtectionConfig } from "./AudioNavigator";
|
|
4
|
+
|
|
5
|
+
const UPPER_BOUNDARY = 1;
|
|
6
|
+
const LOWER_BOUNDARY = 1;
|
|
3
7
|
|
|
4
8
|
export class AudioPoolManager {
|
|
5
|
-
private
|
|
9
|
+
private readonly pool: Map<string, HTMLAudioElement> = new Map();
|
|
6
10
|
private _audioEngine: WebAudioEngine;
|
|
11
|
+
private readonly _publication: Publication;
|
|
12
|
+
private readonly _supportedAudioTypes: Map<string, "probably" | "maybe">;
|
|
7
13
|
|
|
8
|
-
constructor(audioEngine: WebAudioEngine) {
|
|
14
|
+
constructor(audioEngine: WebAudioEngine, publication: Publication, contentProtection: IAudioContentProtectionConfig = {}) {
|
|
9
15
|
this._audioEngine = audioEngine;
|
|
10
|
-
|
|
16
|
+
this._publication = publication;
|
|
17
|
+
this._supportedAudioTypes = this.detectSupportedAudioTypes();
|
|
11
18
|
|
|
12
|
-
|
|
13
|
-
|
|
19
|
+
if (contentProtection.disableRemotePlayback) {
|
|
20
|
+
this._audioEngine.getMediaElement().disableRemotePlayback = true;
|
|
21
|
+
}
|
|
14
22
|
}
|
|
15
23
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
setCurrentAudio(href: string, publication: Publication, currentIndex: number, direction: 'forward' | 'backward'): void {
|
|
25
|
-
// When Web Audio is active, preloaded elements lack crossOrigin="anonymous"
|
|
26
|
-
// and cannot be connected to MediaElementAudioSourceNode, so bypass the pool.
|
|
27
|
-
const preloadedElement = !this.audioEngine.isWebAudioActive ? this.get(href) : undefined;
|
|
28
|
-
if (preloadedElement) {
|
|
29
|
-
this.audioEngine.setMediaElement(preloadedElement);
|
|
30
|
-
this.clear(href);
|
|
31
|
-
} else {
|
|
32
|
-
this.clear(href);
|
|
33
|
-
this.audioEngine.loadAudio(href);
|
|
24
|
+
private detectSupportedAudioTypes(): Map<string, "probably" | "maybe"> {
|
|
25
|
+
const audio = document.createElement("audio");
|
|
26
|
+
const unique = new Set<string>();
|
|
27
|
+
for (const link of this._publication.readingOrder.items) {
|
|
28
|
+
if (link.type) unique.add(link.type);
|
|
29
|
+
for (const alt of link.alternates?.items ?? []) {
|
|
30
|
+
if (alt.type) unique.add(alt.type);
|
|
31
|
+
}
|
|
34
32
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
return; // Already preloaded
|
|
33
|
+
const supported = new Map<string, "probably" | "maybe">();
|
|
34
|
+
for (const type of unique) {
|
|
35
|
+
const result = audio.canPlayType(type);
|
|
36
|
+
if (result !== "") supported.set(type, result as "probably" | "maybe");
|
|
40
37
|
}
|
|
41
|
-
|
|
42
|
-
const audioElement = document.createElement("audio");
|
|
43
|
-
audioElement.preload = "auto";
|
|
44
|
-
audioElement.src = href;
|
|
45
|
-
audioElement.load(); // Start buffering
|
|
46
|
-
|
|
47
|
-
this.preloadedElements.set(href, audioElement);
|
|
38
|
+
return supported;
|
|
48
39
|
}
|
|
49
40
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
41
|
+
private pickPlayableHref(link: Link): string {
|
|
42
|
+
const candidates = [link, ...(link.alternates?.items ?? [])];
|
|
43
|
+
let best: { href: string; confidence: "probably" | "maybe" } | undefined;
|
|
44
|
+
for (const candidate of candidates) {
|
|
45
|
+
if (!candidate.type) continue;
|
|
46
|
+
const confidence = this._supportedAudioTypes.get(candidate.type);
|
|
47
|
+
if (!confidence) continue;
|
|
48
|
+
if (confidence === "probably") return candidate.href;
|
|
49
|
+
if (!best) best = { href: candidate.href, confidence };
|
|
50
|
+
}
|
|
51
|
+
return best?.href ?? link.href;
|
|
57
52
|
}
|
|
58
53
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
* @param href The URL of the audio resource.
|
|
62
|
-
*/
|
|
63
|
-
clear(href: string): void {
|
|
64
|
-
this.preloadedElements.delete(href);
|
|
54
|
+
get audioEngine(): WebAudioEngine {
|
|
55
|
+
return this._audioEngine;
|
|
65
56
|
}
|
|
66
57
|
|
|
67
58
|
/**
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
* @param currentIndex The current track index.
|
|
59
|
+
* Ensures an audio element exists in the pool for the given href.
|
|
60
|
+
* If one already exists, it is left untouched (preserving its buffered data).
|
|
71
61
|
*/
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
if (
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
62
|
+
private ensure(href: string): HTMLAudioElement {
|
|
63
|
+
let element = this.pool.get(href);
|
|
64
|
+
if (!element) {
|
|
65
|
+
element = document.createElement("audio");
|
|
66
|
+
element.preload = "auto";
|
|
67
|
+
// Match the primary element's CORS mode so cached responses
|
|
68
|
+
// are reusable when changeSrc() loads this href on it.
|
|
69
|
+
if (this._audioEngine.isWebAudioActive) {
|
|
70
|
+
element.crossOrigin = "anonymous";
|
|
78
71
|
}
|
|
72
|
+
element.src = href;
|
|
73
|
+
element.load();
|
|
74
|
+
this.pool.set(href, element);
|
|
79
75
|
}
|
|
76
|
+
return element;
|
|
80
77
|
}
|
|
81
78
|
|
|
82
79
|
/**
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
80
|
+
* Updates the pool around the given index: ensures elements exist within
|
|
81
|
+
* the LOWER_BOUNDARY and disposes those beyond the UPPER_BOUNDARY.
|
|
82
|
+
* The current track is excluded — the primary engine element represents it.
|
|
86
83
|
*/
|
|
87
|
-
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
84
|
+
private update(currentIndex: number): void {
|
|
85
|
+
const items = this._publication.readingOrder.items;
|
|
86
|
+
const keep = new Set<string>();
|
|
87
|
+
|
|
88
|
+
for (let j = 0; j < items.length; j++) {
|
|
89
|
+
if (j === currentIndex) continue; // primary element handles the current track
|
|
90
|
+
const href = this.pickPlayableHref(items[j]);
|
|
91
|
+
if (j >= currentIndex - LOWER_BOUNDARY && j <= currentIndex + LOWER_BOUNDARY) {
|
|
92
|
+
this.ensure(href);
|
|
93
|
+
keep.add(href);
|
|
94
|
+
} else if (j >= currentIndex - UPPER_BOUNDARY && j <= currentIndex + UPPER_BOUNDARY) {
|
|
95
|
+
// Between lower and upper: keep if already loaded, don't create
|
|
96
|
+
if (this.pool.has(href)) {
|
|
97
|
+
keep.add(href);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Dispose elements beyond the upper boundary
|
|
103
|
+
for (const [href, element] of this.pool) {
|
|
104
|
+
if (!keep.has(href)) {
|
|
105
|
+
element.removeAttribute("src");
|
|
106
|
+
element.load(); // release network resources
|
|
107
|
+
this.pool.delete(href);
|
|
93
108
|
}
|
|
94
109
|
}
|
|
95
110
|
}
|
|
96
111
|
|
|
97
112
|
/**
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
* @param direction The navigation direction ('forward' or 'backward').
|
|
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.
|
|
102
116
|
*/
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
this.
|
|
117
|
+
setCurrentAudio(currentIndex: number, _direction: 'forward' | 'backward'): void {
|
|
118
|
+
const href = this.pickPlayableHref(this._publication.readingOrder.items[currentIndex]);
|
|
119
|
+
this.audioEngine.changeSrc(href);
|
|
120
|
+
|
|
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);
|
|
110
127
|
}
|
|
128
|
+
|
|
129
|
+
// Manage the pool around the new position
|
|
130
|
+
this.update(currentIndex);
|
|
111
131
|
}
|
|
112
132
|
|
|
113
|
-
/**
|
|
114
|
-
* Destroys the pool by stopping the engine and clearing all preloaded elements.
|
|
115
|
-
*/
|
|
116
133
|
destroy(): void {
|
|
117
134
|
this.audioEngine.stop();
|
|
118
|
-
this.
|
|
135
|
+
for (const [, element] of this.pool) {
|
|
136
|
+
element.removeAttribute("src");
|
|
137
|
+
element.load();
|
|
138
|
+
}
|
|
139
|
+
this.pool.clear();
|
|
119
140
|
}
|
|
120
141
|
}
|