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

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@readium/navigator",
3
- "version": "2.4.0-beta.1",
3
+ "version": "2.4.0-beta.11",
4
4
  "type": "module",
5
5
  "description": "Next generation SDK for publications in Web Apps",
6
6
  "author": "readium",
@@ -1,5 +1,5 @@
1
- import { Link, Locator, LocatorLocations, Publication } from "@readium/shared";
2
- import { MediaNavigator } from "../Navigator";
1
+ import { Link, Locator, LocatorLocations, Publication, Timeline, TimelineItem } from "@readium/shared";
2
+ import { MediaNavigator, IContentProtectionConfig, IKeyboardPeripheralsConfig } from "../Navigator";
3
3
  import { Configurable } from "../preferences";
4
4
  import { WebAudioEngine, PlaybackState } from "./engine";
5
5
  import {
@@ -11,29 +11,70 @@ import {
11
11
  IAudioDefaults
12
12
  } from "./preferences";
13
13
  import { AudioPoolManager } from "./AudioPoolManager";
14
+ import { ContextMenuEvent, KeyboardEventData, SuspiciousActivityEvent } from "@readium/navigator-html-injectables";
15
+ import { AudioNavigatorProtector } from "./protection/AudioNavigatorProtector";
16
+ import { NAVIGATOR_SUSPICIOUS_ACTIVITY_EVENT } from "../protection/NavigatorProtector";
17
+ import { KeyboardPeripherals, NAVIGATOR_KEYBOARD_PERIPHERAL_EVENT } from "../peripherals/KeyboardPeripherals";
18
+
19
+ export interface AudioMetadata {
20
+ duration: number;
21
+ textTracks: TextTrackList;
22
+ readyState: number;
23
+ networkState: number;
24
+ }
14
25
 
15
26
  export interface AudioNavigatorListeners {
16
27
  trackLoaded: (media: HTMLMediaElement) => void;
17
28
  positionChanged: (locator: Locator) => void;
29
+ timelineItemChanged: (item: TimelineItem | undefined) => void;
18
30
  error: (error: any, locator: Locator) => void;
19
31
  trackEnded: (locator: Locator) => void;
20
32
  play: (locator: Locator) => void;
21
33
  pause: (locator: Locator) => void;
22
- metadataLoaded: (duration: number) => void;
34
+ metadataLoaded: (metadata: AudioMetadata) => void;
23
35
  stalled: (isStalled: boolean) => void;
24
36
  seeking: (isSeeking: boolean) => void;
25
37
  seekable: (seekable: TimeRanges) => void;
38
+ contentProtection: (type: string, data: SuspiciousActivityEvent) => void;
39
+ peripheral: (data: KeyboardEventData) => void;
40
+ contextMenu: (data: ContextMenuEvent) => void;
41
+ remotePlaybackStateChanged?: (state: RemotePlaybackState) => void;
42
+ }
43
+
44
+ const defaultListeners = (listeners: Partial<AudioNavigatorListeners>): AudioNavigatorListeners => ({
45
+ trackLoaded: listeners.trackLoaded ?? (() => {}),
46
+ positionChanged: listeners.positionChanged ?? (() => {}),
47
+ timelineItemChanged: listeners.timelineItemChanged ?? (() => {}),
48
+ error: listeners.error ?? (() => {}),
49
+ trackEnded: listeners.trackEnded ?? (() => {}),
50
+ play: listeners.play ?? (() => {}),
51
+ pause: listeners.pause ?? (() => {}),
52
+ metadataLoaded: listeners.metadataLoaded ?? (() => {}),
53
+ stalled: listeners.stalled ?? (() => {}),
54
+ seeking: listeners.seeking ?? (() => {}),
55
+ seekable: listeners.seekable ?? (() => {}),
56
+ contentProtection: listeners.contentProtection ?? (() => {}),
57
+ peripheral: listeners.peripheral ?? (() => {}),
58
+ contextMenu: listeners.contextMenu ?? (() => {}),
59
+ });
60
+
61
+ export interface IAudioContentProtectionConfig extends IContentProtectionConfig {
62
+ /** Prevents the media element from being cast to remote devices via the Remote Playback API. */
63
+ disableRemotePlayback?: boolean;
26
64
  }
27
65
 
28
66
  export interface AudioNavigatorConfiguration {
29
67
  preferences: IAudioPreferences;
30
68
  defaults: IAudioDefaults;
69
+ contentProtection?: IAudioContentProtectionConfig;
70
+ keyboardPeripherals?: IKeyboardPeripheralsConfig;
31
71
  }
32
72
 
33
73
  export class AudioNavigator extends MediaNavigator implements Configurable<AudioSettings, AudioPreferences> {
34
74
  private readonly pub: Publication;
35
75
  private positionPollInterval: ReturnType<typeof setInterval> | null = null;
36
76
  private navigationId: number = 0;
77
+ private _playIntent: boolean = false;
37
78
  private listeners: AudioNavigatorListeners;
38
79
  private currentLocation!: Locator;
39
80
 
@@ -41,7 +82,16 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
41
82
  private _defaults: AudioDefaults;
42
83
  private _settings: AudioSettings;
43
84
  private _preferencesEditor: AudioPreferencesEditor | null = null;
85
+ private _mediaSessionEnabled: boolean = false;
44
86
  private pool: AudioPoolManager;
87
+ private readonly _navigatorProtector: AudioNavigatorProtector | null = null;
88
+ private _currentTimelineItem: TimelineItem | undefined;
89
+ private readonly _keyboardPeripheralsManager: KeyboardPeripherals | null = null;
90
+ private readonly _suspiciousActivityListener: ((event: Event) => void) | null = null;
91
+ private readonly _keyboardPeripheralListener: ((event: Event) => void) | null = null;
92
+ private readonly _contentProtection: IAudioContentProtectionConfig;
93
+ /** True while a track transition is in progress; suppresses spurious mid-navigation events. */
94
+ private _isNavigating: boolean = false;
45
95
 
46
96
  constructor(publication: Publication, listeners: AudioNavigatorListeners, initialPosition?: Locator, configuration: AudioNavigatorConfiguration = {
47
97
  preferences: {},
@@ -49,11 +99,11 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
49
99
  }) {
50
100
  super();
51
101
  this.pub = publication;
102
+ this.listeners = defaultListeners(listeners);
52
103
 
53
104
  this._preferences = new AudioPreferences(configuration.preferences);
54
105
  this._defaults = new AudioDefaults(configuration.defaults);
55
106
  this._settings = new AudioSettings(this._preferences, this._defaults);
56
- this.listeners = listeners;
57
107
 
58
108
  if (initialPosition) {
59
109
  this.currentLocation = this.ensureLocatorLocations(initialPosition);
@@ -81,30 +131,67 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
81
131
  state: {
82
132
  currentTime: initialTime,
83
133
  duration: 0,
84
- volume: this._settings.volume
85
134
  } as PlaybackState,
86
135
  playWhenReady: false,
87
136
  index: trackIndex
88
137
  }
89
138
  });
90
139
 
91
- this.pool = new AudioPoolManager(audioEngine);
92
- this.setupEventListeners();
140
+ this.pool = new AudioPoolManager(audioEngine, publication, configuration.contentProtection);
141
+
142
+ // Initialize content protection
143
+ const contentProtection = configuration.contentProtection || {};
144
+ this._contentProtection = contentProtection;
145
+ const keyboardPeripherals = this.mergeKeyboardPeripherals(
146
+ contentProtection,
147
+ configuration.keyboardPeripherals || []
148
+ );
149
+
150
+ if (contentProtection.disableContextMenu ||
151
+ contentProtection.checkAutomation ||
152
+ contentProtection.checkIFrameEmbedding ||
153
+ contentProtection.monitorDevTools ||
154
+ contentProtection.protectPrinting?.disable ||
155
+ contentProtection.disableDragAndDrop ||
156
+ contentProtection.protectCopy) {
157
+ this._navigatorProtector = new AudioNavigatorProtector(contentProtection);
158
+ this._suspiciousActivityListener = (event: Event) => {
159
+ const { type, ...detail } = (event as CustomEvent).detail;
160
+ if (type === "context_menu") {
161
+ this.listeners.contextMenu(detail as ContextMenuEvent);
162
+ } else {
163
+ this.listeners.contentProtection(type, detail as SuspiciousActivityEvent);
164
+ }
165
+ };
166
+ window.addEventListener(NAVIGATOR_SUSPICIOUS_ACTIVITY_EVENT, this._suspiciousActivityListener);
167
+ }
93
168
 
94
- if (this._settings.enableMediaSession) {
95
- this.setupMediaSession();
169
+ if (keyboardPeripherals.length > 0) {
170
+ this._keyboardPeripheralsManager = new KeyboardPeripherals({ keyboardPeripherals });
171
+ this._keyboardPeripheralListener = (event: Event) => {
172
+ this.listeners.peripheral((event as CustomEvent).detail);
173
+ };
174
+ window.addEventListener(NAVIGATOR_KEYBOARD_PERIPHERAL_EVENT, this._keyboardPeripheralListener);
96
175
  }
97
176
 
98
- this.pool.setCurrentAudio(initialHref, this.pub, trackIndex, "forward");
177
+ this.setupEventListeners();
178
+ this.applyPreferences();
179
+
180
+ this._isNavigating = true;
181
+ this.pool.setCurrentAudio(trackIndex, "forward");
99
182
 
100
183
  // Load and seek to initial position, then notify consumer.
101
184
  // No cancellation needed here — the constructor runs once.
102
185
  this.waitForLoadedAndSeeked(initialTime)
103
186
  .then(() => {
187
+ this._isNavigating = false;
104
188
  this.listeners.trackLoaded(this.pool.audioEngine.getMediaElement());
189
+ this._notifyTimelineChange(this.currentLocator);
105
190
  this.listeners.positionChanged(this.currentLocator);
191
+ this._setupRemotePlayback();
106
192
  })
107
193
  .catch(() => {
194
+ this._isNavigating = false;
108
195
  // Error already forwarded via the error event listener.
109
196
  });
110
197
  }
@@ -126,7 +213,6 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
126
213
  }
