@readium/navigator 2.7.0-alpha.3 → 2.7.0-alpha.4
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 +471 -393
- package/dist/index.umd.cjs +19 -19
- package/package.json +1 -1
- package/src/audio/AudioNavigator.ts +106 -5
- package/src/audio/AudioPoolManager.ts +14 -8
- package/src/audio/engine/AudioEngine.ts +34 -1
- package/src/audio/engine/WebAudioEngine.ts +48 -3
- package/types/src/audio/AudioNavigator.d.ts +29 -0
- package/types/src/audio/engine/AudioEngine.d.ts +27 -1
- package/types/src/audio/engine/WebAudioEngine.d.ts +14 -2
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Link, Locator, LocatorLocations, Publication, Timeline, TimelineItem } from "@readium/shared";
|
|
2
2
|
import { MediaNavigator, IContentProtectionConfig, IKeyboardPeripheralsConfig, KeyboardPeripheralEventData } from "../Navigator.ts";
|
|
3
3
|
import { Configurable } from "../preferences/Configurable.ts";
|
|
4
|
-
import { WebAudioEngine, PlaybackState } from "./engine/index.ts";
|
|
4
|
+
import { WebAudioEngine, PlaybackState, AudioMseLoaderFactory } from "./engine/index.ts";
|
|
5
5
|
import {
|
|
6
6
|
AudioPreferences,
|
|
7
7
|
AudioDefaults,
|
|
@@ -69,6 +69,26 @@ export interface AudioNavigatorConfiguration {
|
|
|
69
69
|
defaults: IAudioDefaults;
|
|
70
70
|
contentProtection?: IAudioContentProtectionConfig;
|
|
71
71
|
keyboardPeripherals?: IKeyboardPeripheralsConfig;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Called with the persistent playback element before
|
|
75
|
+
* the first src is assigned, so the host can prepare it. Use for MSE/EME
|
|
76
|
+
* setups. When it returns a promise, loading of
|
|
77
|
+
* the initial track (and prefetching of adjacent ones) is deferred until
|
|
78
|
+
* the promise settles; a rejection is forwarded to the error listener and
|
|
79
|
+
* loading proceeds anyway.
|
|
80
|
+
*/
|
|
81
|
+
mediaElementSetup?: (element: HTMLMediaElement) => void | Promise<void>;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* When provided, media bytes reach the playback element through Media
|
|
85
|
+
* Source Extensions instead of direct `src` assignment: the engine
|
|
86
|
+
* creates one loader per track via this factory and the loader owns all
|
|
87
|
+
* fetching. Supply one for e.g. EME encrypted audio, since browsers have
|
|
88
|
+
* poor support for encrypted audio directly through `src`. The container
|
|
89
|
+
* handling (WebM, fMP4, …) is the loader implementation's concern.
|
|
90
|
+
*/
|
|
91
|
+
mseLoaderFactory?: AudioMseLoaderFactory;
|
|
72
92
|
}
|
|
73
93
|
|
|
74
94
|
export class AudioNavigator extends MediaNavigator implements Configurable<AudioSettings, AudioPreferences> {
|
|
@@ -94,6 +114,8 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
94
114
|
/** True while a track transition is in progress; suppresses spurious mid-navigation events. */
|
|
95
115
|
private _isNavigating: boolean = false;
|
|
96
116
|
private _isStalled: boolean = false;
|
|
117
|
+
/** Set by destroy(); stops the deferred initial load from touching a dead instance. */
|
|
118
|
+
private _destroyed: boolean = false;
|
|
97
119
|
private _stalledWatchdog: ReturnType<typeof setInterval> | null = null;
|
|
98
120
|
private _stalledCheckTime: number = 0;
|
|
99
121
|
|
|
@@ -131,9 +153,19 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
131
153
|
}
|
|
132
154
|
|
|
133
155
|
const initialHref = this.currentLocation.href.split("#")[0];
|
|
134
|
-
|
|
156
|
+
let trackIndex = this.hrefToTrackIndex(initialHref);
|
|
135
157
|
if (trackIndex === -1) {
|
|
136
|
-
|
|
158
|
+
// Progression-only locators (e.g. restored from an OPDS progression)
|
|
159
|
+
// Resolve totalProgression against the
|
|
160
|
+
// cumulative track durations instead of failing outright.
|
|
161
|
+
const totalProgression = this.currentLocation.locations?.totalProgression;
|
|
162
|
+
if (totalProgression !== undefined) {
|
|
163
|
+
const resolved = this.locatorFromTotalProgression(totalProgression);
|
|
164
|
+
this.currentLocation = resolved.locator;
|
|
165
|
+
trackIndex = resolved.trackIndex;
|
|
166
|
+
} else {
|
|
167
|
+
throw new Error(`AudioNavigator: initial href "${ initialHref }" not found in reading order`);
|
|
168
|
+
}
|
|
137
169
|
}
|
|
138
170
|
const initialTime = this.currentLocation.locations?.time() || 0;
|
|
139
171
|
|
|
@@ -145,7 +177,8 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
145
177
|
} as PlaybackState,
|
|
146
178
|
playWhenReady: false,
|
|
147
179
|
index: trackIndex
|
|
148
|
-
}
|
|
180
|
+
},
|
|
181
|
+
mseLoaderFactory: configuration.mseLoaderFactory,
|
|
149
182
|
});
|
|
150
183
|
|
|
151
184
|
this.pool = new AudioPoolManager(audioEngine, publication, configuration.contentProtection);
|
|
@@ -188,6 +221,28 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
188
221
|
this.setupEventListeners();
|
|
189
222
|
|
|
190
223
|
this._isNavigating = true;
|
|
224
|
+
|
|
225
|
+
const startInitialLoad = () => this.startInitialLoad(trackIndex, initialTime);
|
|
226
|
+
if (configuration.mediaElementSetup) {
|
|
227
|
+
// Defer the initial load until the host has prepared the element
|
|
228
|
+
// (e.g. attached MediaKeys for EME) so no media data is fetched or
|
|
229
|
+
// decoded before protection is in place.
|
|
230
|
+
Promise.resolve()
|
|
231
|
+
.then(() => configuration.mediaElementSetup!(this.pool.audioEngine.getMediaElement()))
|
|
232
|
+
.catch((error) => { this.listeners.error(error, this.currentLocator); })
|
|
233
|
+
.then(startInitialLoad);
|
|
234
|
+
} else {
|
|
235
|
+
startInitialLoad();
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Sets the initial track on the primary element and seeks to the starting position. */
|
|
240
|
+
private startInitialLoad(trackIndex: number, initialTime: number): void {
|
|
241
|
+
// The navigator may have been destroyed while an async
|
|
242
|
+
// mediaElementSetup was pending (React StrictMode does this in dev) —
|
|
243
|
+
// don't start loading media on the orphaned element.
|
|
244
|
+
if (this._destroyed) return;
|
|
245
|
+
|
|
191
246
|
this.pool.setCurrentAudio(trackIndex, "forward");
|
|
192
247
|
|
|
193
248
|
// applyPreferences() must come after setCurrentAudio() so that the src
|
|
@@ -289,6 +344,47 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
289
344
|
return this.hrefToTrackIndex(this.currentLocation.href);
|
|
290
345
|
}
|
|
291
346
|
|
|
347
|
+
/**
|
|
348
|
+
* Builds a locator for an overall publication progression (0–1) by walking
|
|
349
|
+
* the cumulative track durations. Falls back to the start of the first
|
|
350
|
+
* track when the reading order carries no duration metadata.
|
|
351
|
+
*/
|
|
352
|
+
private locatorFromTotalProgression(totalProgression: number): { locator: Locator, trackIndex: number } {
|
|
353
|
+
const items = this.pub.readingOrder.items;
|
|
354
|
+
const durations = items.map(link => link.duration ?? 0);
|
|
355
|
+
const totalDuration = durations.reduce((sum, d) => sum + d, 0);
|
|
356
|
+
|
|
357
|
+
let trackIndex = 0;
|
|
358
|
+
let time = 0;
|
|
359
|
+
if (totalDuration > 0) {
|
|
360
|
+
let remaining = Math.min(Math.max(totalProgression, 0), 1) * totalDuration;
|
|
361
|
+
for (let i = 0; i < durations.length; i++) {
|
|
362
|
+
trackIndex = i;
|
|
363
|
+
if (remaining <= durations[i] || i === durations.length - 1) {
|
|
364
|
+
time = remaining;
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
remaining -= durations[i];
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const link = items[trackIndex];
|
|
372
|
+
return {
|
|
373
|
+
trackIndex,
|
|
374
|
+
locator: new Locator({
|
|
375
|
+
href: link.href,
|
|
376
|
+
type: link.type || "", // Should have a mimetype
|
|
377
|
+
title: link.title,
|
|
378
|
+
locations: new LocatorLocations({
|
|
379
|
+
position: trackIndex + 1,
|
|
380
|
+
progression: durations[trackIndex] > 0 ? time / durations[trackIndex] : 0,
|
|
381
|
+
totalProgression: Math.min(Math.max(totalProgression, 0), 1),
|
|
382
|
+
fragments: [`t=${ time }`]
|
|
383
|
+
})
|
|
384
|
+
})
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
292
388
|
get currentLocator(): Locator {
|
|
293
389
|
return this.currentLocation;
|
|
294
390
|
}
|
|
@@ -384,7 +480,11 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
384
480
|
fragments: [`t=${this.duration}`]
|
|
385
481
|
}));
|
|
386
482
|
this.listeners.trackEnded(this.currentLocator);
|
|
387
|
-
if (!this.canGoForward)
|
|
483
|
+
if (!this.canGoForward) {
|
|
484
|
+
// Set final progress in audiobook
|
|
485
|
+
this.listeners.positionChanged(this.currentLocator);
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
388
488
|
await this.nextTrack();
|
|
389
489
|
if (this._settings.autoPlay) this.play();
|
|
390
490
|
});
|
|
@@ -698,6 +798,7 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
698
798
|
}
|
|
699
799
|
|
|
700
800
|
destroy(): void {
|
|
801
|
+
this._destroyed = true;
|
|
701
802
|
this.stopPositionPolling();
|
|
702
803
|
this._stopStalledWatchdog();
|
|
703
804
|
this.destroyMediaSession();
|
|
@@ -38,19 +38,20 @@ export class AudioPoolManager {
|
|
|
38
38
|
return supported;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
private pickPlayableHref(link: Link): string {
|
|
41
|
+
private pickPlayableHref(link: Link): { href: string, type?: string } {
|
|
42
42
|
const base = this._publication.baseURL;
|
|
43
43
|
const candidates = [link, ...(link.alternates?.items ?? [])];
|
|
44
|
-
let best: { href: string; confidence: "probably" | "maybe" } | undefined;
|
|
44
|
+
let best: { href: string; type?: string; confidence: "probably" | "maybe" } | undefined;
|
|
45
45
|
for (const candidate of candidates) {
|
|
46
46
|
if (!candidate.type) continue;
|
|
47
47
|
const confidence = this._supportedAudioTypes.get(candidate.type);
|
|
48
48
|
if (!confidence) continue;
|
|
49
49
|
const href = candidate.toURL(base) ?? candidate.href;
|
|
50
|
-
if (confidence === "probably") return href;
|
|
51
|
-
if (!best) best = { href, confidence };
|
|
50
|
+
if (confidence === "probably") return { href, type: candidate.type };
|
|
51
|
+
if (!best) best = { href, type: candidate.type, confidence };
|
|
52
52
|
}
|
|
53
|
-
return best
|
|
53
|
+
if (best) return { href: best.href, type: best.type };
|
|
54
|
+
return { href: link.toURL(base) ?? link.href, type: link.type };
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
get audioEngine(): WebAudioEngine {
|
|
@@ -84,12 +85,16 @@ export class AudioPoolManager {
|
|
|
84
85
|
* The current track is excluded — the primary engine element represents it.
|
|
85
86
|
*/
|
|
86
87
|
private update(currentIndex: number): void {
|
|
88
|
+
// Progressive preload elements would feed encrypted media through the
|
|
89
|
+
// broken src= demuxer path — the MSE loader owns fetching instead
|
|
90
|
+
if (this._audioEngine.usesMse) return;
|
|
91
|
+
|
|
87
92
|
const items = this._publication.readingOrder.items;
|
|
88
93
|
const keep = new Set<string>();
|
|
89
94
|
|
|
90
95
|
for (let j = 0; j < items.length; j++) {
|
|
91
96
|
if (j === currentIndex) continue; // primary element handles the current track
|
|
92
|
-
const href = this.pickPlayableHref(items[j]);
|
|
97
|
+
const { href } = this.pickPlayableHref(items[j]);
|
|
93
98
|
if (j >= currentIndex - LOWER_BOUNDARY && j <= currentIndex + LOWER_BOUNDARY) {
|
|
94
99
|
this.ensure(href);
|
|
95
100
|
keep.add(href);
|
|
@@ -117,8 +122,8 @@ export class AudioPoolManager {
|
|
|
117
122
|
* session and any Web Audio graph connections across track changes.
|
|
118
123
|
*/
|
|
119
124
|
setCurrentAudio(currentIndex: number, _direction: 'forward' | 'backward'): void {
|
|
120
|
-
const href = this.pickPlayableHref(this._publication.readingOrder.items[currentIndex]);
|
|
121
|
-
this.audioEngine.changeSrc(href);
|
|
125
|
+
const { href, type } = this.pickPlayableHref(this._publication.readingOrder.items[currentIndex]);
|
|
126
|
+
this.audioEngine.changeSrc(href, type);
|
|
122
127
|
|
|
123
128
|
// Discard any pool entry for this href — the primary element owns it now
|
|
124
129
|
if (this.pool.has(href)) {
|
|
@@ -134,6 +139,7 @@ export class AudioPoolManager {
|
|
|
134
139
|
|
|
135
140
|
destroy(): void {
|
|
136
141
|
this.audioEngine.stop();
|
|
142
|
+
this.audioEngine.destroy();
|
|
137
143
|
for (const [, element] of this.pool) {
|
|
138
144
|
element.removeAttribute("src");
|
|
139
145
|
element.load();
|
|
@@ -30,6 +30,37 @@ export interface Playback {
|
|
|
30
30
|
buffered?: number;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* A consumer-supplied loader that feeds one resource to the playback element
|
|
35
|
+
* through Media Source Extensions. The engine constructs one per track via
|
|
36
|
+
* the [AudioMseLoaderFactory] and calls [start] immediately; the loader owns
|
|
37
|
+
* the element's `src` (a MediaSource object URL), all fetching, buffering and
|
|
38
|
+
* seek servicing until [destroy] is called. Container specifics (WebM, fMP4,
|
|
39
|
+
* byte ranges vs. segments…) are entirely up to the implementation.
|
|
40
|
+
*/
|
|
41
|
+
export interface AudioMseLoader {
|
|
42
|
+
/** Attaches to the element and begins streaming. Called exactly once. */
|
|
43
|
+
start(): void;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Cancels in-flight fetches, detaches element listeners and releases the
|
|
47
|
+
* MediaSource object URL. The element itself must be left intact — the
|
|
48
|
+
* engine reuses it for the next track.
|
|
49
|
+
*/
|
|
50
|
+
destroy(): void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Creates the [AudioMseLoader] for a resource. [mimeType] is the resource's
|
|
55
|
+
* media type from the manifest, when known — needed to configure the
|
|
56
|
+
* SourceBuffer.
|
|
57
|
+
*/
|
|
58
|
+
export type AudioMseLoaderFactory = (
|
|
59
|
+
element: HTMLMediaElement,
|
|
60
|
+
href: string,
|
|
61
|
+
mimeType?: string,
|
|
62
|
+
) => AudioMseLoader;
|
|
63
|
+
|
|
33
64
|
/**
|
|
34
65
|
* An audio engine that plays audio resources from a publication.
|
|
35
66
|
* @playback - The current [Playback] state.
|
|
@@ -58,8 +89,10 @@ export interface AudioEngine {
|
|
|
58
89
|
* Changes the src of the primary media element without swapping it,
|
|
59
90
|
* preserving the RemotePlayback session and all attached event listeners.
|
|
60
91
|
* @param href The URL of the new audio resource.
|
|
92
|
+
* @param mimeType The media type of the resource, when known. Required for
|
|
93
|
+
* MSE-based loading to configure the SourceBuffer.
|
|
61
94
|
*/
|
|
62
|
-
changeSrc(href: string): void;
|
|
95
|
+
changeSrc(href: string, mimeType?: string): void;
|
|
63
96
|
|
|
64
97
|
/**
|
|
65
98
|
* Plays the current audio resource.
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
AudioEngine,
|
|
5
|
+
AudioMseLoader,
|
|
6
|
+
AudioMseLoaderFactory,
|
|
5
7
|
Playback,
|
|
6
8
|
} from "./AudioEngine.ts";
|
|
7
9
|
import { PreservePitchWorklet } from "./PreservePitchWorklet.ts";
|
|
@@ -25,6 +27,10 @@ export class WebAudioEngine implements AudioEngine {
|
|
|
25
27
|
private isStoppedValue: boolean = false;
|
|
26
28
|
private worklet: PreservePitchWorklet | null = null;
|
|
27
29
|
private webAudioActive: boolean = false;
|
|
30
|
+
private readonly mseLoaderFactory: AudioMseLoaderFactory | null;
|
|
31
|
+
private mseLoader: AudioMseLoader | null = null;
|
|
32
|
+
/** The resource href currently loaded (element src is a blob: URL in MSE mode). */
|
|
33
|
+
private currentHref: string = "";
|
|
28
34
|
|
|
29
35
|
private readonly boundOnCanPlayThrough = this.onCanPlayThrough.bind(this);
|
|
30
36
|
private readonly boundOnTimeUpdate = this.onTimeUpdate.bind(this);
|
|
@@ -42,8 +48,9 @@ export class WebAudioEngine implements AudioEngine {
|
|
|
42
48
|
private readonly boundOnPause = this.onPause.bind(this);
|
|
43
49
|
private readonly boundOnProgress = this.onProgress.bind(this);
|
|
44
50
|
|
|
45
|
-
constructor(values: { playback: Playback }) {
|
|
51
|
+
constructor(values: { playback: Playback, mseLoaderFactory?: AudioMseLoaderFactory }) {
|
|
46
52
|
this.playback = values.playback;
|
|
53
|
+
this.mseLoaderFactory = values.mseLoaderFactory ?? null;
|
|
47
54
|
|
|
48
55
|
// crossOrigin is set lazily in activateWebAudio() only when the worklet is needed
|
|
49
56
|
this.mediaElement = document.createElement("audio");
|
|
@@ -386,6 +393,19 @@ export class WebAudioEngine implements AudioEngine {
|
|
|
386
393
|
private async activateWebAudio(): Promise<void> {
|
|
387
394
|
if (this.webAudioActive) return;
|
|
388
395
|
|
|
396
|
+
if (this.usesMse) {
|
|
397
|
+
// blob: MediaSource URLs are same-origin — no CORS reload required
|
|
398
|
+
this.sourceNode = new MediaElementAudioSourceNode(this.getOrCreateAudioContext(), { mediaElement: this.mediaElement });
|
|
399
|
+
const audioContext = this.getOrCreateAudioContext();
|
|
400
|
+
this.gainNode = audioContext.createGain();
|
|
401
|
+
this.gainNode.gain.value = this.mediaElement.volume;
|
|
402
|
+
this.mediaElement.volume = 1;
|
|
403
|
+
this.sourceNode.connect(this.gainNode);
|
|
404
|
+
this.gainNode.connect(audioContext.destination);
|
|
405
|
+
this.webAudioActive = true;
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
|
|
389
409
|
const src = this.mediaElement.src;
|
|
390
410
|
if (!src) return;
|
|
391
411
|
|
|
@@ -469,6 +489,11 @@ export class WebAudioEngine implements AudioEngine {
|
|
|
469
489
|
return this.webAudioActive;
|
|
470
490
|
}
|
|
471
491
|
|
|
492
|
+
/** True when this engine streams media through Media Source Extensions. */
|
|
493
|
+
public get usesMse(): boolean {
|
|
494
|
+
return this.mseLoaderFactory !== null;
|
|
495
|
+
}
|
|
496
|
+
|
|
472
497
|
/**
|
|
473
498
|
* Tears down the Web Audio graph and restores the media element to standalone
|
|
474
499
|
* playback. Safe to call even if Web Audio was never activated.
|
|
@@ -499,10 +524,11 @@ export class WebAudioEngine implements AudioEngine {
|
|
|
499
524
|
* the required headers), the graph is torn down and the src is reloaded
|
|
500
525
|
* without CORS so playback continues — just without pitch correction.
|
|
501
526
|
*/
|
|
502
|
-
public changeSrc(href: string): void {
|
|
503
|
-
if (this.mediaElement.src === href) {
|
|
527
|
+
public changeSrc(href: string, mimeType?: string): void {
|
|
528
|
+
if (this.currentHref === href || this.mediaElement.src === href) {
|
|
504
529
|
return;
|
|
505
530
|
}
|
|
531
|
+
this.currentHref = href;
|
|
506
532
|
this.mediaElement.pause();
|
|
507
533
|
this.isPlayingValue = false;
|
|
508
534
|
this.isPausedValue = false;
|
|
@@ -510,6 +536,16 @@ export class WebAudioEngine implements AudioEngine {
|
|
|
510
536
|
this.isLoadingValue = true;
|
|
511
537
|
this.isEndedValue = false;
|
|
512
538
|
|
|
539
|
+
if (this.mseLoaderFactory) {
|
|
540
|
+
// MSE path: swap loaders on the persistent element. The blob: object
|
|
541
|
+
// URL is same-origin, so none of the crossOrigin reload handling below
|
|
542
|
+
// applies here — the Web Audio graph (if active) keeps working.
|
|
543
|
+
this.mseLoader?.destroy();
|
|
544
|
+
this.mseLoader = this.mseLoaderFactory(this.mediaElement, href, mimeType);
|
|
545
|
+
this.mseLoader.start();
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
|
|
513
549
|
if (this.webAudioActive) {
|
|
514
550
|
this.mediaElement.crossOrigin = "anonymous";
|
|
515
551
|
this.mediaElement.src = href;
|
|
@@ -542,4 +578,13 @@ export class WebAudioEngine implements AudioEngine {
|
|
|
542
578
|
public getMediaElement(): HTMLMediaElement {
|
|
543
579
|
return this.mediaElement;
|
|
544
580
|
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Releases loader resources (in-flight fetches, MediaSource object URL).
|
|
584
|
+
* The media element itself is left intact.
|
|
585
|
+
*/
|
|
586
|
+
public destroy(): void {
|
|
587
|
+
this.mseLoader?.destroy();
|
|
588
|
+
this.mseLoader = null;
|
|
589
|
+
}
|
|
545
590
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Link, Locator, Publication, Timeline, TimelineItem } from "@readium/shared";
|
|
2
2
|
import { MediaNavigator, IContentProtectionConfig, IKeyboardPeripheralsConfig, KeyboardPeripheralEventData } from "../Navigator.ts";
|
|
3
3
|
import { Configurable } from "../preferences/Configurable.ts";
|
|
4
|
+
import { AudioMseLoaderFactory } from "./engine/index.ts";
|
|
4
5
|
import { AudioPreferences, AudioSettings, AudioPreferencesEditor, IAudioPreferences, IAudioDefaults } from "./preferences/index.ts";
|
|
5
6
|
import { ContextMenuEvent, SuspiciousActivityEvent } from "@readium/navigator-html-injectables";
|
|
6
7
|
export interface AudioMetadata {
|
|
@@ -35,6 +36,24 @@ export interface AudioNavigatorConfiguration {
|
|
|
35
36
|
defaults: IAudioDefaults;
|
|
36
37
|
contentProtection?: IAudioContentProtectionConfig;
|
|
37
38
|
keyboardPeripherals?: IKeyboardPeripheralsConfig;
|
|
39
|
+
/**
|
|
40
|
+
* Called with the persistent playback element before
|
|
41
|
+
* the first src is assigned, so the host can prepare it. Use for MSE/EME
|
|
42
|
+
* setups. When it returns a promise, loading of
|
|
43
|
+
* the initial track (and prefetching of adjacent ones) is deferred until
|
|
44
|
+
* the promise settles; a rejection is forwarded to the error listener and
|
|
45
|
+
* loading proceeds anyway.
|
|
46
|
+
*/
|
|
47
|
+
mediaElementSetup?: (element: HTMLMediaElement) => void | Promise<void>;
|
|
48
|
+
/**
|
|
49
|
+
* When provided, media bytes reach the playback element through Media
|
|
50
|
+
* Source Extensions instead of direct `src` assignment: the engine
|
|
51
|
+
* creates one loader per track via this factory and the loader owns all
|
|
52
|
+
* fetching. Supply one for e.g. EME encrypted audio, since browsers have
|
|
53
|
+
* poor support for encrypted audio directly through `src`. The container
|
|
54
|
+
* handling (WebM, fMP4, …) is the loader implementation's concern.
|
|
55
|
+
*/
|
|
56
|
+
mseLoaderFactory?: AudioMseLoaderFactory;
|
|
38
57
|
}
|
|
39
58
|
export declare class AudioNavigator extends MediaNavigator implements Configurable<AudioSettings, AudioPreferences> {
|
|
40
59
|
private readonly pub;
|
|
@@ -58,9 +77,13 @@ export declare class AudioNavigator extends MediaNavigator implements Configurab
|
|
|
58
77
|
/** True while a track transition is in progress; suppresses spurious mid-navigation events. */
|
|
59
78
|
private _isNavigating;
|
|
60
79
|
private _isStalled;
|
|
80
|
+
/** Set by destroy(); stops the deferred initial load from touching a dead instance. */
|
|
81
|
+
private _destroyed;
|
|
61
82
|
private _stalledWatchdog;
|
|
62
83
|
private _stalledCheckTime;
|
|
63
84
|
constructor(publication: Publication, listeners: AudioNavigatorListeners, initialPosition?: Locator, configuration?: AudioNavigatorConfiguration);
|
|
85
|
+
/** Sets the initial track on the primary element and seeks to the starting position. */
|
|
86
|
+
private startInitialLoad;
|
|
64
87
|
get settings(): AudioSettings;
|
|
65
88
|
get preferencesEditor(): AudioPreferencesEditor;
|
|
66
89
|
submitPreferences(preferences: AudioPreferences): Promise<void>;
|
|
@@ -73,6 +96,12 @@ export declare class AudioNavigator extends MediaNavigator implements Configurab
|
|
|
73
96
|
private hrefToTrackIndex;
|
|
74
97
|
/** Current track index derived from the current location's href. */
|
|
75
98
|
private currentTrackIndex;
|
|
99
|
+
/**
|
|
100
|
+
* Builds a locator for an overall publication progression (0–1) by walking
|
|
101
|
+
* the cumulative track durations. Falls back to the start of the first
|
|
102
|
+
* track when the reading order carries no duration metadata.
|
|
103
|
+
*/
|
|
104
|
+
private locatorFromTotalProgression;
|
|
76
105
|
get currentLocator(): Locator;
|
|
77
106
|
get isPlaying(): boolean;
|
|
78
107
|
get isPaused(): boolean;
|
|
@@ -27,6 +27,30 @@ export interface Playback {
|
|
|
27
27
|
offset?: number;
|
|
28
28
|
buffered?: number;
|
|
29
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* A consumer-supplied loader that feeds one resource to the playback element
|
|
32
|
+
* through Media Source Extensions. The engine constructs one per track via
|
|
33
|
+
* the [AudioMseLoaderFactory] and calls [start] immediately; the loader owns
|
|
34
|
+
* the element's `src` (a MediaSource object URL), all fetching, buffering and
|
|
35
|
+
* seek servicing until [destroy] is called. Container specifics (WebM, fMP4,
|
|
36
|
+
* byte ranges vs. segments…) are entirely up to the implementation.
|
|
37
|
+
*/
|
|
38
|
+
export interface AudioMseLoader {
|
|
39
|
+
/** Attaches to the element and begins streaming. Called exactly once. */
|
|
40
|
+
start(): void;
|
|
41
|
+
/**
|
|
42
|
+
* Cancels in-flight fetches, detaches element listeners and releases the
|
|
43
|
+
* MediaSource object URL. The element itself must be left intact — the
|
|
44
|
+
* engine reuses it for the next track.
|
|
45
|
+
*/
|
|
46
|
+
destroy(): void;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Creates the [AudioMseLoader] for a resource. [mimeType] is the resource's
|
|
50
|
+
* media type from the manifest, when known — needed to configure the
|
|
51
|
+
* SourceBuffer.
|
|
52
|
+
*/
|
|
53
|
+
export type AudioMseLoaderFactory = (element: HTMLMediaElement, href: string, mimeType?: string) => AudioMseLoader;
|
|
30
54
|
/**
|
|
31
55
|
* An audio engine that plays audio resources from a publication.
|
|
32
56
|
* @playback - The current [Playback] state.
|
|
@@ -52,8 +76,10 @@ export interface AudioEngine {
|
|
|
52
76
|
* Changes the src of the primary media element without swapping it,
|
|
53
77
|
* preserving the RemotePlayback session and all attached event listeners.
|
|
54
78
|
* @param href The URL of the new audio resource.
|
|
79
|
+
* @param mimeType The media type of the resource, when known. Required for
|
|
80
|
+
* MSE-based loading to configure the SourceBuffer.
|
|
55
81
|
*/
|
|
56
|
-
changeSrc(href: string): void;
|
|
82
|
+
changeSrc(href: string, mimeType?: string): void;
|
|
57
83
|
/**
|
|
58
84
|
* Plays the current audio resource.
|
|
59
85
|
*/
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AudioEngine, Playback } from "./AudioEngine.ts";
|
|
1
|
+
import { AudioEngine, AudioMseLoaderFactory, Playback } from "./AudioEngine.ts";
|
|
2
2
|
type EventCallback = (data: any) => void;
|
|
3
3
|
export declare class WebAudioEngine implements AudioEngine {
|
|
4
4
|
readonly playback: Playback;
|
|
@@ -16,6 +16,10 @@ export declare class WebAudioEngine implements AudioEngine {
|
|
|
16
16
|
private isStoppedValue;
|
|
17
17
|
private worklet;
|
|
18
18
|
private webAudioActive;
|
|
19
|
+
private readonly mseLoaderFactory;
|
|
20
|
+
private mseLoader;
|
|
21
|
+
/** The resource href currently loaded (element src is a blob: URL in MSE mode). */
|
|
22
|
+
private currentHref;
|
|
19
23
|
private readonly boundOnCanPlayThrough;
|
|
20
24
|
private readonly boundOnTimeUpdate;
|
|
21
25
|
private readonly boundOnError;
|
|
@@ -33,6 +37,7 @@ export declare class WebAudioEngine implements AudioEngine {
|
|
|
33
37
|
private readonly boundOnProgress;
|
|
34
38
|
constructor(values: {
|
|
35
39
|
playback: Playback;
|
|
40
|
+
mseLoaderFactory?: AudioMseLoaderFactory;
|
|
36
41
|
});
|
|
37
42
|
/**
|
|
38
43
|
* Adds an event listener to the audio engine.
|
|
@@ -132,6 +137,8 @@ export declare class WebAudioEngine implements AudioEngine {
|
|
|
132
137
|
*/
|
|
133
138
|
private activateWebAudio;
|
|
134
139
|
get isWebAudioActive(): boolean;
|
|
140
|
+
/** True when this engine streams media through Media Source Extensions. */
|
|
141
|
+
get usesMse(): boolean;
|
|
135
142
|
/**
|
|
136
143
|
* Tears down the Web Audio graph and restores the media element to standalone
|
|
137
144
|
* playback. Safe to call even if Web Audio was never activated.
|
|
@@ -145,10 +152,15 @@ export declare class WebAudioEngine implements AudioEngine {
|
|
|
145
152
|
* the required headers), the graph is torn down and the src is reloaded
|
|
146
153
|
* without CORS so playback continues — just without pitch correction.
|
|
147
154
|
*/
|
|
148
|
-
changeSrc(href: string): void;
|
|
155
|
+
changeSrc(href: string, mimeType?: string): void;
|
|
149
156
|
/**
|
|
150
157
|
* Returns the HTML media element used for playback.
|
|
151
158
|
*/
|
|
152
159
|
getMediaElement(): HTMLMediaElement;
|
|
160
|
+
/**
|
|
161
|
+
* Releases loader resources (in-flight fetches, MediaSource object URL).
|
|
162
|
+
* The media element itself is left intact.
|
|
163
|
+
*/
|
|
164
|
+
destroy(): void;
|
|
153
165
|
}
|
|
154
166
|
export {};
|