@internetarchive/radio-player 0.0.4-alpha.1 → 0.1.1-alpha.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 (69) hide show
  1. package/LICENSE +661 -661
  2. package/README.md +142 -142
  3. package/index.d.ts +15 -15
  4. package/index.js +28 -28
  5. package/lib/src/assets/img/left.d.ts +2 -2
  6. package/lib/src/assets/img/left.js +15 -15
  7. package/lib/src/assets/img/right.d.ts +2 -2
  8. package/lib/src/assets/img/right.js +15 -15
  9. package/lib/src/models/music-zone.d.ts +12 -12
  10. package/lib/src/models/music-zone.js +15 -15
  11. package/lib/src/models/radio-player-config.d.ts +26 -26
  12. package/lib/src/models/radio-player-config.js +27 -27
  13. package/lib/src/radio-player.d.ts +589 -589
  14. package/lib/src/radio-player.js +1337 -1338
  15. package/lib/src/radio-player.js.map +1 -1
  16. package/lib/src/search-handler/search-backends/full-text-search-backend/full-text-search-backend.d.ts +82 -82
  17. package/lib/src/search-handler/search-backends/full-text-search-backend/full-text-search-backend.js +107 -107
  18. package/lib/src/search-handler/search-backends/full-text-search-backend/full-text-search-delegate.d.ts +9 -0
  19. package/lib/src/search-handler/search-backends/full-text-search-backend/full-text-search-delegate.js +1 -0
  20. package/lib/src/search-handler/search-backends/full-text-search-backend/full-text-search-delegate.js.map +1 -0
  21. package/lib/src/search-handler/search-backends/full-text-search-backend/full-text-search-response.d.ts +48 -48
  22. package/lib/src/search-handler/search-backends/full-text-search-backend/full-text-search-response.js +58 -58
  23. package/lib/src/search-handler/search-backends/full-text-search-backend/full-text-search-service-interface.d.ts +16 -16
  24. package/lib/src/search-handler/search-backends/full-text-search-backend/full-text-search-service-interface.js +1 -1
  25. package/lib/src/search-handler/search-backends/local-search-backend/local-search-backend.d.ts +27 -27
  26. package/lib/src/search-handler/search-backends/local-search-backend/local-search-backend.js +42 -42
  27. package/lib/src/search-handler/search-backends/search-backend-interface.d.ts +9 -9
  28. package/lib/src/search-handler/search-backends/search-backend-interface.js +1 -1
  29. package/lib/src/search-handler/search-handler-interface.d.ts +17 -17
  30. package/lib/src/search-handler/search-handler-interface.js +1 -1
  31. package/lib/src/search-handler/search-handler.d.ts +83 -83
  32. package/lib/src/search-handler/search-handler.js +177 -177
  33. package/lib/src/search-handler/search-helper.d.ts +12 -12
  34. package/lib/src/search-handler/search-helper.js +22 -22
  35. package/lib/src/search-handler/search-index.d.ts +53 -0
  36. package/lib/src/search-handler/search-index.js +118 -0
  37. package/lib/src/search-handler/search-index.js.map +1 -0
  38. package/lib/src/search-handler/search-models.d.ts +45 -45
  39. package/lib/src/search-handler/search-models.js +49 -49
  40. package/lib/src/search-handler/transcript-index-interface.d.ts +38 -38
  41. package/lib/src/search-handler/transcript-index-interface.js +1 -1
  42. package/lib/src/search-handler/transcript-index.d.ts +53 -53
  43. package/lib/src/search-handler/transcript-index.js +80 -80
  44. package/lib/src/search-results-switcher.d.ts +58 -58
  45. package/lib/src/search-results-switcher.js +133 -133
  46. package/package.json +93 -93
  47. package/src/assets/img/left.ts +17 -17
  48. package/src/assets/img/right.ts +17 -17
  49. package/src/models/music-zone.ts +17 -17
  50. package/src/models/radio-player-config.ts +47 -47
  51. package/src/radio-player.ts +1398 -1402
  52. package/src/search-handler/search-backends/full-text-search-backend/full-text-search-backend.ts +140 -140
  53. package/src/search-handler/search-backends/full-text-search-backend/full-text-search-response.ts +110 -110
  54. package/src/search-handler/search-backends/full-text-search-backend/full-text-search-service-interface.ts +17 -17
  55. package/src/search-handler/search-backends/local-search-backend/local-search-backend.ts +50 -50
  56. package/src/search-handler/search-backends/search-backend-interface.ts +10 -10
  57. package/src/search-handler/search-handler-interface.ts +18 -18
  58. package/src/search-handler/search-handler.ts +217 -217
  59. package/src/search-handler/search-helper.ts +25 -25
  60. package/src/search-handler/search-models.ts +69 -69
  61. package/src/search-handler/transcript-index-interface.ts +42 -42
  62. package/src/search-handler/transcript-index.ts +93 -93
  63. package/src/search-results-switcher.ts +131 -131
  64. package/lib/demo/full-text-search-service.d.ts +0 -14
  65. package/lib/demo/full-text-search-service.js +0 -34
  66. package/lib/demo/full-text-search-service.js.map +0 -1
  67. package/lib/demo/radio-player-controller.d.ts +0 -41
  68. package/lib/demo/radio-player-controller.js +0 -200
  69. package/lib/demo/radio-player-controller.js.map +0 -1