127
214
 
128
215
  private applyPreferences(): void {
129
- const oldSettings = this._settings;
130
216
  this._settings = new AudioSettings(this._preferences, this._defaults);
131
217
 
132
218
  if (this._preferencesEditor !== null) {
@@ -136,9 +222,11 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
136
222
  this.pool.audioEngine.setVolume(this._settings.volume);
137
223
  this.pool.audioEngine.setPlaybackRate(this._settings.playbackRate, this._settings.preservePitch);
138
224
 
139
- if (this._settings.enableMediaSession && !oldSettings.enableMediaSession) {
225
+ if (this._settings.enableMediaSession && !this._mediaSessionEnabled) {
226
+ this._mediaSessionEnabled = true;
140
227
  this.setupMediaSession();
141
- } else if (!this._settings.enableMediaSession && oldSettings.enableMediaSession) {
228
+ } else if (!this._settings.enableMediaSession && this._mediaSessionEnabled) {
229
+ this._mediaSessionEnabled = false;
142
230
  this.destroyMediaSession();
143
231
  }
144
232
  }
@@ -147,6 +235,18 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
147
235
  return this.pub;
148
236
  }
149
237
 
238
+ get timeline(): Timeline {
239
+ return this.pub.timeline;
240
+ }
241
+
242
+ private _notifyTimelineChange(locator: Locator): void {
243
+ const item = this.pub.timeline.locate(locator);
244
+ if (item !== this._currentTimelineItem) {
245
+ this._currentTimelineItem = item;
246
+ this.listeners.timelineItemChanged(item);
247
+ }
248
+ }
249
+
150
250
  private ensureLocatorLocations(locator: Locator): Locator {
151
251
  return new Locator({
152
252
  ...locator,
@@ -264,25 +364,30 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
264
364
  fragments: [`t=${this.duration}`]
265
365
  }));
266
366
  this.listeners.trackEnded(this.currentLocator);
367
+ if (!this.canGoForward) return;
267
368
  await this.nextTrack();
268
369
  if (this._settings.autoPlay) this.play();
269
370
  });
