@readium/navigator 2.4.0-beta.1 → 2.4.0-beta.10
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 +723 -647
- package/dist/index.umd.cjs +22 -22
- package/package.json +1 -1
- package/src/audio/AudioNavigator.ts +120 -16
- package/src/audio/AudioPoolManager.ts +93 -83
- package/src/audio/engine/AudioEngine.ts +0 -11
- package/src/audio/engine/WebAudioEngine.ts +36 -54
- 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 +18 -2
- package/types/src/audio/AudioPoolManager.d.ts +15 -39
- package/types/src/audio/engine/AudioEngine.d.ts +0 -9
- package/types/src/audio/engine/WebAudioEngine.d.ts +1 -5
- 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,10 +11,15 @@ 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";
|
|
14
18
|
|
|
15
19
|
export interface AudioNavigatorListeners {
|
|
16
20
|
trackLoaded: (media: HTMLMediaElement) => void;
|
|
17
21
|
positionChanged: (locator: Locator) => void;
|
|
22
|
+
timelineItemChanged: (item: TimelineItem | undefined) => void;
|
|
18
23
|
error: (error: any, locator: Locator) => void;
|
|
19
24
|
trackEnded: (locator: Locator) => void;
|
|
20
25
|
play: (locator: Locator) => void;
|
|
@@ -23,17 +28,40 @@ export interface AudioNavigatorListeners {
|
|
|
23
28
|
stalled: (isStalled: boolean) => void;
|
|
24
29
|
seeking: (isSeeking: boolean) => void;
|
|
25
30
|
seekable: (seekable: TimeRanges) => void;
|
|
31
|
+
contentProtection: (type: string, data: SuspiciousActivityEvent) => void;
|
|
32
|
+
peripheral: (data: KeyboardEventData) => void;
|
|
33
|
+
contextMenu: (data: ContextMenuEvent) => void;
|
|
26
34
|
}
|
|
27
35
|
|
|
36
|
+
const defaultListeners = (listeners: Partial<AudioNavigatorListeners>): AudioNavigatorListeners => ({
|
|
37
|
+
trackLoaded: listeners.trackLoaded ?? (() => {}),
|
|
38
|
+
positionChanged: listeners.positionChanged ?? (() => {}),
|
|
39
|
+
timelineItemChanged: listeners.timelineItemChanged ?? (() => {}),
|
|
40
|
+
error: listeners.error ?? (() => {}),
|
|
41
|
+
trackEnded: listeners.trackEnded ?? (() => {}),
|
|
42
|
+
play: listeners.play ?? (() => {}),
|
|
43
|
+
pause: listeners.pause ?? (() => {}),
|
|
44
|
+
metadataLoaded: listeners.metadataLoaded ?? (() => {}),
|
|
45
|
+
stalled: listeners.stalled ?? (() => {}),
|
|
46
|
+
seeking: listeners.seeking ?? (() => {}),
|
|
47
|
+
seekable: listeners.seekable ?? (() => {}),
|
|
48
|
+
contentProtection: listeners.contentProtection ?? (() => {}),
|
|
49
|
+
peripheral: listeners.peripheral ?? (() => {}),
|
|
50
|
+
contextMenu: listeners.contextMenu ?? (() => {}),
|
|
51
|
+
});
|
|
52
|
+
|
|
28
53
|
export interface AudioNavigatorConfiguration {
|
|
29
54
|
preferences: IAudioPreferences;
|
|
30
55
|
defaults: IAudioDefaults;
|
|
56
|
+
contentProtection?: IContentProtectionConfig;
|
|
57
|
+
keyboardPeripherals?: IKeyboardPeripheralsConfig;
|
|
31
58
|
}
|
|
32
59
|
|
|
33
60
|
export class AudioNavigator extends MediaNavigator implements Configurable<AudioSettings, AudioPreferences> {
|
|
34
61
|
private readonly pub: Publication;
|
|
35
62
|
private positionPollInterval: ReturnType<typeof setInterval> | null = null;
|
|
36
63
|
private navigationId: number = 0;
|
|
64
|
+
private _playIntent: boolean = false;
|
|
37
65
|
private listeners: AudioNavigatorListeners;
|
|
38
66
|
private currentLocation!: Locator;
|
|
39
67
|
|
|
@@ -41,7 +69,13 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
41
69
|
private _defaults: AudioDefaults;
|
|
42
70
|
private _settings: AudioSettings;
|
|
43
71
|
private _preferencesEditor: AudioPreferencesEditor | null = null;
|
|
72
|
+
private _mediaSessionEnabled: boolean = false;
|
|
44
73
|
private pool: AudioPoolManager;
|
|
74
|
+
private readonly _navigatorProtector: AudioNavigatorProtector | null = null;
|
|
75
|
+
private _currentTimelineItem: TimelineItem | undefined;
|
|
76
|
+
private readonly _keyboardPeripheralsManager: KeyboardPeripherals | null = null;
|
|
77
|
+
private readonly _suspiciousActivityListener: ((event: Event) => void) | null = null;
|
|
78
|
+
private readonly _keyboardPeripheralListener: ((event: Event) => void) | null = null;
|
|
45
79
|
|
|
46
80
|
constructor(publication: Publication, listeners: AudioNavigatorListeners, initialPosition?: Locator, configuration: AudioNavigatorConfiguration = {
|
|
47
81
|
preferences: {},
|
|
@@ -49,11 +83,11 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
49
83
|
}) {
|
|
50
84
|
super();
|
|
51
85
|
this.pub = publication;
|
|
86
|
+
this.listeners = defaultListeners(listeners);
|
|
52
87
|
|
|
53
88
|
this._preferences = new AudioPreferences(configuration.preferences);
|
|
54
89
|
this._defaults = new AudioDefaults(configuration.defaults);
|
|
55
90
|
this._settings = new AudioSettings(this._preferences, this._defaults);
|
|
56
|
-
this.listeners = listeners;
|
|
57
91
|
|
|
58
92
|
if (initialPosition) {
|
|
59
93
|
this.currentLocation = this.ensureLocatorLocations(initialPosition);
|
|
@@ -81,27 +115,59 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
81
115
|
state: {
|
|
82
116
|
currentTime: initialTime,
|
|
83
117
|
duration: 0,
|
|
84
|
-
volume: this._settings.volume
|
|
85
118
|
} as PlaybackState,
|
|
86
119
|
playWhenReady: false,
|
|
87
120
|
index: trackIndex
|
|
88
121
|
}
|
|
89
122
|
});
|
|
90
123
|
|
|
91
|
-
this.pool = new AudioPoolManager(audioEngine);
|
|
92
|
-
|
|
124
|
+
this.pool = new AudioPoolManager(audioEngine, publication);
|
|
125
|
+
|
|
126
|
+
// Initialize content protection
|
|
127
|
+
const contentProtection = configuration.contentProtection || {};
|
|
128
|
+
const keyboardPeripherals = this.mergeKeyboardPeripherals(
|
|
129
|
+
contentProtection,
|
|
130
|
+
configuration.keyboardPeripherals || []
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
if (contentProtection.disableContextMenu ||
|
|
134
|
+
contentProtection.checkAutomation ||
|
|
135
|
+
contentProtection.checkIFrameEmbedding ||
|
|
136
|
+
contentProtection.monitorDevTools ||
|
|
137
|
+
contentProtection.protectPrinting?.disable ||
|
|
138
|
+
contentProtection.disableDragAndDrop ||
|
|
139
|
+
contentProtection.protectCopy) {
|
|
140
|
+
this._navigatorProtector = new AudioNavigatorProtector(contentProtection);
|
|
141
|
+
this._suspiciousActivityListener = (event: Event) => {
|
|
142
|
+
const { type, ...detail } = (event as CustomEvent).detail;
|
|
143
|
+
if (type === "context_menu") {
|
|
144
|
+
this.listeners.contextMenu(detail as ContextMenuEvent);
|
|
145
|
+
} else {
|
|
146
|
+
this.listeners.contentProtection(type, detail as SuspiciousActivityEvent);
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
window.addEventListener(NAVIGATOR_SUSPICIOUS_ACTIVITY_EVENT, this._suspiciousActivityListener);
|
|
150
|
+
}
|
|
93
151
|
|
|
94
|
-
if (
|
|
95
|
-
this.
|
|
152
|
+
if (keyboardPeripherals.length > 0) {
|
|
153
|
+
this._keyboardPeripheralsManager = new KeyboardPeripherals({ keyboardPeripherals });
|
|
154
|
+
this._keyboardPeripheralListener = (event: Event) => {
|
|
155
|
+
this.listeners.peripheral((event as CustomEvent).detail);
|
|
156
|
+
};
|
|
157
|
+
window.addEventListener(NAVIGATOR_KEYBOARD_PERIPHERAL_EVENT, this._keyboardPeripheralListener);
|
|
96
158
|
}
|
|
97
159
|
|
|
98
|
-
this.
|
|
160
|
+
this.setupEventListeners();
|
|
161
|
+
this.applyPreferences();
|
|
162
|
+
|
|
163
|
+
this.pool.setCurrentAudio(trackIndex, "forward");
|
|
99
164
|
|
|
100
165
|
// Load and seek to initial position, then notify consumer.
|
|
101
166
|
// No cancellation needed here — the constructor runs once.
|
|
102
167
|
this.waitForLoadedAndSeeked(initialTime)
|
|
103
168
|
.then(() => {
|
|
104
169
|
this.listeners.trackLoaded(this.pool.audioEngine.getMediaElement());
|
|
170
|
+
this._notifyTimelineChange(this.currentLocator);
|
|
105
171
|
this.listeners.positionChanged(this.currentLocator);
|
|
106
172
|
})
|
|
107
173
|
.catch(() => {
|
|
@@ -126,7 +192,6 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
126
192
|
}
|
|
127
193
|
|
|
128
194
|
private applyPreferences(): void {
|
|
129
|
-
const oldSettings = this._settings;
|
|
130
195
|
this._settings = new AudioSettings(this._preferences, this._defaults);
|
|
131
196
|
|
|
132
197
|
if (this._preferencesEditor !== null) {
|
|
@@ -136,9 +201,11 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
136
201
|
this.pool.audioEngine.setVolume(this._settings.volume);
|
|
137
202
|
this.pool.audioEngine.setPlaybackRate(this._settings.playbackRate, this._settings.preservePitch);
|
|
138
203
|
|
|
139
|
-
if (this._settings.enableMediaSession && !
|
|
204
|
+
if (this._settings.enableMediaSession && !this._mediaSessionEnabled) {
|
|
205
|
+
this._mediaSessionEnabled = true;
|
|
140
206
|
this.setupMediaSession();
|
|
141
|
-
} else if (!this._settings.enableMediaSession &&
|
|
207
|
+
} else if (!this._settings.enableMediaSession && this._mediaSessionEnabled) {
|
|
208
|
+
this._mediaSessionEnabled = false;
|
|
142
209
|
this.destroyMediaSession();
|
|
143
210
|
}
|
|
144
211
|
}
|
|
@@ -147,6 +214,18 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
147
214
|
return this.pub;
|
|
148
215
|
}
|
|
149
216
|
|
|
217
|
+
get timeline(): Timeline {
|
|
218
|
+
return this.pub.timeline;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private _notifyTimelineChange(locator: Locator): void {
|
|
222
|
+
const item = this.pub.timeline.locate(locator);
|
|
223
|
+
if (item !== this._currentTimelineItem) {
|
|
224
|
+
this._currentTimelineItem = item;
|
|
225
|
+
this.listeners.timelineItemChanged(item);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
150
229
|
private ensureLocatorLocations(locator: Locator): Locator {
|
|
151
230
|
return new Locator({
|
|
152
231
|
...locator,
|
|
@@ -264,6 +343,7 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
264
343
|
fragments: [`t=${this.duration}`]
|
|
265
344
|
}));
|
|
266
345
|
this.listeners.trackEnded(this.currentLocator);
|
|
346
|
+
if (!this.canGoForward) return;
|
|
267
347
|
await this.nextTrack();
|
|
268
348
|
if (this._settings.autoPlay) this.play();
|
|
269
349
|
});
|
|
@@ -293,6 +373,7 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
293
373
|
progression,
|
|
294
374
|
fragments: [`t=${currentTime}`]
|
|
295
375
|
}));
|
|
376
|
+
this._notifyTimelineChange(this.currentLocation);
|
|
296
377
|
this.listeners.positionChanged(this.currentLocation);
|
|
297
378
|
}
|
|
298
379
|
});
|
|
@@ -300,6 +381,7 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
300
381
|
this.pool.audioEngine.on("seeking", () => this.listeners.seeking(true));
|
|
301
382
|
this.pool.audioEngine.on("waiting", () => this.listeners.seeking(true));
|
|
302
383
|
this.pool.audioEngine.on("stalled", () => this.listeners.stalled(true));
|
|
384
|
+
this.pool.audioEngine.on("canplaythrough", () => this.listeners.stalled(false));
|
|
303
385
|
this.pool.audioEngine.on("progress", (seekable: TimeRanges) => this.listeners.seekable(seekable));
|
|
304
386
|
|
|
305
387
|
this.pool.audioEngine.on("loadedmetadata", () => {
|
|
@@ -322,12 +404,14 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
322
404
|
if (!("mediaSession" in navigator)) return;
|
|
323
405
|
const trackIndex = this.currentTrackIndex();
|
|
324
406
|
const track = this.pub.readingOrder.items[trackIndex];
|
|
407
|
+
const cover = this.pub.getCover();
|
|
325
408
|
navigator.mediaSession.metadata = new MediaMetadata({
|
|
326
409
|
title: track?.title || `Track ${trackIndex + 1}`,
|
|
327
410
|
artist: this.pub.metadata.authors
|
|
328
411
|
? this.pub.metadata.authors.items.map((a) => a.name.getTranslation()).join(", ")
|
|
329
412
|
: undefined,
|
|
330
413
|
album: this.pub.metadata.title.getTranslation(),
|
|
414
|
+
artwork: cover ? [{ src: cover.href, type: cover.type }] : undefined,
|
|
331
415
|
});
|
|
332
416
|
}
|
|
333
417
|
|
|
@@ -342,6 +426,7 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
342
426
|
progression,
|
|
343
427
|
fragments: [`t=${currentTime}`]
|
|
344
428
|
}));
|
|
429
|
+
this._notifyTimelineChange(this.currentLocation);
|
|
345
430
|
this.listeners.positionChanged(this.currentLocation);
|
|
346
431
|
}, this._settings.pollInterval);
|
|
347
432
|
}
|
|
@@ -367,17 +452,25 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
367
452
|
|
|
368
453
|
const id = ++this.navigationId;
|
|
369
454
|
const direction: "forward" | "backward" = trackIndex >= this.currentTrackIndex() ? "forward" : "backward";
|
|
370
|
-
|
|
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
|
+
const wasPlaying = this.isPlaying || this._playIntent;
|
|
459
|
+
this._playIntent = wasPlaying;
|
|
371
460
|
|
|
372
461
|
this.stopPositionPolling();
|
|
373
|
-
this.pool.setCurrentAudio(
|
|
462
|
+
this.pool.setCurrentAudio(trackIndex, direction);
|
|
374
463
|
this.currentLocation = locator.copyWithLocations(locator.locations);
|
|
375
464
|
|
|
376
465
|
await this.waitForLoadedAndSeeked(time, id);
|
|
377
466
|
|
|
378
|
-
if (id !== this.navigationId)
|
|
467
|
+
if (id !== this.navigationId) {
|
|
468
|
+
cb(false);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
379
471
|
|
|
380
472
|
this.listeners.trackLoaded(this.pool.audioEngine.getMediaElement());
|
|
473
|
+
this._notifyTimelineChange(this.currentLocator);
|
|
381
474
|
this.listeners.positionChanged(this.currentLocator);
|
|
382
475
|
|
|
383
476
|
if (this._settings.enableMediaSession) {
|
|
@@ -390,6 +483,8 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
390
483
|
} catch (error) {
|
|
391
484
|
console.error("Failed to go to locator:", error);
|
|
392
485
|
cb(false);
|
|
486
|
+
} finally {
|
|
487
|
+
this._playIntent = false;
|
|
393
488
|
}
|
|
394
489
|
}
|
|
395
490
|
|
|
@@ -399,7 +494,8 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
399
494
|
cb(false);
|
|
400
495
|
return;
|
|
401
496
|
}
|
|
402
|
-
const
|
|
497
|
+
const time = link.locator.locations?.time() ?? 0;
|
|
498
|
+
const locator = this.createLocator(trackIndex, time);
|
|
403
499
|
await this.go(locator, _animated, cb);
|
|
404
500
|
}
|
|
405
501
|
|
|
@@ -492,6 +588,14 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
492
588
|
destroy(): void {
|
|
493
589
|
this.stopPositionPolling();
|
|
494
590
|
this.destroyMediaSession();
|
|
591
|
+
if (this._suspiciousActivityListener) {
|
|
592
|
+
window.removeEventListener(NAVIGATOR_SUSPICIOUS_ACTIVITY_EVENT, this._suspiciousActivityListener);
|
|
593
|
+
}
|
|
594
|
+
if (this._keyboardPeripheralListener) {
|
|
595
|
+
window.removeEventListener(NAVIGATOR_KEYBOARD_PERIPHERAL_EVENT, this._keyboardPeripheralListener);
|
|
596
|
+
}
|
|
597
|
+
this._navigatorProtector?.destroy();
|
|
598
|
+
this._keyboardPeripheralsManager?.destroy();
|
|
495
599
|
this.pool.destroy();
|
|
496
600
|
}
|
|
497
601
|
}
|
|
@@ -1,120 +1,130 @@
|
|
|
1
|
-
import { Publication } from "@readium/shared";
|
|
1
|
+
import { Link, Publication } from "@readium/shared";
|
|
2
2
|
import { WebAudioEngine } from "./engine/WebAudioEngine";
|
|
3
3
|
|
|
4
|
+
const UPPER_BOUNDARY = 1;
|
|
5
|
+
const LOWER_BOUNDARY = 1;
|
|
6
|
+
|
|
4
7
|
export class AudioPoolManager {
|
|
5
|
-
private
|
|
8
|
+
private readonly pool: Map<string, HTMLAudioElement> = new Map();
|
|
6
9
|
private _audioEngine: WebAudioEngine;
|
|
10
|
+
private readonly _publication: Publication;
|
|
11
|
+
private readonly _supportedAudioTypes: Map<string, "probably" | "maybe">;
|
|
7
12
|
|
|
8
|
-
constructor(audioEngine: WebAudioEngine) {
|
|
13
|
+
constructor(audioEngine: WebAudioEngine, publication: Publication) {
|
|
9
14
|
this._audioEngine = audioEngine;
|
|
15
|
+
this._publication = publication;
|
|
16
|
+
this._supportedAudioTypes = this.detectSupportedAudioTypes();
|
|
10
17
|
}
|
|
11
18
|
|
|
12
|
-
|
|
13
|
-
|
|
19
|
+
private detectSupportedAudioTypes(): Map<string, "probably" | "maybe"> {
|
|
20
|
+
const audio = document.createElement("audio");
|
|
21
|
+
const unique = new Set<string>();
|
|
22
|
+
for (const link of this._publication.readingOrder.items) {
|
|
23
|
+
if (link.type) unique.add(link.type);
|
|
24
|
+
for (const alt of link.alternates?.items ?? []) {
|
|
25
|
+
if (alt.type) unique.add(alt.type);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const supported = new Map<string, "probably" | "maybe">();
|
|
29
|
+
for (const type of unique) {
|
|
30
|
+
const result = audio.canPlayType(type);
|
|
31
|
+
if (result !== "") supported.set(type, result as "probably" | "maybe");
|
|
32
|
+
}
|
|
33
|
+
return supported;
|
|
14
34
|
}
|
|
15
35
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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);
|
|
36
|
+
private pickPlayableHref(link: Link): string {
|
|
37
|
+
const candidates = [link, ...(link.alternates?.items ?? [])];
|
|
38
|
+
let best: { href: string; confidence: "probably" | "maybe" } | undefined;
|
|
39
|
+
for (const candidate of candidates) {
|
|
40
|
+
if (!candidate.type) continue;
|
|
41
|
+
const confidence = this._supportedAudioTypes.get(candidate.type);
|
|
42
|
+
if (!confidence) continue;
|
|
43
|
+
if (confidence === "probably") return candidate.href;
|
|
44
|
+
if (!best) best = { href: candidate.href, confidence };
|
|
34
45
|
}
|
|
35
|
-
|
|
46
|
+
return best?.href ?? link.href;
|
|
36
47
|
}
|
|
37
|
-
preload(href: string): void {
|
|
38
|
-
if (this.preloadedElements.has(href)) {
|
|
39
|
-
return; // Already preloaded
|
|
40
|
-
}
|
|
41
48
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
audioElement.src = href;
|
|
45
|
-
audioElement.load(); // Start buffering
|
|
46
|
-
|
|
47
|
-
this.preloadedElements.set(href, audioElement);
|
|
49
|
+
get audioEngine(): WebAudioEngine {
|
|
50
|
+
return this._audioEngine;
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
/**
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* @returns The preloaded HTMLAudioElement, or undefined if not preloaded.
|
|
54
|
+
* Ensures an audio element exists in the pool for the given href.
|
|
55
|
+
* If one already exists, it is left untouched (preserving its buffered data).
|
|
54
56
|
*/
|
|
55
|
-
|
|
56
|
-
|
|
57
|
+
private ensure(href: string): HTMLAudioElement {
|
|
58
|
+
let element = this.pool.get(href);
|
|
59
|
+
if (!element) {
|
|
60
|
+
element = document.createElement("audio");
|
|
61
|
+
element.preload = "auto";
|
|
62
|
+
// When Web Audio is active CORS already succeeded, so preload
|
|
63
|
+
// with crossOrigin to avoid a destructive reload at swap time.
|
|
64
|
+
if (this._audioEngine.isWebAudioActive) {
|
|
65
|
+
element.crossOrigin = "anonymous";
|
|
66
|
+
}
|
|
67
|
+
element.src = href;
|
|
68
|
+
element.load();
|
|
69
|
+
this.pool.set(href, element);
|
|
70
|
+
}
|
|
71
|
+
return element;
|
|
57
72
|
}
|
|
58
73
|
|
|
59
74
|
/**
|
|
60
|
-
*
|
|
61
|
-
*
|
|
75
|
+
* Updates the pool around the given index: ensures elements exist within
|
|
76
|
+
* the LOWER_BOUNDARY and disposes those beyond the UPPER_BOUNDARY.
|
|
62
77
|
*/
|
|
63
|
-
|
|
64
|
-
this.
|
|
65
|
-
|
|
78
|
+
private update(currentIndex: number): void {
|
|
79
|
+
const items = this._publication.readingOrder.items;
|
|
80
|
+
const keep = new Set<string>();
|
|
66
81
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
this.preload(nextLink.href);
|
|
82
|
+
for (let j = 0; j < items.length; j++) {
|
|
83
|
+
const href = this.pickPlayableHref(items[j]);
|
|
84
|
+
if (j >= currentIndex - LOWER_BOUNDARY && j <= currentIndex + LOWER_BOUNDARY) {
|
|
85
|
+
this.ensure(href);
|
|
86
|
+
keep.add(href);
|
|
87
|
+
} else if (j >= currentIndex - UPPER_BOUNDARY && j <= currentIndex + UPPER_BOUNDARY) {
|
|
88
|
+
// Between lower and upper: keep if already loaded, don't create
|
|
89
|
+
if (this.pool.has(href)) {
|
|
90
|
+
keep.add(href);
|
|
91
|
+
}
|
|
78
92
|
}
|
|
79
93
|
}
|
|
80
|
-
}
|
|
81
94
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const prevIndex = currentIndex - 1;
|
|
89
|
-
if (prevIndex >= 0) {
|
|
90
|
-
const prevLink = publication.readingOrder.items[prevIndex];
|
|
91
|
-
if (prevLink.href) {
|
|
92
|
-
this.preload(prevLink.href);
|
|
95
|
+
// Dispose elements beyond the upper boundary
|
|
96
|
+
for (const [href, element] of this.pool) {
|
|
97
|
+
if (!keep.has(href)) {
|
|
98
|
+
element.removeAttribute("src");
|
|
99
|
+
element.load(); // release network resources
|
|
100
|
+
this.pool.delete(href);
|
|
93
101
|
}
|
|
94
102
|
}
|
|
95
103
|
}
|
|
96
104
|
|
|
97
105
|
/**
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
* @param currentIndex The current track index.
|
|
101
|
-
* @param direction The navigation direction ('forward' or 'backward').
|
|
106
|
+
* Sets the current audio for playback at the given track index.
|
|
107
|
+
* The element is always sourced from the pool — never loaded ad-hoc on the engine.
|
|
102
108
|
*/
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
109
|
+
setCurrentAudio(currentIndex: number, _direction: 'forward' | 'backward'): void {
|
|
110
|
+
const href = this.pickPlayableHref(this._publication.readingOrder.items[currentIndex]);
|
|
111
|
+
const element = this.ensure(href);
|
|
112
|
+
|
|
113
|
+
this.audioEngine.setMediaElement(element);
|
|
114
|
+
|
|
115
|
+
// Remove from pool so the engine fully owns it and we don't dispose it
|
|
116
|
+
this.pool.delete(href);
|
|
117
|
+
|
|
118
|
+
// Manage the pool around the new position
|
|
119
|
+
this.update(currentIndex);
|
|
111
120
|
}
|
|
112
121
|
|
|
113
|
-
/**
|
|
114
|
-
* Destroys the pool by stopping the engine and clearing all preloaded elements.
|
|
115
|
-
*/
|
|
116
122
|
destroy(): void {
|
|
117
123
|
this.audioEngine.stop();
|
|
118
|
-
this.
|
|
124
|
+
for (const [, element] of this.pool) {
|
|
125
|
+
element.removeAttribute("src");
|
|
126
|
+
element.load();
|
|
127
|
+
}
|
|
128
|
+
this.pool.clear();
|
|
119
129
|
}
|
|
120
130
|
}
|
|
@@ -11,11 +11,6 @@ export interface PlaybackState {
|
|
|
11
11
|
* The duration of the audio resource.
|
|
12
12
|
*/
|
|
13
13
|
duration: number;
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* The volume of the audio resource.
|
|
17
|
-
*/
|
|
18
|
-
volume: number;
|
|
19
14
|
}
|
|
20
15
|
|
|
21
16
|
/**
|
|
@@ -45,12 +40,6 @@ export interface AudioEngine {
|
|
|
45
40
|
*/
|
|
46
41
|
playback: Playback;
|
|
47
42
|
|
|
48
|
-
/**
|
|
49
|
-
* Loads the audio resource at the given URL.
|
|
50
|
-
* @param url The URL of the audio resource.
|
|
51
|
-
*/
|
|
52
|
-
loadAudio(url: string): void;
|
|
53
|
-
|
|
54
43
|
/**
|
|
55
44
|
* Adds an event listener to the audio engine.
|
|
56
45
|
* @param event The event name to listen.
|