@@ -1,1402 +1,1398 @@
1
- /* eslint-disable import/no-duplicates */
2
- import {
3
- LitElement,
4
- html,
5
- css,
6
- CSSResult,
7
- TemplateResult,
8
- PropertyValues,
9
- } from 'lit';
10
- import {
11
- customElement,
12
- property,
13
- } from 'lit/decorators.js';
14
- import { AudioElement, AudioSource } from '@internetarchive/audio-element';
15
- import {
16
- TranscriptConfig,
17
- TranscriptEntryConfig,
18
- TranscriptView,
19
- } from '@internetarchive/transcript-view';
20
-
21
- import '@internetarchive/waveform-progress';
22
- import '@internetarchive/playback-controls';
23
- import '@internetarchive/scrubber-bar';
24
-
25
- import { QuickSearchEntry } from '@internetarchive/expandable-search-bar';
26
- import '@internetarchive/expandable-search-bar';
27
- import '@internetarchive/ia-activity-indicator/ia-activity-indicator';
28
-
29
- import './search-results-switcher';
30
-
31
- import { ZoneOfSilence } from '@internetarchive/waveform-progress';
32
- import { PlaybackControls, PlaybackMode } from '@internetarchive/playback-controls';
33
- import SearchResultsSwitcher from './search-results-switcher';
34
- import MusicZone from './models/music-zone';
35
- import RadioPlayerConfig from './models/radio-player-config';
36
- import { SearchHandlerInterface } from './search-handler/search-handler-interface';
37
-
38
- /**
39
- * A Radio Player element to play back transcribed audio.
40
- *
41
- * It is responsible for orchestrating all of the other components like:
42
- * - Waveform Progress
43
- * - Scrubber Bar
44
- * - Playback Controls
45
- * - Transcript View
46
- * - Audio Element
47
- * - Search Bar
48
- *
49
- * @export
50
- * @class RadioPlayer
51
- * @extends {LitElement}
52
- */
53
- @customElement('radio-player')
54
- export default class RadioPlayer extends LitElement {
55
- /**
56
- * Radio player configuration
57
- *
58
- * @type {(RadioPlayerConfig | undefined)}
59
- * @memberof RadioPlayer
60
- */
61
- @property({ type: Object }) config: RadioPlayerConfig | undefined = undefined;
62
-
63
- /**
64
- * The Search Handler
65
- *
66
- * @type {(SearchHandlerInterface | undefined)}
67
- * @memberof RadioPlayer
68
- */
69
- @property({ type: Object }) searchHandler: SearchHandlerInterface | undefined = undefined;
70
-
71
- /**
72
- * Transcript configuration
73
- *
74
- * @type {(TranscriptConfig | undefined)}
75
- * @memberof RadioPlayer
76
- */
77
- @property({ type: Object }) transcriptConfig: TranscriptConfig | undefined = undefined;
78
-
79
- /**
80
- * Search result transcript. It's also a `TranscriptConfig` object, but is broken up by
81
- * transcript entries as well as search results.
82
- *
83
- * If this is set, the transcript view, uses this, otherwise it uses `transcriptConfig`
84
- *
85
- * @type {(TranscriptConfig | undefined)}
86
- * @memberof RadioPlayer
87
- */
88
- @property({ type: Object }) searchResultsTranscript: TranscriptConfig | undefined = undefined;
89
-
90
- /**
91
- * Current playback time
92
- *
93
- * @type {number}
94
- * @memberof RadioPlayer
95
- */
96
- @property({ type: Number }) currentTime = 0;
97
-
98
- /**
99
- * Current search term
100
- *
101
- * @type {string}
102
- * @memberof RadioPlayer
103
- */
104
- @property({ type: String }) searchTerm = '';
105
-
106
- /**
107
- * Flag to determine whether or not to skip the music sections
108
- *
109
- * @type {boolean}
110
- * @memberof RadioPlayer
111
- */
112
- @property({ type: Boolean }) skipMusicSections = false;
113
-
114
- /**
115
- * Percent of playback that is complete
116
- *
117
- * @private
118
- * @type {number}
119
- * @memberof RadioPlayer
120
- */
121
- @property({ type: Number }) private percentComplete = 0;
122
-
123
- /**
124
- * Flag for whether or not the radio player is playing
125
- *
126
- * @private
127
- * @type {boolean}
128
- * @memberof RadioPlayer
129
- */
130
- @property({ type: Boolean }) private isPlaying = false;
131
-
132
- /**
133
- * Duration of the audio file
134
- *
135
- * @private
136
- * @type {number}
137
- * @memberof RadioPlayer
138
- */
139
- @property({ type: Number }) private duration = 0;
140
-
141
- /**
142
- * The playback rate where 1 is "normal", 0 is paused, 2 is twice as fase
143
- *
144
- * @private
145
- * @type {number}
146
- * @memberof RadioPlayer
147
- */
148
- @property({ type: Number }) private playbackRate = 1;
149
-
150
- /**
151
- * Volume from 0 to 1
152
- *
153
- * @private
154
- * @type {number}
155
- * @memberof RadioPlayer
156
- */
157
- @property({ type: Number }) private volume = 1;
158
-
159
- /**
160
- * Flag for whether we should show the search results switcher
161
- *
162
- * @private
163
- * @type {boolean}
164
- * @memberof RadioPlayer
165
- */
166
- @property({ type: Boolean }) private shouldShowSearchResultSwitcher = false;
167
-
168
- /**
169
- * Flag for whether we should show the 'no results' message
170
- *
171
- * @private
172
- * @type {boolean}
173
- * @memberof RadioPlayer
174
- */
175
- @property({ type: Boolean }) private shouldShowNoSearchResultMessage = false;
176
-
177
- /**
178
- * Are we searching or not?
179
- *
180
- * @private
181
- * @type {boolean}
182
- * @memberof RadioPlayer
183
- */
184
- @property({ type: Boolean }) private isSearching = false;
185
-
186
- private musicZones: MusicZone[] = [];
187
-
188
- /**
189
- * LitElement lifecycle main render method
190
- *
191
- * @returns {TemplateResult}
192
- * @memberof RadioPlayer
193
- */
194
- render(): TemplateResult {
195
- return html`
196
- ${this.audioElementTemplate}
197
- <section role="main">
198
- ${this.titleDateTemplate} ${this.collectionLogoTemplate} ${this.playbackControlsTemplate}
199
- <div class="waveform-scrubber-container">
200
- ${this.waveFormProgressTemplate} ${this.scrubberBarTemplate}
201
- </div>
202
- ${this.searchSectionTemplate} ${this.transcriptViewTemplate}
203
- </section>
204
- `;
205
- }
206
-
207
- /**
208
- * Start playback
209
- *
210
- * @memberof RadioPlayer
211
- */
212
- play(): void {
213
- /* istanbul ignore else */
214
- if (this.audioElement) this.audioElement.play();
215
- }
216
-
217
- /**
218
- * Pause playback
219
- *
220
- * @memberof RadioPlayer
221
- */
222
- pause(): void {
223
- /* istanbul ignore else */
224
- if (this.audioElement) this.audioElement.pause();
225
- }
226
-
227
- /**
228
- * Seek audio to given number of seconds
229
- *
230
- * @param {number} seconds
231
- * @memberof RadioPlayer
232
- */
233
- seekTo(seconds: number): void {
234
- /* istanbul ignore else */
235
- if (this.audioElement) this.audioElement.seekTo(seconds);
236
- }
237
-
238
- /**
239
- * Generate markup for the title and date view sections
240
- *
241
- * @readonly
242
- * @private
243
- * @type {TemplateResult}
244
- * @memberof RadioPlayer
245
- */
246
- private get titleDateTemplate(): TemplateResult {
247
- return html`
248
- <div class="title-date">
249
- <div class="title">
250
- ${this.config ? this.config.title : ''}
251
- </div>
252
-
253
- <div class="date">
254
- ${this.config ? this.config.date : ''}
255
- </div>
256
- </div>
257
- `;
258
- }
259
-
260
- /**
261
- * Generate markup for the logo element
262
- *
263
- * @readonly
264
- * @private
265
- * @type {TemplateResult}
266
- * @memberof RadioPlayer
267
- */
268
- private get collectionLogoTemplate(): TemplateResult {
269
- return html`
270
- <img class="collection-logo" src=${this.logoUrl} alt="collection logo" />
271
- `;
272
- }
273
-
274
- /**
275
- * Return the logoUrl from the config if provided
276
- *
277
- * @readonly
278
- * @private
279
- * @type {string}
280
- * @memberof RadioPlayer
281
- */
282
- private get logoUrl(): string {
283
- return this.config ? this.config.logoUrl : '';
284
- }
285
-
286
- /**
287
- * Generate the markup for the waveform progress element
288
- *
289
- * @readonly
290
- * @private
291
- * @type {TemplateResult}
292
- * @memberof RadioPlayer
293
- */
294
- private get waveFormProgressTemplate(): TemplateResult | undefined {
295
- return this.waveformUrl
296
- ? html`
297
- <waveform-progress
298
- interactive="true"
299
- .waveformUrl=${this.waveformUrl}
300
- .percentComplete=${this.percentComplete}
301
- @valuechange=${this.valueChangedFromScrub}
302
- >
303
- </waveform-progress>
304
- `
305
- : undefined;
306
- }
307
-
308
- /**
309
- * Generate an array of `ZoneOfSilence` models for the waveform progress element
310
- *
311
- * It does this by parsing the transcript for music sections and converting
312
- * those music sections to zones of silence.
313
- *
314
- * @readonly
315
- * @private
316
- * @type {ZoneOfSilence[]}
317
- * @memberof RadioPlayer
318
- */
319
- private get zonesOfSilence(): ZoneOfSilence[] {
320
- /* istanbul ignore else */
321
- if (this.duration === 0) {
322
- return [];
323
- }
324
-
325
- // eslint-disable-next-line max-len
326
- const musicEntries: TranscriptEntryConfig[] = this.transcriptEntries.filter(
327
- (entry: TranscriptEntryConfig) => entry.isMusic === true,
328
- );
329
-
330
- const zonesOfSilence: ZoneOfSilence[] = musicEntries.map((entry: TranscriptEntryConfig) => {
331
- const startPercent: number = (entry.start / this.duration) * 100;
332
- const endPercent: number = (entry.end / this.duration) * 100;
333
- return new ZoneOfSilence(startPercent, endPercent);
334
- });
335
-
336
- return zonesOfSilence;
337
- }
338
-
339
- /**
340
- * Return the waveform url from the configuration
341
- *
342
- * @readonly
343
- * @private
344
- * @type {string}
345
- * @memberof RadioPlayer
346
- */
347
- private get waveformUrl(): string | undefined {
348
- return this.config ? this.config.waveformUrl : '';
349
- }
350
-
351
- /**
352
- * Generate the audio element markup
353
- *
354
- * @readonly
355
- * @private
356
- * @type {TemplateResult}
357
- * @memberof RadioPlayer
358
- */
359
- private get audioElementTemplate(): TemplateResult {
360
- return html`
361
- <audio-element
362
- .sources=${this.audioSources}
363
- .playbackRate=${this.playbackRate}
364
- .volume=${this.volume}
365
- @timeupdate=${this.handleTimeChange}
366
- @durationchange=${this.handleDurationChange}
367
- @playbackStarted=${this.playbackStarted}
368
- @playbackPaused=${this.playbackPaused}
369
- @canplay=${this.canplay}
370
- >
371
- </audio-element>
372
- `;
373
- }
374
-
375
- /**
376
- * Return the audio sources from the configuration
377
- *
378
- * @readonly
379
- * @private
380
- * @type {AudioSource[]}
381
- * @memberof RadioPlayer
382
- */
383
- private get audioSources(): AudioSource[] {
384
- return this.config ? this.config.audioSources : [];
385
- }
386
-
387
- /**
388
- * Generate markup for the playback controls
389
- *
390
- * @readonly
391
- * @private
392
- * @type {TemplateResult}
393
- * @memberof RadioPlayer
394
- */
395
- private get playbackControlsTemplate(): TemplateResult {
396
- return html`
397
- <playback-controls
398
- @back-button-pressed=${this.backButtonHandler}
399
- @play-pause-button-pressed=${this.playPauseButtonHandler}
400
- @forward-button-pressed=${this.forwardButtonHandler}
401
- @volumeChange=${this.volumeChanged}
402
- @playbackRateChange=${this.changePlaybackRate}
403
- @next-section-button-pressed=${this.nextSectionButtonHandler}
404
- @prev-section-button-pressed=${this.prevSectionButtonHandler}
405
- >
406
- </playback-controls>
407
- `;
408
- }
409
-
410
- /**
411
- * Generate an array of start and end points for each of the music zones.
412
- * This allows us to show the markers on the scrubber bar and optionally skip those sections.
413
- *
414
- * @readonly
415
- * @private
416
- * @type {number[]}
417
- * @memberof RadioPlayer
418
- */
419
- private get scrubberBarMarkerPercentages(): number[] {
420
- const percentages: number[] = [0];
421
- this.zonesOfSilence.forEach((zone) => {
422
- percentages.push(zone.startPercent);
423
- percentages.push(zone.endPercent);
424
- });
425
- percentages.push(100);
426
- return percentages;
427
- }
428
-
429
- /**
430
- * Generate markup for the scrubber bar
431
- *
432
- * @readonly
433
- * @private
434
- * @type {TemplateResult}
435
- * @memberof RadioPlayer
436
- */
437
- private get scrubberBarTemplate(): TemplateResult {
438
- return html`
439
- <scrubber-bar
440
- .sectionMarkerPercentages=${this.scrubberBarMarkerPercentages}
441
- .value=${this.percentComplete}
442
- @valuechange=${this.valueChangedFromScrub}
443
- >
444
- </scrubber-bar>
445
- `;
446
- }
447
-
448
- /**
449
- * Generate markup for the transcript view
450
- *
451
- * @readonly
452
- * @private
453
- * @type {TemplateResult}
454
- * @memberof RadioPlayer
455
- */
456
- private get transcriptViewTemplate(): TemplateResult {
457
- return html`
458
- <div class="transcript-container">
459
- <transcript-view
460
- .config=${this.currentTranscript}
461
- .currentTime=${this.currentTime}
462
- @transcriptEntrySelected=${this.transcriptEntrySelected}
463
- >
464
- </transcript-view>
465
- </div>
466
- `;
467
- }
468
-
469
- private get currentTranscript(): TranscriptConfig | undefined {
470
- return this.searchResultsTranscript || this.transcriptConfig;
471
- }
472
-
473
- /**
474
- * Generate markup for search section
475
- *
476
- * @readonly
477
- * @private
478
- * @type {TemplateResult}
479
- * @memberof RadioPlayer
480
- */
481
- private get searchSectionTemplate(): TemplateResult {
482
- return html`
483
- <div class="search-section">
484
- <expandable-search-bar
485
- searchTerm=${this.searchTerm}
486
- .quickSearches=${this.quickSearches}
487
- @inputchange=${this.updateSearchTerm}
488
- @enterKeyPressed=${this.searchEnterKeyPressed}
489
- @searchCleared=${this.searchCleared}
490
- >
491
- </expandable-search-bar>
492
- <div class="search-results-info">
493
- ${this.searchActivityIndicator}
494
- ${this.searchResultsSwitcherTemplate}
495
- ${this.noSearchResultsTemplate}
496
- </div>
497
- </div>
498
- `;
499
- }
500
-
501
- /**
502
- * Generate markup for the search results switcher
503
- *
504
- * @readonly
505
- * @private
506
- * @type {TemplateResult}
507
- * @memberof RadioPlayer
508
- */
509
- private get searchResultsSwitcherTemplate(): TemplateResult {
510
- return html`
511
- <search-results-switcher
512
- class="${this.shouldShowSearchResultSwitcher && !this.isSearching ? '' : 'hidden'}"
513
- @searchResultIndexChanged=${this.searchResultIndexChanged}
514
- >
515
- </search-results-switcher>
516
- `;
517
- }
518
-
519
- /**
520
- * Search activity indicator template
521
- *
522
- * @readonly
523
- * @private
524
- * @type {TemplateResult}
525
- * @memberof RadioPlayer
526
- */
527
- private get searchActivityIndicator(): TemplateResult {
528
- return html`
529
- <ia-activity-indicator
530
- class="${this.isSearching ? '' : 'hidden'}">
531
- </ia-activity-indicator>
532
- `;
533
- }
534
-
535
- /**
536
- * Generate markup for the "No Search Results" message
537
- *
538
- * @readonly
539
- * @private
540
- * @type {TemplateResult}
541
- * @memberof RadioPlayer
542
- */
543
- private get noSearchResultsTemplate(): TemplateResult {
544
- return html`
545
- <div
546
- class="
547
- no-search-results-message
548
- ${this.shouldShowNoSearchResultMessage && !this.isSearching ? '' : 'hidden'}"
549
- >
550
- No search results.
551
- </div>
552
- `;
553
- }
554
-
555
- /**
556
- * Generate an array of `QuickSearchEntry` objects based on the quick search
557
- * configuration passed in.
558
- *
559
- * @readonly
560
- * @private
561
- * @type {QuickSearchEntry[]}
562
- * @memberof RadioPlayer
563
- */
564
- private get quickSearches(): QuickSearchEntry[] {
565
- if (!this.config) {
566
- return [];
567
- }
568
-
569
- const entries: QuickSearchEntry[] = this.config.quickSearches.map(
570
- entry => new QuickSearchEntry(entry),
571
- );
572
-
573
- return entries;
574
- }
575
-
576
- /**
577
- * Update the local search term when the SearchBar widget gets updated and emits the event
578
- *
579
- * @private
580
- * @param {CustomEvent} e
581
- * @returns {void}
582
- * @memberof RadioPlayer
583
- */
584
- private updateSearchTerm(e: CustomEvent): void {
585
- const detail = e.detail || {};
586
- if (!detail.value) {
587
- return;
588
- }
589
- const searchTerm = detail.value;
590
- this.searchTerm = searchTerm;
591
- this.emitSearchTermChangedEvent(searchTerm);
592
- }
593
-
594
- /**
595
- * Triggered when the user clicks the clear search button in the search bar.
596
- * Clean up all of the search info and reset to the base transcript.
597
- *
598
- * @private
599
- * @memberof RadioPlayer
600
- */
601
- private searchCleared(): void {
602
- this.searchTerm = '';
603
- this.searchResultsTranscript = undefined;
604
- this.emitSearchClearedEvent();
605
- this.emitSearchTermChangedEvent('');
606
- /* istanbul ignore else */
607
- if (this.transcriptView) {
608
- this.transcriptView.selectedSearchResultIndex = 0;
609
- }
610
- /* istanbul ignore else */
611
- if (this.searchResultsSwitcher) {
612
- this.searchResultsSwitcher.currentResultIndex = 0;
613
- }
614
- }
615
-
616
- /**
617
- * Triggered when the user scrubs through search result indices so we can coordinate
618
- * the UI updates like scrolling the transcript view
619
- *
620
- * @private
621
- * @param {CustomEvent} e
622
- * @returns {void}
623
- * @memberof RadioPlayer
624
- */
625
- private searchResultIndexChanged(e: CustomEvent): void {
626
- const detail = e.detail || {};
627
- const { searchResultIndex } = detail;
628
- if (searchResultIndex === undefined || !this.transcriptView) {
629
- return;
630
- }
631
- this.transcriptView.selectedSearchResultIndex = searchResultIndex;
632
- this.transcriptView.scrollToSelectedSearchResult();
633
-
634
- const event = new CustomEvent('highlightedSearchResultChanged', {
635
- detail: { searchResultIndex },
636
- });
637
- this.dispatchEvent(event);
638
- }
639
-
640
- /**
641
- * Triggered when the user presses enter in the search bar so we can trigger a search.
642
- *
643
- * @private
644
- * @param {CustomEvent} e
645
- * @memberof RadioPlayer
646
- */
647
- private searchEnterKeyPressed(e: CustomEvent): void {
648
- const detail = e.detail || {};
649
- if (!detail.value) {
650
- return;
651
- }
652
- this.executeSearch(detail.value);
653
- }
654
-
655
- private async executeSearch(term: string): Promise<void> {
656
- if (!this.searchHandler || term.length < 2) {
657
- this.searchResultsTranscript = undefined;
658
- return;
659
- }
660
- this.searchTerm = term;
661
- this.isSearching = true;
662
- const event = new Event('searchExecuted');
663
- this.dispatchEvent(event);
664
- this.searchResultsTranscript = await this.searchHandler.search(term);
665
- this.isSearching = false;
666
- }
667
-
668
- /**
669
- * Return transcript entries from the transcript configuration
670
- *
671
- * @readonly
672
- * @private
673
- * @type {TranscriptEntryConfig[]}
674
- * @memberof RadioPlayer
675
- */
676
- private get transcriptEntries(): TranscriptEntryConfig[] {
677
- return this.currentTranscript ? this.currentTranscript.entries : [];
678
- }
679
-
680
- /**
681
- * Return the transcript view DOM element
682
- *
683
- * @readonly
684
- * @private
685
- * @type {(TranscriptView | null)}
686
- * @memberof RadioPlayer
687
- */
688
- private get transcriptView(): TranscriptView | null {
689
- /* istanbul ignore next */
690
- return this.shadowRoot
691
- ? (this.shadowRoot.querySelector('transcript-view') as TranscriptView)
692
- : null;
693
- }
694
-
695
- /**
696
- * Return the audio element DOM element
697
- *
698
- * @readonly
699
- * @private
700
- * @type {(AudioElement | null)}
701
- * @memberof RadioPlayer
702
- */
703
- private get audioElement(): AudioElement | null {
704
- /* istanbul ignore next */
705
- return this.shadowRoot
706
- ? (this.shadowRoot.querySelector('audio-element') as AudioElement)
707
- : null;
708
- }
709
-
710
- /**
711
- * Return the playback controls DOM element
712
- *
713
- * @readonly
714
- * @private
715
- * @type {(PlaybackControls | null)}
716
- * @memberof RadioPlayer
717
- */
718
- private get playbackControls(): PlaybackControls | null {
719
- /* istanbul ignore next */
720
- return this.shadowRoot
721
- ? (this.shadowRoot.querySelector('playback-controls') as PlaybackControls)
722
- : null;
723
- }
724
-
725
- /**
726
- * Return the search results switcher DOM element
727
- *
728
- * @readonly
729
- * @private
730
- * @type {(SearchResultsSwitcher | null)}
731
- * @memberof RadioPlayer
732
- */
733
- private get searchResultsSwitcher(): SearchResultsSwitcher | null {
734
- /* istanbul ignore next */
735
- return this.shadowRoot
736
- ? (this.shadowRoot.querySelector('search-results-switcher') as SearchResultsSwitcher)
737
- : null;
738
- }
739
-
740
- /**
741
- * Triggered when the user changes the playback rate so we can update the audio element
742
- *
743
- * @private
744
- * @param {CustomEvent} e
745
- * @returns {void}
746
- * @memberof RadioPlayer
747
- */
748
- private changePlaybackRate(e: CustomEvent): void {
749
- const detail = e.detail || {};
750
- if (!detail.playbackRate) {
751
- return;
752
- }
753
- this.playbackRate = detail.playbackRate;
754
-
755
- const event = new CustomEvent('playbackRateChanged', {
756
- detail: { playbackRate: this.playbackRate },
757
- });
758
- this.dispatchEvent(event);
759
- }
760
-
761
- /**
762
- * Triggered when the user changes the volume from the playback controls
763
- *
764
- * @private
765
- * @param {CustomEvent} e
766
- * @returns {void}
767
- * @memberof RadioPlayer
768
- */
769
- private volumeChanged(e: CustomEvent): void {
770
- const detail = e.detail || {};
771
- if (detail.volume === undefined) {
772
- return;
773
- }
774
- this.volume = e.detail.volume;
775
-
776
- const event = new CustomEvent('volumeChanged', {
777
- detail: { volume: this.volume },
778
- });
779
- this.dispatchEvent(event);
780
- }
781
-
782
- /**
783
- * Triggered when the user presses the back button in the playback controls
784
- *
785
- * @private
786
- * @memberof RadioPlayer
787
- */
788
- private backButtonHandler(): void {
789
- /* istanbul ignore else */
790
- if (this.audioElement) {
791
- this.audioElement.seekBy(-10);
792
- }
793
-
794
- const event = new Event('jumpBackButtonPressed');
795
- this.dispatchEvent(event);
796
- }
797
-
798
- /**
799
- * Triggered when the user presses the play/pause button
800
- *
801
- * @private
802
- * @returns {void}
803
- * @memberof RadioPlayer
804
- */
805
- private playPauseButtonHandler(): void {
806
- this.isPlaying = !this.isPlaying;
807
- /* istanbul ignore if */
808
- if (!this.audioElement) {
809
- return;
810
- }
811
- if (this.isPlaying) {
812
- this.audioElement.play();
813
- } else {
814
- this.audioElement.pause();
815
- }
816
-
817
- const event = new CustomEvent('playPauseButtonPressed', {
818
- detail: { isPlaying: this.isPlaying },
819
- });
820
- this.dispatchEvent(event);
821
- }
822
-
823
- /**
824
- * Triggered when the user presses the forward button in the playback controls
825
- *
826
- * @private
827
- * @memberof RadioPlayer
828
- */
829
- private forwardButtonHandler(): void {
830
- /* istanbul ignore else */
831
- if (this.audioElement) {
832
- this.audioElement.seekBy(10);
833
- }
834
-
835
- const event = new Event('jumpForwardButtonPressed');
836
- this.dispatchEvent(event);
837
- }
838
-
839
- /**
840
- * Skip to the next section
841
- *
842
- * @private
843
- * @returns {void}
844
- * @memberof RadioPlayer
845
- */
846
- private nextSectionButtonHandler(): void {
847
- /* istanbul ignore if */
848
- if (!this.audioElement) {
849
- return;
850
- }
851
- const percentsGreaterThanValue: number[] = this.scrubberBarMarkerPercentages.filter(
852
- value => value > this.percentComplete + 0.1,
853
- );
854
- const closestUpper = Math.min(...percentsGreaterThanValue);
855
- const seekTo: number = this.duration * (closestUpper / 100) + 0.1;
856
- this.audioElement.seekTo(seekTo);
857
-
858
- const event = new Event('nextSectionButtonPressed');
859
- this.dispatchEvent(event);
860
- }
861
-
862
- /**
863
- * Jump to the previous section
864
- *
865
- * @private
866
- * @returns {void}
867
- * @memberof RadioPlayer
868
- */
869
- private prevSectionButtonHandler(): void {
870
- /* istanbul ignore if */
871
- if (!this.audioElement) {
872
- return;
873
- }
874
- const percentsLessThanValue: number[] = this.scrubberBarMarkerPercentages.filter(
875
- value => value < this.percentComplete - 0.1,
876
- );
877
- const closestLower = Math.max(...percentsLessThanValue);
878
- const seekTo: number = this.duration * (closestLower / 100) - 0.1;
879
- this.audioElement.seekTo(seekTo);
880
-
881
- const event = new Event('prevSectionButtonPressed');
882
- this.dispatchEvent(event);
883
- }
884
-
885
- /**
886
- * Handle the duration change event like when the audio file first loads
887
- *
888
- * @private
889
- * @param {CustomEvent} e
890
- * @returns {void}
891
- * @memberof RadioPlayer
892
- */
893
- private handleDurationChange(e: CustomEvent): void {
894
- const detail = e.detail || {};
895
- if (!detail.duration) {
896
- return;
897
- }
898
- this.duration = detail.duration;
899
- }
900
-
901
- /**
902
- * Triggered as the playback progresses in the audio element
903
- *
904
- * @private
905
- * @param {CustomEvent} e
906
- * @returns {void}
907
- * @memberof RadioPlayer
908
- */
909
- private handleTimeChange(e: CustomEvent): void {
910
- const detail = e.detail || {};
911
- if (!detail.currentTime) {
912
- return;
913
- }
914
-
915
- this.currentTime = detail.currentTime;
916
- const percent = this.currentTime / this.duration;
917
- this.percentComplete = percent * 100;
918
- }
919
-
920
- /**
921
- * Update any consumers that the current time has changed
922
- *
923
- * @private
924
- * @memberof RadioPlayer
925
- */
926
- private emitCurrentTimeChangedEvent(): void {
927
- const event = new CustomEvent('currentTimeChanged', {
928
- detail: { currentTime: this.currentTime },
929
- });
930
- this.dispatchEvent(event);
931
- }
932
-
933
- /**
934
- * When the user clears the search, we want to bubble up the event to other consumers.
935
- *
936
- * @private
937
- * @memberof RadioPlayer
938
- */
939
- private emitSearchClearedEvent(): void {
940
- const event = new Event('searchCleared');
941
- this.dispatchEvent(event);
942
- }
943
-
944
- private emitSearchTermChangedEvent(term: string): void {
945
- const event = new CustomEvent('searchTermChanged', {
946
- detail: { searchTerm: term },
947
- });
948
- this.dispatchEvent(event);
949
- }
950
-
951
- /**
952
- * Handle the playback paused event
953
- *
954
- * @private
955
- * @memberof RadioPlayer
956
- */
957
- private playbackPaused(): void {
958
- this.isPlaying = false;
959
- /* istanbul ignore else */
960
- if (this.playbackControls) {
961
- this.playbackControls.playbackMode = PlaybackMode.paused;
962
- }
963
- const event = new Event('playbackPaused');
964
- this.dispatchEvent(event);
965
- }
966
-
967
- /**
968
- * Handle the playback started event
969
- *
970
- * @private
971
- * @memberof RadioPlayer
972
- */
973
- private playbackStarted(): void {
974
- this.isPlaying = true;
975
- /* istanbul ignore else */
976
- if (this.playbackControls) {
977
- this.playbackControls.playbackMode = PlaybackMode.playing;
978
- }
979
- const event = new Event('playbackStarted');
980
- this.dispatchEvent(event);
981
- }
982
-
983
- /**
984
- * Emits a `canplay` event when the media is ready to be played
985
- *
986
- * @private
987
- * @memberof RadioPlayer
988
- */
989
- private canplay(): void {
990
- const event: Event = new Event('canplay');
991
- this.dispatchEvent(event);
992
- }
993
-
994
- /**
995
- * Triggered when the user scrubs the scrubber bar or waveform progress bar
996
- *
997
- * Handles updating the audioElement to the new time and emitting an event
998
- * that the time changed
999
- *
1000
- * @private
1001
- * @param {CustomEvent} e
1002
- * @returns {void}
1003
- * @memberof RadioPlayer
1004
- */
1005
- private valueChangedFromScrub(e: CustomEvent): void {
1006
- const detail = e.detail || {};
1007
- if (!detail.value) {
1008
- return;
1009
- }
1010
-
1011
- const percentage = detail.value;
1012
- const newTime = this.duration * (percentage / 100);
1013
- this.currentTime = newTime;
1014
- /* istanbul ignore else */
1015
- if (this.audioElement) {
1016
- this.audioElement.seekTo(newTime);
1017
- }
1018
- this.percentComplete = percentage;
1019
- const event = new CustomEvent('timeChangedFromScrub', {
1020
- detail: { newTime: this.currentTime },
1021
- });
1022
- this.dispatchEvent(event);
1023
- }
1024
-
1025
- /**
1026
- * Triggered when the user selects a transcript entry.
1027
- * Allows it to jump to that spot in the playback.
1028
- *
1029
- * @private
1030
- * @param {CustomEvent} e
1031
- * @returns {void}
1032
- * @memberof RadioPlayer
1033
- */
1034
- private transcriptEntrySelected(e: CustomEvent): void {
1035
- const detail = e.detail || {};
1036
- const entry = detail.entry || {};
1037
- if (!entry.start) {
1038
- return;
1039
- }
1040
-
1041
- const newTime = entry.start;
1042
- this.currentTime = newTime;
1043
- /* istanbul ignore else */
1044
- if (this.audioElement) {
1045
- this.audioElement.seekTo(newTime);
1046
- this.audioElement.play();
1047
- }
1048
- const event = new CustomEvent('transcriptEntrySelected', {
1049
- detail: { newTime: this.currentTime },
1050
- });
1051
- this.dispatchEvent(event);
1052
- }
1053
-
1054
- /**
1055
- * Creates the music zones based on the transcript so we know when to skip past sections
1056
- *
1057
- * @private
1058
- * @memberof RadioPlayer
1059
- */
1060
- private updateMusicZones(): void {
1061
- // eslint-disable-next-line max-len
1062
- const musicEntries: TranscriptEntryConfig[] = this.transcriptEntries.filter(
1063
- (entry: TranscriptEntryConfig) => entry.isMusic === true,
1064
- );
1065
-
1066
- // eslint-disable-next-line max-len
1067
- const musicZones: MusicZone[] = musicEntries.map(
1068
- (entry: TranscriptEntryConfig) => new MusicZone(entry.start, entry.end),
1069
- );
1070
-
1071
- this.musicZones = musicZones;
1072
- }
1073
-
1074
- /**
1075
- * Skips a music zone if enabled
1076
- *
1077
- * @private
1078
- * @memberof RadioPlayer
1079
- */
1080
- private skipMusicZone(): void {
1081
- // eslint-disable-next-line max-len
1082
- const activeMusicZone: MusicZone | undefined = this.musicZones.find(
1083
- (zone: MusicZone) => this.currentTime > zone.start && this.currentTime < zone.end,
1084
- );
1085
-
1086
- if (activeMusicZone && this.audioElement) {
1087
- this.audioElement.seekTo(activeMusicZone.end + 0.1);
1088
- }
1089
- }
1090
-
1091
- /**
1092
- * Updates the search results switcher numbers
1093
- *
1094
- * @private
1095
- * @returns {void}
1096
- * @memberof RadioPlayer
1097
- */
1098
- private updateSearchResultSwitcher(): void {
1099
- this.shouldShowNoSearchResultMessage = false;
1100
- this.shouldShowSearchResultSwitcher = false;
1101
-
1102
- if (this.searchTerm.length === 0) {
1103
- return;
1104
- }
1105
-
1106
- const resultCount: number = this.searchResults.length;
1107
-
1108
- if (resultCount === 0) {
1109
- this.shouldShowNoSearchResultMessage = true;
1110
- } else {
1111
- this.shouldShowSearchResultSwitcher = true;
1112
- /* istanbul ignore else */
1113
- if (this.searchResultsSwitcher) {
1114
- this.searchResultsSwitcher.numberOfResults = resultCount;
1115
- }
1116
- }
1117
- }
1118
-
1119
- /**
1120
- * Returns the search result template entries from the transcript config
1121
- *
1122
- * @readonly
1123
- * @private
1124
- * @type {TranscriptEntryConfig[]}
1125
- * @memberof RadioPlayer
1126
- */
1127
- private get searchResults(): TranscriptEntryConfig[] {
1128
- return this.searchResultsTranscript ? this.searchResultsTranscript.searchResults : [];
1129
- }
1130
-
1131
- /**
1132
- * LitElement life cycle event when any reactive property gets updated
1133
- *
1134
- * @param {PropertyValues} changedProperties
1135
- * @memberof RadioPlayer
1136
- */
1137
- updated(changedProperties: PropertyValues): void {
1138
- // when the transcriptConfig gets changed, reload the music zones and search results switcher
1139
- if (changedProperties.has('transcriptConfig')) {
1140
- this.updateMusicZones();
1141
- }
1142
-
1143
- if (changedProperties.has('searchResultsTranscript')) {
1144
- this.updateSearchResultSwitcher();
1145
- }
1146
-
1147
- if (changedProperties.has('searchHandler') && this.searchTerm) {
1148
- this.executeSearch(this.searchTerm);
1149
- }
1150
-
1151
- // as the currentTime gets updated, emit an event and skip the music zone if enabled
1152
- if (changedProperties.has('currentTime')) {
1153
- this.emitCurrentTimeChangedEvent();
1154
- if (this.skipMusicSections) {
1155
- this.skipMusicZone();
1156
- }
1157
- }
1158
- }
1159
-
1160
- /**
1161
- * Component styles
1162
- *
1163
- * @readonly
1164
- * @static
1165
- * @type {CSSResult}
1166
- * @memberof RadioPlayer
1167
- */
1168
- static get styles(): CSSResult {
1169
- const collectionLogoMaxHeightCss = css`var(--collectionLogoMaxHeight, 8rem)`;
1170
-
1171
- const titleColorCss = css`var(--titleColor, white)`;
1172
- const titleFontCss = css`var(--titleFont, 1.5em sans-serif)`;
1173
-
1174
- const dateColorCss = css`var(--dateColor, white)`;
1175
- const dateFontCss = css`var(--dateFont, 1em sans-serif)`;
1176
-
1177
- const waveformProgressHeightCss = css`var(--waveformProgressHeight, 5rem)`;
1178
-
1179
- return css`
1180
- section[role='main'] {
1181
- display: -ms-grid;
1182
- display: grid;
1183
- grid-gap: 0.5rem;
1184
- }
1185
-
1186
- ia-activity-indicator {
1187
- width: 1.5em;
1188
- height: 1.5em;
1189
- display: block;
1190
- margin: auto;
1191
- --activityIndicatorLoadingRingColor: #999;
1192
- --activityIndicatorLoadingDotColor: #999;
1193
- }
1194
-
1195
- /* mobile view */
1196
- @media (max-width: 770px) {
1197
- section[role='main'] {
1198
- -ms-grid-columns: 25% 0.5rem 1fr;
1199
- -ms-grid-rows: auto 0.5rem auto 0.5rem auto 0.5rem auto 0.5rem auto;
1200
- grid-template-columns: 25% 1fr;
1201
- grid-template-areas:
1202
- 'collection-logo title-date'
1203
- 'waveform-scrubber waveform-scrubber'
1204
- 'playback-controls playback-controls'
1205
- 'search-section search-section'
1206
- 'transcript-container transcript-container';
1207
- }
1208
- .date {
1209
- text-align: left;
1210
- }
1211
- transcript-view {
1212
- --timeDisplay: none;
1213
- }
1214
- playback-controls {
1215
- width: 75%;
1216
- margin: auto;
1217
- grid-row: 5;
1218
- grid-column: 1;
1219
- -ms-grid-row: 5;
1220
- -ms-grid-column: 1;
1221
- -ms-grid-column-span: 3;
1222
- }
1223
- .title-date {
1224
- grid-row: 1;
1225
- grid-column: 3;
1226
- -ms-grid-row: 1;
1227
- -ms-grid-column: 3;
1228
- }
1229
- .transcript-container {
1230
- grid-row: 9;
1231
- grid-column: 1;
1232
- -ms-grid-row: 9;
1233
- -ms-grid-column: 1;
1234
- -ms-grid-column-span: 3;
1235
- }
1236
- .collection-logo {
1237
- grid-row: 1;
1238
- grid-column: 1;
1239
- -ms-grid-row: 1;
1240
- -ms-grid-column: 1;
1241
- }
1242
- .waveform-scrubber-container {
1243
- grid-row: 3;
1244
- grid-column: 1;
1245
- -ms-grid-row: 3;
1246
- -ms-grid-column: 1;
1247
- -ms-grid-column-span: 3;
1248
- }
1249
- .search-section {
1250
- grid-row: 7;
1251
- grid-column: 1;
1252
- -ms-grid-row: 7;
1253
- -ms-grid-column: 1;
1254
- -ms-grid-column-span: 3;
1255
- width: 75%;
1256
- margin: auto;
1257
- }
1258
- expandable-search-bar {
1259
- width: 100%;
1260
- }
1261
- }
1262
-
1263
- /* wide view */
1264
- @media (min-width: 770px) {
1265
- section[role='main'] {
1266
- -ms-grid-columns: 192px 0.5rem 0 0.5rem 250px 0.5rem 1fr;
1267
- -ms-grid-rows: auto 0.5rem auto 0.5rem auto;
1268
- grid-template-columns: 192px 0 250px 1fr;
1269
- grid-template-areas:
1270
- 'title-date title-date title-date title-date'
1271
- 'collection-logo 1 playback-controls waveform-scrubber'
1272
- 'search-section transcript-container transcript-container transcript-container';
1273
- }
1274
- .title-date {
1275
- display: flex;
1276
- justify-content: space-between;
1277
- align-items: baseline;
1278
- grid-row: 1;
1279
- grid-column: 1;
1280
- -ms-grid-row: 1;
1281
- -ms-grid-column: 1;
1282
- -ms-grid-column-span: 7;
1283
- }
1284
- transcript-view {
1285
- --timeDisplay: block;
1286
- }
1287
- playback-controls {
1288
- grid-row: 3;
1289
- grid-column: 5;
1290
- -ms-grid-row: 3;
1291
- -ms-grid-column: 5;
1292
- -ms-grid-column-span: 1;
1293
- }
1294
- .transcript-container {
1295
- grid-row: 5;
1296
- grid-column: 3;
1297
- -ms-grid-row: 5;
1298
- -ms-grid-column: 3;
1299
- -ms-grid-column-span: 5;
1300
- }
1301
- .collection-logo {
1302
- grid-row: 3;
1303
- grid-column: 1;
1304
- -ms-grid-row: 3;
1305
- -ms-grid-column: 1;
1306
- }
1307
- .waveform-scrubber-container {
1308
- grid-row: 3;
1309
- grid-column: 7;
1310
- -ms-grid-row: 3;
1311
- -ms-grid-column: 7;
1312
- -ms-grid-column-span: 1;
1313
- }
1314
- .search-section {
1315
- grid-row: 5;
1316
- grid-column: 1;
1317
- -ms-grid-row: 5;
1318
- -ms-grid-column: 1;
1319
- -ms-grid-column-span: 1;
1320
- }
1321
- }
1322
-
1323
- .title-date {
1324
- grid-area: title-date;
1325
- }
1326
-
1327
- .title {
1328
- color: ${titleColorCss};
1329
- font: ${titleFontCss};
1330
- }
1331
-
1332
- .date {
1333
- color: ${dateColorCss};
1334
- font: ${dateFontCss};
1335
- }
1336
-
1337
- waveform-progress {
1338
- width: 100%;
1339
- height: ${waveformProgressHeightCss};
1340
- }
1341
-
1342
- playback-controls {
1343
- grid-area: playback-controls;
1344
- }
1345
-
1346
- .transcript-container {
1347
- grid-area: transcript-container;
1348
- }
1349
-
1350
- transcript-view {
1351
- max-width: 600px;
1352
- display: block;
1353
- }
1354
-
1355
- .collection-logo {
1356
- width: 100%;
1357
- max-height: ${collectionLogoMaxHeightCss};
1358
- object-fit: contain;
1359
- grid-area: collection-logo;
1360
- align-self: center;
1361
- }
1362
-
1363
- .waveform-scrubber-container {
1364
- width: 100%;
1365
- height: 100%;
1366
- grid-area: waveform-scrubber;
1367
- }
1368
-
1369
- .search-section {
1370
- grid-area: search-section;
1371
- }
1372
-
1373
- .search-results-info {
1374
- margin-top: 0.5em;
1375
- }
1376
-
1377
- .quick-search-container {
1378
- max-height: 150px;
1379
- overflow-y: scroll;
1380
- scrollbar-width: none;
1381
- margin: 0 0.5em;
1382
- }
1383
-
1384
- .quick-search-container::-webkit-scrollbar {
1385
- display: none;
1386
- }
1387
-
1388
- expandable-search-bar {
1389
- display: block;
1390
- margin: auto;
1391
- }
1392
-
1393
- .no-search-results-message {
1394
- text-align: center;
1395
- }
1396
-
1397
- .hidden {
1398
- display: none;
1399
- }
1400
- `;
1401
- }
1402
- }
1
+ /* eslint-disable import/no-duplicates */
2
+ import { AudioElement, AudioSource } from '@internetarchive/audio-element';
3
+ import '@internetarchive/expandable-search-bar';
4
+ import { QuickSearchEntry } from '@internetarchive/expandable-search-bar';
5
+ import '@internetarchive/ia-activity-indicator';
6
+ import '@internetarchive/playback-controls';
7
+ import { PlaybackControls, PlaybackMode } from '@internetarchive/playback-controls';
8
+ import '@internetarchive/scrubber-bar';
9
+ import {
10
+ TranscriptConfig,
11
+ TranscriptEntryConfig,
12
+ TranscriptView,
13
+ } from '@internetarchive/transcript-view';
14
+ import '@internetarchive/waveform-progress';
15
+ import { ZoneOfSilence } from '@internetarchive/waveform-progress';
16
+ import {
17
+ css,
18
+ CSSResult,
19
+ html,
20
+ LitElement,
21
+ PropertyValues,
22
+ TemplateResult,
23
+ } from 'lit';
24
+ import {
25
+ customElement,
26
+ property,
27
+ } from 'lit/decorators.js';
28
+ import MusicZone from './models/music-zone';
29
+ import RadioPlayerConfig from './models/radio-player-config';
30
+ import { SearchHandlerInterface } from './search-handler/search-handler-interface';
31
+ import './search-results-switcher';
32
+ import SearchResultsSwitcher from './search-results-switcher';
33
+
34
+ /**
35
+ * A Radio Player element to play back transcribed audio.
36
+ *
37
+ * It is responsible for orchestrating all of the other components like:
38
+ * - Waveform Progress
39
+ * - Scrubber Bar
40
+ * - Playback Controls
41
+ * - Transcript View
42
+ * - Audio Element
43
+ * - Search Bar
44
+ *
45
+ * @export
46
+ * @class RadioPlayer
47
+ * @extends {LitElement}
48
+ */
49
+ @customElement('radio-player')
50
+ export default class RadioPlayer extends LitElement {
51
+ /**
52
+ * Radio player configuration
53
+ *
54
+ * @type {(RadioPlayerConfig | undefined)}
55
+ * @memberof RadioPlayer
56
+ */
57
+ @property({ type: Object }) config: RadioPlayerConfig | undefined = undefined;
58
+
59
+ /**
60
+ * The Search Handler
61
+ *
62
+ * @type {(SearchHandlerInterface | undefined)}
63
+ * @memberof RadioPlayer
64
+ */
65
+ @property({ type: Object }) searchHandler: SearchHandlerInterface | undefined = undefined;
66
+
67
+ /**
68
+ * Transcript configuration
69
+ *
70
+ * @type {(TranscriptConfig | undefined)}
71
+ * @memberof RadioPlayer
72
+ */
73
+ @property({ type: Object }) transcriptConfig: TranscriptConfig | undefined = undefined;
74
+
75
+ /**
76
+ * Search result transcript. It's also a `TranscriptConfig` object, but is broken up by
77
+ * transcript entries as well as search results.
78
+ *
79
+ * If this is set, the transcript view, uses this, otherwise it uses `transcriptConfig`
80
+ *
81
+ * @type {(TranscriptConfig | undefined)}
82
+ * @memberof RadioPlayer
83
+ */
84
+ @property({ type: Object }) searchResultsTranscript: TranscriptConfig | undefined = undefined;
85
+
86
+ /**
87
+ * Current playback time
88
+ *
89
+ * @type {number}
90
+ * @memberof RadioPlayer
91
+ */
92
+ @property({ type: Number }) currentTime = 0;
93
+
94
+ /**
95
+ * Current search term
96
+ *
97
+ * @type {string}
98
+ * @memberof RadioPlayer
99
+ */
100
+ @property({ type: String }) searchTerm = '';
101
+
102
+ /**
103
+ * Flag to determine whether or not to skip the music sections
104
+ *
105
+ * @type {boolean}
106
+ * @memberof RadioPlayer
107
+ */
108
+ @property({ type: Boolean }) skipMusicSections = false;
109
+
110
+ /**
111
+ * Percent of playback that is complete
112
+ *
113
+ * @private
114
+ * @type {number}
115
+ * @memberof RadioPlayer
116
+ */
117
+ @property({ type: Number }) private percentComplete = 0;
118
+
119
+ /**
120
+ * Flag for whether or not the radio player is playing
121
+ *
122
+ * @private
123
+ * @type {boolean}
124
+ * @memberof RadioPlayer
125
+ */
126
+ @property({ type: Boolean }) private isPlaying = false;
127
+
128
+ /**
129
+ * Duration of the audio file
130
+ *
131
+ * @private
132
+ * @type {number}
133
+ * @memberof RadioPlayer
134
+ */
135
+ @property({ type: Number }) private duration = 0;
136
+
137
+ /**
138
+ * The playback rate where 1 is "normal", 0 is paused, 2 is twice as fase
139
+ *
140
+ * @private
141
+ * @type {number}
142
+ * @memberof RadioPlayer
143
+ */
144
+ @property({ type: Number }) private playbackRate = 1;
145
+
146
+ /**
147
+ * Volume from 0 to 1
148
+ *
149
+ * @private
150
+ * @type {number}
151
+ * @memberof RadioPlayer
152
+ */
153
+ @property({ type: Number }) private volume = 1;
154
+
155
+ /**
156
+ * Flag for whether we should show the search results switcher
157
+ *
158
+ * @private
159
+ * @type {boolean}
160
+ * @memberof RadioPlayer
161
+ */
162
+ @property({ type: Boolean }) private shouldShowSearchResultSwitcher = false;
163
+
164
+ /**
165
+ * Flag for whether we should show the 'no results' message
166
+ *
167
+ * @private
168
+ * @type {boolean}
169
+ * @memberof RadioPlayer
170
+ */
171
+ @property({ type: Boolean }) private shouldShowNoSearchResultMessage = false;
172
+
173
+ /**
174
+ * Are we searching or not?
175
+ *
176
+ * @private
177
+ * @type {boolean}
178
+ * @memberof RadioPlayer
179
+ */
180
+ @property({ type: Boolean }) private isSearching = false;
181
+
182
+ private musicZones: MusicZone[] = [];
183
+
184
+ /**
185
+ * LitElement lifecycle main render method
186
+ *
187
+ * @returns {TemplateResult}
188
+ * @memberof RadioPlayer
189
+ */
190
+ render(): TemplateResult {
191
+ return html`
192
+ ${this.audioElementTemplate}
193
+ <section role="main">
194
+ ${this.titleDateTemplate} ${this.collectionLogoTemplate} ${this.playbackControlsTemplate}
195
+ <div class="waveform-scrubber-container">
196
+ ${this.waveFormProgressTemplate} ${this.scrubberBarTemplate}
197
+ </div>
198
+ ${this.searchSectionTemplate} ${this.transcriptViewTemplate}
199
+ </section>
200
+ `;
201
+ }
202
+
203
+ /**
204
+ * Start playback
205
+ *
206
+ * @memberof RadioPlayer
207
+ */
208
+ play(): void {
209
+ /* istanbul ignore else */
210
+ if (this.audioElement) this.audioElement.play();
211
+ }
212
+
213
+ /**
214
+ * Pause playback
215
+ *
216
+ * @memberof RadioPlayer
217
+ */
218
+ pause(): void {
219
+ /* istanbul ignore else */
220
+ if (this.audioElement) this.audioElement.pause();
221
+ }
222
+
223
+ /**
224
+ * Seek audio to given number of seconds
225
+ *
226
+ * @param {number} seconds
227
+ * @memberof RadioPlayer
228
+ */
229
+ seekTo(seconds: number): void {
230
+ /* istanbul ignore else */
231
+ if (this.audioElement) this.audioElement.seekTo(seconds);
232
+ }
233
+
234
+ /**
235
+ * Generate markup for the title and date view sections
236
+ *
237
+ * @readonly
238
+ * @private
239
+ * @type {TemplateResult}
240
+ * @memberof RadioPlayer
241
+ */
242
+ private get titleDateTemplate(): TemplateResult {
243
+ return html`
244
+ <div class="title-date">
245
+ <div class="title">
246
+ ${this.config ? this.config.title : ''}
247
+ </div>
248
+
249
+ <div class="date">
250
+ ${this.config ? this.config.date : ''}
251
+ </div>
252
+ </div>
253
+ `;
254
+ }
255
+
256
+ /**
257
+ * Generate markup for the logo element
258
+ *
259
+ * @readonly
260
+ * @private
261
+ * @type {TemplateResult}
262
+ * @memberof RadioPlayer
263
+ */
264
+ private get collectionLogoTemplate(): TemplateResult {
265
+ return html`
266
+ <img class="collection-logo" src=${this.logoUrl} alt="collection logo" />
267
+ `;
268
+ }
269
+
270
+ /**
271
+ * Return the logoUrl from the config if provided
272
+ *
273
+ * @readonly
274
+ * @private
275
+ * @type {string}
276
+ * @memberof RadioPlayer
277
+ */
278
+ private get logoUrl(): string {
279
+ return this.config ? this.config.logoUrl : '';
280
+ }
281
+
282
+ /**
283
+ * Generate the markup for the waveform progress element
284
+ *
285
+ * @readonly
286
+ * @private
287
+ * @type {TemplateResult}
288
+ * @memberof RadioPlayer
289
+ */
290
+ private get waveFormProgressTemplate(): TemplateResult | undefined {
291
+ return this.waveformUrl
292
+ ? html`
293
+ <waveform-progress
294
+ interactive="true"
295
+ .waveformUrl=${this.waveformUrl}
296
+ .percentComplete=${this.percentComplete}
297
+ @valuechange=${this.valueChangedFromScrub}
298
+ >
299
+ </waveform-progress>
300
+ `
301
+ : undefined;
302
+ }
303
+
304
+ /**
305
+ * Generate an array of `ZoneOfSilence` models for the waveform progress element
306
+ *
307
+ * It does this by parsing the transcript for music sections and converting
308
+ * those music sections to zones of silence.
309
+ *
310
+ * @readonly
311
+ * @private
312
+ * @type {ZoneOfSilence[]}
313
+ * @memberof RadioPlayer
314
+ */
315
+ private get zonesOfSilence(): ZoneOfSilence[] {
316
+ /* istanbul ignore else */
317
+ if (this.duration === 0) {
318
+ return [];
319
+ }
320
+
321
+ // eslint-disable-next-line max-len
322
+ const musicEntries: TranscriptEntryConfig[] = this.transcriptEntries.filter(
323
+ (entry: TranscriptEntryConfig) => entry.isMusic === true,
324
+ );
325
+
326
+ const zonesOfSilence: ZoneOfSilence[] = musicEntries.map((entry: TranscriptEntryConfig) => {
327
+ const startPercent: number = (entry.start / this.duration) * 100;
328
+ const endPercent: number = (entry.end / this.duration) * 100;
329
+ return new ZoneOfSilence(startPercent, endPercent);
330
+ });
331
+
332
+ return zonesOfSilence;
333
+ }
334
+
335
+ /**
336
+ * Return the waveform url from the configuration
337
+ *
338
+ * @readonly
339
+ * @private
340
+ * @type {string}
341
+ * @memberof RadioPlayer
342
+ */
343
+ private get waveformUrl(): string | undefined {
344
+ return this.config ? this.config.waveformUrl : '';
345
+ }
346
+
347
+ /**
348
+ * Generate the audio element markup
349
+ *
350
+ * @readonly
351
+ * @private
352
+ * @type {TemplateResult}
353
+ * @memberof RadioPlayer
354
+ */
355
+ private get audioElementTemplate(): TemplateResult {
356
+ return html`
357
+ <audio-element
358
+ .sources=${this.audioSources}
359
+ .playbackRate=${this.playbackRate}
360
+ .volume=${this.volume}
361
+ @timeupdate=${this.handleTimeChange}
362
+ @durationchange=${this.handleDurationChange}
363
+ @playbackStarted=${this.playbackStarted}
364
+ @playbackPaused=${this.playbackPaused}
365
+ @canplay=${this.canplay}
366
+ >
367
+ </audio-element>
368
+ `;
369
+ }
370
+
371
+ /**
372
+ * Return the audio sources from the configuration
373
+ *
374
+ * @readonly
375
+ * @private
376
+ * @type {AudioSource[]}
377
+ * @memberof RadioPlayer
378
+ */
379
+ private get audioSources(): AudioSource[] {
380
+ return this.config ? this.config.audioSources : [];
381
+ }
382
+
383
+ /**
384
+ * Generate markup for the playback controls
385
+ *
386
+ * @readonly
387
+ * @private
388
+ * @type {TemplateResult}
389
+ * @memberof RadioPlayer
390
+ */
391
+ private get playbackControlsTemplate(): TemplateResult {
392
+ return html`
393
+ <playback-controls
394
+ @back-button-pressed=${this.backButtonHandler}
395
+ @play-pause-button-pressed=${this.playPauseButtonHandler}
396
+ @forward-button-pressed=${this.forwardButtonHandler}
397
+ @volumeChange=${this.volumeChanged}
398
+ @playbackRateChange=${this.changePlaybackRate}
399
+ @next-section-button-pressed=${this.nextSectionButtonHandler}
400
+ @prev-section-button-pressed=${this.prevSectionButtonHandler}
401
+ >
402
+ </playback-controls>
403
+ `;
404
+ }
405
+
406
+ /**
407
+ * Generate an array of start and end points for each of the music zones.
408
+ * This allows us to show the markers on the scrubber bar and optionally skip those sections.
409
+ *
410
+ * @readonly
411
+ * @private
412
+ * @type {number[]}
413
+ * @memberof RadioPlayer
414
+ */
415
+ private get scrubberBarMarkerPercentages(): number[] {
416
+ const percentages: number[] = [0];
417
+ this.zonesOfSilence.forEach((zone) => {
418
+ percentages.push(zone.startPercent);
419
+ percentages.push(zone.endPercent);
420
+ });
421
+ percentages.push(100);
422
+ return percentages;
423
+ }
424
+
425
+ /**
426
+ * Generate markup for the scrubber bar
427
+ *
428
+ * @readonly
429
+ * @private
430
+ * @type {TemplateResult}
431
+ * @memberof RadioPlayer
432
+ */
433
+ private get scrubberBarTemplate(): TemplateResult {
434
+ return html`
435
+ <scrubber-bar
436
+ .sectionMarkerPercentages=${this.scrubberBarMarkerPercentages}
437
+ .value=${this.percentComplete}
438
+ @valuechange=${this.valueChangedFromScrub}
439
+ >
440
+ </scrubber-bar>
441
+ `;
442
+ }
443
+
444
+ /**
445
+ * Generate markup for the transcript view
446
+ *
447
+ * @readonly
448
+ * @private
449
+ * @type {TemplateResult}
450
+ * @memberof RadioPlayer
451
+ */
452
+ private get transcriptViewTemplate(): TemplateResult {
453
+ return html`
454
+ <div class="transcript-container">
455
+ <transcript-view
456
+ .config=${this.currentTranscript}
457
+ .currentTime=${this.currentTime}
458
+ @transcriptEntrySelected=${this.transcriptEntrySelected}
459
+ >
460
+ </transcript-view>
461
+ </div>
462
+ `;
463
+ }
464
+
465
+ private get currentTranscript(): TranscriptConfig | undefined {
466
+ return this.searchResultsTranscript || this.transcriptConfig;
467
+ }
468
+
469
+ /**
470
+ * Generate markup for search section
471
+ *
472
+ * @readonly
473
+ * @private
474
+ * @type {TemplateResult}
475
+ * @memberof RadioPlayer
476
+ */
477
+ private get searchSectionTemplate(): TemplateResult {
478
+ return html`
479
+ <div class="search-section">
480
+ <expandable-search-bar
481
+ searchTerm=${this.searchTerm}
482
+ .quickSearches=${this.quickSearches}
483
+ @inputchange=${this.updateSearchTerm}
484
+ @enterKeyPressed=${this.searchEnterKeyPressed}
485
+ @searchCleared=${this.searchCleared}
486
+ >
487
+ </expandable-search-bar>
488
+ <div class="search-results-info">
489
+ ${this.searchActivityIndicator}
490
+ ${this.searchResultsSwitcherTemplate}
491
+ ${this.noSearchResultsTemplate}
492
+ </div>
493
+ </div>
494
+ `;
495
+ }
496
+
497
+ /**
498
+ * Generate markup for the search results switcher
499
+ *
500
+ * @readonly
501
+ * @private
502
+ * @type {TemplateResult}
503
+ * @memberof RadioPlayer
504
+ */
505
+ private get searchResultsSwitcherTemplate(): TemplateResult {
506
+ return html`
507
+ <search-results-switcher
508
+ class="${this.shouldShowSearchResultSwitcher && !this.isSearching ? '' : 'hidden'}"
509
+ @searchResultIndexChanged=${this.searchResultIndexChanged}
510
+ >
511
+ </search-results-switcher>
512
+ `;
513
+ }
514
+
515
+ /**
516
+ * Search activity indicator template
517
+ *
518
+ * @readonly
519
+ * @private
520
+ * @type {TemplateResult}
521
+ * @memberof RadioPlayer
522
+ */
523
+ private get searchActivityIndicator(): TemplateResult {
524
+ return html`
525
+ <ia-activity-indicator
526
+ class="${this.isSearching ? '' : 'hidden'}">
527
+ </ia-activity-indicator>
528
+ `;
529
+ }
530
+
531
+ /**
532
+ * Generate markup for the "No Search Results" message
533
+ *
534
+ * @readonly
535
+ * @private
536
+ * @type {TemplateResult}
537
+ * @memberof RadioPlayer
538
+ */
539
+ private get noSearchResultsTemplate(): TemplateResult {
540
+ return html`
541
+ <div
542
+ class="
543
+ no-search-results-message
544
+ ${this.shouldShowNoSearchResultMessage && !this.isSearching ? '' : 'hidden'}"
545
+ >
546
+ No search results.
547
+ </div>
548
+ `;
549
+ }
550
+
551
+ /**
552
+ * Generate an array of `QuickSearchEntry` objects based on the quick search
553
+ * configuration passed in.
554
+ *
555
+ * @readonly
556
+ * @private
557
+ * @type {QuickSearchEntry[]}
558
+ * @memberof RadioPlayer
559
+ */
560
+ private get quickSearches(): QuickSearchEntry[] {
561
+ if (!this.config) {
562
+ return [];
563
+ }
564
+
565
+ const entries: QuickSearchEntry[] = this.config.quickSearches.map(
566
+ entry => new QuickSearchEntry(entry),
567
+ );
568
+
569
+ return entries;
570
+ }
571
+
572
+ /**
573
+ * Update the local search term when the SearchBar widget gets updated and emits the event
574
+ *
575
+ * @private
576
+ * @param {CustomEvent} e
577
+ * @returns {void}
578
+ * @memberof RadioPlayer
579
+ */
580
+ private updateSearchTerm(e: CustomEvent): void {
581
+ const detail = e.detail || {};
582
+ if (!detail.value) {
583
+ return;
584
+ }
585
+ const searchTerm = detail.value;
586
+ this.searchTerm = searchTerm;
587
+ this.emitSearchTermChangedEvent(searchTerm);
588
+ }
589
+
590
+ /**
591
+ * Triggered when the user clicks the clear search button in the search bar.
592
+ * Clean up all of the search info and reset to the base transcript.
593
+ *
594
+ * @private
595
+ * @memberof RadioPlayer
596
+ */
597
+ private searchCleared(): void {
598
+ this.searchTerm = '';
599
+ this.searchResultsTranscript = undefined;
600
+ this.emitSearchClearedEvent();
601
+ this.emitSearchTermChangedEvent('');
602
+ /* istanbul ignore else */
603
+ if (this.transcriptView) {
604
+ this.transcriptView.selectedSearchResultIndex = 0;
605
+ }
606
+ /* istanbul ignore else */
607
+ if (this.searchResultsSwitcher) {
608
+ this.searchResultsSwitcher.currentResultIndex = 0;
609
+ }
610
+ }
611
+
612
+ /**
613
+ * Triggered when the user scrubs through search result indices so we can coordinate
614
+ * the UI updates like scrolling the transcript view
615
+ *
616
+ * @private
617
+ * @param {CustomEvent} e
618
+ * @returns {void}
619
+ * @memberof RadioPlayer
620
+ */
621
+ private searchResultIndexChanged(e: CustomEvent): void {
622
+ const detail = e.detail || {};
623
+ const { searchResultIndex } = detail;
624
+ if (searchResultIndex === undefined || !this.transcriptView) {
625
+ return;
626
+ }
627
+ this.transcriptView.selectedSearchResultIndex = searchResultIndex;
628
+ this.transcriptView.scrollToSelectedSearchResult();
629
+
630
+ const event = new CustomEvent('highlightedSearchResultChanged', {
631
+ detail: { searchResultIndex },
632
+ });
633
+ this.dispatchEvent(event);
634
+ }
635
+
636
+ /**
637
+ * Triggered when the user presses enter in the search bar so we can trigger a search.
638
+ *
639
+ * @private
640
+ * @param {CustomEvent} e
641
+ * @memberof RadioPlayer
642
+ */
643
+ private searchEnterKeyPressed(e: CustomEvent): void {
644
+ const detail = e.detail || {};
645
+ if (!detail.value) {
646
+ return;
647
+ }
648
+ this.executeSearch(detail.value);
649
+ }
650
+
651
+ private async executeSearch(term: string): Promise<void> {
652
+ if (!this.searchHandler || term.length < 2) {
653
+ this.searchResultsTranscript = undefined;
654
+ return;
655
+ }
656
+ this.searchTerm = term;
657
+ this.isSearching = true;
658
+ const event = new Event('searchExecuted');
659
+ this.dispatchEvent(event);
660
+ this.searchResultsTranscript = await this.searchHandler.search(term);
661
+ this.isSearching = false;
662
+ }
663
+
664
+ /**
665
+ * Return transcript entries from the transcript configuration
666
+ *
667
+ * @readonly
668
+ * @private
669
+ * @type {TranscriptEntryConfig[]}
670
+ * @memberof RadioPlayer
671
+ */
672
+ private get transcriptEntries(): TranscriptEntryConfig[] {
673
+ return this.currentTranscript ? this.currentTranscript.entries : [];
674
+ }
675
+
676
+ /**
677
+ * Return the transcript view DOM element
678
+ *
679
+ * @readonly
680
+ * @private
681
+ * @type {(TranscriptView | null)}
682
+ * @memberof RadioPlayer
683
+ */
684
+ private get transcriptView(): TranscriptView | null {
685
+ /* istanbul ignore next */
686
+ return this.shadowRoot
687
+ ? (this.shadowRoot.querySelector('transcript-view') as TranscriptView)
688
+ : null;
689
+ }
690
+
691
+ /**
692
+ * Return the audio element DOM element
693
+ *
694
+ * @readonly
695
+ * @private
696
+ * @type {(AudioElement | null)}
697
+ * @memberof RadioPlayer
698
+ */
699
+ private get audioElement(): AudioElement | null {
700
+ /* istanbul ignore next */
701
+ return this.shadowRoot
702
+ ? (this.shadowRoot.querySelector('audio-element') as AudioElement)
703
+ : null;
704
+ }
705
+
706
+ /**
707
+ * Return the playback controls DOM element
708
+ *
709
+ * @readonly
710
+ * @private
711
+ * @type {(PlaybackControls | null)}
712
+ * @memberof RadioPlayer
713
+ */
714
+ private get playbackControls(): PlaybackControls | null {
715
+ /* istanbul ignore next */
716
+ return this.shadowRoot
717
+ ? (this.shadowRoot.querySelector('playback-controls') as PlaybackControls)
718
+ : null;
719
+ }
720
+
721
+ /**
722
+ * Return the search results switcher DOM element
723
+ *
724
+ * @readonly
725
+ * @private
726
+ * @type {(SearchResultsSwitcher | null)}
727
+ * @memberof RadioPlayer
728
+ */
729
+ private get searchResultsSwitcher(): SearchResultsSwitcher | null {
730
+ /* istanbul ignore next */
731
+ return this.shadowRoot
732
+ ? (this.shadowRoot.querySelector('search-results-switcher') as SearchResultsSwitcher)
733
+ : null;
734
+ }
735
+
736
+ /**
737
+ * Triggered when the user changes the playback rate so we can update the audio element
738
+ *
739
+ * @private
740
+ * @param {CustomEvent} e
741
+ * @returns {void}
742
+ * @memberof RadioPlayer
743
+ */
744
+ private changePlaybackRate(e: CustomEvent): void {
745
+ const detail = e.detail || {};
746
+ if (!detail.playbackRate) {
747
+ return;
748
+ }
749
+ this.playbackRate = detail.playbackRate;
750
+
751
+ const event = new CustomEvent('playbackRateChanged', {
752
+ detail: { playbackRate: this.playbackRate },
753
+ });
754
+ this.dispatchEvent(event);
755
+ }
756
+
757
+ /**
758
+ * Triggered when the user changes the volume from the playback controls
759
+ *
760
+ * @private
761
+ * @param {CustomEvent} e
762
+ * @returns {void}
763
+ * @memberof RadioPlayer
764
+ */
765
+ private volumeChanged(e: CustomEvent): void {
766
+ const detail = e.detail || {};
767
+ if (detail.volume === undefined) {
768
+ return;
769
+ }
770
+ this.volume = e.detail.volume;
771
+
772
+ const event = new CustomEvent('volumeChanged', {
773
+ detail: { volume: this.volume },
774
+ });
775
+ this.dispatchEvent(event);
776
+ }
777
+
778
+ /**
779
+ * Triggered when the user presses the back button in the playback controls
780
+ *
781
+ * @private
782
+ * @memberof RadioPlayer
783
+ */
784
+ private backButtonHandler(): void {
785
+ /* istanbul ignore else */
786
+ if (this.audioElement) {
787
+ this.audioElement.seekBy(-10);
788
+ }
789
+
790
+ const event = new Event('jumpBackButtonPressed');
791
+ this.dispatchEvent(event);
792
+ }
793
+
794
+ /**
795
+ * Triggered when the user presses the play/pause button
796
+ *
797
+ * @private
798
+ * @returns {void}
799
+ * @memberof RadioPlayer
800
+ */
801
+ private playPauseButtonHandler(): void {
802
+ this.isPlaying = !this.isPlaying;
803
+ /* istanbul ignore if */
804
+ if (!this.audioElement) {
805
+ return;
806
+ }
807
+ if (this.isPlaying) {
808
+ this.audioElement.play();
809
+ } else {
810
+ this.audioElement.pause();
811
+ }
812
+
813
+ const event = new CustomEvent('playPauseButtonPressed', {
814
+ detail: { isPlaying: this.isPlaying },
815
+ });
816
+ this.dispatchEvent(event);
817
+ }
818
+
819
+ /**
820
+ * Triggered when the user presses the forward button in the playback controls
821
+ *
822
+ * @private
823
+ * @memberof RadioPlayer
824
+ */
825
+ private forwardButtonHandler(): void {
826
+ /* istanbul ignore else */
827
+ if (this.audioElement) {
828
+ this.audioElement.seekBy(10);
829
+ }
830
+
831
+ const event = new Event('jumpForwardButtonPressed');
832
+ this.dispatchEvent(event);
833
+ }
834
+
835
+ /**
836
+ * Skip to the next section
837
+ *
838
+ * @private
839
+ * @returns {void}
840
+ * @memberof RadioPlayer
841
+ */
842
+ private nextSectionButtonHandler(): void {
843
+ /* istanbul ignore if */
844
+ if (!this.audioElement) {
845
+ return;
846
+ }
847
+ const percentsGreaterThanValue: number[] = this.scrubberBarMarkerPercentages.filter(
848
+ value => value > this.percentComplete + 0.1,
849
+ );
850
+ const closestUpper = Math.min(...percentsGreaterThanValue);
851
+ const seekTo: number = this.duration * (closestUpper / 100) + 0.1;
852
+ this.audioElement.seekTo(seekTo);
853
+
854
+ const event = new Event('nextSectionButtonPressed');
855
+ this.dispatchEvent(event);
856
+ }
857
+
858
+ /**
859
+ * Jump to the previous section
860
+ *
861
+ * @private
862
+ * @returns {void}
863
+ * @memberof RadioPlayer
864
+ */
865
+ private prevSectionButtonHandler(): void {
866
+ /* istanbul ignore if */
867
+ if (!this.audioElement) {
868
+ return;
869
+ }
870
+ const percentsLessThanValue: number[] = this.scrubberBarMarkerPercentages.filter(
871
+ value => value < this.percentComplete - 0.1,
872
+ );
873
+ const closestLower = Math.max(...percentsLessThanValue);
874
+ const seekTo: number = this.duration * (closestLower / 100) - 0.1;
875
+ this.audioElement.seekTo(seekTo);
876
+
877
+ const event = new Event('prevSectionButtonPressed');
878
+ this.dispatchEvent(event);
879
+ }
880
+
881
+ /**
882
+ * Handle the duration change event like when the audio file first loads
883
+ *
884
+ * @private
885
+ * @param {CustomEvent} e
886
+ * @returns {void}
887
+ * @memberof RadioPlayer
888
+ */
889
+ private handleDurationChange(e: CustomEvent): void {
890
+ const detail = e.detail || {};
891
+ if (!detail.duration) {
892
+ return;
893
+ }
894
+ this.duration = detail.duration;
895
+ }
896
+
897
+ /**
898
+ * Triggered as the playback progresses in the audio element
899
+ *
900
+ * @private
901
+ * @param {CustomEvent} e
902
+ * @returns {void}
903
+ * @memberof RadioPlayer
904
+ */
905
+ private handleTimeChange(e: CustomEvent): void {
906
+ const detail = e.detail || {};
907
+ if (!detail.currentTime) {
908
+ return;
909
+ }
910
+
911
+ this.currentTime = detail.currentTime;
912
+ const percent = this.currentTime / this.duration;
913
+ this.percentComplete = percent * 100;
914
+ }
915
+
916
+ /**
917
+ * Update any consumers that the current time has changed
918
+ *
919
+ * @private
920
+ * @memberof RadioPlayer
921
+ */
922
+ private emitCurrentTimeChangedEvent(): void {
923
+ const event = new CustomEvent('currentTimeChanged', {
924
+ detail: { currentTime: this.currentTime },
925
+ });
926
+ this.dispatchEvent(event);
927
+ }
928
+
929
+ /**
930
+ * When the user clears the search, we want to bubble up the event to other consumers.
931
+ *
932
+ * @private
933
+ * @memberof RadioPlayer
934
+ */
935
+ private emitSearchClearedEvent(): void {
936
+ const event = new Event('searchCleared');
937
+ this.dispatchEvent(event);
938
+ }
939
+
940
+ private emitSearchTermChangedEvent(term: string): void {
941
+ const event = new CustomEvent('searchTermChanged', {
942
+ detail: { searchTerm: term },
943
+ });
944
+ this.dispatchEvent(event);
945
+ }
946
+
947
+ /**
948
+ * Handle the playback paused event
949
+ *
950
+ * @private
951
+ * @memberof RadioPlayer
952
+ */
953
+ private playbackPaused(): void {
954
+ this.isPlaying = false;
955
+ /* istanbul ignore else */
956
+ if (this.playbackControls) {
957
+ this.playbackControls.playbackMode = PlaybackMode.paused;
958
+ }
959
+ const event = new Event('playbackPaused');
960
+ this.dispatchEvent(event);
961
+ }
962
+
963
+ /**
964
+ * Handle the playback started event
965
+ *
966
+ * @private
967
+ * @memberof RadioPlayer
968
+ */
969
+ private playbackStarted(): void {
970
+ this.isPlaying = true;
971
+ /* istanbul ignore else */
972
+ if (this.playbackControls) {
973
+ this.playbackControls.playbackMode = PlaybackMode.playing;
974
+ }
975
+ const event = new Event('playbackStarted');
976
+ this.dispatchEvent(event);
977
+ }
978
+
979
+ /**
980
+ * Emits a `canplay` event when the media is ready to be played
981
+ *
982
+ * @private
983
+ * @memberof RadioPlayer
984
+ */
985
+ private canplay(): void {
986
+ const event: Event = new Event('canplay');
987
+ this.dispatchEvent(event);
988
+ }
989
+
990
+ /**
991
+ * Triggered when the user scrubs the scrubber bar or waveform progress bar
992
+ *
993
+ * Handles updating the audioElement to the new time and emitting an event
994
+ * that the time changed
995
+ *
996
+ * @private
997
+ * @param {CustomEvent} e
998
+ * @returns {void}
999
+ * @memberof RadioPlayer
1000
+ */
1001
+ private valueChangedFromScrub(e: CustomEvent): void {
1002
+ const detail = e.detail || {};
1003
+ if (!detail.value) {
1004
+ return;
1005
+ }
1006
+
1007
+ const percentage = detail.value;
1008
+ const newTime = this.duration * (percentage / 100);
1009
+ this.currentTime = newTime;
1010
+ /* istanbul ignore else */
1011
+ if (this.audioElement) {
1012
+ this.audioElement.seekTo(newTime);
1013
+ }
1014
+ this.percentComplete = percentage;
1015
+ const event = new CustomEvent('timeChangedFromScrub', {
1016
+ detail: { newTime: this.currentTime },
1017
+ });
1018
+ this.dispatchEvent(event);
1019
+ }
1020
+
1021
+ /**
1022
+ * Triggered when the user selects a transcript entry.
1023
+ * Allows it to jump to that spot in the playback.
1024
+ *
1025
+ * @private
1026
+ * @param {CustomEvent} e
1027
+ * @returns {void}
1028
+ * @memberof RadioPlayer
1029
+ */
1030
+ private transcriptEntrySelected(e: CustomEvent): void {
1031
+ const detail = e.detail || {};
1032
+ const entry = detail.entry || {};
1033
+ if (!entry.start) {
1034
+ return;
1035
+ }
1036
+
1037
+ const newTime = entry.start;
1038
+ this.currentTime = newTime;
1039
+ /* istanbul ignore else */
1040
+ if (this.audioElement) {
1041
+ this.audioElement.seekTo(newTime);
1042
+ this.audioElement.play();
1043
+ }
1044
+ const event = new CustomEvent('transcriptEntrySelected', {
1045
+ detail: { newTime: this.currentTime },
1046
+ });
1047
+ this.dispatchEvent(event);
1048
+ }
1049
+
1050
+ /**
1051
+ * Creates the music zones based on the transcript so we know when to skip past sections
1052
+ *
1053
+ * @private
1054
+ * @memberof RadioPlayer
1055
+ */
1056
+ private updateMusicZones(): void {
1057
+ // eslint-disable-next-line max-len
1058
+ const musicEntries: TranscriptEntryConfig[] = this.transcriptEntries.filter(
1059
+ (entry: TranscriptEntryConfig) => entry.isMusic === true,
1060
+ );
1061
+
1062
+ // eslint-disable-next-line max-len
1063
+ const musicZones: MusicZone[] = musicEntries.map(
1064
+ (entry: TranscriptEntryConfig) => new MusicZone(entry.start, entry.end),
1065
+ );
1066
+
1067
+ this.musicZones = musicZones;
1068
+ }
1069
+
1070
+ /**
1071
+ * Skips a music zone if enabled
1072
+ *
1073
+ * @private
1074
+ * @memberof RadioPlayer
1075
+ */
1076
+ private skipMusicZone(): void {
1077
+ // eslint-disable-next-line max-len
1078
+ const activeMusicZone: MusicZone | undefined = this.musicZones.find(
1079
+ (zone: MusicZone) => this.currentTime > zone.start && this.currentTime < zone.end,
1080
+ );
1081
+
1082
+ if (activeMusicZone && this.audioElement) {
1083
+ this.audioElement.seekTo(activeMusicZone.end + 0.1);
1084
+ }
1085
+ }
1086
+
1087
+ /**
1088
+ * Updates the search results switcher numbers
1089
+ *
1090
+ * @private
1091
+ * @returns {void}
1092
+ * @memberof RadioPlayer
1093
+ */
1094
+ private updateSearchResultSwitcher(): void {
1095
+ this.shouldShowNoSearchResultMessage = false;
1096
+ this.shouldShowSearchResultSwitcher = false;
1097
+
1098
+ if (this.searchTerm.length === 0) {
1099
+ return;
1100
+ }
1101
+
1102
+ const resultCount: number = this.searchResults.length;
1103
+
1104
+ if (resultCount === 0) {
1105
+ this.shouldShowNoSearchResultMessage = true;
1106
+ } else {
1107
+ this.shouldShowSearchResultSwitcher = true;
1108
+ /* istanbul ignore else */
1109
+ if (this.searchResultsSwitcher) {
1110
+ this.searchResultsSwitcher.numberOfResults = resultCount;
1111
+ }
1112
+ }
1113
+ }
1114
+
1115
+ /**
1116
+ * Returns the search result template entries from the transcript config
1117
+ *
1118
+ * @readonly
1119
+ * @private
1120
+ * @type {TranscriptEntryConfig[]}
1121
+ * @memberof RadioPlayer
1122
+ */
1123
+ private get searchResults(): TranscriptEntryConfig[] {
1124
+ return this.searchResultsTranscript ? this.searchResultsTranscript.searchResults : [];
1125
+ }
1126
+
1127
+ /**
1128
+ * LitElement life cycle event when any reactive property gets updated
1129
+ *
1130
+ * @param {PropertyValues} changedProperties
1131
+ * @memberof RadioPlayer
1132
+ */
1133
+ updated(changedProperties: PropertyValues): void {
1134
+ // when the transcriptConfig gets changed, reload the music zones and search results switcher
1135
+ if (changedProperties.has('transcriptConfig')) {
1136
+ this.updateMusicZones();
1137
+ }
1138
+
1139
+ if (changedProperties.has('searchResultsTranscript')) {
1140
+ this.updateSearchResultSwitcher();
1141
+ }
1142
+
1143
+ if (changedProperties.has('searchHandler') && this.searchTerm) {
1144
+ this.executeSearch(this.searchTerm);
1145
+ }
1146
+
1147
+ // as the currentTime gets updated, emit an event and skip the music zone if enabled
1148
+ if (changedProperties.has('currentTime')) {
1149
+ this.emitCurrentTimeChangedEvent();
1150
+ if (this.skipMusicSections) {
1151
+ this.skipMusicZone();
1152
+ }
1153
+ }
1154
+ }
1155
+
1156
+ /**
1157
+ * Component styles
1158
+ *
1159
+ * @readonly
1160
+ * @static
1161
+ * @type {CSSResult}
1162
+ * @memberof RadioPlayer
1163
+ */
1164
+ static get styles(): CSSResult {
1165
+ const collectionLogoMaxHeightCss = css`var(--collectionLogoMaxHeight, 8rem)`;
1166
+
1167
+ const titleColorCss = css`var(--titleColor, white)`;
1168
+ const titleFontCss = css`var(--titleFont, 1.5em sans-serif)`;
1169
+
1170
+ const dateColorCss = css`var(--dateColor, white)`;
1171
+ const dateFontCss = css`var(--dateFont, 1em sans-serif)`;
1172
+
1173
+ const waveformProgressHeightCss = css`var(--waveformProgressHeight, 5rem)`;
1174
+
1175
+ return css`
1176
+ section[role='main'] {
1177
+ display: -ms-grid;
1178
+ display: grid;
1179
+ grid-gap: 0.5rem;
1180
+ }
1181
+
1182
+ ia-activity-indicator {
1183
+ width: 1.5em;
1184
+ height: 1.5em;
1185
+ display: block;
1186
+ margin: auto;
1187
+ --activityIndicatorLoadingRingColor: #999;
1188
+ --activityIndicatorLoadingDotColor: #999;
1189
+ }
1190
+
1191
+ /* mobile view */
1192
+ @media (max-width: 770px) {
1193
+ section[role='main'] {
1194
+ -ms-grid-columns: 25% 0.5rem 1fr;
1195
+ -ms-grid-rows: auto 0.5rem auto 0.5rem auto 0.5rem auto 0.5rem auto;
1196
+ grid-template-columns: 25% 1fr;
1197
+ grid-template-areas:
1198
+ 'collection-logo title-date'
1199
+ 'waveform-scrubber waveform-scrubber'
1200
+ 'playback-controls playback-controls'
1201
+ 'search-section search-section'
1202
+ 'transcript-container transcript-container';
1203
+ }
1204
+ .date {
1205
+ text-align: left;
1206
+ }
1207
+ transcript-view {
1208
+ --timeDisplay: none;
1209
+ }
1210
+ playback-controls {
1211
+ width: 75%;
1212
+ margin: auto;
1213
+ grid-row: 5;
1214
+ grid-column: 1;
1215
+ -ms-grid-row: 5;
1216
+ -ms-grid-column: 1;
1217
+ -ms-grid-column-span: 3;
1218
+ }
1219
+ .title-date {
1220
+ grid-row: 1;
1221
+ grid-column: 3;
1222
+ -ms-grid-row: 1;
1223
+ -ms-grid-column: 3;
1224
+ }
1225
+ .transcript-container {
1226
+ grid-row: 9;
1227
+ grid-column: 1;
1228
+ -ms-grid-row: 9;
1229
+ -ms-grid-column: 1;
1230
+ -ms-grid-column-span: 3;
1231
+ }
1232
+ .collection-logo {
1233
+ grid-row: 1;
1234
+ grid-column: 1;
1235
+ -ms-grid-row: 1;
1236
+ -ms-grid-column: 1;
1237
+ }
1238
+ .waveform-scrubber-container {
1239
+ grid-row: 3;
1240
+ grid-column: 1;
1241
+ -ms-grid-row: 3;
1242
+ -ms-grid-column: 1;
1243
+ -ms-grid-column-span: 3;
1244
+ }
1245
+ .search-section {
1246
+ grid-row: 7;
1247
+ grid-column: 1;
1248
+ -ms-grid-row: 7;
1249
+ -ms-grid-column: 1;
1250
+ -ms-grid-column-span: 3;
1251
+ width: 75%;
1252
+ margin: auto;
1253
+ }
1254
+ expandable-search-bar {
1255
+ width: 100%;
1256
+ }
1257
+ }
1258
+
1259
+ /* wide view */
1260
+ @media (min-width: 770px) {
1261
+ section[role='main'] {
1262
+ -ms-grid-columns: 192px 0.5rem 0 0.5rem 250px 0.5rem 1fr;
1263
+ -ms-grid-rows: auto 0.5rem auto 0.5rem auto;
1264
+ grid-template-columns: 192px 0 250px 1fr;
1265
+ grid-template-areas:
1266
+ 'title-date title-date title-date title-date'
1267
+ 'collection-logo 1 playback-controls waveform-scrubber'
1268
+ 'search-section transcript-container transcript-container transcript-container';
1269
+ }
1270
+ .title-date {
1271
+ display: flex;
1272
+ justify-content: space-between;
1273
+ align-items: baseline;
1274
+ grid-row: 1;
1275
+ grid-column: 1;
1276
+ -ms-grid-row: 1;
1277
+ -ms-grid-column: 1;
1278
+ -ms-grid-column-span: 7;
1279
+ }
1280
+ transcript-view {
1281
+ --timeDisplay: block;
1282
+ }
1283
+ playback-controls {
1284
+ grid-row: 3;
1285
+ grid-column: 5;
1286
+ -ms-grid-row: 3;
1287
+ -ms-grid-column: 5;
1288
+ -ms-grid-column-span: 1;
1289
+ }
1290
+ .transcript-container {
1291
+ grid-row: 5;
1292
+ grid-column: 3;
1293
+ -ms-grid-row: 5;
1294
+ -ms-grid-column: 3;
1295
+ -ms-grid-column-span: 5;
1296
+ }
1297
+ .collection-logo {
1298
+ grid-row: 3;
1299
+ grid-column: 1;
1300
+ -ms-grid-row: 3;
1301
+ -ms-grid-column: 1;
1302
+ }
1303
+ .waveform-scrubber-container {
1304
+ grid-row: 3;
1305
+ grid-column: 7;
1306
+ -ms-grid-row: 3;
1307
+ -ms-grid-column: 7;
1308
+ -ms-grid-column-span: 1;
1309
+ }
1310
+ .search-section {
1311
+ grid-row: 5;
1312
+ grid-column: 1;
1313
+ -ms-grid-row: 5;
1314
+ -ms-grid-column: 1;
1315
+ -ms-grid-column-span: 1;
1316
+ }
1317
+ }
1318
+
1319
+ .title-date {
1320
+ grid-area: title-date;
1321
+ }
1322
+
1323
+ .title {
1324
+ color: ${titleColorCss};
1325
+ font: ${titleFontCss};
1326
+ }
1327
+
1328
+ .date {
1329
+ color: ${dateColorCss};
1330
+ font: ${dateFontCss};
1331
+ }
1332
+
1333
+ waveform-progress {
1334
+ width: 100%;
1335
+ height: ${waveformProgressHeightCss};
1336
+ }
1337
+
1338
+ playback-controls {
1339
+ grid-area: playback-controls;
1340
+ }
1341
+
1342
+ .transcript-container {
1343
+ grid-area: transcript-container;
1344
+ }
1345
+
1346
+ transcript-view {
1347
+ max-width: 600px;
1348
+ display: block;
1349
+ }
1350
+
1351
+ .collection-logo {
1352
+ width: 100%;
1353
+ max-height: ${collectionLogoMaxHeightCss};
1354
+ object-fit: contain;
1355
+ grid-area: collection-logo;
1356
+ align-self: center;
1357
+ }
1358
+
1359
+ .waveform-scrubber-container {
1360
+ width: 100%;
1361
+ height: 100%;
1362
+ grid-area: waveform-scrubber;
1363
+ }
1364
+
1365
+ .search-section {
1366
+ grid-area: search-section;
1367
+ }
1368
+
1369
+ .search-results-info {
1370
+ margin-top: 0.5em;
1371
+ }
1372
+
1373
+ .quick-search-container {
1374
+ max-height: 150px;
1375
+ overflow-y: scroll;
1376
+ scrollbar-width: none;
1377
+ margin: 0 0.5em;
1378
+ }
1379
+
1380
+ .quick-search-container::-webkit-scrollbar {
1381
+ display: none;
1382
+ }
1383
+
1384
+ expandable-search-bar {
1385
+ display: block;
1386
+ margin: auto;
1387
+ }
1388
+
1389
+ .no-search-results-message {
1390
+ text-align: center;
1391
+ }
1392
+
1393
+ .hidden {
1394
+ display: none;
1395
+ }
1396
+ `;
1397
+ }
1398
+ }