@readium/navigator 2.4.0-alpha.8 → 2.4.0-beta.1

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.
Files changed (54) hide show
  1. package/dist/index.js +1809 -1055
  2. package/dist/index.umd.cjs +170 -23
  3. package/package.json +10 -10
  4. package/src/Navigator.ts +55 -1
  5. package/src/audio/AudioNavigator.ts +497 -0
  6. package/src/audio/AudioPoolManager.ts +120 -0
  7. package/src/audio/engine/AudioEngine.ts +26 -10
  8. package/src/audio/engine/PreservePitchProcessor.js +149 -0
  9. package/src/audio/engine/PreservePitchWorklet.ts +79 -0
  10. package/src/audio/engine/WebAudioEngine.ts +558 -259
  11. package/src/audio/index.ts +3 -1
  12. package/src/audio/preferences/AudioDefaults.ts +43 -0
  13. package/src/audio/preferences/AudioPreferences.ts +54 -0
  14. package/src/audio/preferences/AudioPreferencesEditor.ts +123 -0
  15. package/src/audio/preferences/AudioSettings.ts +36 -0
  16. package/src/audio/preferences/index.ts +4 -0
  17. package/src/epub/EpubNavigator.ts +2 -2
  18. package/src/epub/frame/FrameBlobBuilder.ts +33 -84
  19. package/src/epub/frame/FramePoolManager.ts +23 -18
  20. package/src/epub/fxl/FXLFrameManager.ts +4 -11
  21. package/src/epub/fxl/FXLFramePoolManager.ts +22 -26
  22. package/src/epub/preferences/EpubPreferences.ts +4 -4
  23. package/src/injection/Injector.ts +5 -5
  24. package/src/preferences/Configurable.ts +2 -3
  25. package/src/preferences/PreferencesEditor.ts +1 -1
  26. package/src/preferences/Types.ts +19 -0
  27. package/src/webpub/WebPubNavigator.ts +1 -2
  28. package/src/webpub/preferences/WebPubPreferences.ts +3 -3
  29. package/types/src/Navigator.d.ts +46 -0
  30. package/types/src/audio/AudioNavigator.d.ts +79 -0
  31. package/types/src/audio/AudioPoolManager.d.ts +52 -0
  32. package/types/src/audio/engine/AudioEngine.d.ts +21 -7
  33. package/types/src/audio/engine/PreservePitchWorklet.d.ts +18 -0
  34. package/types/src/audio/engine/WebAudioEngine.d.ts +52 -7
  35. package/types/src/audio/index.d.ts +2 -0
  36. package/types/src/audio/preferences/AudioDefaults.d.ts +21 -0
  37. package/types/src/audio/preferences/AudioPreferences.d.ts +23 -0
  38. package/types/src/audio/preferences/AudioPreferencesEditor.d.ts +19 -0
  39. package/types/src/audio/preferences/AudioSettings.d.ts +24 -0
  40. package/types/src/audio/preferences/index.d.ts +4 -0
  41. package/types/src/epub/EpubNavigator.d.ts +2 -2
  42. package/types/src/epub/frame/FrameBlobBuilder.d.ts +3 -6
  43. package/types/src/epub/fxl/FXLFrameManager.d.ts +0 -2
  44. package/types/src/epub/preferences/EpubPreferences.d.ts +2 -2
  45. package/types/src/preferences/Configurable.d.ts +2 -3
  46. package/types/src/preferences/PreferencesEditor.d.ts +1 -1
  47. package/types/src/preferences/Types.d.ts +3 -0
  48. package/types/src/webpub/WebPubNavigator.d.ts +2 -2
  49. package/types/src/webpub/preferences/WebPubPreferences.d.ts +2 -2
  50. package/LICENSE +0 -28
  51. package/src/divina/DivinaNavigator.ts +0 -0
  52. package/src/divina/index.ts +0 -0
  53. package/types/src/divina/DivinaNavigator.d.ts +0 -0
  54. package/types/src/divina/index.d.ts +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@readium/navigator",
