@readium/navigator 2.4.0-beta.1 → 2.4.0-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,6 +16,7 @@ export class WebAudioEngine implements AudioEngine {
16
16
  private sourceNode: MediaElementAudioSourceNode | null = null;
17
17
  private gainNode: GainNode | null = null;
18
18
  private listeners: { [event: string]: EventCallback[] } = {};
19
+ private currentVolume: number = 1;
19
20
  private currentPlaybackRate: number = 1;
20
21
  private isMutedValue: boolean = false;
21
22
  private isPlayingValue: boolean = false;
@@ -48,7 +49,6 @@ export class WebAudioEngine implements AudioEngine {
48
49
 
49
50
  // crossOrigin is set lazily in activateWebAudio() only when the worklet is needed
50
51
  this.mediaElement = document.createElement("audio");
51
- this.setVolume(this.playback.state.volume);
52
52
 
53
53
  // Event listeners (to report the client app about some async events)
54
54
  this.mediaElement.addEventListener("canplaythrough", this.boundOnCanPlayThrough);
@@ -95,44 +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
- this.isLoadingValue = true;
104
- this.isLoadedValue = false;
105
- this.isPlayingValue = false;
106
- this.isPausedValue = false;
107
-
108
- if (this.webAudioActive) {
109
- this.mediaElement.crossOrigin = "anonymous";
110
- this.mediaElement.src = url;
111
- this.mediaElement.load();
112
-
113
- // If the server doesn't honour the CORS preflight, fall back to a
114
- // non-CORS load and tear down the Web Audio graph so the element
115
- // is never passed to MediaElementAudioSourceNode in a tainted state.
116
- const cleanup = () => {
117
- this.mediaElement.removeEventListener("error", onCORSError);
118
- this.mediaElement.removeEventListener("canplaythrough", onCORSSuccess);
119
- };
120
- const onCORSError = () => {
121
- cleanup();
122
- this.deactivateWebAudio();
123
- this.mediaElement.removeAttribute("crossOrigin");
124
- this.mediaElement.src = url;
125
- this.mediaElement.load();
126
- };
127
- const onCORSSuccess = () => cleanup();
128
- this.mediaElement.addEventListener("error", onCORSError);
129
- this.mediaElement.addEventListener("canplaythrough", onCORSSuccess);
130
- } else {
131
- this.mediaElement.src = url;
132
- this.mediaElement.load();
133
- }
134
- }
135
-
136
98
  private deactivateWebAudio(): void {
137
99
  if (this.worklet) {
138
100
  this.worklet.destroy();
@@ -154,18 +116,7 @@ export class WebAudioEngine implements AudioEngine {
154
116
  * @param element The HTML audio element to use.
155
117
  */
156
118
  public setMediaElement(element: HTMLAudioElement): void {
157
- // Pause the outgoing element before replacing it
158
- this.mediaElement.pause();
159
- this.isPlayingValue = false;
160
- this.isPausedValue = false;
161
-
162
- // Disconnect old source node if it exists
163
- if (this.sourceNode) {
164
- this.sourceNode.disconnect();
165
- this.sourceNode = null;
166
- }
167
-
168
- // Remove old event listeners from current mediaElement
119
+ // Remove listeners BEFORE pausing so the pause doesn't leak through
169
120
  this.mediaElement.removeEventListener("canplaythrough", this.boundOnCanPlayThrough);
170
121
  this.mediaElement.removeEventListener("timeupdate", this.boundOnTimeUpdate);
171
122
  this.mediaElement.removeEventListener("error", this.boundOnError);
@@ -182,6 +133,17 @@ export class WebAudioEngine implements AudioEngine {
182
133
  this.mediaElement.removeEventListener("pause", this.boundOnPause);
183
134
  this.mediaElement.removeEventListener("progress", this.boundOnProgress);
184
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
+
185
147
  // Set new media element
186
148
  this.mediaElement = element;
187
149
 
@@ -203,9 +165,29 @@ export class WebAudioEngine implements AudioEngine {
203
165
  this.mediaElement.addEventListener("progress", this.boundOnProgress);
204
166
 
205
167
  // Re-apply current volume and playback rate to the new element
206
- this.mediaElement.volume = this.isMutedValue ? 0 : this.playback.state.volume;
168
+ this.mediaElement.volume = this.isMutedValue ? 0 : this.currentVolume;
207
169
  this.mediaElement.playbackRate = this.currentPlaybackRate;
208
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
+
209
191
  // Check if metadata is already loaded (common with preloaded elements)
210
192
  if (this.mediaElement.readyState >= 1) {
211
193
  this.onLoadedMetadata(new Event('loadedmetadata'));
@@ -370,23 +352,23 @@ export class WebAudioEngine implements AudioEngine {
370
352
  */
371
353
  public setVolume(volume: number): void {
372
354
  if (volume < 0) {
355
+ this.currentVolume = 0;
373
356
  this.mediaElement.volume = 0;
374
357
  if (this.gainNode) {
375
358
  this.gainNode.gain.value = 0;
376
359
  }
377
360
  this.isMutedValue = true;
378
- this.playback.state.volume = 0;
379
361
  return;
380
362
  }
381
363
  if (volume > 1) {
382
364
  this.setVolume(volume / 100);
383
365
  return;
384
366
  }
367
+ this.currentVolume = volume;
385
368
  this.mediaElement.volume = volume;
386
369
  if (this.gainNode) {
387
370
  this.gainNode.gain.value = volume;
388
371
  }
389
- this.playback.state.volume = volume;
390
372
  }
391
373
 
392
374
  /**
@@ -0,0 +1,38 @@
1
+ import { NavigatorProtector } from "../../protection/NavigatorProtector";
2
+ import { DragAndDropProtector } from "../../protection/DragAndDropProtector";
3
+ import { CopyProtector } from "../../protection/CopyProtector";
4
+ import { IContentProtectionConfig } from "../../Navigator";
5
+
6
+ export class AudioNavigatorProtector extends NavigatorProtector {
7
+ private dragAndDropProtector?: DragAndDropProtector;
8
+ private copyProtector?: CopyProtector;
9
+
10
+ constructor(config: IContentProtectionConfig = {}) {
11
+ super(config);
12
+
13
+ if (config.disableDragAndDrop) {
14
+ this.dragAndDropProtector = new DragAndDropProtector({
15
+ onDragDetected: (dataTransferTypes) => {
16
+ this.dispatchSuspiciousActivity("drag_detected", { dataTransferTypes, targetFrameSrc: "" });
17
+ },
18
+ onDropDetected: (dataTransferTypes, fileCount) => {
19
+ this.dispatchSuspiciousActivity("drop_detected", { dataTransferTypes, fileCount, targetFrameSrc: "" });
20
+ }
21
+ });
22
+ }
23
+
24
+ if (config.protectCopy) {
25
+ this.copyProtector = new CopyProtector({
26
+ onCopyBlocked: () => {
27
+ this.dispatchSuspiciousActivity("bulk_copy", { targetFrameSrc: "" });
28
+ }
29
+ });
30
+ }
31
+ }
32
+
33
+ public override destroy() {
34
+ super.destroy();
35
+ this.dragAndDropProtector?.destroy();
36
+ this.copyProtector?.destroy();
37
+ }
38
+ }
@@ -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
  }
@@ -81,8 +81,8 @@ export const volumeRangeConfig: RangeConfig = {
81
81
  }
82
82
 
83
83
  export const playbackRateRangeConfig: RangeConfig = {
84
- range: [0.5, 2],
85
- step: 0.25
84
+ range: [0.5, 4],
85
+ step: 0.1
86
86
  }
87
87
 
88
88
  export const skipIntervalRangeConfig: RangeConfig = {
@@ -0,0 +1,22 @@
1
+ export interface CopyProtectionOptions {
2
+ onCopyBlocked?: () => void;
3
+ }
4
+
5
+ export class CopyProtector {
6
+ private copyHandler: (event: ClipboardEvent) => void;
7
+
8
+ constructor(options: CopyProtectionOptions = {}) {
9
+ this.copyHandler = (event: ClipboardEvent) => {
10
+ event.preventDefault();
11
+ event.stopPropagation();
12
+ options.onCopyBlocked?.();
13
+ };
14
+
15
+ document.addEventListener("copy", this.copyHandler, true);
16
+ window.addEventListener("unload", () => this.destroy());
17
+ }
18
+
19
+ public destroy() {
20
+ document.removeEventListener("copy", this.copyHandler, true);
21
+ }
22
+ }
@@ -282,6 +282,7 @@ export class DevToolsDetector {
282
282
  // Cleanup Web Worker
283
283
  if (this.workerConsole) {
284
284
  this.workerConsole.destroy();
285
+ this.workerConsole = undefined;
285
286
  }
286
287
 
287
288
  this.isOpen = false;
@@ -0,0 +1,34 @@
1
+ export interface DragAndDropProtectionOptions {
2
+ onDragDetected?: (dataTransferTypes: readonly string[]) => void;
3
+ onDropDetected?: (dataTransferTypes: readonly string[], fileCount: number) => void;
4
+ }
5
+
6
+ export class DragAndDropProtector {
7
+ private dragstartHandler: (event: DragEvent) => void;
8
+ private dropHandler: (event: DragEvent) => void;
9
+
10
+ constructor(options: DragAndDropProtectionOptions = {}) {
11
+ this.dragstartHandler = (event: DragEvent) => {
12
+ event.preventDefault();
13
+ event.stopPropagation();
14
+ options.onDragDetected?.(Array.from(event.dataTransfer?.types ?? []));
15
+ };
16
+
17
+ this.dropHandler = (event: DragEvent) => {
18
+ event.preventDefault();
19
+ event.stopPropagation();
20
+ const types = Array.from(event.dataTransfer?.types ?? []);
21
+ const fileCount = event.dataTransfer?.files.length ?? 0;
22
+ options.onDropDetected?.(types, fileCount);
23
+ };
24
+
25
+ document.addEventListener("dragstart", this.dragstartHandler, true);
26
+ document.addEventListener("drop", this.dropHandler, true);
27
+ window.addEventListener("unload", () => this.destroy());
28
+ }
29
+
30
+ public destroy() {
31
+ document.removeEventListener("dragstart", this.dragstartHandler, true);
32
+ document.removeEventListener("drop", this.dropHandler, true);
33
+ }
34
+ }
@@ -15,7 +15,7 @@ export class NavigatorProtector {
15
15
  private printProtector?: PrintProtector;
16
16
  private contextMenuProtector?: ContextMenuProtector;
17
17
 
18
- private dispatchSuspiciousActivity(type: string, detail: Record<string, unknown>) {
18
+ protected dispatchSuspiciousActivity(type: string, detail: Record<string, unknown>) {
19
19
  const event = new CustomEvent(NAVIGATOR_SUSPICIOUS_ACTIVITY_EVENT, {
20
20
  detail: {
21
21
  type,
@@ -55,7 +55,7 @@ export class NavigatorProtector {
55
55
  }
56
56
  });
57
57
  }
58
-
58
+
59
59
  // Enable iframe embedding detection if explicitly enabled in config
60
60
  if (config.checkIFrameEmbedding) {
61
61
  this.iframeEmbeddingDetector = new IframeEmbeddingDetector({
@@ -74,7 +74,7 @@ export class NavigatorProtector {
74
74
  }
75
75
  });
76
76
  }
77
-
77
+
78
78
  // Enable context menu protection if configured
79
79
  if (config.disableContextMenu) {
80
80
  this.contextMenuProtector = new ContextMenuProtector({
@@ -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 customEvent = event as CustomEvent;
132
- this.listeners.contentProtection(customEvent.detail.type, customEvent.detail);
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
  }
@@ -1,10 +1,12 @@
1
- import { Link, Locator, Publication } from "@readium/shared";
2
- import { MediaNavigator } from "../Navigator";
1
+ import { Link, Locator, Publication, Timeline, TimelineItem } from "@readium/shared";
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 { ContextMenuEvent, KeyboardEventData, SuspiciousActivityEvent } from "@readium/navigator-html-injectables";
5
6
  export interface AudioNavigatorListeners {
6
7
  trackLoaded: (media: HTMLMediaElement) => void;
7
8
  positionChanged: (locator: Locator) => void;
9
+ timelineItemChanged: (item: TimelineItem | undefined) => void;
8
10
  error: (error: any, locator: Locator) => void;
9
11
  trackEnded: (locator: Locator) => void;
10
12
  play: (locator: Locator) => void;
@@ -13,28 +15,42 @@ export interface AudioNavigatorListeners {
13
15
  stalled: (isStalled: boolean) => void;
14
16
  seeking: (isSeeking: boolean) => void;
15
17
  seekable: (seekable: TimeRanges) => void;
18
+ contentProtection: (type: string, data: SuspiciousActivityEvent) => void;
19
+ peripheral: (data: KeyboardEventData) => void;
20
+ contextMenu: (data: ContextMenuEvent) => void;
16
21
  }
17
22
  export interface AudioNavigatorConfiguration {
18
23
  preferences: IAudioPreferences;
19
24
  defaults: IAudioDefaults;
25
+ contentProtection?: IContentProtectionConfig;
26
+ keyboardPeripherals?: IKeyboardPeripheralsConfig;
20
27
  }
21
28
  export declare class AudioNavigator extends MediaNavigator implements Configurable<AudioSettings, AudioPreferences> {
22
29
  private readonly pub;
23
30
  private positionPollInterval;
24
31
  private navigationId;
32
+ private _playIntent;
25
33
  private listeners;
26
34
  private currentLocation;
27
35
  private _preferences;
28
36
  private _defaults;
29
37
  private _settings;
30
38
  private _preferencesEditor;
39
+ private _mediaSessionEnabled;
31
40
  private pool;
41
+ private readonly _navigatorProtector;
42
+ private _currentTimelineItem;
43
+ private readonly _keyboardPeripheralsManager;
44
+ private readonly _suspiciousActivityListener;
45
+ private readonly _keyboardPeripheralListener;
32
46
  constructor(publication: Publication, listeners: AudioNavigatorListeners, initialPosition?: Locator, configuration?: AudioNavigatorConfiguration);
33
47
  get settings(): AudioSettings;
34
48
  get preferencesEditor(): AudioPreferencesEditor;
35
49
  submitPreferences(preferences: AudioPreferences): Promise<void>;
36
50
  private applyPreferences;
37
51
  get publication(): Publication;
52
+ get timeline(): Timeline;
53
+ private _notifyTimelineChange;
38
54
  private ensureLocatorLocations;
39
55
  /** Resolves a bare href (no fragment) to its index in the reading order. Returns -1 if not found. */
40
56
  private hrefToTrackIndex;
@@ -1,52 +1,28 @@
1
1
  import { Publication } from "@readium/shared";
2
2
  import { WebAudioEngine } from "./engine/WebAudioEngine";
3
3
  export declare class AudioPoolManager {
4
- private preloadedElements;
4
+ private readonly pool;
5
5
  private _audioEngine;
6
- constructor(audioEngine: WebAudioEngine);
6
+ private readonly _publication;
7
+ private readonly _supportedAudioTypes;
8
+ constructor(audioEngine: WebAudioEngine, publication: Publication);
9
+ private detectSupportedAudioTypes;
10
+ private pickPlayableHref;
7
11
  get audioEngine(): WebAudioEngine;
8
12
  /**
9
- * Sets the current audio by href, using preloaded element if available or loading otherwise,
10
- * and preloads adjacent tracks.
11
- * @param href The URL of the audio resource.
12
- * @param publication The publication containing the reading order.
13
- * @param currentIndex The current track index.
14
- * @param direction The navigation direction ('forward' or 'backward').
13
+ * Ensures an audio element exists in the pool for the given href.
14
+ * If one already exists, it is left untouched (preserving its buffered data).
15
15
  */
16
- setCurrentAudio(href: string, publication: Publication, currentIndex: number, direction: 'forward' | 'backward'): void;
17
- preload(href: string): void;
16
+ private ensure;
18
17
  /**
19
- * Retrieves a preloaded audio element by URL.
20
- * @param href The URL of the audio resource.
21
- * @returns The preloaded HTMLAudioElement, or undefined if not preloaded.
18
+ * Updates the pool around the given index: ensures elements exist within
19
+ * the LOWER_BOUNDARY and disposes those beyond the UPPER_BOUNDARY.
22
20
  */
23
- get(href: string): HTMLAudioElement | undefined;
21
+ private update;
24
22
  /**
25
- * Removes a preloaded element from the pool.
26
- * @param href The URL of the audio resource.
27
- */
28
- clear(href: string): void;
29
- /**
30
- * Preloads the next track in the reading order.
31
- * @param publication The publication containing the reading order.
32
- * @param currentIndex The current track index.
33
- */
34
- preloadNext(publication: Publication, currentIndex: number): void;
35
- /**
36
- * Preloads the previous track in the reading order.
37
- * @param publication The publication containing the reading order.
38
- * @param currentIndex The current track index.
39
- */
40
- preloadPrevious(publication: Publication, currentIndex: number): void;
41
- /**
42
- * Preloads adjacent tracks (previous and next) for smoother navigation.
43
- * @param publication The publication containing the reading order.
44
- * @param currentIndex The current track index.
45
- * @param direction The navigation direction ('forward' or 'backward').
46
- */
47
- preloadAdjacent(publication: Publication, currentIndex: number, direction?: 'forward' | 'backward'): void;
48
- /**
49
- * Destroys the pool by stopping the engine and clearing all preloaded elements.
23
+ * Sets the current audio for playback at the given track index.
24
+ * The element is always sourced from the pool — never loaded ad-hoc on the engine.
50
25
  */
26
+ setCurrentAudio(currentIndex: number, _direction: 'forward' | 'backward'): void;
51
27
  destroy(): void;
52
28
  }
@@ -10,10 +10,6 @@ export interface PlaybackState {
10
10
  * The duration of the audio resource.
11
11
  */
12
12
  duration: number;
13
- /**
14
- * The volume of the audio resource.
15
- */
16
- volume: number;
17
13
  }
18
14
  /**
19
15
  * Playback interface for an audio engine state
@@ -40,11 +36,6 @@ export interface AudioEngine {
40
36
  * The current playback state.
41
37
  */
42
38
  playback: Playback;
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
39
  /**
49
40
  * Adds an event listener to the audio engine.
50
41
  * @param event The event name to listen.
@@ -7,6 +7,7 @@ export declare class WebAudioEngine implements AudioEngine {
7
7
  private sourceNode;
8
8
  private gainNode;
9
9
  private listeners;
10
+ private currentVolume;
10
11
  private currentPlaybackRate;
11
12
  private isMutedValue;
12
13
  private isPlayingValue;
@@ -47,11 +48,6 @@ export declare class WebAudioEngine implements AudioEngine {
47
48
  * @param callback - callback function to be removed.
48
49
  */
49
50
  off(event: string, callback: EventCallback): void;
50
- /**
51
- * Load the audio resource at the given URL.
52
- * @param url The URL of the audio resource.
53
- * */
54
- loadAudio(url: string): void;
55
51
  private deactivateWebAudio;
56
52
  /**
57
53
  * Sets the media element for playback.
@@ -0,0 +1,8 @@
1
+ import { NavigatorProtector } from "../../protection/NavigatorProtector";
2
+ import { IContentProtectionConfig } from "../../Navigator";
3
+ export declare class AudioNavigatorProtector extends NavigatorProtector {
4
+ private dragAndDropProtector?;
5
+ private copyProtector?;
6
+ constructor(config?: IContentProtectionConfig);
7
+ destroy(): void;
8
+ }
@@ -0,0 +1,8 @@
1
+ export interface CopyProtectionOptions {
2
+ onCopyBlocked?: () => void;
3
+ }
4
+ export declare class CopyProtector {
5
+ private copyHandler;
6
+ constructor(options?: CopyProtectionOptions);
7
+ destroy(): void;
8
+ }
@@ -0,0 +1,10 @@
1
+ export interface DragAndDropProtectionOptions {
2
+ onDragDetected?: (dataTransferTypes: readonly string[]) => void;
3
+ onDropDetected?: (dataTransferTypes: readonly string[], fileCount: number) => void;
4
+ }
5
+ export declare class DragAndDropProtector {
6
+ private dragstartHandler;
7
+ private dropHandler;
8
+ constructor(options?: DragAndDropProtectionOptions);
9
+ destroy(): void;
10
+ }
@@ -6,7 +6,7 @@ export declare class NavigatorProtector {
6
6
  private iframeEmbeddingDetector?;
7
7
  private printProtector?;
8
8
  private contextMenuProtector?;
9
- private dispatchSuspiciousActivity;
9
+ protected dispatchSuspiciousActivity(type: string, detail: Record<string, unknown>): void;
10
10
  constructor(config?: IContentProtectionConfig);
11
11
  destroy(): void;
12
12
  }