270
371
 
271
372
  this.pool.audioEngine.on("play", () => {
373
+ if (this._isNavigating) return;
272
374
  this.startPositionPolling();
273
375
  this.listeners.play(this.currentLocator);
274
376
  });
275
377
 
276
378
  this.pool.audioEngine.on("playing", () => {
379
+ if (this._isNavigating) return;
277
380
  this.listeners.stalled(false);
278
381
  });
279
382
 
280
383
  this.pool.audioEngine.on("pause", () => {
384
+ if (this._isNavigating) return;
281
385
  this.stopPositionPolling();
282
386
  this.listeners.pause(this.currentLocator);
283
387
  });
284
388
 
285
389
  this.pool.audioEngine.on("seeked", () => {
390
+ if (this._isNavigating) return;
286
391
  this.listeners.seeking(false);
287
392
  if (!this.isPlaying) {
288
393
  const currentTime = this.currentTime;
@@ -293,17 +398,26 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
293
398
  progression,
294
399
  fragments: [`t=${currentTime}`]
295
400
  }));
401
+ this._notifyTimelineChange(this.currentLocation);
296
402
  this.listeners.positionChanged(this.currentLocation);
297
403
  }
298
404
  });
299
405
 
300
- this.pool.audioEngine.on("seeking", () => this.listeners.seeking(true));
301
- this.pool.audioEngine.on("waiting", () => this.listeners.seeking(true));
302
- this.pool.audioEngine.on("stalled", () => this.listeners.stalled(true));
303
- this.pool.audioEngine.on("progress", (seekable: TimeRanges) => this.listeners.seekable(seekable));
304
-
406
+ this.pool.audioEngine.on("seeking", () => { if (!this._isNavigating) this.listeners.seeking(true); });
407
+ this.pool.audioEngine.on("waiting", () => { if (!this._isNavigating) this.listeners.seeking(true); });
408
+ this.pool.audioEngine.on("stalled", () => { if (!this._isNavigating) this.listeners.stalled(true); });
409
+ this.pool.audioEngine.on("canplaythrough", () => { if (!this._isNavigating) this.listeners.stalled(false); });
410
+ this.pool.audioEngine.on("progress", (seekable: TimeRanges) => { if (!this._isNavigating) this.listeners.seekable(seekable); });
411
+
305
412
  this.pool.audioEngine.on("loadedmetadata", () => {
306
- this.listeners.metadataLoaded(this.pool.audioEngine.duration());
413
+ const mediaElement = this.pool.audioEngine.getMediaElement();
414
+ const metadata: AudioMetadata = {
415
+ duration: this.pool.audioEngine.duration(),
416
+ textTracks: mediaElement.textTracks,
417
+ readyState: mediaElement.readyState,
418
+ networkState: mediaElement.networkState
419
+ };
420
+ this.listeners.metadataLoaded(metadata);
307
421
  });
