@readium/navigator 2.4.0-beta.6 → 2.4.0-beta.8
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 +974 -877
- package/dist/index.umd.cjs +22 -22
- package/package.json +1 -1
- package/src/Timeline.ts +58 -0
- package/src/audio/AudioNavigator.ts +18 -1
- package/src/audio/AudioPoolManager.ts +6 -10
- package/src/audio/AudioTimeline.ts +156 -0
- package/src/audio/engine/AudioEngine.ts +0 -6
- package/src/audio/engine/WebAudioEngine.ts +32 -58
- package/src/audio/index.ts +2 -1
- package/src/epub/frame/FrameManager.ts +1 -1
- package/src/epub/fxl/FXLFrameManager.ts +1 -1
- package/src/index.ts +1 -0
- package/src/preferences/Types.ts +1 -1
- package/src/protection/DevToolsDetector.ts +1 -0
- package/src/webpub/WebPubFrameManager.ts +1 -1
- package/src/webpub/WebPubNavigator.ts +6 -2
- package/types/src/Timeline.d.ts +48 -0
- package/types/src/audio/AudioNavigator.d.ts +4 -0
- package/types/src/audio/AudioTimeline.d.ts +34 -0
- package/types/src/audio/engine/AudioEngine.d.ts +0 -5
- package/types/src/audio/engine/WebAudioEngine.d.ts +0 -5
- package/types/src/audio/index.d.ts +1 -0
- package/types/src/index.d.ts +1 -0
package/package.json
CHANGED
package/src/Timeline.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Represents a single entry in the publication's timeline.
|
|
3
|
+
* The timeline contextualizes the reading order, table of contents,
|
|
4
|
+
* positions list, and guided navigation document (GND) so that
|
|
5
|
+
* consuming apps can, for instance, display position numbers
|
|
6
|
+
* for TOC items, select the current TOC entry when
|
|
7
|
+
* it has a fragment, or display previous/next as chapter titles while
|
|
8
|
+
* pointing to the actual resources.
|
|
9
|
+
*
|
|
10
|
+
* Properties vary by format — not all will be populated for every format.
|
|
11
|
+
*/
|
|
12
|
+
export interface TimelineItem {
|
|
13
|
+
/** Title of this timeline entry (e.g. chapter or section name). */
|
|
14
|
+
title: string;
|
|
15
|
+
/** References as hrefs with optional fragments, e.g. ["chapter1.html"], ["track1.mp3#t=60"], ["#page=6"]. */
|
|
16
|
+
references: string[];
|
|
17
|
+
/** Roles of this entry, e.g. ["chapter"], ["section"], ["part"]. */
|
|
18
|
+
role?: string[];
|
|
19
|
+
/** Position number in the reading order context. */
|
|
20
|
+
position?: number;
|
|
21
|
+
/** Scroll progression within the resource (0 to 1), for entries that start mid-way in a resource. */
|
|
22
|
+
scroll?: number;
|
|
23
|
+
/** Nested timeline entries. */
|
|
24
|
+
children?: TimelineItem[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type TimelineChangeCallback = (current: TimelineItem | undefined, previous: TimelineItem | undefined, next: TimelineItem | undefined) => void;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A timeline built from a publication's structure.
|
|
31
|
+
* Format-specific subclasses are responsible for building the items
|
|
32
|
+
* from the publication's reading order, table of contents, positions list and GND.
|
|
33
|
+
*/
|
|
34
|
+
export abstract class Timeline {
|
|
35
|
+
abstract readonly items: TimelineItem[];
|
|
36
|
+
|
|
37
|
+
/** The current timeline item matching the navigator's position. */
|
|
38
|
+
abstract get current(): TimelineItem | undefined;
|
|
39
|
+
|
|
40
|
+
/** The previous entry relative to current, based on resource boundaries in the TOC tree. */
|
|
41
|
+
abstract get previous(): TimelineItem | undefined;
|
|
42
|
+
|
|
43
|
+
/** The next entry relative to current, based on resource boundaries in the TOC tree. */
|
|
44
|
+
abstract get next(): TimelineItem | undefined;
|
|
45
|
+
|
|
46
|
+
/** Returns the previous and next entries relative to current. */
|
|
47
|
+
get adjacent(): { previous: TimelineItem | undefined; next: TimelineItem | undefined } {
|
|
48
|
+
return { previous: this.previous, next: this.next };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
protected changeCallback: TimelineChangeCallback | null = null;
|
|
52
|
+
|
|
53
|
+
/** Register a callback invoked when the current timeline item changes. */
|
|
54
|
+
onChange(callback: TimelineChangeCallback): void {
|
|
55
|
+
this.changeCallback = callback;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
}
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
IAudioDefaults
|
|
12
12
|
} from "./preferences";
|
|
13
13
|
import { AudioPoolManager } from "./AudioPoolManager";
|
|
14
|
+
import { AudioTimeline } from "./AudioTimeline";
|
|
14
15
|
import { ContextMenuEvent, KeyboardEventData, SuspiciousActivityEvent } from "@readium/navigator-html-injectables";
|
|
15
16
|
import { AudioNavigatorProtector } from "./protection/AudioNavigatorProtector";
|
|
16
17
|
import { NAVIGATOR_SUSPICIOUS_ACTIVITY_EVENT } from "../protection/NavigatorProtector";
|
|
@@ -59,6 +60,7 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
59
60
|
private readonly pub: Publication;
|
|
60
61
|
private positionPollInterval: ReturnType<typeof setInterval> | null = null;
|
|
61
62
|
private navigationId: number = 0;
|
|
63
|
+
private _playIntent: boolean = false;
|
|
62
64
|
private listeners: AudioNavigatorListeners;
|
|
63
65
|
private currentLocation!: Locator;
|
|
64
66
|
|
|
@@ -69,6 +71,7 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
69
71
|
private _mediaSessionEnabled: boolean = false;
|
|
70
72
|
private pool: AudioPoolManager;
|
|
71
73
|
private readonly _navigatorProtector: AudioNavigatorProtector | null = null;
|
|
74
|
+
private readonly _timeline: AudioTimeline;
|
|
72
75
|
private readonly _keyboardPeripheralsManager: KeyboardPeripherals | null = null;
|
|
73
76
|
private readonly _suspiciousActivityListener: ((event: Event) => void) | null = null;
|
|
74
77
|
private readonly _keyboardPeripheralListener: ((event: Event) => void) | null = null;
|
|
@@ -79,6 +82,7 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
79
82
|
}) {
|
|
80
83
|
super();
|
|
81
84
|
this.pub = publication;
|
|
85
|
+
this._timeline = new AudioTimeline(publication);
|
|
82
86
|
this.listeners = defaultListeners(listeners);
|
|
83
87
|
|
|
84
88
|
this._preferences = new AudioPreferences(configuration.preferences);
|
|
@@ -163,6 +167,7 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
163
167
|
this.waitForLoadedAndSeeked(initialTime)
|
|
164
168
|
.then(() => {
|
|
165
169
|
this.listeners.trackLoaded(this.pool.audioEngine.getMediaElement());
|
|
170
|
+
this._timeline.update(this.currentLocator);
|
|
166
171
|
this.listeners.positionChanged(this.currentLocator);
|
|
167
172
|
})
|
|
168
173
|
.catch(() => {
|
|
@@ -209,6 +214,10 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
209
214
|
return this.pub;
|
|
210
215
|
}
|
|
211
216
|
|
|
217
|
+
get timeline(): AudioTimeline {
|
|
218
|
+
return this._timeline;
|
|
219
|
+
}
|
|
220
|
+
|
|
212
221
|
private ensureLocatorLocations(locator: Locator): Locator {
|
|
213
222
|
return new Locator({
|
|
214
223
|
...locator,
|
|
@@ -356,6 +365,7 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
356
365
|
progression,
|
|
357
366
|
fragments: [`t=${currentTime}`]
|
|
358
367
|
}));
|
|
368
|
+
this._timeline.update(this.currentLocation);
|
|
359
369
|
this.listeners.positionChanged(this.currentLocation);
|
|
360
370
|
}
|
|
361
371
|
});
|
|
@@ -407,6 +417,7 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
407
417
|
progression,
|
|
408
418
|
fragments: [`t=${currentTime}`]
|
|
409
419
|
}));
|
|
420
|
+
this._timeline.update(this.currentLocation);
|
|
410
421
|
this.listeners.positionChanged(this.currentLocation);
|
|
411
422
|
}, this._settings.pollInterval);
|
|
412
423
|
}
|
|
@@ -432,7 +443,11 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
432
443
|
|
|
433
444
|
const id = ++this.navigationId;
|
|
434
445
|
const direction: "forward" | "backward" = trackIndex >= this.currentTrackIndex() ? "forward" : "backward";
|
|
435
|
-
|
|
446
|
+
// Use _playIntent rather than isPlaying — setMediaElement resets the
|
|
447
|
+
// engine's playing flag, so a rapid second go() would see false and
|
|
448
|
+
// never resume playback.
|
|
449
|
+
const wasPlaying = this.isPlaying || this._playIntent;
|
|
450
|
+
this._playIntent = wasPlaying;
|
|
436
451
|
|
|
437
452
|
this.stopPositionPolling();
|
|
438
453
|
this.pool.setCurrentAudio(trackIndex, direction);
|
|
@@ -443,6 +458,7 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
443
458
|
if (id !== this.navigationId) return;
|
|
444
459
|
|
|
445
460
|
this.listeners.trackLoaded(this.pool.audioEngine.getMediaElement());
|
|
461
|
+
this._timeline.update(this.currentLocator);
|
|
446
462
|
this.listeners.positionChanged(this.currentLocator);
|
|
447
463
|
|
|
448
464
|
if (this._settings.enableMediaSession) {
|
|
@@ -450,6 +466,7 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
|
|
|
450
466
|
}
|
|
451
467
|
|
|
452
468
|
if (wasPlaying) this.play();
|
|
469
|
+
this._playIntent = false;
|
|
453
470
|
|
|
454
471
|
cb(true);
|
|
455
472
|
} catch (error) {
|
|
@@ -59,6 +59,11 @@ export class AudioPoolManager {
|
|
|
59
59
|
if (!element) {
|
|
60
60
|
element = document.createElement("audio");
|
|
61
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
|
+
}
|
|
62
67
|
element.src = href;
|
|
63
68
|
element.load();
|
|
64
69
|
this.pool.set(href, element);
|
|
@@ -105,16 +110,7 @@ export class AudioPoolManager {
|
|
|
105
110
|
const href = this.pickPlayableHref(this._publication.readingOrder.items[currentIndex]);
|
|
106
111
|
const element = this.ensure(href);
|
|
107
112
|
|
|
108
|
-
|
|
109
|
-
// element doesn't have crossOrigin set (it would break non-CORS servers
|
|
110
|
-
// during preload), so we swap in the fresh element and let loadAudio
|
|
111
|
-
// handle CORS setup + fallback on the engine's own mediaElement.
|
|
112
|
-
if (this.audioEngine.isWebAudioActive) {
|
|
113
|
-
this.audioEngine.setMediaElement(element);
|
|
114
|
-
this.audioEngine.loadAudio(href);
|
|
115
|
-
} else {
|
|
116
|
-
this.audioEngine.setMediaElement(element);
|
|
117
|
-
}
|
|
113
|
+
this.audioEngine.setMediaElement(element);
|
|
118
114
|
|
|
119
115
|
// Remove from pool so the engine fully owns it and we don't dispose it
|
|
120
116
|
this.pool.delete(href);
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { Link, Locator, Publication } from "@readium/shared";
|
|
2
|
+
import { Timeline, TimelineItem } from "../Timeline";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Audio-specific timeline built from the publication's table of contents,
|
|
6
|
+
* falling back to the reading order when no TOC is available.
|
|
7
|
+
*/
|
|
8
|
+
export class AudioTimeline extends Timeline {
|
|
9
|
+
readonly items: TimelineItem[];
|
|
10
|
+
|
|
11
|
+
private _current: TimelineItem | undefined;
|
|
12
|
+
private _previous: TimelineItem | undefined;
|
|
13
|
+
private _next: TimelineItem | undefined;
|
|
14
|
+
private readonly flat: TimelineItem[];
|
|
15
|
+
|
|
16
|
+
constructor(publication: Publication) {
|
|
17
|
+
super();
|
|
18
|
+
const toc = publication.toc;
|
|
19
|
+
this.items = toc && toc.items.length > 0
|
|
20
|
+
? AudioTimeline.itemsFromToc(toc.items)
|
|
21
|
+
: AudioTimeline.itemsFromReadingOrder(publication.readingOrder.items);
|
|
22
|
+
this.flat = this.flatten(this.items);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
get current(): TimelineItem | undefined {
|
|
26
|
+
return this._current;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
get previous(): TimelineItem | undefined {
|
|
30
|
+
return this._previous;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
get next(): TimelineItem | undefined {
|
|
34
|
+
return this._next;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
update(locator: Locator): void {
|
|
38
|
+
const href = locator.href.split("#")[0];
|
|
39
|
+
const time = locator.locations?.time() ?? 0;
|
|
40
|
+
|
|
41
|
+
const matched = this.findCurrent(this.items, href, time);
|
|
42
|
+
|
|
43
|
+
// Only fire callback when timeline values actually change
|
|
44
|
+
if (matched !== this._current) {
|
|
45
|
+
this._current = matched;
|
|
46
|
+
this._previous = matched ? this.findPrevious(matched) : undefined;
|
|
47
|
+
this._next = matched ? this.findNext(matched) : undefined;
|
|
48
|
+
this.changeCallback?.(this._current, this._previous, this._next);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Finds the deepest timeline item whose reference matches the given
|
|
54
|
+
* href and whose time offset is at or before the current time.
|
|
55
|
+
*/
|
|
56
|
+
private findCurrent(_items: TimelineItem[], href: string, time: number): TimelineItem | undefined {
|
|
57
|
+
// Search for time-based match first
|
|
58
|
+
let match: TimelineItem | undefined;
|
|
59
|
+
|
|
60
|
+
for (const item of this.flat) {
|
|
61
|
+
if (this.itemMatchesPosition(item, href, time)) {
|
|
62
|
+
match = item;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// If no time-based match, fallback to base href match
|
|
67
|
+
if (!match) {
|
|
68
|
+
for (const item of this.flat) {
|
|
69
|
+
if (this.bareHref(item) === href) {
|
|
70
|
+
match = item;
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return match;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** First preceding entry in the flat list */
|
|
80
|
+
private findPrevious(target: TimelineItem): TimelineItem | undefined {
|
|
81
|
+
const index = this.flat.indexOf(target);
|
|
82
|
+
if (index > 0) {
|
|
83
|
+
return this.flat[index - 1];
|
|
84
|
+
}
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** First following entry in the flat list */
|
|
89
|
+
private findNext(target: TimelineItem): TimelineItem | undefined {
|
|
90
|
+
const index = this.flat.indexOf(target);
|
|
91
|
+
if (index < this.flat.length - 1) {
|
|
92
|
+
return this.flat[index + 1];
|
|
93
|
+
}
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private bareHref(item: TimelineItem): string {
|
|
98
|
+
const ref = item.references[0];
|
|
99
|
+
if (!ref) return "";
|
|
100
|
+
return ref.split("#")[0];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private flatten(items: TimelineItem[]): TimelineItem[] {
|
|
104
|
+
const result: TimelineItem[] = [];
|
|
105
|
+
for (const item of items) {
|
|
106
|
+
result.push(item);
|
|
107
|
+
if (item.children) {
|
|
108
|
+
result.push(...this.flatten(item.children));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return result;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private itemMatchesPosition(item: TimelineItem, href: string, time: number): boolean {
|
|
115
|
+
for (const ref of item.references) {
|
|
116
|
+
const [refHref, refFragment] = ref.split("#");
|
|
117
|
+
const refBare = refHref || href; // empty refHref means same resource (e.g. "#t=60")
|
|
118
|
+
if (refBare !== href) continue;
|
|
119
|
+
const refTime = this.parseTimeFragment(refFragment);
|
|
120
|
+
if (time >= refTime) return true;
|
|
121
|
+
}
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private parseTimeFragment(fragment: string | undefined): number {
|
|
126
|
+
if (!fragment) return 0;
|
|
127
|
+
const match = fragment.match(/(?:^|&)t=(\d+(?:\.\d+)?)/);
|
|
128
|
+
return match ? parseFloat(match[1]) : 0;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private static itemsFromToc(links: Link[]): TimelineItem[] {
|
|
132
|
+
return links
|
|
133
|
+
.map(link => AudioTimeline.linkToItem(link))
|
|
134
|
+
.filter(Boolean) as TimelineItem[];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private static itemsFromReadingOrder(links: Link[]): TimelineItem[] {
|
|
138
|
+
return links
|
|
139
|
+
.map(link => AudioTimeline.linkToItem(link))
|
|
140
|
+
.filter(Boolean) as TimelineItem[];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private static linkToItem(link: Link): TimelineItem | undefined {
|
|
144
|
+
if (!link.title) return undefined;
|
|
145
|
+
|
|
146
|
+
const children = link.children?.items
|
|
147
|
+
.map(child => AudioTimeline.linkToItem(child))
|
|
148
|
+
.filter(Boolean) as TimelineItem[] | undefined;
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
title: link.title,
|
|
152
|
+
references: [link.href],
|
|
153
|
+
children: children && children.length > 0 ? children : undefined,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -40,12 +40,6 @@ export interface AudioEngine {
|
|
|
40
40
|
*/
|
|
41
41
|
playback: Playback;
|
|
42
42
|
|
|
43
|
-
/**
|
|
44
|
-
* Loads the audio resource at the given URL.
|
|
45
|
-
* @param url The URL of the audio resource.
|
|
46
|
-
*/
|
|
47
|
-
loadAudio(url: string): void;
|
|
48
|
-
|
|
49
43
|
/**
|
|
50
44
|
* Adds an event listener to the audio engine.
|
|
51
45
|
* @param event The event name to listen.
|
|
@@ -95,52 +95,6 @@ export class WebAudioEngine implements AudioEngine {
|
|
|
95
95
|
);
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
/**
|
|
99
|
-
* Load the audio resource at the given URL.
|
|
100
|
-
* @param url The URL of the audio resource.
|
|
101
|
-
* */
|
|
102
|
-
public loadAudio(url: string): void {
|
|
103
|
-
// Abort any in-progress load before starting a new one.
|
|
104
|
-
this.mediaElement.pause();
|
|
105
|
-
this.mediaElement.removeAttribute("src");
|
|
106
|
-
this.mediaElement.load();
|
|
107
|
-
|
|
108
|
-
this.isLoadingValue = true;
|
|
109
|
-
this.isLoadedValue = false;
|
|
110
|
-
this.isPlayingValue = false;
|
|
111
|
-
this.isPausedValue = false;
|
|
112
|
-
|
|
113
|
-
if (this.webAudioActive) {
|
|
114
|
-
this.mediaElement.crossOrigin = "anonymous";
|
|
115
|
-
this.mediaElement.src = url;
|
|
116
|
-
this.mediaElement.load();
|
|
117
|
-
this.mediaElement.playbackRate = this.currentPlaybackRate;
|
|
118
|
-
|
|
119
|
-
// If the server doesn't honour the CORS preflight, fall back to a
|
|
120
|
-
// non-CORS load and tear down the Web Audio graph so the element
|
|
121
|
-
// is never passed to MediaElementAudioSourceNode in a tainted state.
|
|
122
|
-
const cleanup = () => {
|
|
123
|
-
this.mediaElement.removeEventListener("error", onCORSError);
|
|
124
|
-
this.mediaElement.removeEventListener("canplaythrough", onCORSSuccess);
|
|
125
|
-
};
|
|
126
|
-
const onCORSError = () => {
|
|
127
|
-
cleanup();
|
|
128
|
-
this.deactivateWebAudio();
|
|
129
|
-
this.mediaElement.removeAttribute("crossOrigin");
|
|
130
|
-
this.mediaElement.src = url;
|
|
131
|
-
this.mediaElement.load();
|
|
132
|
-
this.mediaElement.playbackRate = this.currentPlaybackRate;
|
|
133
|
-
};
|
|
134
|
-
const onCORSSuccess = () => cleanup();
|
|
135
|
-
this.mediaElement.addEventListener("error", onCORSError);
|
|
136
|
-
this.mediaElement.addEventListener("canplaythrough", onCORSSuccess);
|
|
137
|
-
} else {
|
|
138
|
-
this.mediaElement.src = url;
|
|
139
|
-
this.mediaElement.load();
|
|
140
|
-
this.mediaElement.playbackRate = this.currentPlaybackRate;
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
98
|
private deactivateWebAudio(): void {
|
|
145
99
|
if (this.worklet) {
|
|
146
100
|
this.worklet.destroy();
|
|
@@ -162,18 +116,7 @@ export class WebAudioEngine implements AudioEngine {
|
|
|
162
116
|
* @param element The HTML audio element to use.
|
|
163
117
|
*/
|
|
164
118
|
public setMediaElement(element: HTMLAudioElement): void {
|
|
165
|
-
//
|
|
166
|
-
this.mediaElement.pause();
|
|
167
|
-
this.isPlayingValue = false;
|
|
168
|
-
this.isPausedValue = false;
|
|
169
|
-
|
|
170
|
-
// Disconnect old source node if it exists
|
|
171
|
-
if (this.sourceNode) {
|
|
172
|
-
this.sourceNode.disconnect();
|
|
173
|
-
this.sourceNode = null;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// Remove old event listeners from current mediaElement
|
|
119
|
+
// Remove listeners BEFORE pausing so the pause doesn't leak through
|
|
177
120
|
this.mediaElement.removeEventListener("canplaythrough", this.boundOnCanPlayThrough);
|
|
178
121
|
this.mediaElement.removeEventListener("timeupdate", this.boundOnTimeUpdate);
|
|
179
122
|
this.mediaElement.removeEventListener("error", this.boundOnError);
|
|
@@ -190,6 +133,17 @@ export class WebAudioEngine implements AudioEngine {
|
|
|
190
133
|
this.mediaElement.removeEventListener("pause", this.boundOnPause);
|
|
191
134
|
this.mediaElement.removeEventListener("progress", this.boundOnProgress);
|
|
192
135
|
|
|
136
|
+
// Now safe to pause the outgoing element
|
|
137
|
+
this.mediaElement.pause();
|
|
138
|
+
this.isPlayingValue = false;
|
|
139
|
+
this.isPausedValue = false;
|
|
140
|
+
|
|
141
|
+
// Disconnect old source node if it exists
|
|
142
|
+
if (this.sourceNode) {
|
|
143
|
+
this.sourceNode.disconnect();
|
|
144
|
+
this.sourceNode = null;
|
|
145
|
+
}
|
|
146
|
+
|
|
193
147
|
// Set new media element
|
|
194
148
|
this.mediaElement = element;
|
|
195
149
|
|
|
@@ -214,6 +168,26 @@ export class WebAudioEngine implements AudioEngine {
|
|
|
214
168
|
this.mediaElement.volume = this.isMutedValue ? 0 : this.currentVolume;
|
|
215
169
|
this.mediaElement.playbackRate = this.currentPlaybackRate;
|
|
216
170
|
|
|
171
|
+
// Reconnect the Web Audio graph to the new element
|
|
172
|
+
if (this.webAudioActive) {
|
|
173
|
+
try {
|
|
174
|
+
const ctx = this.getOrCreateAudioContext();
|
|
175
|
+
this.sourceNode = new MediaElementAudioSourceNode(ctx, { mediaElement: this.mediaElement });
|
|
176
|
+
if (!this.gainNode) {
|
|
177
|
+
this.gainNode = ctx.createGain();
|
|
178
|
+
this.gainNode.connect(ctx.destination);
|
|
179
|
+
}
|
|
180
|
+
if (this.worklet?.workletNode) {
|
|
181
|
+
this.sourceNode.connect(this.worklet.workletNode);
|
|
182
|
+
} else {
|
|
183
|
+
this.sourceNode.connect(this.gainNode);
|
|
184
|
+
}
|
|
185
|
+
} catch {
|
|
186
|
+
// CORS failed on this element — deactivate Web Audio gracefully
|
|
187
|
+
this.deactivateWebAudio();
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
217
191
|
// Check if metadata is already loaded (common with preloaded elements)
|
|
218
192
|
if (this.mediaElement.readyState >= 1) {
|
|
219
193
|
this.onLoadedMetadata(new Event('loadedmetadata'));
|
package/src/audio/index.ts
CHANGED
|
@@ -85,7 +85,7 @@ export class FrameManager {
|
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
// Apply print protection if configured
|
|
88
|
-
if (this.contentProtectionConfig.protectPrinting) {
|
|
88
|
+
if (this.contentProtectionConfig.protectPrinting?.disable) {
|
|
89
89
|
this.comms!.send("print_protection", this.contentProtectionConfig.protectPrinting);
|
|
90
90
|
}
|
|
91
91
|
}
|
|
@@ -219,7 +219,7 @@ export class FXLFrameManager {
|
|
|
219
219
|
}
|
|
220
220
|
|
|
221
221
|
// Apply print protection if configured
|
|
222
|
-
if (this.contentProtectionConfig.protectPrinting) {
|
|
222
|
+
if (this.contentProtectionConfig.protectPrinting?.disable) {
|
|
223
223
|
this.comms!.send("print_protection", this.contentProtectionConfig.protectPrinting);
|
|
224
224
|
}
|
|
225
225
|
}
|
package/src/index.ts
CHANGED
package/src/preferences/Types.ts
CHANGED
|
@@ -83,7 +83,7 @@ export class WebPubFrameManager {
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
// Apply print protection if configured
|
|
86
|
-
if (this.contentProtectionConfig.protectPrinting) {
|
|
86
|
+
if (this.contentProtectionConfig.protectPrinting?.disable) {
|
|
87
87
|
this.comms!.send("print_protection", this.contentProtectionConfig.protectPrinting);
|
|
88
88
|
}
|
|
89
89
|
}
|
|
@@ -128,8 +128,12 @@ export class WebPubNavigator extends VisualNavigator implements Configurable<Web
|
|
|
128
128
|
|
|
129
129
|
// Listen for custom events from NavigatorProtector
|
|
130
130
|
this._suspiciousActivityListener = (event: Event) => {
|
|
131
|
-
const
|
|
132
|
-
|
|
131
|
+
const { type, ...activity } = (event as CustomEvent).detail;
|
|
132
|
+
if (type === "context_menu") {
|
|
133
|
+
this.listeners.contextMenu(activity as ContextMenuEvent);
|
|
134
|
+
} else {
|
|
135
|
+
this.listeners.contentProtection(type, activity);
|
|
136
|
+
}
|
|
133
137
|
};
|
|
134
138
|
window.addEventListener(NAVIGATOR_SUSPICIOUS_ACTIVITY_EVENT, this._suspiciousActivityListener);
|
|
135
139
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Represents a single entry in the publication's timeline.
|
|
3
|
+
* The timeline contextualizes the reading order, table of contents,
|
|
4
|
+
* positions list, and guided navigation document (GND) so that
|
|
5
|
+
* consuming apps can, for instance, display position numbers
|
|
6
|
+
* for TOC items, select the current TOC entry when
|
|
7
|
+
* it has a fragment, or display previous/next as chapter titles while
|
|
8
|
+
* pointing to the actual resources.
|
|
9
|
+
*
|
|
10
|
+
* Properties vary by format — not all will be populated for every format.
|
|
11
|
+
*/
|
|
12
|
+
export interface TimelineItem {
|
|
13
|
+
/** Title of this timeline entry (e.g. chapter or section name). */
|
|
14
|
+
title: string;
|
|
15
|
+
/** References as hrefs with optional fragments, e.g. ["chapter1.html"], ["track1.mp3#t=60"], ["#page=6"]. */
|
|
16
|
+
references: string[];
|
|
17
|
+
/** Roles of this entry, e.g. ["chapter"], ["section"], ["part"]. */
|
|
18
|
+
role?: string[];
|
|
19
|
+
/** Position number in the reading order context. */
|
|
20
|
+
position?: number;
|
|
21
|
+
/** Scroll progression within the resource (0 to 1), for entries that start mid-way in a resource. */
|
|
22
|
+
scroll?: number;
|
|
23
|
+
/** Nested timeline entries. */
|
|
24
|
+
children?: TimelineItem[];
|
|
25
|
+
}
|
|
26
|
+
export type TimelineChangeCallback = (current: TimelineItem | undefined, previous: TimelineItem | undefined, next: TimelineItem | undefined) => void;
|
|
27
|
+
/**
|
|
28
|
+
* A timeline built from a publication's structure.
|
|
29
|
+
* Format-specific subclasses are responsible for building the items
|
|
30
|
+
* from the publication's reading order, table of contents, positions list and GND.
|
|
31
|
+
*/
|
|
32
|
+
export declare abstract class Timeline {
|
|
33
|
+
abstract readonly items: TimelineItem[];
|
|
34
|
+
/** The current timeline item matching the navigator's position. */
|
|
35
|
+
abstract get current(): TimelineItem | undefined;
|
|
36
|
+
/** The previous entry relative to current, based on resource boundaries in the TOC tree. */
|
|
37
|
+
abstract get previous(): TimelineItem | undefined;
|
|
38
|
+
/** The next entry relative to current, based on resource boundaries in the TOC tree. */
|
|
39
|
+
abstract get next(): TimelineItem | undefined;
|
|
40
|
+
/** Returns the previous and next entries relative to current. */
|
|
41
|
+
get adjacent(): {
|
|
42
|
+
previous: TimelineItem | undefined;
|
|
43
|
+
next: TimelineItem | undefined;
|
|
44
|
+
};
|
|
45
|
+
protected changeCallback: TimelineChangeCallback | null;
|
|
46
|
+
/** Register a callback invoked when the current timeline item changes. */
|
|
47
|
+
onChange(callback: TimelineChangeCallback): void;
|
|
48
|
+
}
|
|
@@ -2,6 +2,7 @@ import { Link, Locator, Publication } from "@readium/shared";
|
|
|
2
2
|
import { MediaNavigator, IContentProtectionConfig, IKeyboardPeripheralsConfig } from "../Navigator";
|
|
3
3
|
import { Configurable } from "../preferences";
|
|
4
4
|
import { AudioPreferences, AudioSettings, AudioPreferencesEditor, IAudioPreferences, IAudioDefaults } from "./preferences";
|
|
5
|
+
import { AudioTimeline } from "./AudioTimeline";
|
|
5
6
|
import { ContextMenuEvent, KeyboardEventData, SuspiciousActivityEvent } from "@readium/navigator-html-injectables";
|
|
6
7
|
export interface AudioNavigatorListeners {
|
|
7
8
|
trackLoaded: (media: HTMLMediaElement) => void;
|
|
@@ -28,6 +29,7 @@ export declare class AudioNavigator extends MediaNavigator implements Configurab
|
|
|
28
29
|
private readonly pub;
|
|
29
30
|
private positionPollInterval;
|
|
30
31
|
private navigationId;
|
|
32
|
+
private _playIntent;
|
|
31
33
|
private listeners;
|
|
32
34
|
private currentLocation;
|
|
33
35
|
private _preferences;
|
|
@@ -37,6 +39,7 @@ export declare class AudioNavigator extends MediaNavigator implements Configurab
|
|
|
37
39
|
private _mediaSessionEnabled;
|
|
38
40
|
private pool;
|
|
39
41
|
private readonly _navigatorProtector;
|
|
42
|
+
private readonly _timeline;
|
|
40
43
|
private readonly _keyboardPeripheralsManager;
|
|
41
44
|
private readonly _suspiciousActivityListener;
|
|
42
45
|
private readonly _keyboardPeripheralListener;
|
|
@@ -46,6 +49,7 @@ export declare class AudioNavigator extends MediaNavigator implements Configurab
|
|
|
46
49
|
submitPreferences(preferences: AudioPreferences): Promise<void>;
|
|
47
50
|
private applyPreferences;
|
|
48
51
|
get publication(): Publication;
|
|
52
|
+
get timeline(): AudioTimeline;
|
|
49
53
|
private ensureLocatorLocations;
|
|
50
54
|
/** Resolves a bare href (no fragment) to its index in the reading order. Returns -1 if not found. */
|
|
51
55
|
private hrefToTrackIndex;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Locator, Publication } from "@readium/shared";
|
|
2
|
+
import { Timeline, TimelineItem } from "../Timeline";
|
|
3
|
+
/**
|
|
4
|
+
* Audio-specific timeline built from the publication's table of contents,
|
|
5
|
+
* falling back to the reading order when no TOC is available.
|
|
6
|
+
*/
|
|
7
|
+
export declare class AudioTimeline extends Timeline {
|
|
8
|
+
readonly items: TimelineItem[];
|
|
9
|
+
private _current;
|
|
10
|
+
private _previous;
|
|
11
|
+
private _next;
|
|
12
|
+
private readonly flat;
|
|
13
|
+
constructor(publication: Publication);
|
|
14
|
+
get current(): TimelineItem | undefined;
|
|
15
|
+
get previous(): TimelineItem | undefined;
|
|
16
|
+
get next(): TimelineItem | undefined;
|
|
17
|
+
update(locator: Locator): void;
|
|
18
|
+
/**
|
|
19
|
+
* Finds the deepest timeline item whose reference matches the given
|
|
20
|
+
* href and whose time offset is at or before the current time.
|
|
21
|
+
*/
|
|
22
|
+
private findCurrent;
|
|
23
|
+
/** First preceding entry in the flat list */
|
|
24
|
+
private findPrevious;
|
|
25
|
+
/** First following entry in the flat list */
|
|
26
|
+
private findNext;
|
|
27
|
+
private bareHref;
|
|
28
|
+
private flatten;
|
|
29
|
+
private itemMatchesPosition;
|
|
30
|
+
private parseTimeFragment;
|
|
31
|
+
private static itemsFromToc;
|
|
32
|
+
private static itemsFromReadingOrder;
|
|
33
|
+
private static linkToItem;
|
|
34
|
+
}
|