3
- "version": "2.4.0-alpha.8",
3
+ "version": "2.4.0-beta.1",
4
4
  "type": "module",
5
5
  "description": "Next generation SDK for publications in Web Apps",
6
6
  "author": "readium",
@@ -44,8 +44,15 @@
44
44
  "engines": {
45
45
  "node": ">=18"
46
46
  },
47
+ "scripts": {
48
+ "clean": "rimraf types dist",
49
+ "build": "pnpm clean && node scripts/generate-css-selector.js && tsc && vite build",
50
+ "generate:css-selector": "node scripts/generate-css-selector.js"
51
+ },
47
52
  "devDependencies": {
48
53
  "@readium/css": "^2.0.0",
54
+ "@readium/navigator-html-injectables": "workspace:*",
55
+ "@readium/shared": "workspace:*",
49
56
  "@types/path-browserify": "^1.0.3",
50
57
  "css-selector-generator": "^3.8.0",
51
58
  "path-browserify": "^1.0.1",
@@ -54,13 +61,6 @@
54
61
  "typescript": "^5.9.3",
55
62
  "typescript-plugin-css-modules": "^5.2.0",
56
63
  "user-agent-data-types": "^0.4.2",
57
- "vite": "^7.3.1",
58
- "@readium/navigator-html-injectables": "2.3.0",
59
- "@readium/shared": "2.1.5"
60
- },
61
- "scripts": {
62
- "clean": "rimraf types dist",
63
- "build": "pnpm clean && node scripts/generate-css-selector.js && tsc && vite build",
64
- "generate:css-selector": "node scripts/generate-css-selector.js"
64
+ "vite": "^7.3.1"
65
65
  }
66
- }
66
+ }
package/src/Navigator.ts CHANGED
@@ -149,5 +149,59 @@ export abstract class VisualNavigator extends Navigator {
149
149
  }
150
150
  }
151
151
 
