@readium/navigator 2.4.0-beta.8 → 2.4.0
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 +1307 -1253
- package/dist/index.umd.cjs +196 -133
- package/package.json +2 -2
- package/src/audio/AudioNavigator.ts +178 -52
- package/src/audio/AudioPoolManager.ts +27 -14
- package/src/audio/engine/AudioEngine.ts +4 -3
- package/src/audio/engine/PreservePitchProcessor.js +166 -101
- package/src/audio/engine/PreservePitchWorklet.ts +2 -17
- package/src/audio/engine/WebAudioEngine.ts +138 -160
- package/src/audio/engine/index.ts +2 -2
- package/src/audio/index.ts +3 -4
- package/src/audio/preferences/AudioDefaults.ts +2 -2
- package/src/audio/preferences/AudioPreferences.ts +11 -11
- package/src/audio/preferences/AudioPreferencesEditor.ts +13 -13
- package/src/audio/preferences/AudioSettings.ts +3 -3
- package/src/audio/preferences/index.ts +4 -4
- package/src/audio/protection/AudioNavigatorProtector.ts +4 -4
- package/src/css/index.ts +1 -1
- package/src/epub/EpubNavigator.ts +52 -52
- package/src/epub/css/Properties.ts +15 -15
- package/src/epub/css/ReadiumCSS.ts +43 -43
- package/src/epub/css/index.ts +2 -2
- package/src/epub/frame/FrameBlobBuilder.ts +10 -11
- package/src/epub/frame/FrameComms.ts +1 -1
- package/src/epub/frame/FrameManager.ts +9 -9
- package/src/epub/frame/FramePoolManager.ts +13 -13
- package/src/epub/frame/index.ts +4 -4
- package/src/epub/fxl/FXLCoordinator.ts +3 -3
- package/src/epub/fxl/FXLFrameManager.ts +8 -8
- package/src/epub/fxl/FXLFramePoolManager.ts +13 -13
- package/src/epub/fxl/FXLPeripherals.ts +4 -4
- package/src/epub/fxl/index.ts +5 -5
- package/src/epub/index.ts +5 -5
- package/src/epub/preferences/EpubDefaults.ts +23 -23
- package/src/epub/preferences/EpubPreferences.ts +16 -16
- package/src/epub/preferences/EpubPreferencesEditor.ts +53 -53
- package/src/epub/preferences/EpubSettings.ts +101 -101
- package/src/epub/preferences/index.ts +4 -4
- package/src/helpers/index.ts +2 -2
- package/src/index.ts +8 -9
- package/src/injection/Injector.ts +42 -42
- package/src/injection/epubInjectables.ts +8 -8
- package/src/injection/index.ts +2 -2
- package/src/injection/webpubInjectables.ts +1 -1
- package/src/preferences/Configurable.ts +2 -2
- package/src/preferences/PreferencesEditor.ts +2 -2
- package/src/preferences/guards.ts +2 -2
- package/src/preferences/index.ts +5 -5
- package/src/protection/CopyProtector.ts +5 -1
- package/src/protection/DevToolsDetector.ts +16 -16
- package/src/protection/DragAndDropProtector.ts +14 -1
- package/src/protection/NavigatorProtector.ts +6 -6
- package/src/webpub/WebPubBlobBuilder.ts +1 -1
- package/src/webpub/WebPubFrameManager.ts +8 -8
- package/src/webpub/WebPubFramePoolManager.ts +7 -7
- package/src/webpub/WebPubNavigator.ts +27 -27
- package/src/webpub/css/Properties.ts +3 -3
- package/src/webpub/css/WebPubCSS.ts +11 -11
- package/src/webpub/css/index.ts +2 -2
- package/src/webpub/index.ts +6 -6
- package/src/webpub/preferences/WebPubDefaults.ts +12 -12
- package/src/webpub/preferences/WebPubPreferences.ts +8 -8
- package/src/webpub/preferences/WebPubPreferencesEditor.ts +31 -31
- package/src/webpub/preferences/WebPubSettings.ts +45 -45
- package/src/webpub/preferences/index.ts +4 -4
- package/types/src/audio/AudioNavigator.d.ts +39 -9
- package/types/src/audio/AudioPoolManager.d.ts +7 -4
- package/types/src/audio/engine/AudioEngine.d.ts +4 -3
- package/types/src/audio/engine/PreservePitchWorklet.d.ts +1 -4
- package/types/src/audio/engine/WebAudioEngine.d.ts +15 -9
- package/types/src/audio/engine/index.d.ts +2 -2
- package/types/src/audio/index.d.ts +3 -4
- package/types/src/audio/preferences/AudioPreferences.d.ts +9 -9
- package/types/src/audio/preferences/AudioPreferencesEditor.d.ts +4 -4
- package/types/src/audio/preferences/AudioSettings.d.ts +3 -3
- package/types/src/audio/preferences/index.d.ts +4 -4
- package/types/src/audio/protection/AudioNavigatorProtector.d.ts +2 -2
- package/types/src/css/index.d.ts +1 -1
- package/types/src/epub/EpubNavigator.d.ts +11 -11
- package/types/src/epub/css/Properties.d.ts +2 -2
- package/types/src/epub/css/ReadiumCSS.d.ts +3 -3
- package/types/src/epub/css/index.d.ts +2 -2
- package/types/src/epub/frame/FrameBlobBuilder.d.ts +1 -1
- package/types/src/epub/frame/FrameComms.d.ts +1 -1
- package/types/src/epub/frame/FrameManager.d.ts +2 -2
- package/types/src/epub/frame/FramePoolManager.d.ts +3 -3
- package/types/src/epub/frame/index.d.ts +4 -4
- package/types/src/epub/fxl/FXLFrameManager.d.ts +3 -3
- package/types/src/epub/fxl/FXLFramePoolManager.d.ts +5 -5
- package/types/src/epub/fxl/FXLPeripherals.d.ts +2 -2
- package/types/src/epub/fxl/index.d.ts +5 -5
- package/types/src/epub/index.d.ts +5 -5
- package/types/src/epub/preferences/EpubDefaults.d.ts +1 -1
- package/types/src/epub/preferences/EpubPreferences.d.ts +2 -2
- package/types/src/epub/preferences/EpubPreferencesEditor.d.ts +5 -5
- package/types/src/epub/preferences/EpubSettings.d.ts +4 -4
- package/types/src/epub/preferences/index.d.ts +4 -4
- package/types/src/helpers/index.d.ts +2 -2
- package/types/src/index.d.ts +8 -9
- package/types/src/injection/Injector.d.ts +1 -1
- package/types/src/injection/epubInjectables.d.ts +1 -1
- package/types/src/injection/index.d.ts +2 -2
- package/types/src/preferences/Configurable.d.ts +1 -1
- package/types/src/preferences/PreferencesEditor.d.ts +1 -1
- package/types/src/preferences/guards.d.ts +1 -1
- package/types/src/preferences/index.d.ts +5 -5
- package/types/src/protection/CopyProtector.d.ts +1 -0
- package/types/src/protection/DragAndDropProtector.d.ts +2 -0
- package/types/src/protection/NavigatorProtector.d.ts +1 -1
- package/types/src/webpub/WebPubBlobBuilder.d.ts +1 -1
- package/types/src/webpub/WebPubFrameManager.d.ts +2 -2
- package/types/src/webpub/WebPubFramePoolManager.d.ts +3 -3
- package/types/src/webpub/WebPubNavigator.d.ts +10 -10
- package/types/src/webpub/css/Properties.d.ts +2 -2
- package/types/src/webpub/css/WebPubCSS.d.ts +2 -2
- package/types/src/webpub/css/index.d.ts +2 -2
- package/types/src/webpub/index.d.ts +6 -6
- package/types/src/webpub/preferences/WebPubDefaults.d.ts +1 -1
- package/types/src/webpub/preferences/WebPubPreferences.d.ts +2 -2
- package/types/src/webpub/preferences/WebPubPreferencesEditor.d.ts +5 -5
- package/types/src/webpub/preferences/WebPubSettings.d.ts +4 -4
- package/types/src/webpub/preferences/index.d.ts +4 -4
- package/src/Timeline.ts +0 -58
- package/src/audio/AudioTimeline.ts +0 -156
- package/types/src/Timeline.d.ts +0 -48
- package/types/src/audio/AudioTimeline.d.ts +0 -34
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@readium/navigator",
|
|
3
|
-
"version": "2.4.0
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Next generation SDK for publications in Web Apps",
|
|
6
6
|
"author": "readium",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"generate:css-selector": "node scripts/generate-css-selector.js"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
|
-
"@readium/css": "^2.0.
|
|
53
|
+
"@readium/css": "^2.0.1",
|
|
54
54
|
"@readium/navigator-html-injectables": "workspace:*",
|
|
55
55
|
"@readium/shared": "workspace:*",
|
|
56
56
|
"@types/path-browserify": "^1.0.3",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { Link, Locator, LocatorLocations, Publication } from "@readium/shared";
|
|
2
|
-
import { MediaNavigator, IContentProtectionConfig, IKeyboardPeripheralsConfig } from "../Navigator";
|
|
3
|
-
import { Configurable } from "../preferences";
|
|
4
|
-
import { WebAudioEngine, PlaybackState } from "./engine";
|
|
1
|
+
import { Link, Locator, LocatorLocations, Publication, Timeline, TimelineItem } from "@readium/shared";
|
|
2
|
+
import { MediaNavigator, IContentProtectionConfig, IKeyboardPeripheralsConfig } from "../Navigator.ts";
|
|
3
|
+
import { Configurable } from "../preferences/Configurable.ts";
|
|
4
|
+
import { WebAudioEngine, PlaybackState } from "./engine/index.ts";
|
|
5
5
|
import {
|
|
6
6
|
AudioPreferences,
|
|
7
7
|
AudioDefaults,
|
|
@@ -9,33 +9,42 @@ import {
|
|
|
9
9
|
AudioPreferencesEditor,
|
|
10
10
|
IAudioPreferences,
|
|
11
11
|
IAudioDefaults
|
|
12
|
-
} from "./preferences";
|
|
13
|
-
import { AudioPoolManager } from "./AudioPoolManager";
|
|
14
|
-
import { AudioTimeline } from "./AudioTimeline";
|
|
12
|
+
} from "./preferences/index.ts";
|
|
13
|
+
import { AudioPoolManager } from "./AudioPoolManager.ts";
|
|
15
14
|
import { ContextMenuEvent, KeyboardEventData, SuspiciousActivityEvent } from "@readium/navigator-html-injectables";
|
|
16
|
-
import { AudioNavigatorProtector } from "./protection/AudioNavigatorProtector";
|
|
17
|
-
import { NAVIGATOR_SUSPICIOUS_ACTIVITY_EVENT } from "../protection/NavigatorProtector";
|
|
18
|
-
import { KeyboardPeripherals, NAVIGATOR_KEYBOARD_PERIPHERAL_EVENT } from "../peripherals/KeyboardPeripherals";
|
|
15
|
+
import { AudioNavigatorProtector } from "./protection/AudioNavigatorProtector.ts";
|
|
16
|
+
import { NAVIGATOR_SUSPICIOUS_ACTIVITY_EVENT } from "../protection/NavigatorProtector.ts";
|
|
17
|
+
import { KeyboardPeripherals, NAVIGATOR_KEYBOARD_PERIPHERAL_EVENT } from "../peripherals/KeyboardPeripherals.ts";
|
|
18
|
+
|
|
19
|
+
export interface AudioMetadata {
|
|
20
|
+
duration: number;
|
|
21
|
+
textTracks: TextTrackList;
|
|
22
|
+
readyState: number;
|
|
23
|
+
networkState: number;
|
|
24
|
+
}
|
|
19
25
|
|
|
20
26
|
export interface AudioNavigatorListeners {
|
|
21
27
|
trackLoaded: (media: HTMLMediaElement) => void;
|
|
22
28
|
positionChanged: (locator: Locator) => void;
|
|
29
|
+
timelineItemChanged: (item: TimelineItem | undefined) => void;
|
|
23
30
|
error: (error: any, locator: Locator) => void;
|
|
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 => ({
|
|
37
45
|
trackLoaded: listeners.trackLoaded ?? (() => {}),
|
|
38
46
|
positionChanged: listeners.positionChanged ?? (() => {}),
|
|
47
|
+
timelineItemChanged: listeners.timelineItemChanged ?? (() => {}),
|
|
39
48
|
error: listeners.error ?? (() => {}),
|
|
40
49
|
trackEnded: listeners.trackEnded ?? (() => {}),
|
|
41
50
|
play: listeners.play ?? (() => {}),
|
|
@@ -47,12 +56,18 @@ const defaultListeners = (listeners: Partial<AudioNavigatorListeners>): AudioNav
|
|
|
47
56
|
contentProtection: listeners.contentProtection ?? (() => {}),
|
|
48
57
|
peripheral: listeners.peripheral ?? (() => {}),
|
|
49
58
|
contextMenu: listeners.contextMenu ?? (() => {}),
|
|
59
|
+
remotePlaybackStateChanged: listeners.remotePlaybackStateChanged ?? (() => {}),
|
|
50
60
|
});
|
|
51
61
|
|
|
62
|
+
export interface IAudioContentProtectionConfig extends IContentProtectionConfig {
|
|
63
|
+
/** Prevents the media element from being cast to remote devices via the Remote Playback API. */
|
|
64
|
+
disableRemotePlayback?: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
52
67
|
export interface AudioNavigatorConfiguration {
|
|
53
68
|
preferences: IAudioPreferences;
|
|
54
69
|
defaults: IAudioDefaults;
|
|
55
|
-
contentProtection?:
|
|
70
|
+
contentProtection?: IAudioContentProtectionConfig;
|
|
56
71
|
keyboardPeripherals?: IKeyboardPeripheralsConfig;
|
|
57
72
|
}
|
|
58
73
|
|
|
@@ -71,10 +86,16 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
71
86
|
private _mediaSessionEnabled: boolean = false;
|
|
72
87
|
private pool: AudioPoolManager;
|
|
73
88
|
private readonly _navigatorProtector: AudioNavigatorProtector | null = null;
|
|
74
|
-
private
|
|
89
|
+
private _currentTimelineItem: TimelineItem | undefined;
|
|
75
90
|
private readonly _keyboardPeripheralsManager: KeyboardPeripherals | null = null;
|
|
76
91
|
private readonly _suspiciousActivityListener: ((event: Event) => void) | null = null;
|
|
77
92
|
private readonly _keyboardPeripheralListener: ((event: Event) => void) | null = null;
|
|
93
|
+
private readonly _contentProtection: IAudioContentProtectionConfig;
|
|
94
|
+
/** True while a track transition is in progress; suppresses spurious mid-navigation events. */
|
|
95
|
+
private _isNavigating: boolean = false;
|
|
96
|
+
private _isStalled: boolean = false;
|
|
97
|
+
private _stalledWatchdog: ReturnType<typeof setInterval> | null = null;
|
|
98
|
+
private _stalledCheckTime: number = 0;
|
|
78
99
|
|
|
79
100
|
constructor(publication: Publication, listeners: AudioNavigatorListeners, initialPosition?: Locator, configuration: AudioNavigatorConfiguration = {
|
|
80
101
|
preferences: {},
|
|
@@ -82,13 +103,16 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
82
103
|
}) {
|
|
83
104
|
super();
|
|
84
105
|
this.pub = publication;
|
|
85
|
-
this._timeline = new AudioTimeline(publication);
|
|
86
106
|
this.listeners = defaultListeners(listeners);
|
|
87
107
|
|
|
88
108
|
this._preferences = new AudioPreferences(configuration.preferences);
|
|
89
109
|
this._defaults = new AudioDefaults(configuration.defaults);
|
|
90
110
|
this._settings = new AudioSettings(this._preferences, this._defaults);
|
|
91
111
|
|
|
112
|
+
if (publication.readingOrder.items.length === 0) {
|
|
113
|
+
throw new Error("AudioNavigator: publication has an empty reading order");
|
|
114
|
+
}
|
|
115
|
+
|
|
92
116
|
if (initialPosition) {
|
|
93
117
|
this.currentLocation = this.ensureLocatorLocations(initialPosition);
|
|
94
118
|
} else {
|
|
@@ -98,7 +122,7 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
98
122
|
type: firstLink.type || "audio/mpeg",
|
|
99
123
|
title: firstLink.title,
|
|
100
124
|
locations: new LocatorLocations({
|
|
101
|
-
position:
|
|
125
|
+
position: 1,
|
|
102
126
|
progression: 0,
|
|
103
127
|
totalProgression: 0,
|
|
104
128
|
fragments: ["t=0"]
|
|
@@ -108,6 +132,9 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
108
132
|
|
|
109
133
|
const initialHref = this.currentLocation.href.split("#")[0];
|
|
110
134
|
const trackIndex = this.hrefToTrackIndex(initialHref);
|
|
135
|
+
if (trackIndex === -1) {
|
|
136
|
+
throw new Error(`AudioNavigator: initial href "${ initialHref }" not found in reading order`);
|
|
137
|
+
}
|
|
111
138
|
const initialTime = this.currentLocation.locations?.time() || 0;
|
|
112
139
|
|
|
113
140
|
const audioEngine = new WebAudioEngine({
|
|
@@ -121,10 +148,11 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
121
148
|
}
|
|
122
149
|
});
|
|
123
150
|
|
|
124
|
-
this.pool = new AudioPoolManager(audioEngine, publication);
|
|
151
|
+
this.pool = new AudioPoolManager(audioEngine, publication, configuration.contentProtection);
|
|
125
152
|
|
|
126
153
|
// Initialize content protection
|
|
127
154
|
const contentProtection = configuration.contentProtection || {};
|
|
155
|
+
this._contentProtection = contentProtection;
|
|
128
156
|
const keyboardPeripherals = this.mergeKeyboardPeripherals(
|
|
129
157
|
contentProtection,
|
|
130
158
|
configuration.keyboardPeripherals || []
|
|
@@ -158,19 +186,27 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
158
186
|
}
|
|
159
187
|
|
|
160
188
|
this.setupEventListeners();
|
|
161
|
-
this.applyPreferences();
|
|
162
189
|
|
|
190
|
+
this._isNavigating = true;
|
|
163
191
|
this.pool.setCurrentAudio(trackIndex, "forward");
|
|
164
192
|
|
|
193
|
+
// applyPreferences() must come after setCurrentAudio() so that the src
|
|
194
|
+
// is already set on the media element when setPlaybackRate() tries to
|
|
195
|
+
// activate the Web Audio graph for the preservePitch polyfill path.
|
|
196
|
+
this.applyPreferences();
|
|
197
|
+
|
|
165
198
|
// Load and seek to initial position, then notify consumer.
|
|
166
199
|
// No cancellation needed here — the constructor runs once.
|
|
167
200
|
this.waitForLoadedAndSeeked(initialTime)
|
|
168
201
|
.then(() => {
|
|
202
|
+
this._isNavigating = false;
|
|
169
203
|
this.listeners.trackLoaded(this.pool.audioEngine.getMediaElement());
|
|
170
|
-
this.
|
|
204
|
+
this._notifyTimelineChange(this.currentLocator);
|
|
171
205
|
this.listeners.positionChanged(this.currentLocator);
|
|
206
|
+
this._setupRemotePlayback();
|
|
172
207
|
})
|
|
173
208
|
.catch(() => {
|
|
209
|
+
this._isNavigating = false;
|
|
174
210
|
// Error already forwarded via the error event listener.
|
|
175
211
|
});
|
|
176
212
|
}
|
|
@@ -201,6 +237,8 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
201
237
|
this.pool.audioEngine.setVolume(this._settings.volume);
|
|
202
238
|
this.pool.audioEngine.setPlaybackRate(this._settings.playbackRate, this._settings.preservePitch);
|
|
203
239
|
|
|
240
|
+
if (this.positionPollInterval !== null) this.startPositionPolling();
|
|
241
|
+
|
|
204
242
|
if (this._settings.enableMediaSession && !this._mediaSessionEnabled) {
|
|
205
243
|
this._mediaSessionEnabled = true;
|
|
206
244
|
this.setupMediaSession();
|
|
@@ -214,8 +252,19 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
214
252
|
return this.pub;
|
|
215
253
|
}
|
|
216
254
|
|
|
217
|
-
get timeline():
|
|
218
|
-
return this.
|
|
255
|
+
get timeline(): Timeline {
|
|
256
|
+
return this.pub.timeline;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private _notifyTimelineChange(locator: Locator): void {
|
|
260
|
+
const item = this.pub.timeline.locate(locator);
|
|
261
|
+
if (item !== this._currentTimelineItem) {
|
|
262
|
+
this._currentTimelineItem = item;
|
|
263
|
+
this.listeners.timelineItemChanged(item);
|
|
264
|
+
if (this._settings.enableMediaSession) {
|
|
265
|
+
this.updateMediaSessionMetadata();
|
|
266
|
+
}
|
|
267
|
+
}
|
|
219
268
|
}
|
|
220
269
|
|
|
221
270
|
private ensureLocatorLocations(locator: Locator): Locator {
|
|
@@ -271,7 +320,7 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
271
320
|
title: link.title,
|
|
272
321
|
locations: new LocatorLocations({
|
|
273
322
|
progression: duration > 0 ? timestamp / duration : 0,
|
|
274
|
-
position: trackIndex,
|
|
323
|
+
position: trackIndex + 1,
|
|
275
324
|
fragments: [`t=${timestamp}`]
|
|
276
325
|
})
|
|
277
326
|
});
|
|
@@ -330,7 +379,7 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
330
379
|
this.pool.audioEngine.on("ended", async () => {
|
|
331
380
|
this.stopPositionPolling();
|
|
332
381
|
this.currentLocation = this.currentLocation.copyWithLocations(new LocatorLocations({
|
|
333
|
-
position: this.currentTrackIndex(),
|
|
382
|
+
position: this.currentTrackIndex() + 1,
|
|
334
383
|
progression: 1,
|
|
335
384
|
fragments: [`t=${this.duration}`]
|
|
336
385
|
}));
|
|
@@ -341,45 +390,91 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
341
390
|
});
|
|
342
391
|
|
|
343
392
|
this.pool.audioEngine.on("play", () => {
|
|
393
|
+
if (this._isNavigating) return;
|
|
344
394
|
this.startPositionPolling();
|
|
345
395
|
this.listeners.play(this.currentLocator);
|
|
346
396
|
});
|
|
347
397
|
|
|
348
398
|
this.pool.audioEngine.on("playing", () => {
|
|
349
|
-
this.
|
|
399
|
+
if (this._isNavigating) return;
|
|
400
|
+
this._setStalled(false);
|
|
350
401
|
});
|
|
351
402
|
|
|
352
403
|
this.pool.audioEngine.on("pause", () => {
|
|
404
|
+
if (this._isNavigating) return;
|
|
353
405
|
this.stopPositionPolling();
|
|
354
406
|
this.listeners.pause(this.currentLocator);
|
|
355
407
|
});
|
|
356
408
|
|
|
357
409
|
this.pool.audioEngine.on("seeked", () => {
|
|
410
|
+
if (this._isNavigating) return;
|
|
358
411
|
this.listeners.seeking(false);
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
412
|
+
const currentTime = this.currentTime;
|
|
413
|
+
const duration = this.duration;
|
|
414
|
+
const progression = duration > 0 ? currentTime / duration : 0;
|
|
415
|
+
this.currentLocation = this.currentLocation.copyWithLocations(new LocatorLocations({
|
|
416
|
+
position: this.currentTrackIndex() + 1,
|
|
417
|
+
progression,
|
|
418
|
+
fragments: [`t=${currentTime}`]
|
|
419
|
+
}));
|
|
420
|
+
// Always notify on seeked — don't defer to polling — so that a skip
|
|
421
|
+
// crossing a timeline item boundary fires timelineItemChanged immediately
|
|
422
|
+
// regardless of play state. _notifyTimelineChange deduplicates internally.
|
|
423
|
+
this._notifyTimelineChange(this.currentLocation);
|
|
424
|
+
this.listeners.positionChanged(this.currentLocation);
|
|
371
425
|
});
|
|
372
426
|
|
|
373
|
-
this.pool.audioEngine.on("seeking", () => this.listeners.seeking(true));
|
|
374
|
-
this.pool.audioEngine.on("waiting", () => this.listeners.seeking(true));
|
|
375
|
-
this.pool.audioEngine.on("stalled", () => this.
|
|
376
|
-
this.pool.audioEngine.on("
|
|
377
|
-
|
|
427
|
+
this.pool.audioEngine.on("seeking", () => { if (!this._isNavigating) this.listeners.seeking(true); });
|
|
428
|
+
this.pool.audioEngine.on("waiting", () => { if (!this._isNavigating) this.listeners.seeking(true); });
|
|
429
|
+
this.pool.audioEngine.on("stalled", () => { if (!this._isNavigating) this._setStalled(true); });
|
|
430
|
+
this.pool.audioEngine.on("canplaythrough", () => { if (!this._isNavigating) this._setStalled(false); });
|
|
431
|
+
this.pool.audioEngine.on("progress", (seekable: TimeRanges) => { if (!this._isNavigating) this.listeners.seekable(seekable); });
|
|
432
|
+
|
|
378
433
|
this.pool.audioEngine.on("loadedmetadata", () => {
|
|
379
|
-
this.
|
|
434
|
+
const mediaElement = this.pool.audioEngine.getMediaElement();
|
|
435
|
+
const metadata: AudioMetadata = {
|
|
436
|
+
duration: this.pool.audioEngine.duration(),
|
|
437
|
+
textTracks: mediaElement.textTracks,
|
|
438
|
+
readyState: mediaElement.readyState,
|
|
439
|
+
networkState: mediaElement.networkState
|
|
440
|
+
};
|
|
441
|
+
this.listeners.metadataLoaded(metadata);
|
|
380
442
|
});
|
|
381
443
|
}
|
|
382
444
|
|
|
445
|
+
private _setStalled(isStalled: boolean): void {
|
|
446
|
+
if (this._isStalled === isStalled) return;
|
|
447
|
+
this._isStalled = isStalled;
|
|
448
|
+
this.listeners.stalled(isStalled);
|
|
449
|
+
if (isStalled) {
|
|
450
|
+
this._stalledCheckTime = this.currentTime;
|
|
451
|
+
this._startStalledWatchdog();
|
|
452
|
+
} else {
|
|
453
|
+
this._stopStalledWatchdog();
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
private _startStalledWatchdog(): void {
|
|
458
|
+
this._stalledWatchdog = setInterval(() => {
|
|
459
|
+
if (!this.isPlaying) {
|
|
460
|
+
this._setStalled(false);
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
const t = this.currentTime;
|
|
464
|
+
if (t !== this._stalledCheckTime) {
|
|
465
|
+
this._setStalled(false);
|
|
466
|
+
}
|
|
467
|
+
this._stalledCheckTime = t;
|
|
468
|
+
}, 500);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
private _stopStalledWatchdog(): void {
|
|
472
|
+
if (this._stalledWatchdog !== null) {
|
|
473
|
+
clearInterval(this._stalledWatchdog);
|
|
474
|
+
this._stalledWatchdog = null;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
383
478
|
private setupMediaSession(): void {
|
|
384
479
|
if (!("mediaSession" in navigator)) return;
|
|
385
480
|
navigator.mediaSession.setActionHandler("play", () => this.play());
|
|
@@ -402,7 +497,7 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
402
497
|
? this.pub.metadata.authors.items.map((a) => a.name.getTranslation()).join(", ")
|
|
403
498
|
: undefined,
|
|
404
499
|
album: this.pub.metadata.title.getTranslation(),
|
|
405
|
-
artwork: cover ? [{ src: cover.href, type: cover.type }] : undefined,
|
|
500
|
+
artwork: cover ? [{ src: cover.toURL(this.pub.baseURL) ?? cover.href, type: cover.type }] : undefined,
|
|
406
501
|
});
|
|
407
502
|
}
|
|
408
503
|
|
|
@@ -413,11 +508,11 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
413
508
|
const duration = this.duration;
|
|
414
509
|
const progression = duration > 0 ? currentTime / duration : 0;
|
|
415
510
|
this.currentLocation = this.currentLocation.copyWithLocations(new LocatorLocations({
|
|
416
|
-
position: this.currentTrackIndex(),
|
|
511
|
+
position: this.currentTrackIndex() + 1,
|
|
417
512
|
progression,
|
|
418
513
|
fragments: [`t=${currentTime}`]
|
|
419
514
|
}));
|
|
420
|
-
this.
|
|
515
|
+
this._notifyTimelineChange(this.currentLocation);
|
|
421
516
|
this.listeners.positionChanged(this.currentLocation);
|
|
422
517
|
}, this._settings.pollInterval);
|
|
423
518
|
}
|
|
@@ -442,23 +537,28 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
442
537
|
}
|
|
443
538
|
|
|
444
539
|
const id = ++this.navigationId;
|
|
445
|
-
const
|
|
446
|
-
|
|
447
|
-
// engine's playing flag, so a rapid second go() would see false and
|
|
448
|
-
// never resume playback.
|
|
540
|
+
const previousTrackIndex = this.currentTrackIndex();
|
|
541
|
+
const direction: "forward" | "backward" = trackIndex >= previousTrackIndex ? "forward" : "backward";
|
|
449
542
|
const wasPlaying = this.isPlaying || this._playIntent;
|
|
450
543
|
this._playIntent = wasPlaying;
|
|
451
544
|
|
|
545
|
+
this._isNavigating = true;
|
|
452
546
|
this.stopPositionPolling();
|
|
453
547
|
this.pool.setCurrentAudio(trackIndex, direction);
|
|
454
548
|
this.currentLocation = locator.copyWithLocations(locator.locations);
|
|
455
549
|
|
|
456
550
|
await this.waitForLoadedAndSeeked(time, id);
|
|
551
|
+
this._isNavigating = false;
|
|
457
552
|
|
|
458
|
-
if (id !== this.navigationId)
|
|
553
|
+
if (id !== this.navigationId) {
|
|
554
|
+
cb(false);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
459
557
|
|
|
460
|
-
|
|
461
|
-
|
|
558
|
+
if (trackIndex !== previousTrackIndex) {
|
|
559
|
+
this.listeners.trackLoaded(this.pool.audioEngine.getMediaElement());
|
|
560
|
+
}
|
|
561
|
+
this._notifyTimelineChange(this.currentLocator);
|
|
462
562
|
this.listeners.positionChanged(this.currentLocator);
|
|
463
563
|
|
|
464
564
|
if (this._settings.enableMediaSession) {
|
|
@@ -466,12 +566,14 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
466
566
|
}
|
|
467
567
|
|
|
468
568
|
if (wasPlaying) this.play();
|
|
469
|
-
this._playIntent = false;
|
|
470
569
|
|
|
471
570
|
cb(true);
|
|
472
571
|
} catch (error) {
|
|
572
|
+
this._isNavigating = false;
|
|
473
573
|
console.error("Failed to go to locator:", error);
|
|
474
574
|
cb(false);
|
|
575
|
+
} finally {
|
|
576
|
+
this._playIntent = false;
|
|
475
577
|
}
|
|
476
578
|
}
|
|
477
579
|
|
|
@@ -561,6 +663,29 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
561
663
|
return this.currentTrackIndex() < this.pub.readingOrder.items.length - 1;
|
|
562
664
|
}
|
|
563
665
|
|
|
666
|
+
/**
|
|
667
|
+
* The RemotePlayback object for the primary media element.
|
|
668
|
+
* Because the element is never swapped, this reference is stable for the
|
|
669
|
+
* lifetime of the navigator — host apps can store it and call `.prompt()`,
|
|
670
|
+
* `.watchAvailability()`, etc. directly.
|
|
671
|
+
*/
|
|
672
|
+
get remotePlayback(): RemotePlayback | undefined {
|
|
673
|
+
const el = this.pool.audioEngine.getMediaElement();
|
|
674
|
+
return "remote" in el ? el.remote : undefined;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/** Wires up the optional remotePlaybackStateChanged listener. Called once after initial load. */
|
|
678
|
+
private _setupRemotePlayback(): void {
|
|
679
|
+
if (this._contentProtection.disableRemotePlayback) {
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
const remote = this.remotePlayback;
|
|
683
|
+
if (!remote) return;
|
|
684
|
+
remote.onconnecting = () => this.listeners.remotePlaybackStateChanged("connecting");
|
|
685
|
+
remote.onconnect = () => this.listeners.remotePlaybackStateChanged("connected");
|
|
686
|
+
remote.ondisconnect = () => this.listeners.remotePlaybackStateChanged("disconnected");
|
|
687
|
+
}
|
|
688
|
+
|
|
564
689
|
private destroyMediaSession(): void {
|
|
565
690
|
if (!("mediaSession" in navigator)) return;
|
|
566
691
|
navigator.mediaSession.metadata = null;
|
|
@@ -574,6 +699,7 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
574
699
|
|
|
575
700
|
destroy(): void {
|
|
576
701
|
this.stopPositionPolling();
|
|
702
|
+
this._stopStalledWatchdog();
|
|
577
703
|
this.destroyMediaSession();
|
|
578
704
|
if (this._suspiciousActivityListener) {
|
|
579
705
|
window.removeEventListener(NAVIGATOR_SUSPICIOUS_ACTIVITY_EVENT, this._suspiciousActivityListener);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Link, Publication } from "@readium/shared";
|
|
2
|
-
import { WebAudioEngine } from "./engine/WebAudioEngine";
|
|
2
|
+
import { WebAudioEngine } from "./engine/WebAudioEngine.ts";
|
|
3
|
+
import type { IAudioContentProtectionConfig } from "./AudioNavigator.ts";
|
|
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"> {
|
|
@@ -34,16 +39,18 @@ export class AudioPoolManager {
|
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
private pickPlayableHref(link: Link): string {
|
|
42
|
+
const base = this._publication.baseURL;
|
|
37
43
|
const candidates = [link, ...(link.alternates?.items ?? [])];
|
|
38
44
|
let best: { href: string; confidence: "probably" | "maybe" } | undefined;
|
|
39
45
|
for (const candidate of candidates) {
|
|
40
46
|
if (!candidate.type) continue;
|
|
41
47
|
const confidence = this._supportedAudioTypes.get(candidate.type);
|
|
42
48
|
if (!confidence) continue;
|
|
43
|
-
|
|
44
|
-
if (
|
|
49
|
+
const href = candidate.toURL(base) ?? candidate.href;
|
|
50
|
+
if (confidence === "probably") return href;
|
|
51
|
+
if (!best) best = { href, confidence };
|
|
45
52
|
}
|
|
46
|
-
return best?.href ?? link.href;
|
|
53
|
+
return best?.href ?? (link.toURL(base) ?? link.href);
|
|
47
54
|
}
|
|
48
55
|
|
|
49
56
|
get audioEngine(): WebAudioEngine {
|
|
@@ -59,8 +66,8 @@ export class AudioPoolManager {
|
|
|
59
66
|
if (!element) {
|
|
60
67
|
element = document.createElement("audio");
|
|
61
68
|
element.preload = "auto";
|
|
62
|
-
//
|
|
63
|
-
//
|
|
69
|
+
// Match the primary element's CORS mode so cached responses
|
|
70
|
+
// are reusable when changeSrc() loads this href on it.
|
|
64
71
|
if (this._audioEngine.isWebAudioActive) {
|
|
65
72
|
element.crossOrigin = "anonymous";
|
|
66
73
|
}
|
|
@@ -74,12 +81,14 @@ export class AudioPoolManager {
|
|
|
74
81
|
/**
|
|
75
82
|
* Updates the pool around the given index: ensures elements exist within
|
|
76
83
|
* the LOWER_BOUNDARY and disposes those beyond the UPPER_BOUNDARY.
|
|
84
|
+
* The current track is excluded — the primary engine element represents it.
|
|
77
85
|
*/
|
|
78
86
|
private update(currentIndex: number): void {
|
|
79
87
|
const items = this._publication.readingOrder.items;
|
|
80
88
|
const keep = new Set<string>();
|
|
81
89
|
|
|
82
90
|
for (let j = 0; j < items.length; j++) {
|
|
91
|
+
if (j === currentIndex) continue; // primary element handles the current track
|
|
83
92
|
const href = this.pickPlayableHref(items[j]);
|
|
84
93
|
if (j >= currentIndex - LOWER_BOUNDARY && j <= currentIndex + LOWER_BOUNDARY) {
|
|
85
94
|
this.ensure(href);
|
|
@@ -103,17 +112,21 @@ export class AudioPoolManager {
|
|
|
103
112
|
}
|
|
104
113
|
|
|
105
114
|
/**
|
|
106
|
-
* Sets the current audio for playback at the given track index
|
|
107
|
-
*
|
|
115
|
+
* Sets the current audio for playback at the given track index by changing
|
|
116
|
+
* the src on the persistent primary element. This preserves the RemotePlayback
|
|
117
|
+
* session and any Web Audio graph connections across track changes.
|
|
108
118
|
*/
|
|
109
119
|
setCurrentAudio(currentIndex: number, _direction: 'forward' | 'backward'): void {
|
|
110
120
|
const href = this.pickPlayableHref(this._publication.readingOrder.items[currentIndex]);
|
|
111
|
-
|
|
121
|
+
this.audioEngine.changeSrc(href);
|
|
112
122
|
|
|
113
|
-
this
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
123
|
+
// Discard any pool entry for this href — the primary element owns it now
|
|
124
|
+
if (this.pool.has(href)) {
|
|
125
|
+
const existing = this.pool.get(href)!;
|
|
126
|
+
existing.removeAttribute("src");
|
|
127
|
+
existing.load();
|
|
128
|
+
this.pool.delete(href);
|
|
129
|
+
}
|
|
117
130
|
|
|
118
131
|
// Manage the pool around the new position
|
|
119
132
|
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.
|