@prose-reader/enhancer-audio 1.285.0

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 ADDED
@@ -0,0 +1,908 @@
1
+ import { ReactiveEntity, mapKeysTo, isDefined, DocumentRenderer } from '@prose-reader/core';
2
+ import { BehaviorSubject, Subscription, distinctUntilChanged, switchMap, of, concat, map, defaultIfEmpty, defer, from, EMPTY, animationFrames, fromEvent, share, merge, retry, take, shareReplay, combineLatest, Subject, withLatestFrom, filter, tap, catchError, takeUntil } from 'rxjs';
3
+
4
+ const AUDIO_VISUALIZER_LEVEL_COUNT = 80;
5
+ const AUDIO_VISUALIZER_NOISE_FLOOR = 0.035;
6
+ const getIdleVisualizerLevels = () => Array.from({ length: AUDIO_VISUALIZER_LEVEL_COUNT }, () => 0);
7
+ const getVisualizerLevels = (data) => {
8
+ if (data.length === 0) return getIdleVisualizerLevels();
9
+ const bucketSize = Math.max(
10
+ 1,
11
+ Math.floor(data.length / AUDIO_VISUALIZER_LEVEL_COUNT)
12
+ );
13
+ return Array.from({ length: AUDIO_VISUALIZER_LEVEL_COUNT }, (_, index) => {
14
+ const start = index * bucketSize;
15
+ const end = Math.min(data.length, start + bucketSize);
16
+ if (start >= data.length || start === end) return 0;
17
+ let total = 0;
18
+ for (let cursor = start; cursor < end; cursor += 1) {
19
+ total += data[cursor] ?? 0;
20
+ }
21
+ const average = total / (end - start);
22
+ const normalizedAverage = average / 255;
23
+ const gatedAverage = (normalizedAverage - AUDIO_VISUALIZER_NOISE_FLOOR) / (1 - AUDIO_VISUALIZER_NOISE_FLOOR);
24
+ return Math.max(0, Math.min(1, gatedAverage));
25
+ });
26
+ };
27
+
28
+ const AUDIO_VISUALIZER_FFT_SIZE = 256;
29
+ class VisualizerAudioGraph {
30
+ constructor(audioElement) {
31
+ this.audioElement = audioElement;
32
+ }
33
+ audioContext;
34
+ audioSourceNode;
35
+ analyserNode;
36
+ frequencyData;
37
+ async resumeIfNeeded() {
38
+ if (!this.ensure()) {
39
+ return false;
40
+ }
41
+ const audioContext = this.audioContext;
42
+ if (!audioContext) {
43
+ return false;
44
+ }
45
+ if (audioContext.state !== `suspended`) {
46
+ return true;
47
+ }
48
+ try {
49
+ await audioContext.resume();
50
+ return true;
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
55
+ readLevels() {
56
+ if (!this.analyserNode || !this.frequencyData) {
57
+ return getIdleVisualizerLevels();
58
+ }
59
+ this.analyserNode.getByteFrequencyData(this.frequencyData);
60
+ return getVisualizerLevels(this.frequencyData);
61
+ }
62
+ destroy() {
63
+ this.audioSourceNode?.disconnect();
64
+ this.analyserNode?.disconnect();
65
+ this.audioContext?.close().catch(() => void 0);
66
+ this.audioContext = void 0;
67
+ this.audioSourceNode = void 0;
68
+ this.analyserNode = void 0;
69
+ this.frequencyData = void 0;
70
+ }
71
+ ensure() {
72
+ if (this.audioContext && this.analyserNode && this.frequencyData) {
73
+ return true;
74
+ }
75
+ if (typeof window === `undefined` || !window.AudioContext) {
76
+ return false;
77
+ }
78
+ const audioContext = new window.AudioContext();
79
+ const audioSourceNode = audioContext.createMediaElementSource(
80
+ this.audioElement
81
+ );
82
+ const analyserNode = audioContext.createAnalyser();
83
+ analyserNode.fftSize = AUDIO_VISUALIZER_FFT_SIZE;
84
+ analyserNode.smoothingTimeConstant = 0.8;
85
+ audioSourceNode.connect(analyserNode);
86
+ analyserNode.connect(audioContext.destination);
87
+ this.audioContext = audioContext;
88
+ this.audioSourceNode = audioSourceNode;
89
+ this.analyserNode = analyserNode;
90
+ this.frequencyData = new Uint8Array(analyserNode.frequencyBinCount);
91
+ return true;
92
+ }
93
+ }
94
+
95
+ class AudioVisualizer extends ReactiveEntity {
96
+ audioGraph;
97
+ playbackState$ = new BehaviorSubject({
98
+ trackId: void 0,
99
+ isRunning: false,
100
+ resetLevels: false
101
+ });
102
+ subscriptions = new Subscription();
103
+ constructor(audioElement) {
104
+ super({
105
+ levels: getIdleVisualizerLevels(),
106
+ isActive: false,
107
+ trackId: void 0
108
+ });
109
+ this.audioGraph = new VisualizerAudioGraph(audioElement);
110
+ this.subscriptions.add(
111
+ this.playbackState$.pipe(
112
+ distinctUntilChanged(
113
+ (previous, next) => previous.trackId === next.trackId && previous.isRunning === next.isRunning && previous.resetLevels === next.resetLevels
114
+ ),
115
+ switchMap(({ trackId, isRunning, resetLevels }) => {
116
+ if (!trackId || !isRunning) {
117
+ return of({
118
+ trackId,
119
+ isActive: false,
120
+ ...resetLevels ? { levels: getIdleVisualizerLevels() } : void 0
121
+ });
122
+ }
123
+ const shouldResetLevels = this.value.trackId !== trackId;
124
+ return concat(
125
+ of({
126
+ trackId,
127
+ isActive: true,
128
+ ...shouldResetLevels ? { levels: getIdleVisualizerLevels() } : void 0
129
+ }),
130
+ this.createLevels$().pipe(
131
+ map((levels) => ({
132
+ trackId,
133
+ levels,
134
+ isActive: true
135
+ })),
136
+ defaultIfEmpty({
137
+ trackId,
138
+ isActive: false,
139
+ levels: getIdleVisualizerLevels()
140
+ })
141
+ )
142
+ );
143
+ })
144
+ ).subscribe((value) => {
145
+ this.mergeCompare(value);
146
+ })
147
+ );
148
+ }
149
+ start(currentTrack) {
150
+ if (!currentTrack) return;
151
+ this.playbackState$.next({
152
+ trackId: currentTrack.id,
153
+ isRunning: true,
154
+ resetLevels: false
155
+ });
156
+ }
157
+ setTrack(trackId) {
158
+ this.playbackState$.next({
159
+ trackId,
160
+ isRunning: false,
161
+ resetLevels: false
162
+ });
163
+ }
164
+ reset(trackId) {
165
+ this.playbackState$.next({
166
+ trackId,
167
+ isRunning: false,
168
+ resetLevels: true
169
+ });
170
+ }
171
+ stop({ resetLevels = false } = {}) {
172
+ this.playbackState$.next({
173
+ trackId: this.playbackState$.value.trackId,
174
+ isRunning: false,
175
+ resetLevels
176
+ });
177
+ }
178
+ destroy() {
179
+ this.stop({
180
+ resetLevels: true
181
+ });
182
+ this.subscriptions.unsubscribe();
183
+ this.playbackState$.complete();
184
+ this.audioGraph.destroy();
185
+ super.destroy();
186
+ }
187
+ createLevels$() {
188
+ return defer(() => {
189
+ return from(this.audioGraph.resumeIfNeeded()).pipe(
190
+ switchMap(
191
+ (isReady) => isReady ? animationFrames().pipe(map(() => this.audioGraph.readLevels())) : EMPTY
192
+ )
193
+ );
194
+ });
195
+ }
196
+ }
197
+
198
+ class AudioElementAdapter {
199
+ element;
200
+ canPlay$;
201
+ ended$;
202
+ isPlaying$;
203
+ metrics$;
204
+ constructor() {
205
+ this.element = document.createElement(`audio`);
206
+ this.element.preload = `metadata`;
207
+ this.canPlay$ = fromEvent(this.element, `canplay`).pipe(share());
208
+ this.ended$ = fromEvent(this.element, `ended`).pipe(share());
209
+ this.isPlaying$ = merge(
210
+ fromEvent(this.element, `play`).pipe(map(() => true)),
211
+ fromEvent(this.element, `pause`).pipe(map(() => false))
212
+ ).pipe(share());
213
+ this.metrics$ = merge(
214
+ fromEvent(this.element, `timeupdate`),
215
+ fromEvent(this.element, `seeking`),
216
+ fromEvent(this.element, `seeked`),
217
+ fromEvent(this.element, `loadedmetadata`),
218
+ fromEvent(this.element, `durationchange`),
219
+ this.canPlay$
220
+ ).pipe(
221
+ map(() => ({
222
+ currentTime: this.element.currentTime,
223
+ duration: Number.isFinite(this.element.duration) ? this.element.duration : void 0
224
+ })),
225
+ share()
226
+ );
227
+ }
228
+ get paused() {
229
+ return this.element.paused;
230
+ }
231
+ get hasSource() {
232
+ return this.element.hasAttribute(`src`);
233
+ }
234
+ play$() {
235
+ return defer(() => from(this.element.play())).pipe(
236
+ retry({
237
+ count: 1,
238
+ delay: () => this.canPlay$.pipe(take(1))
239
+ })
240
+ );
241
+ }
242
+ pause() {
243
+ this.element.pause();
244
+ }
245
+ loadSource(src) {
246
+ this.element.src = src;
247
+ this.element.load();
248
+ }
249
+ unloadSource() {
250
+ this.element.removeAttribute(`src`);
251
+ this.element.load();
252
+ }
253
+ setCurrentTime(value) {
254
+ this.element.currentTime = value;
255
+ }
256
+ }
257
+
258
+ class ResourcesResolver {
259
+ cachedSourceByTrackId = /* @__PURE__ */ new Map();
260
+ hasCachedSource(trackId) {
261
+ return this.cachedSourceByTrackId.has(trackId);
262
+ }
263
+ getTrackResourceUrl$ = (track, resourcesHandler) => {
264
+ const cachedSource = this.cachedSourceByTrackId.get(track.id);
265
+ if (cachedSource) {
266
+ return of(cachedSource.url);
267
+ }
268
+ const resource$ = from(resourcesHandler.getResource());
269
+ return resource$.pipe(
270
+ switchMap((resource) => {
271
+ if (resource instanceof URL) {
272
+ this.cachedSourceByTrackId.set(track.id, {
273
+ url: resource.href
274
+ });
275
+ return of(resource.href);
276
+ }
277
+ if (resource instanceof Response) {
278
+ return from(resource.blob()).pipe(
279
+ map((blob) => {
280
+ const objectUrl = URL.createObjectURL(blob);
281
+ this.cachedSourceByTrackId.set(track.id, {
282
+ url: objectUrl,
283
+ release: () => {
284
+ URL.revokeObjectURL(objectUrl);
285
+ }
286
+ });
287
+ return objectUrl;
288
+ })
289
+ );
290
+ }
291
+ this.cachedSourceByTrackId.set(track.id, {
292
+ url: track.href
293
+ });
294
+ return of(track.href);
295
+ })
296
+ );
297
+ };
298
+ releaseTrackSource(trackId) {
299
+ const cachedSource = this.cachedSourceByTrackId.get(trackId);
300
+ if (!cachedSource) return;
301
+ this.cachedSourceByTrackId.delete(trackId);
302
+ cachedSource.release?.();
303
+ }
304
+ releaseAll() {
305
+ for (const trackId of this.cachedSourceByTrackId.keys()) {
306
+ this.releaseTrackSource(trackId);
307
+ }
308
+ }
309
+ destroy() {
310
+ this.releaseAll();
311
+ }
312
+ }
313
+
314
+ const $ = (e, n) => e === n ? true : e.length !== n.length ? false : e.every((t, r) => t === n[r]);
315
+ const d = Object.prototype.hasOwnProperty, h = (e, n) => e === n ? e !== 0 || n !== 0 || 1 / e === 1 / n : false, _ = (e, n, t) => {
316
+ if (e === n)
317
+ return true;
318
+ if (typeof e != "object" || e === null || typeof n != "object" || n === null)
319
+ return false;
320
+ const r = Object.keys(e), l = Object.keys(n);
321
+ if (r.length !== l.length)
322
+ return false;
323
+ const o = t && typeof t.customEqual == "function" ? t.customEqual : h;
324
+ for (let c = 0; c < r.length; c++) {
325
+ const u = r[c] || "";
326
+ if (!d.call(n, u) || !o(e[u], n[u]))
327
+ return false;
328
+ }
329
+ return true;
330
+ };
331
+ const E = () => {
332
+ if (!(typeof window > "u"))
333
+ return window;
334
+ };
335
+ function w() {
336
+ const e = E()?.__PROSE_READER_DEBUG;
337
+ return e === true || e === "true";
338
+ }
339
+ const s = (e, n = w(), t) => {
340
+ let r = n;
341
+ const l = t?.color ? `color: ${t.color}` : void 0, o = {
342
+ enable: (i) => {
343
+ u(i);
344
+ },
345
+ namespace: (i, a) => s(`${e} [${i}]`, a, t),
346
+ isEnabled: () => r,
347
+ debug: () => {
348
+ },
349
+ info: () => {
350
+ },
351
+ log: () => {
352
+ },
353
+ groupCollapsed: () => {
354
+ },
355
+ groupEnd: () => {
356
+ },
357
+ getGroupArgs: (i) => l ? [`%c${e ? `${e} ${i}` : i}`, l] : [e ? `${e} ${i}` : i],
358
+ warn: () => {
359
+ },
360
+ error: () => {
361
+ }
362
+ }, c = (i) => {
363
+ if (!i) {
364
+ o.debug = () => {
365
+ }, o.info = () => {
366
+ }, o.log = () => {
367
+ }, o.groupCollapsed = () => {
368
+ }, o.groupEnd = () => {
369
+ }, o.warn = () => {
370
+ }, o.error = () => {
371
+ };
372
+ return;
373
+ }
374
+ o.debug = e ? Function.prototype.bind.call(
375
+ console.debug,
376
+ console,
377
+ e,
378
+ `%c${e}`,
379
+ l
380
+ ) : Function.prototype.bind.call(
381
+ console.debug,
382
+ console,
383
+ `%c${e}`,
384
+ l
385
+ ), o.info = Function.prototype.bind.call(
386
+ console.info,
387
+ console,
388
+ `%c${e}`,
389
+ l
390
+ ), o.log = e ? Function.prototype.bind.call(
391
+ console.log,
392
+ console,
393
+ `%c${e}`,
394
+ l
395
+ ) : Function.prototype.bind.call(console.log, console), o.groupCollapsed = Function.prototype.bind.call(
396
+ console.groupCollapsed,
397
+ console
398
+ ), o.groupEnd = Function.prototype.bind.call(console.groupEnd, console), o.warn = Function.prototype.bind.call(
399
+ console.warn,
400
+ console,
401
+ `%c${e}`,
402
+ l
403
+ ), o.error = Function.prototype.bind.call(
404
+ console.error,
405
+ console,
406
+ `%c${e}`,
407
+ l
408
+ );
409
+ }, u = (i) => {
410
+ r !== i && (r = i, c(r));
411
+ };
412
+ return c(r), o;
413
+ }; ({
414
+ ...s()});
415
+
416
+ const AUDIO_EXTENSIONS = /* @__PURE__ */ new Set([
417
+ `mp3`,
418
+ `m4a`,
419
+ `m4b`,
420
+ `aac`,
421
+ `ogg`,
422
+ `oga`,
423
+ `wav`,
424
+ `flac`,
425
+ `opus`
426
+ ]);
427
+ const getExtensionFromHref = (href) => {
428
+ const [pathname = ``] = href.split(/[?#]/);
429
+ const segments = pathname.split(`/`);
430
+ const basename = segments.at(-1) ?? ``;
431
+ const extension = basename.split(`.`).at(-1);
432
+ return extension?.toLowerCase();
433
+ };
434
+ const isAudioSpineItem = (item) => {
435
+ const mediaType = item.mediaType?.split(`;`).at(0)?.trim().toLowerCase();
436
+ if (mediaType?.startsWith(`audio/`)) {
437
+ return true;
438
+ }
439
+ const extension = getExtensionFromHref(item.href);
440
+ return extension ? AUDIO_EXTENSIONS.has(extension) : false;
441
+ };
442
+
443
+ const getTrackAtSpineItemIndex = (tracks, index) => {
444
+ if (index === void 0) return void 0;
445
+ return tracks.find((track) => track.index === index);
446
+ };
447
+ const getVisibleTracks = (tracks, pagination) => {
448
+ const beginTrack = getTrackAtSpineItemIndex(
449
+ tracks,
450
+ pagination.beginSpineItemIndex
451
+ );
452
+ const endTrack = getTrackAtSpineItemIndex(
453
+ tracks,
454
+ pagination.endSpineItemIndex
455
+ );
456
+ return [beginTrack, endTrack].filter(
457
+ (track, i, arr) => track !== void 0 && arr.indexOf(track) === i
458
+ );
459
+ };
460
+ function createTrackStreams(reader, state$) {
461
+ const tracks$ = reader.context.manifest$.pipe(
462
+ map(
463
+ (manifest) => manifest.spineItems.filter(isAudioSpineItem).map((item) => ({
464
+ id: item.id,
465
+ href: item.href,
466
+ index: item.index,
467
+ mediaType: item.mediaType
468
+ }))
469
+ ),
470
+ shareReplay({ bufferSize: 1, refCount: true })
471
+ );
472
+ const pagination$ = reader.pagination.state$.pipe(
473
+ mapKeysTo([`beginSpineItemIndex`, `endSpineItemIndex`]),
474
+ distinctUntilChanged(_),
475
+ shareReplay({ bufferSize: 1, refCount: true })
476
+ );
477
+ const visibleTrackIds$ = combineLatest([tracks$, pagination$]).pipe(
478
+ map(
479
+ ([tracks, pagination]) => getVisibleTracks(tracks, pagination).map(({ id }) => id)
480
+ ),
481
+ distinctUntilChanged($),
482
+ shareReplay({ bufferSize: 1, refCount: true })
483
+ );
484
+ const currentTrack$ = state$.pipe(
485
+ map((state) => state.currentTrack),
486
+ distinctUntilChanged()
487
+ );
488
+ const nextTrack$ = combineLatest([tracks$, pagination$, currentTrack$]).pipe(
489
+ map(([tracks, { endSpineItemIndex }, currentTrack]) => {
490
+ const nextTrackInPaginationWindow = currentTrack && endSpineItemIndex !== void 0 ? tracks.find(
491
+ ({ index }) => index > currentTrack.index && index <= endSpineItemIndex
492
+ ) : void 0;
493
+ const nextTrackAfterCurrentTrack = currentTrack ? tracks.find(({ index }) => index > currentTrack.index) : void 0;
494
+ return { nextTrackInPaginationWindow, nextTrackAfterCurrentTrack };
495
+ }),
496
+ shareReplay({ bufferSize: 1, refCount: true })
497
+ );
498
+ return {
499
+ tracks$,
500
+ visibleTrackIds$,
501
+ nextTrack$
502
+ };
503
+ }
504
+
505
+ const initialDesiredPlayback = {
506
+ shouldPlay: false,
507
+ trackId: void 0
508
+ };
509
+ const initialState = {
510
+ tracks: [],
511
+ currentTrack: void 0,
512
+ isPlaying: false,
513
+ isLoading: false,
514
+ hasError: false,
515
+ currentTime: 0,
516
+ duration: void 0
517
+ };
518
+ class AudioController extends ReactiveEntity {
519
+ reader;
520
+ audio;
521
+ visualizer$;
522
+ resourcesResolver = new ResourcesResolver();
523
+ visibleTrackIds$;
524
+ playCommandSubject = new Subject();
525
+ pauseCommandSubject = new Subject();
526
+ selectCommandSubject = new Subject();
527
+ desiredPlayback$ = new BehaviorSubject(
528
+ initialDesiredPlayback
529
+ );
530
+ subscriptions = new Subscription();
531
+ constructor(reader, audio = new AudioElementAdapter()) {
532
+ super(initialState);
533
+ this.reader = reader;
534
+ this.audio = audio;
535
+ this.visualizer$ = new AudioVisualizer(this.audio.element);
536
+ const { tracks$, visibleTrackIds$, nextTrack$ } = createTrackStreams(
537
+ this.reader,
538
+ this.state$
539
+ );
540
+ this.visibleTrackIds$ = visibleTrackIds$;
541
+ const firstVisibleTrackId$ = this.visibleTrackIds$.pipe(
542
+ map((trackIds) => trackIds[0]),
543
+ distinctUntilChanged(),
544
+ share()
545
+ );
546
+ const visibleTrackReset$ = firstVisibleTrackId$.pipe(
547
+ withLatestFrom(this.state$),
548
+ filter(
549
+ ([trackId, state]) => trackId === void 0 && (state.currentTrack?.id !== void 0 || state.isLoading)
550
+ ),
551
+ map(([, state]) => state.tracks)
552
+ );
553
+ const visibleTrackSelectionIntent$ = firstVisibleTrackId$.pipe(
554
+ filter((trackId) => trackId !== void 0),
555
+ withLatestFrom(this.desiredPlayback$),
556
+ map(([trackId, { shouldPlay }]) => ({
557
+ trackId,
558
+ options: {
559
+ navigate: false,
560
+ play: shouldPlay ? true : void 0
561
+ }
562
+ }))
563
+ );
564
+ const tracksChanged$ = tracks$.pipe(
565
+ tap(() => this.resourcesResolver.releaseAll())
566
+ );
567
+ const playbackReset$ = merge(visibleTrackReset$, tracksChanged$).pipe(
568
+ tap((tracks) => {
569
+ this.emitDesiredPlayback({ shouldPlay: false, trackId: void 0 });
570
+ this.visualizer$.stop({ resetLevels: true });
571
+ this.unmountCurrentSource();
572
+ this.mergeCompare({
573
+ tracks,
574
+ currentTrack: void 0,
575
+ isLoading: false,
576
+ isPlaying: false,
577
+ hasError: false,
578
+ currentTime: 0,
579
+ duration: void 0
580
+ });
581
+ this.visualizer$.reset(void 0);
582
+ }),
583
+ share()
584
+ );
585
+ const playSelectionIntent$ = this.playCommandSubject.pipe(
586
+ filter(() => !this.audio.hasSource),
587
+ map(() => this.state.currentTrack ?? this.state.tracks[0]),
588
+ filter(isDefined),
589
+ map((track) => ({
590
+ trackId: track.id,
591
+ options: { navigate: false, play: true }
592
+ }))
593
+ );
594
+ const resumePlayback$ = this.playCommandSubject.pipe(
595
+ filter(() => this.audio.hasSource),
596
+ tap(() => {
597
+ this.emitDesiredPlayback({
598
+ shouldPlay: true,
599
+ trackId: this.state.currentTrack?.id
600
+ });
601
+ })
602
+ );
603
+ const endedSelectionIntent$ = this.audio.ended$.pipe(
604
+ withLatestFrom(nextTrack$),
605
+ switchMap(
606
+ ([, { nextTrackAfterCurrentTrack, nextTrackInPaginationWindow }]) => {
607
+ this.visualizer$.stop({ resetLevels: true });
608
+ if (nextTrackInPaginationWindow) {
609
+ return of({
610
+ trackId: nextTrackInPaginationWindow.id,
611
+ options: { navigate: false, play: true }
612
+ });
613
+ }
614
+ if (!nextTrackAfterCurrentTrack) {
615
+ this.emitDesiredPlayback({ shouldPlay: false, trackId: void 0 });
616
+ }
617
+ this.reader.navigation.goToRightOrBottomSpineItem();
618
+ return EMPTY;
619
+ }
620
+ )
621
+ );
622
+ const selection$ = merge(
623
+ this.selectCommandSubject,
624
+ visibleTrackSelectionIntent$,
625
+ endedSelectionIntent$,
626
+ playSelectionIntent$
627
+ ).pipe(
628
+ withLatestFrom(this.state$),
629
+ filter(
630
+ ([selectionIntent, state]) => state.tracks.some(({ id }) => id === selectionIntent.trackId)
631
+ ),
632
+ map(([selectionIntent, state]) => ({
633
+ selectionIntent,
634
+ state,
635
+ isReselectionWhileLoading: state.currentTrack?.id === selectionIntent.trackId && state.isLoading
636
+ })),
637
+ tap(({ selectionIntent, state, isReselectionWhileLoading }) => {
638
+ if (selectionIntent.options.navigate !== false) {
639
+ this.reader.navigation.navigate({
640
+ spineItem: selectionIntent.trackId,
641
+ animation: `turn`
642
+ });
643
+ }
644
+ if (isReselectionWhileLoading && selectionIntent.options.play !== void 0) {
645
+ this.emitDesiredPlayback({
646
+ shouldPlay: selectionIntent.options.play,
647
+ trackId: state.currentTrack?.id
648
+ });
649
+ }
650
+ }),
651
+ filter(({ isReselectionWhileLoading }) => !isReselectionWhileLoading),
652
+ switchMap(
653
+ ({ selectionIntent: { trackId, options } }) => this.selectTrack$({ trackId, options, playbackReset$ })
654
+ )
655
+ );
656
+ const playback$ = merge(resumePlayback$, selection$).pipe(
657
+ withLatestFrom(this.desiredPlayback$),
658
+ switchMap(([, { shouldPlay, trackId }]) => {
659
+ if (!shouldPlay || !this.audio.hasSource || this.state.currentTrack?.id !== trackId) {
660
+ return EMPTY;
661
+ }
662
+ this.mergeCompare({ hasError: false });
663
+ return this.audio.play$().pipe(
664
+ catchError(() => {
665
+ this.mergeCompare({ hasError: true });
666
+ return EMPTY;
667
+ })
668
+ );
669
+ })
670
+ );
671
+ this.subscriptions.add(playbackReset$.subscribe());
672
+ this.subscriptions.add(playback$.subscribe());
673
+ this.subscriptions.add(
674
+ this.pauseCommandSubject.subscribe(() => {
675
+ this.emitDesiredPlayback({ shouldPlay: false, trackId: void 0 });
676
+ })
677
+ );
678
+ this.subscriptions.add(
679
+ this.audio.isPlaying$.subscribe((isPlaying) => {
680
+ this.mergeCompare({ isPlaying });
681
+ if (!isPlaying) {
682
+ this.visualizer$.stop();
683
+ return;
684
+ }
685
+ if (!this.state.currentTrack) return;
686
+ this.visualizer$.start(this.state.currentTrack);
687
+ })
688
+ );
689
+ this.subscriptions.add(
690
+ this.audio.metrics$.subscribe(({ currentTime, duration }) => {
691
+ this.mergeCompare({ currentTime, duration });
692
+ })
693
+ );
694
+ }
695
+ get state() {
696
+ return this.value;
697
+ }
698
+ get visualizer() {
699
+ return this.visualizer$.value;
700
+ }
701
+ resetTrackSelection({
702
+ currentTrack,
703
+ isLoading
704
+ }) {
705
+ this.mergeCompare({
706
+ currentTrack,
707
+ isLoading,
708
+ isPlaying: false,
709
+ hasError: false,
710
+ currentTime: 0,
711
+ duration: void 0
712
+ });
713
+ this.visualizer$.reset(currentTrack?.id);
714
+ }
715
+ emitDesiredPlayback({ shouldPlay, trackId }) {
716
+ this.desiredPlayback$.next({
717
+ shouldPlay,
718
+ trackId: shouldPlay ? trackId : void 0
719
+ });
720
+ if (!shouldPlay) {
721
+ this.audio.pause();
722
+ }
723
+ }
724
+ unmountCurrentSource() {
725
+ if (!this.audio.hasSource) return;
726
+ const trackId = this.state.currentTrack?.id;
727
+ if (!this.audio.paused) {
728
+ this.audio.pause();
729
+ }
730
+ this.audio.unloadSource();
731
+ if (trackId) {
732
+ this.resourcesResolver.releaseTrackSource(trackId);
733
+ }
734
+ }
735
+ mountSource(source) {
736
+ this.unmountCurrentSource();
737
+ this.audio.loadSource(source);
738
+ }
739
+ resolveTrackSource(track) {
740
+ const spineItem = this.reader.spineItemsManager.get(track.index);
741
+ if (!spineItem) return EMPTY;
742
+ return this.resourcesResolver.getTrackResourceUrl$(
743
+ track,
744
+ spineItem.resourcesHandler
745
+ );
746
+ }
747
+ selectTrack$({
748
+ trackId,
749
+ options,
750
+ playbackReset$
751
+ }) {
752
+ const track = this.state.tracks.find(({ id }) => id === trackId);
753
+ if (!track) return EMPTY;
754
+ const currentTrack = this.state.currentTrack;
755
+ const shouldPlay = options.play ?? (!this.audio.paused && currentTrack !== void 0);
756
+ this.emitDesiredPlayback({ shouldPlay, trackId: track.id });
757
+ if (currentTrack?.id === track.id && this.audio.hasSource) {
758
+ this.visualizer$.setTrack(track.id);
759
+ return shouldPlay ? of(void 0) : EMPTY;
760
+ }
761
+ this.unmountCurrentSource();
762
+ this.resetTrackSelection({
763
+ currentTrack: track,
764
+ isLoading: true
765
+ });
766
+ return this.resolveTrackSource(track).pipe(
767
+ map((source) => ({ source })),
768
+ defaultIfEmpty({ source: void 0 }),
769
+ catchError(() => of({ source: void 0 })),
770
+ takeUntil(playbackReset$),
771
+ tap(({ source }) => {
772
+ if (!source) {
773
+ this.emitDesiredPlayback({ shouldPlay: false, trackId: void 0 });
774
+ } else {
775
+ this.mountSource(source);
776
+ }
777
+ this.mergeCompare({ isLoading: false });
778
+ }),
779
+ filter(({ source }) => source !== void 0),
780
+ map(() => void 0)
781
+ );
782
+ }
783
+ select(trackId, options = {}) {
784
+ this.selectCommandSubject.next({ trackId, options });
785
+ }
786
+ play() {
787
+ this.playCommandSubject.next();
788
+ }
789
+ pause() {
790
+ this.pauseCommandSubject.next();
791
+ }
792
+ toggle() {
793
+ if (this.audio.paused) {
794
+ this.play();
795
+ return;
796
+ }
797
+ this.pause();
798
+ }
799
+ setCurrentTime(value) {
800
+ this.mergeCompare({ currentTime: value });
801
+ this.audio.setCurrentTime(value);
802
+ }
803
+ destroy() {
804
+ this.subscriptions.unsubscribe();
805
+ this.playCommandSubject.complete();
806
+ this.pauseCommandSubject.complete();
807
+ this.selectCommandSubject.complete();
808
+ this.desiredPlayback$.complete();
809
+ this.visualizer$.destroy();
810
+ this.unmountCurrentSource();
811
+ this.resourcesResolver.destroy();
812
+ super.destroy();
813
+ }
814
+ }
815
+
816
+ class AudioRenderer extends DocumentRenderer {
817
+ onCreateDocument() {
818
+ const ownerDocument = this.containerElement.ownerDocument;
819
+ const rootElement = ownerDocument.createElement(`div`);
820
+ rootElement.style.cssText = `
821
+ box-sizing: border-box;
822
+ width: 100%;
823
+ height: 100%;
824
+ display: block;
825
+ `;
826
+ rootElement.setAttribute(`data-prose-reader-audio-page`, this.item.id);
827
+ this.setDocumentContainer(rootElement);
828
+ return of(rootElement);
829
+ }
830
+ onLoadDocument() {
831
+ this.attach();
832
+ return EMPTY;
833
+ }
834
+ onUnload() {
835
+ this.detach();
836
+ return EMPTY;
837
+ }
838
+ onLayout() {
839
+ const { width, height } = this.viewport.pageSize;
840
+ const rootElement = this.documentContainer;
841
+ if (rootElement) {
842
+ rootElement.style.width = `${width}px`;
843
+ rootElement.style.height = `${height}px`;
844
+ }
845
+ return of({ width, height });
846
+ }
847
+ onRenderHeadless() {
848
+ return EMPTY;
849
+ }
850
+ getDocumentFrame() {
851
+ return void 0;
852
+ }
853
+ /**
854
+ * Audio spine items are always pre-paginated (one track per page).
855
+ * Mixed audio/text books with reflowable audio chapters may need
856
+ * to revisit this if that use case arises.
857
+ */
858
+ get renditionLayout() {
859
+ return `pre-paginated`;
860
+ }
861
+ }
862
+
863
+ const audioEnhancer = (next) => (options) => {
864
+ const readerOptions = { ...options };
865
+ const reader = next({
866
+ ...readerOptions,
867
+ getRenderer(item) {
868
+ const maybeFactory = options.getRenderer?.(item);
869
+ if (maybeFactory) {
870
+ return maybeFactory;
871
+ }
872
+ if (isAudioSpineItem(item)) {
873
+ return (params) => new AudioRenderer(params);
874
+ }
875
+ return void 0;
876
+ }
877
+ });
878
+ const controller = new AudioController(reader);
879
+ const destroy = () => {
880
+ controller.destroy();
881
+ reader.destroy();
882
+ };
883
+ return {
884
+ ...reader,
885
+ __PROSE_READER_ENHANCER_AUDIO: true,
886
+ destroy,
887
+ audio: {
888
+ state$: controller.state$,
889
+ visualizer$: controller.visualizer$,
890
+ visibleTrackIds$: controller.visibleTrackIds$,
891
+ get state() {
892
+ return controller.state;
893
+ },
894
+ get visualizer() {
895
+ return controller.visualizer;
896
+ },
897
+ isAudioRenderer: (renderer) => renderer instanceof AudioRenderer,
898
+ play: () => controller.play(),
899
+ pause: () => controller.pause(),
900
+ toggle: () => controller.toggle(),
901
+ setCurrentTime: (value) => controller.setCurrentTime(value),
902
+ select: (trackId, options2) => controller.select(trackId, options2)
903
+ }
904
+ };
905
+ };
906
+
907
+ export { AudioRenderer, audioEnhancer, isAudioSpineItem };
908
+ //# sourceMappingURL=index.js.map