308
422
  }
309
423
 
@@ -322,12 +436,14 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
322
436
  if (!("mediaSession" in navigator)) return;
323
437
  const trackIndex = this.currentTrackIndex();
324
438
  const track = this.pub.readingOrder.items[trackIndex];
439
+ const cover = this.pub.getCover();
325
440
  navigator.mediaSession.metadata = new MediaMetadata({
326
441
  title: track?.title || `Track ${trackIndex + 1}`,
327
442
  artist: this.pub.metadata.authors
328
443
  ? this.pub.metadata.authors.items.map((a) => a.name.getTranslation()).join(", ")
329
444
  : undefined,
330
445
  album: this.pub.metadata.title.getTranslation(),
446
+ artwork: cover ? [{ src: cover.href, type: cover.type }] : undefined,
331
447
  });
332
448
  }
333
449
 
@@ -342,6 +458,7 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
342
458
  progression,
343
459
  fragments: [`t=${currentTime}`]
344
460
  }));
461
+ this._notifyTimelineChange(this.currentLocation);
345
462
  this.listeners.positionChanged(this.currentLocation);
346
463
  }, this._settings.pollInterval);
347
464
  }
@@ -367,17 +484,24 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
367
484
 
368
485
  const id = ++this.navigationId;
369
486
  const direction: "forward" | "backward" = trackIndex >= this.currentTrackIndex() ? "forward" : "backward";
370
- const wasPlaying = this.isPlaying;
487
+ const wasPlaying = this.isPlaying || this._playIntent;
488
+ this._playIntent = wasPlaying;
371
489
 
490
+ this._isNavigating = true;
372
491
  this.stopPositionPolling();
373
- this.pool.setCurrentAudio(href, this.pub, trackIndex, direction);
492
+ this.pool.setCurrentAudio(trackIndex, direction);
374
493
  this.currentLocation = locator.copyWithLocations(locator.locations);
375
494
 
376
495
  await this.waitForLoadedAndSeeked(time, id);
496
+ this._isNavigating = false;
377
497
 
378
- if (id !== this.navigationId) return;
498
+ if (id !== this.navigationId) {
499
+ cb(false);
500
+ return;
501
+ }
379
502
 
380
503
  this.listeners.trackLoaded(this.pool.audioEngine.getMediaElement());