152
+ export abstract class MediaNavigator extends Navigator {
153
+ /**
154
+ * Current playback state - is media currently playing?
155
+ */
156
+ abstract get isPlaying(): boolean;
157
+
158
+ /**
159
+ * Current playback state - is media currently paused?
160
+ */
161
+ abstract get isPaused(): boolean;
162
+
163
+ /**
164
+ * Duration of current media resource in seconds
165
+ */
166
+ abstract get duration(): number;
152
167
 
153
- // TODO MediaNavigator
168
+ /**
169
+ * Current time in seconds within the media resource
170
+ */
171
+ abstract get currentTime(): number;
172
+
173
+ /**
174
+ * Play the current media resource
175
+ */
176
+ abstract play(): void;
177
+
178
+ /**
179
+ * Pause the currently playing media
180
+ */
181
+ abstract pause(): void;
182
+
183
+ /**
184
+ * Stop playback and reset to beginning
185
+ */
186
+ abstract stop(): void;
187
+
188
+ /**
189
+ * Seek to specific time in seconds
190
+ */
191
+ abstract seek(time: number): void;
192
+
193
+ /**
194
+ * Jump forward or backward by specified seconds
195
+ */
196
+ abstract jump(seconds: number): void;
197
+
198
+ /**
199
+ * Skip forward by the configured interval
200
+ */
201
+ abstract skipForward(): void;
202
+
203
+ /**
204
+ * Skip backward by the configured interval
205
+ */
206
+ abstract skipBackward(): void;
207
+ }
@@ -0,0 +1,497 @@
1
+ import { Link, Locator, LocatorLocations, Publication } from "@readium/shared";
2
+ import { MediaNavigator } from "../Navigator";
3
+ import { Configurable } from "../preferences";
4
+ import { WebAudioEngine, PlaybackState } from "./engine";
5
+ import {
6
+ AudioPreferences,
7
+ AudioDefaults,
8
+ AudioSettings,
9
+ AudioPreferencesEditor,
10
+ IAudioPreferences,
11
+ IAudioDefaults
12
+ } from "./preferences";
13
+ import { AudioPoolManager } from "./AudioPoolManager";
14
+
15
+ export interface AudioNavigatorListeners {
16
+ trackLoaded: (media: HTMLMediaElement) => void;
17
+ positionChanged: (locator: Locator) => void;
18
+ error: (error: any, locator: Locator) => void;
19
+ trackEnded: (locator: Locator) => void;
20
+ play: (locator: Locator) => void;
21
+ pause: (locator: Locator) => void;
22
+ metadataLoaded: (duration: number) => void;
23
+ stalled: (isStalled: boolean) => void;
24
+ seeking: (isSeeking: boolean) => void;
25
+ seekable: (seekable: TimeRanges) => void;
26
+ }
27
+
28
+ export interface AudioNavigatorConfiguration {
29
+ preferences: IAudioPreferences;
30
+ defaults: IAudioDefaults;
31
+ }
32
+
33
+ export class AudioNavigator extends MediaNavigator implements Configurable<AudioSettings, AudioPreferences> {
34
+ private readonly pub: Publication;
35
+ private positionPollInterval: ReturnType<typeof setInterval> | null = null;
36
+ private navigationId: number = 0;
37
+ private listeners: AudioNavigatorListeners;
38
+ private currentLocation!: Locator;
39
+
40
+ private _preferences: AudioPreferences;
41
+ private _defaults: AudioDefaults;
42
+ private _settings: AudioSettings;
43
+ private _preferencesEditor: AudioPreferencesEditor | null = null;
44
+ private pool: AudioPoolManager;
45
+
46
+ constructor(publication: Publication, listeners: AudioNavigatorListeners, initialPosition?: Locator, configuration: AudioNavigatorConfiguration = {
47
+ preferences: {},
48
+ defaults: {}
49
+ }) {
50
+ super();
51
+ this.pub = publication;
52
+
53
+ this._preferences = new AudioPreferences(configuration.preferences);
54
+ this._defaults = new AudioDefaults(configuration.defaults);
55
+ this._settings = new AudioSettings(this._preferences, this._defaults);
56
+ this.listeners = listeners;
57
+
58
+ if (initialPosition) {
59
+ this.currentLocation = this.ensureLocatorLocations(initialPosition);
60
+ } else {
61
+ const firstLink = this.pub.readingOrder.items[0];
62
+ this.currentLocation = new Locator({
63
+ href: firstLink.href,
64
+ type: firstLink.type || "audio/mpeg",
65
+ title: firstLink.title,
66
+ locations: new LocatorLocations({
67
+ position: 0,
68
+ progression: 0,
69
+ totalProgression: 0,
70
+ fragments: ["t=0"]
71
+ })
72
+ });
73
+ }
74
+
75
+ const initialHref = this.currentLocation.href.split("#")[0];
76
+ const trackIndex = this.hrefToTrackIndex(initialHref);
77
+ const initialTime = this.currentLocation.locations?.time() || 0;
78
+
79
+ const audioEngine = new WebAudioEngine({
80
+ playback: {
81
+ state: {
82
+ currentTime: initialTime,
83
+ duration: 0,
84
+ volume: this._settings.volume
85
+ } as PlaybackState,
86
+ playWhenReady: false,
87
+ index: trackIndex
88
+ }
89
+ });
90
+
91
+ this.pool = new AudioPoolManager(audioEngine);
92
+ this.setupEventListeners();
93
+
94
+ if (this._settings.enableMediaSession) {
95
+ this.setupMediaSession();
96
+ }
97
+
98
+ this.pool.setCurrentAudio(initialHref, this.pub, trackIndex, "forward");
99
+
100
+ // Load and seek to initial position, then notify consumer.
101
+ // No cancellation needed here — the constructor runs once.
102
+ this.waitForLoadedAndSeeked(initialTime)
103
+ .then(() => {
104
+ this.listeners.trackLoaded(this.pool.audioEngine.getMediaElement());
105
+ this.listeners.positionChanged(this.currentLocator);
106
+ })
107
+ .catch(() => {
108
+ // Error already forwarded via the error event listener.
109
+ });
110
+ }
111
+
112
+ get settings(): AudioSettings {
113
+ return this._settings;
114
+ }
115
+
116
+ get preferencesEditor(): AudioPreferencesEditor {
117
+ if (this._preferencesEditor === null) {
118
+ this._preferencesEditor = new AudioPreferencesEditor(this._preferences, this.settings);
119
+ }
120
+ return this._preferencesEditor;
121
+ }
122
+
123
+ async submitPreferences(preferences: AudioPreferences) {
124
+ this._preferences = this._preferences.merging(preferences) as AudioPreferences;
125
+ this.applyPreferences();
126
+ }
127
+
128
+ private applyPreferences(): void {
129
+ const oldSettings = this._settings;
130
+ this._settings = new AudioSettings(this._preferences, this._defaults);
131
+
132
+ if (this._preferencesEditor !== null) {
133
+ this._preferencesEditor = new AudioPreferencesEditor(this._preferences, this.settings);
134
+ }
135
+
136
+ this.pool.audioEngine.setVolume(this._settings.volume);
137
+ this.pool.audioEngine.setPlaybackRate(this._settings.playbackRate, this._settings.preservePitch);
138
+
139
+ if (this._settings.enableMediaSession && !oldSettings.enableMediaSession) {
140
+ this.setupMediaSession();
141
+ } else if (!this._settings.enableMediaSession && oldSettings.enableMediaSession) {
142
+ this.destroyMediaSession();
143
+ }
144
+ }
145
+
146
+ get publication(): Publication {
147
+ return this.pub;
148
+ }
149
+
150
+ private ensureLocatorLocations(locator: Locator): Locator {
151
+ return new Locator({
152
+ ...locator,
153
+ locations: locator.locations instanceof LocatorLocations
154
+ ? locator.locations
155
+ : locator.locations
156
+ ? new LocatorLocations(locator.locations)
157
+ : undefined
158
+ });
159
+ }
160
+
161
+ /** Resolves a bare href (no fragment) to its index in the reading order. Returns -1 if not found. */
162
+ private hrefToTrackIndex(href: string): number {
163
+ const bare = href.split("#")[0];
164
+ return this.pub.readingOrder.items.findIndex(item => item.href === bare);
165
+ }
166
+
167
+ /** Current track index derived from the current location's href. */
168
+ private currentTrackIndex(): number {
169
+ return this.hrefToTrackIndex(this.currentLocation.href);
170
+ }
171
+
172
+ get currentLocator(): Locator {
173
+ return this.currentLocation;
174
+ }
175
+
176
+ get isPlaying(): boolean {
177
+ return this.pool.audioEngine.isPlaying();
178
+ }
179
+
180
+ get isPaused(): boolean {
181
+ return this.pool.audioEngine.isPaused();
182
+ }
183
+
184
+ get duration(): number {
185
+ return this.pool.audioEngine.duration();
186
+ }
187
+
188
+ get currentTime(): number {
189
+ return this.pool.audioEngine.currentTime();
190
+ }
191
+
192
+ private createLocator(trackIndex: number, timestamp: number): Locator {
193
+ const link = this.pub.readingOrder.items[trackIndex];
194
+ if (!link) throw new Error(`Invalid track index: ${trackIndex}`);
195
+
196
+ const duration = this.pool.audioEngine.duration();
197
+ return new Locator({
198
+ href: link.href,
199
+ type: link.type || "audio/mpeg",
200
+ title: link.title,
201
+ locations: new LocatorLocations({
202
+ progression: duration > 0 ? timestamp / duration : 0,
203
+ position: trackIndex,
204
+ fragments: [`t=${timestamp}`]
205
+ })
206
+ });
207
+ }
208
+
209
+ /**
210
+ * Waits for the current audio to be ready to play, then seeks to seekTime if > 0.
211
+ * Rejects if an error event fires before the audio is ready.
212
+ * When navId is provided, skips the seek if that navigation has been superseded.
213
+ */
214
+ private waitForLoadedAndSeeked(seekTime: number, navId?: number): Promise<void> {
215
+ return new Promise<void>((resolve, reject) => {
216
+ const proceed = () => {
217
+ if (navId !== undefined && navId !== this.navigationId) {
218
+ resolve();
219
+ return;
220
+ }
221
+ if (seekTime <= 0) {
222
+ resolve();
223
+ return;
224
+ }
225
+ const onSeeked = () => {
226
+ this.pool.audioEngine.off("seeked", onSeeked);
227
+ resolve();
228
+ };
229
+ this.pool.audioEngine.on("seeked", onSeeked);
230
+ this.seek(seekTime);
231
+ };
232
+
233
+ if (this.pool.audioEngine.isLoaded()) {
234
+ proceed();
235
+ return;
236
+ }
237
+
238
+ const onReady = () => {
239
+ this.pool.audioEngine.off("canplaythrough", onReady);
240
+ this.pool.audioEngine.off("error", onError);
241
+ proceed();
242
+ };
243
+ const onError = (err: any) => {
244
+ this.pool.audioEngine.off("canplaythrough", onReady);
245
+ this.pool.audioEngine.off("error", onError);
246
+ reject(err);
247
+ };
248
+
249
+ this.pool.audioEngine.on("canplaythrough", onReady);
250
+ this.pool.audioEngine.on("error", onError);
251
+ });
252
+ }
253
+
254
+ private setupEventListeners(): void {
255
+ this.pool.audioEngine.on("error", (error: any) => {
256
+ this.listeners.error(error, this.currentLocator);
257
+ });
258
+
259
+ this.pool.audioEngine.on("ended", async () => {
260
+ this.stopPositionPolling();
261
+ this.currentLocation = this.currentLocation.copyWithLocations(new LocatorLocations({
262
+ position: this.currentTrackIndex(),
263
+ progression: 1,
264
+ fragments: [`t=${this.duration}`]
265
+ }));
266
+ this.listeners.trackEnded(this.currentLocator);
267
+ await this.nextTrack();
268
+ if (this._settings.autoPlay) this.play();
269
+ });
270
+
271
+ this.pool.audioEngine.on("play", () => {
272
+ this.startPositionPolling();
273
+ this.listeners.play(this.currentLocator);
274
+ });
275
+
276
+ this.pool.audioEngine.on("playing", () => {
277
+ this.listeners.stalled(false);
278
+ });
279
+
280
+ this.pool.audioEngine.on("pause", () => {
281
+ this.stopPositionPolling();
282
+ this.listeners.pause(this.currentLocator);
283
+ });
284
+
285
+ this.pool.audioEngine.on("seeked", () => {
286
+ this.listeners.seeking(false);
287
+ if (!this.isPlaying) {
288
+ const currentTime = this.currentTime;
289
+ const duration = this.duration;
290
+ const progression = duration > 0 ? currentTime / duration : 0;
291
+ this.currentLocation = this.currentLocation.copyWithLocations(new LocatorLocations({
292
+ position: this.currentTrackIndex(),
293
+ progression,
294
+ fragments: [`t=${currentTime}`]
295
+ }));
296
+ this.listeners.positionChanged(this.currentLocation);
297
+ }
298
+ });
299
+
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
+
305
+ this.pool.audioEngine.on("loadedmetadata", () => {
306
+ this.listeners.metadataLoaded(this.pool.audioEngine.duration());
307
+ });
308
+ }
309
+
310
+ private setupMediaSession(): void {
311
+ if (!("mediaSession" in navigator)) return;
312
+ navigator.mediaSession.setActionHandler("play", () => this.play());
313
+ navigator.mediaSession.setActionHandler("pause", () => this.pause());
314
+ navigator.mediaSession.setActionHandler("previoustrack", () => this.goBackward(false, () => {}));
315
+ navigator.mediaSession.setActionHandler("nexttrack", () => this.goForward(false, () => {}));
316
+ navigator.mediaSession.setActionHandler("seekbackward", (details) => this.jump(-(details.seekOffset || 10)));
317
+ navigator.mediaSession.setActionHandler("seekforward", (details) => this.jump(details.seekOffset || 10));
318
+ this.updateMediaSessionMetadata();
319
+ }
320
+
321
+ private updateMediaSessionMetadata(): void {
322
+ if (!("mediaSession" in navigator)) return;
323
+ const trackIndex = this.currentTrackIndex();
324
+ const track = this.pub.readingOrder.items[trackIndex];
325
+ navigator.mediaSession.metadata = new MediaMetadata({
326
+ title: track?.title || `Track ${trackIndex + 1}`,
327
+ artist: this.pub.metadata.authors
328
+ ? this.pub.metadata.authors.items.map((a) => a.name.getTranslation()).join(", ")
329
+ : undefined,
330
+ album: this.pub.metadata.title.getTranslation(),
331
+ });
332
+ }
333
+
334
+ private startPositionPolling(): void {
335
+ this.stopPositionPolling();
336
+ this.positionPollInterval = setInterval(() => {
337
+ const currentTime = this.currentTime;
338
+ const duration = this.duration;
339
+ const progression = duration > 0 ? currentTime / duration : 0;
340
+ this.currentLocation = this.currentLocation.copyWithLocations(new LocatorLocations({
341
+ position: this.currentTrackIndex(),
342
+ progression,
343
+ fragments: [`t=${currentTime}`]
344
+ }));
345
+ this.listeners.positionChanged(this.currentLocation);
346
+ }, this._settings.pollInterval);
347
+ }
348
+
349
+ private stopPositionPolling(): void {
350
+ if (this.positionPollInterval !== null) {
351
+ clearInterval(this.positionPollInterval);
352
+ this.positionPollInterval = null;
353
+ }
354
+ }
355
+
356
+ async go(locator: Locator, _animated: boolean, cb: (ok: boolean) => void): Promise<void> {
357
+ try {
358
+ locator = this.ensureLocatorLocations(locator);
359
+ const href = locator.href.split("#")[0];
360
+ const trackIndex = this.hrefToTrackIndex(href);
361
+ const time = locator.locations?.time() || 0;
362
+
363
+ if (trackIndex === -1) {
364
+ cb(false);
365
+ return;
366
+ }
367
+
368
+ const id = ++this.navigationId;
369
+ const direction: "forward" | "backward" = trackIndex >= this.currentTrackIndex() ? "forward" : "backward";
370
+ const wasPlaying = this.isPlaying;
371
+
372
+ this.stopPositionPolling();
373
+ this.pool.setCurrentAudio(href, this.pub, trackIndex, direction);
374
+ this.currentLocation = locator.copyWithLocations(locator.locations);
375
+
376
+ await this.waitForLoadedAndSeeked(time, id);
377
+
378
+ if (id !== this.navigationId) return;
379
+
380
+ this.listeners.trackLoaded(this.pool.audioEngine.getMediaElement());
381
+ this.listeners.positionChanged(this.currentLocator);
382
+
383
+ if (this._settings.enableMediaSession) {
384
+ this.updateMediaSessionMetadata();
385
+ }
386
+
387
+ if (wasPlaying) this.play();
388
+
389
+ cb(true);
390
+ } catch (error) {
391
+ console.error("Failed to go to locator:", error);
392
+ cb(false);
393
+ }
394
+ }
395
+
396
+ async goLink(link: Link, _animated: boolean, cb: (ok: boolean) => void): Promise<void> {
397
+ const trackIndex = this.hrefToTrackIndex(link.href);
398
+ if (trackIndex === -1) {
399
+ cb(false);
400
+ return;
401
+ }
402
+ const locator = this.createLocator(trackIndex, 0);
403
+ await this.go(locator, _animated, cb);
404
+ }
405
+
406
+ async goForward(_animated: boolean, cb: (ok: boolean) => void): Promise<void> {
407
+ if (!this.canGoForward) { cb(false); return; }
408
+ await this.nextTrack();
409
+ cb(true);
410
+ }
411
+
412
+ async goBackward(_animated: boolean, cb: (ok: boolean) => void): Promise<void> {
413
+ if (!this.canGoBackward) { cb(false); return; }
414
+ await this.previousTrack();
415
+ cb(true);
416
+ }
417
+
418
+ play(): void {
419
+ this.pool.audioEngine.play();
420
+ }
421
+
422
+ pause(): void {
423
+ this.pool.audioEngine.pause();
424
+ }
425
+
426
+ stop(): void {
427
+ this.pool.audioEngine.stop();
428
+ }
429
+
430
+ private async nextTrack(): Promise<void> {
431
+ if (!this.canGoForward) return;
432
+ const locator = this.createLocator(this.currentTrackIndex() + 1, 0);
433
+ await this.go(locator, false, () => {});
434
+ }
435
+
436
+ private async previousTrack(): Promise<void> {
437
+ if (!this.canGoBackward) return;
438
+ const locator = this.createLocator(this.currentTrackIndex() - 1, 0);
439
+ await this.go(locator, false, () => {});
440
+ }
441
+
442
+ seek(time: number): void {
443
+ this.pool.audioEngine.skip(time - this.pool.audioEngine.currentTime());
444
+ }
445
+
446
+ jump(seconds: number): void {
447
+ this.pool.audioEngine.skip(seconds);
448
+ }
449
+
450
+ skipForward(): void {
451
+ this.pool.audioEngine.skip(this._settings.skipForwardInterval);
452
+ }
453
+
454
+ skipBackward(): void {
455
+ this.pool.audioEngine.skip(-this._settings.skipBackwardInterval);
456
+ }
457
+
458
+ get isTrackStart(): boolean {
459
+ return this.currentTrackIndex() === 0
460
+ && (this.currentLocation.locations?.time() || 0) === 0;
461
+ }
462
+
463
+ get isTrackEnd(): boolean {
464
+ const trackIndex = this.currentTrackIndex();
465
+ if (trackIndex !== this.pub.readingOrder.items.length - 1) return false;
466
+ const progression = this.currentLocation.locations?.progression;
467
+ if (progression !== undefined) return progression >= 1;
468
+ const link = this.pub.readingOrder.items[trackIndex];
469
+ const duration = this.duration || link?.duration || 0;
470
+ return duration > 0 && (this.currentLocation.locations?.time() ?? 0) >= duration;
471
+ }
472
+
473
+ get canGoBackward(): boolean {
474
+ return this.currentTrackIndex() > 0;
475
+ }
476
+
477
+ get canGoForward(): boolean {
478
+ return this.currentTrackIndex() < this.pub.readingOrder.items.length - 1;
479
+ }
480
+
481
+ private destroyMediaSession(): void {
482
+ if (!("mediaSession" in navigator)) return;
483
+ navigator.mediaSession.metadata = null;
484
+ navigator.mediaSession.setActionHandler("play", null);
485
+ navigator.mediaSession.setActionHandler("pause", null);
486
+ navigator.mediaSession.setActionHandler("previoustrack", null);
487
+ navigator.mediaSession.setActionHandler("nexttrack", null);
488
+ navigator.mediaSession.setActionHandler("seekbackward", null);
489
+ navigator.mediaSession.setActionHandler("seekforward", null);
490
+ }
491
+
492
+ destroy(): void {
493
+ this.stopPositionPolling();
494
+ this.destroyMediaSession();
495
+ this.pool.destroy();
496
+ }
497
+ }