@internetarchive/radio-player 0.0.3-a4 → 0.0.4-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 (60) 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/demo/full-text-search-service.d.ts +14 -14
  6. package/lib/demo/full-text-search-service.js +33 -33
  7. package/lib/demo/radio-player-controller.d.ts +41 -41
  8. package/lib/demo/radio-player-controller.js +199 -199
  9. package/lib/src/assets/img/left.d.ts +2 -2
  10. package/lib/src/assets/img/left.js +15 -15
  11. package/lib/src/assets/img/right.d.ts +2 -2
  12. package/lib/src/assets/img/right.js +15 -15
  13. package/lib/src/models/music-zone.d.ts +12 -12
  14. package/lib/src/models/music-zone.js +15 -15
  15. package/lib/src/models/radio-player-config.d.ts +26 -26
  16. package/lib/src/models/radio-player-config.js +27 -27
  17. package/lib/src/radio-player.d.ts +589 -589
  18. package/lib/src/radio-player.js +1338 -1338
  19. package/lib/src/search-handler/search-backends/full-text-search-backend/full-text-search-backend.d.ts +82 -82
  20. package/lib/src/search-handler/search-backends/full-text-search-backend/full-text-search-backend.js +107 -107
  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-models.d.ts +45 -45
  36. package/lib/src/search-handler/search-models.js +49 -49
  37. package/lib/src/search-handler/transcript-index-interface.d.ts +38 -38
  38. package/lib/src/search-handler/transcript-index-interface.js +1 -1
  39. package/lib/src/search-handler/transcript-index.d.ts +53 -53
  40. package/lib/src/search-handler/transcript-index.js +80 -80
  41. package/lib/src/search-results-switcher.d.ts +58 -58
  42. package/lib/src/search-results-switcher.js +133 -133
  43. package/package.json +93 -93
  44. package/src/assets/img/left.ts +17 -17
  45. package/src/assets/img/right.ts +17 -17
  46. package/src/models/music-zone.ts +17 -17
  47. package/src/models/radio-player-config.ts +47 -47
  48. package/src/radio-player.ts +1402 -1402
  49. package/src/search-handler/search-backends/full-text-search-backend/full-text-search-backend.ts +140 -140
  50. package/src/search-handler/search-backends/full-text-search-backend/full-text-search-response.ts +110 -110
  51. package/src/search-handler/search-backends/full-text-search-backend/full-text-search-service-interface.ts +17 -17
  52. package/src/search-handler/search-backends/local-search-backend/local-search-backend.ts +50 -50
  53. package/src/search-handler/search-backends/search-backend-interface.ts +10 -10
  54. package/src/search-handler/search-handler-interface.ts +18 -18
  55. package/src/search-handler/search-handler.ts +217 -217
  56. package/src/search-handler/search-helper.ts +25 -25
  57. package/src/search-handler/search-models.ts +69 -69
  58. package/src/search-handler/transcript-index-interface.ts +42 -42
  59. package/src/search-handler/transcript-index.ts +93 -93
  60. package/src/search-results-switcher.ts +131 -131
@@ -1,1402 +1,1402 @@
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 {
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
+ }