504
+ this._notifyTimelineChange(this.currentLocator);
381
505
  this.listeners.positionChanged(this.currentLocator);
382
506
 
383
507
  if (this._settings.enableMediaSession) {
@@ -388,8 +512,11 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
388
512
 
389
513
  cb(true);
390
514
  } catch (error) {
515
+ this._isNavigating = false;
391
516
  console.error("Failed to go to locator:", error);
392
517
  cb(false);
518
+ } finally {
519
+ this._playIntent = false;
393
520
  }
394
521
  }
395
522
 
@@ -399,7 +526,8 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
399
526
  cb(false);
400
527
  return;
401
528
  }
402
- const locator = this.createLocator(trackIndex, 0);
529
+ const time = link.locator.locations?.time() ?? 0;
530
+ const locator = this.createLocator(trackIndex, time);
403
531
  await this.go(locator, _animated, cb);
404
532
  }
405
533
 
@@ -478,6 +606,28 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
478
606
  return this.currentTrackIndex() < this.pub.readingOrder.items.length - 1;
479
607
  }
480
608
 
609
+ /**
610
+ * The RemotePlayback object for the primary media element.
611
+ * Because the element is never swapped, this reference is stable for the
612
+ * lifetime of the navigator — host apps can store it and call `.prompt()`,
613
+ * `.watchAvailability()`, etc. directly.
614
+ */
615
+ get remotePlayback(): RemotePlayback {
616
+ return this.pool.audioEngine.getMediaElement().remote;
617
+ }
618
+
619
+ /** Wires up the optional remotePlaybackStateChanged listener. Called once after initial load. */
620
+ private _setupRemotePlayback(): void {
621
+ if (this._contentProtection.disableRemotePlayback) {
622
+ return;
623
+ }
624
+ const remote = this.remotePlayback;
625
+ if (!remote) return;
626
+ remote.onconnecting = () => this.listeners.remotePlaybackStateChanged?.("connecting");
627
+ remote.onconnect = () => this.listeners.remotePlaybackStateChanged?.("connected");
628
+ remote.ondisconnect = () => this.listeners.remotePlaybackStateChanged?.("disconnected");
629
+ }
630
+
481
631
  private destroyMediaSession(): void {
482
632
  if (!("mediaSession" in navigator)) return;
483
633
  navigator.mediaSession.metadata = null;
@@ -492,6 +642,14 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
492
642
  destroy(): void {
493
643
  this.stopPositionPolling();
494
644
  this.destroyMediaSession();
645
+ if (this._suspiciousActivityListener) {
646
+ window.removeEventListener(NAVIGATOR_SUSPICIOUS_ACTIVITY_EVENT, this._suspiciousActivityListener);
647
+ }
648
+ if (this._keyboardPeripheralListener) {
649
+ window.removeEventListener(NAVIGATOR_KEYBOARD_PERIPHERAL_EVENT, this._keyboardPeripheralListener);
650
+ }
651
+ this._navigatorProtector?.destroy();
652
+ this._keyboardPeripheralsManager?.destroy();
495
653
  this.pool.destroy();
496
654
  }
497
655
  }
@@ -1,120 +1,141 @@
1
- import { Publication } from "@readium/shared";
1
+ import { Link, Publication } from "@readium/shared";
2
2
  import { WebAudioEngine } from "./engine/WebAudioEngine";
3
+ import type { IAudioContentProtectionConfig } from "./AudioNavigator";
4
+
5
+ const UPPER_BOUNDARY = 1;
6
+ const LOWER_BOUNDARY = 1;
3
7
 
4
8
  export class AudioPoolManager {
5
- private preloadedElements: Map<string, HTMLAudioElement> = new Map();
9
+ private readonly pool: Map<string, HTMLAudioElement> = new Map();
6
10
  private _audioEngine: WebAudioEngine;
11
+ private readonly _publication: Publication;
12
+ private readonly _supportedAudioTypes: Map<string, "probably" | "maybe">;
7
13
 
8
- constructor(audioEngine: WebAudioEngine) {
14
+ constructor(audioEngine: WebAudioEngine, publication: Publication, contentProtection: IAudioContentProtectionConfig = {}) {
9
15
  this._audioEngine = audioEngine;
10
- }
16
+ this._publication = publication;
17
+ this._supportedAudioTypes = this.detectSupportedAudioTypes();
11
18
 
12
- get audioEngine(): WebAudioEngine {
13
- return this._audioEngine;
19
+ if (contentProtection.disableRemotePlayback) {
20
+ this._audioEngine.getMediaElement().disableRemotePlayback = true;
21
+ }
14
22
  }
15
23
 
16
- /**
17
- * Sets the current audio by href, using preloaded element if available or loading otherwise,
18
- * and preloads adjacent tracks.
19
- * @param href The URL of the audio resource.
20
- * @param publication The publication containing the reading order.
21
- * @param currentIndex The current track index.
22
- * @param direction The navigation direction ('forward' or 'backward').
23
- */
24
- setCurrentAudio(href: string, publication: Publication, currentIndex: number, direction: 'forward' | 'backward'): void {
25
- // When Web Audio is active, preloaded elements lack crossOrigin="anonymous"
26
- // and cannot be connected to MediaElementAudioSourceNode, so bypass the pool.
27
- const preloadedElement = !this.audioEngine.isWebAudioActive ? this.get(href) : undefined;
28
- if (preloadedElement) {
29
- this.audioEngine.setMediaElement(preloadedElement);
30
- this.clear(href);
31
- } else {
32
- this.clear(href);
33
- this.audioEngine.loadAudio(href);
24
+ private detectSupportedAudioTypes(): Map<string, "probably" | "maybe"> {
25
+ const audio = document.createElement("audio");
26
+ const unique = new Set<string>();
27
+ for (const link of this._publication.readingOrder.items) {
28
+ if (link.type) unique.add(link.type);
29
+ for (const alt of link.alternates?.items ?? []) {
30
+ if (alt.type) unique.add(alt.type);
31
+ }
34
32
  }
35
- this.preloadAdjacent(publication, currentIndex, direction);
36
- }
37
- preload(href: string): void {
38
- if (this.preloadedElements.has(href)) {
39
- return; // Already preloaded
33
+ const supported = new Map<string, "probably" | "maybe">();
34
+ for (const type of unique) {
35
+ const result = audio.canPlayType(type);
36
+ if (result !== "") supported.set(type, result as "probably" | "maybe");
40
37
  }
41
-
42
- const audioElement = document.createElement("audio");
43
- audioElement.preload = "auto";
44
- audioElement.src = href;
45
- audioElement.load(); // Start buffering
46
-
47
- this.preloadedElements.set(href, audioElement);
38
+ return supported;
48
39
  }
49
40
 
50
- /**
51
- * Retrieves a preloaded audio element by URL.
52
- * @param href The URL of the audio resource.
53
- * @returns The preloaded HTMLAudioElement, or undefined if not preloaded.
54
- */
55
- get(href: string): HTMLAudioElement | undefined {
56
- return this.preloadedElements.get(href);
41
+ private pickPlayableHref(link: Link): string {
42
+ const candidates = [link, ...(link.alternates?.items ?? [])];
43
+ let best: { href: string; confidence: "probably" | "maybe" } | undefined;
44
+ for (const candidate of candidates) {
45
+ if (!candidate.type) continue;
46
+ const confidence = this._supportedAudioTypes.get(candidate.type);
47
+ if (!confidence) continue;
48
+ if (confidence === "probably") return candidate.href;
49
+ if (!best) best = { href: candidate.href, confidence };
50
+ }
51
+ return best?.href ?? link.href;
57
52
  }
58
53
 
59
- /**
60
- * Removes a preloaded element from the pool.
61
- * @param href The URL of the audio resource.
62
- */
63
- clear(href: string): void {
64
- this.preloadedElements.delete(href);
54
+ get audioEngine(): WebAudioEngine {
55
+ return this._audioEngine;
65
56
  }
66
57
 
67
58
  /**
68
- * Preloads the next track in the reading order.
69
- * @param publication The publication containing the reading order.
70
- * @param currentIndex The current track index.
59
+ * Ensures an audio element exists in the pool for the given href.
60
+ * If one already exists, it is left untouched (preserving its buffered data).
71
61
  */
72
- preloadNext(publication: Publication, currentIndex: number): void {
73
- const nextIndex = currentIndex + 1;
74
- if (nextIndex < publication.readingOrder.items.length) {
75
- const nextLink = publication.readingOrder.items[nextIndex];
76
- if (nextLink.href) {
77
- this.preload(nextLink.href);
62
+ private ensure(href: string): HTMLAudioElement {
63
+ let element = this.pool.get(href);
64
+ if (!element) {
65
+ element = document.createElement("audio");
66
+ element.preload = "auto";
67
+ // Match the primary element's CORS mode so cached responses
68
+ // are reusable when changeSrc() loads this href on it.
69
+ if (this._audioEngine.isWebAudioActive) {
70
+ element.crossOrigin = "anonymous";
78
71
  }
72
+ element.src = href;
73
+ element.load();
74
+ this.pool.set(href, element);
79
75
  }
76
+ return element;
80
77
  }
81
78
 
82
79
  /**
83
- * Preloads the previous track in the reading order.
84
- * @param publication The publication containing the reading order.
85
- * @param currentIndex The current track index.
80
+ * Updates the pool around the given index: ensures elements exist within
81
+ * the LOWER_BOUNDARY and disposes those beyond the UPPER_BOUNDARY.
82
+ * The current track is excluded — the primary engine element represents it.
86
83
  */
87
- preloadPrevious(publication: Publication, currentIndex: number): void {
88
- const prevIndex = currentIndex - 1;
89
- if (prevIndex >= 0) {
90
- const prevLink = publication.readingOrder.items[prevIndex];
91
- if (prevLink.href) {
92
- this.preload(prevLink.href);
84
+ private update(currentIndex: number): void {
85
+ const items = this._publication.readingOrder.items;
86
+ const keep = new Set<string>();
87
+
88
+ for (let j = 0; j < items.length; j++) {
89
+ if (j === currentIndex) continue; // primary element handles the current track
90
+ const href = this.pickPlayableHref(items[j]);
91
+ if (j >= currentIndex - LOWER_BOUNDARY && j <= currentIndex + LOWER_BOUNDARY) {
92
+ this.ensure(href);
93
+ keep.add(href);
94
+ } else if (j >= currentIndex - UPPER_BOUNDARY && j <= currentIndex + UPPER_BOUNDARY) {
95
+ // Between lower and upper: keep if already loaded, don't create
96
+ if (this.pool.has(href)) {
97
+ keep.add(href);
98
+ }
99
+ }
100
+ }
101
+
102
+ // Dispose elements beyond the upper boundary
103
+ for (const [href, element] of this.pool) {
104
+ if (!keep.has(href)) {
105
+ element.removeAttribute("src");
106
+ element.load(); // release network resources
107
+ this.pool.delete(href);
93
108
  }
94
109
  }
95
110
  }
96
111
 
97
112
  /**
98
- * Preloads adjacent tracks (previous and next) for smoother navigation.
99
- * @param publication The publication containing the reading order.
100
- * @param currentIndex The current track index.
101
- * @param direction The navigation direction ('forward' or 'backward').
113
+ * Sets the current audio for playback at the given track index by changing
114
+ * the src on the persistent primary element. This preserves the RemotePlayback
115
+ * session and any Web Audio graph connections across track changes.
102
116
  */
103
- preloadAdjacent(publication: Publication, currentIndex: number, direction: 'forward' | 'backward' = 'forward'): void {
104
- if (direction === 'forward') {
105
- this.preloadNext(publication, currentIndex);
106
- this.preloadPrevious(publication, currentIndex);
107
- } else {
108
- this.preloadPrevious(publication, currentIndex);
109
- this.preloadNext(publication, currentIndex);
117
+ setCurrentAudio(currentIndex: number, _direction: 'forward' | 'backward'): void {
118
+ const href = this.pickPlayableHref(this._publication.readingOrder.items[currentIndex]);
119
+ this.audioEngine.changeSrc(href);
120
+
121
+ // Discard any pool entry for this href — the primary element owns it now
122
+ if (this.pool.has(href)) {
123
+ const existing = this.pool.get(href)!;
124
+ existing.removeAttribute("src");
125
+ existing.load();
126
+ this.pool.delete(href);
110
127
  }
128
+
129
+ // Manage the pool around the new position
130
+ this.update(currentIndex);
111
131
  }
112
132
 
113
- /**
114
- * Destroys the pool by stopping the engine and clearing all preloaded elements.
115
- */
116
133
  destroy(): void {
117
134
  this.audioEngine.stop();
118
- this.preloadedElements.clear();
135
+ for (const [, element] of this.pool) {
136
+ element.removeAttribute("src");
137
+ element.load();
138
+ }
139
+ this.pool.clear();
119
140
  }
120
141
  }