@brightspace-ui/labs 2.63.6 → 2.63.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -112,5 +112,5 @@
112
112
  "parse-srt": "^1.0.0-alpha",
113
113
  "webvtt-parser": "^2"
114
114
  },
115
- "version": "2.63.6"
115
+ "version": "2.63.8"
116
116
  }
@@ -16,6 +16,8 @@ export const AUDIO_DESCRIPTION_SKIP_KEY = 's';
16
16
  export const AUDIO_DESCRIPTION_TRACK_KIND = 'descriptions';
17
17
  const PREFERENCES_AUDIO_DESCRIPTION_LANGUAGE_KEY = 'D2L.MediaPlayer.Preferences.AudioDescriptionLanguage';
18
18
  const VOICES_LOAD_TIMEOUT_MS = 1000;
19
+ const AUDIO_DESCRIPTION_START_TIMEOUT_MS = 1500;
20
+ const AUDIO_DESCRIPTION_END_POLL_MS = 500;
19
21
  const AUDIO_DESCRIPTION_CONTROLS_IN_MENU_MAX_WIDTH_PX = 768;
20
22
  // macOS Eloquence and novelty voices, which Safari lists alphabetically ahead of the natural-sounding ones
21
23
  const LOW_QUALITY_VOICE_NAMES = new Set([
@@ -23,11 +25,15 @@ const LOW_QUALITY_VOICE_NAMES = new Set([
23
25
  'jester', 'junior', 'kathy', 'organ', 'ralph', 'reed', 'rocko', 'sandy', 'shelley', 'superstar', 'trinoids', 'whisper', 'wobble', 'zarvox'
24
26
  ]);
25
27
 
28
+ let speechUnlocked = false;
29
+
26
30
  export const MediaPlayerAudioDescriptionsMixin = superclass => class extends superclass {
27
31
 
28
32
  static properties = {
29
33
  _audioDescriptionControlsInMenu: { state: true },
30
34
  _audioDescriptionDialogOpened: { state: true },
35
+ _audioDescriptionPaused: { state: true },
36
+ _audioDescriptionPausedVideo: { state: true },
31
37
  _audioDescriptionPlaying: { state: true },
32
38
  _audioDescriptionTracks: { state: true },
33
39
  _canReplayAudioDescription: { state: true },
@@ -73,6 +79,7 @@ export const MediaPlayerAudioDescriptionsMixin = superclass => class extends sup
73
79
  this._audioDescriptionTracks = [];
74
80
  this._audioDescriptionIndex = 0;
75
81
  this._audioDescriptionPreviousTime = -0.001;
82
+ this._audioDescriptionPaused = false;
76
83
  this._audioDescriptionPausedVideo = false;
77
84
  this._audioDescriptionPlaying = false;
78
85
  this._audioDescriptionControlsInMenu = false;
@@ -95,6 +102,11 @@ export const MediaPlayerAudioDescriptionsMixin = superclass => class extends sup
95
102
  super.disconnectedCallback();
96
103
  }
97
104
 
105
+ // True while a pause-video description is speaking, so the player should present itself as playing
106
+ get _audioDescriptionHoldingPlayback() {
107
+ return this._audioDescriptionPausedVideo && !this._audioDescriptionPaused;
108
+ }
109
+
98
110
  #audioDescriptionResizeObserver = new ResizeObserver(entries => {
99
111
  for (const entry of entries) {
100
112
  this._audioDescriptionControlsInMenu = entry.contentRect.width < AUDIO_DESCRIPTION_CONTROLS_IN_MENU_MAX_WIDTH_PX;
@@ -104,8 +116,11 @@ export const MediaPlayerAudioDescriptionsMixin = superclass => class extends sup
104
116
  _cancelAudioDescription() {
105
117
  if (this._audioDescriptionUtterance && window.speechSynthesis) {
106
118
  window.speechSynthesis.cancel();
119
+ // Some browsers stay paused after cancel(), which would hold back the next utterance
120
+ if (this._audioDescriptionPaused) window.speechSynthesis.resume();
107
121
  }
108
122
  this._audioDescriptionUtterance = null;
123
+ this._audioDescriptionPaused = false;
109
124
  this._audioDescriptionPausedVideo = false;
110
125
  this._audioDescriptionPlaying = false;
111
126
  }
@@ -285,6 +300,12 @@ export const MediaPlayerAudioDescriptionsMixin = superclass => class extends sup
285
300
  this._audioDescriptionPreviousTime = currentTime;
286
301
  }
287
302
 
303
+ _pauseAudioDescription() {
304
+ if (!this._audioDescriptionPlaying || this._audioDescriptionPaused) return;
305
+ this._audioDescriptionPaused = true;
306
+ window.speechSynthesis?.pause();
307
+ }
308
+
288
309
  _replayAudioDescription() {
289
310
  if (!this._audioDescriptionPlaying) return;
290
311
  const track = this.#getSelectedAudioDescriptionTrack();
@@ -317,11 +338,32 @@ export const MediaPlayerAudioDescriptionsMixin = superclass => class extends sup
317
338
  this._resetAudioDescriptionCursor(this.currentTime);
318
339
  }
319
340
 
341
+ _resumeAudioDescription() {
342
+ if (!this._audioDescriptionPaused) return;
343
+ this._audioDescriptionPaused = false;
344
+ window.speechSynthesis?.resume();
345
+ }
346
+
320
347
  _skipAudioDescription() {
321
348
  if (!this._audioDescriptionPlaying || !this.#getSelectedAudioDescriptionTrack()?.pauseVideo) return;
322
- const pausedVideo = this._audioDescriptionPausedVideo;
349
+ const resumeVideo = this._audioDescriptionHoldingPlayback;
323
350
  this._cancelAudioDescription();
324
- if (pausedVideo) this._play();
351
+ if (resumeVideo) this._play();
352
+ }
353
+
354
+ _toggleAudioDescriptionPause() {
355
+ if (this._audioDescriptionPaused) this._resumeAudioDescription();
356
+ else this._pauseAudioDescription();
357
+ }
358
+
359
+ // iOS Safari only speaks after speak() has been called once from a user gesture
360
+ _unlockAudioDescriptionSpeech() {
361
+ if (speechUnlocked || !this._selectedAudioDescriptionLanguage || !window.speechSynthesis || !window.SpeechSynthesisUtterance) return;
362
+ if (navigator.userActivation && !navigator.userActivation.isActive) return;
363
+ speechUnlocked = true;
364
+ const utterance = new window.SpeechSynthesisUtterance('');
365
+ utterance.volume = 0;
366
+ window.speechSynthesis.speak(utterance);
325
367
  }
326
368
 
327
369
  #getAudioDescriptionVoice(language) {
@@ -377,6 +419,7 @@ export const MediaPlayerAudioDescriptionsMixin = superclass => class extends sup
377
419
  #onAudioDescriptionMenuItemChange(e) {
378
420
  this._cancelAudioDescription();
379
421
  this._selectedAudioDescriptionLanguage = e.target.value || null;
422
+ this._unlockAudioDescriptionSpeech();
380
423
  this._resetAudioDescriptionCursor(this.currentTime);
381
424
  if (this._selectedAudioDescriptionLanguage) {
382
425
  this._setPreference(PREFERENCES_AUDIO_DESCRIPTION_LANGUAGE_KEY, this._selectedAudioDescriptionLanguage);
@@ -387,7 +430,7 @@ export const MediaPlayerAudioDescriptionsMixin = superclass => class extends sup
387
430
  this.shadowRoot?.querySelector('#audio-description-dropdown-content')?.close();
388
431
  }
389
432
 
390
- #speakAudioDescription(description, track) {
433
+ #speakAudioDescription(description, track, isRetry = false) {
391
434
  if (!window.speechSynthesis || !window.SpeechSynthesisUtterance) return;
392
435
 
393
436
  this._cancelAudioDescription();
@@ -412,6 +455,18 @@ export const MediaPlayerAudioDescriptionsMixin = superclass => class extends sup
412
455
  };
413
456
  utterance.onend = finish;
414
457
  utterance.onerror = finish;
458
+ let started = false;
459
+ utterance.onstart = () => {
460
+ started = true;
461
+ // Safari can drop onend (e.g. when iOS backgrounds the page), which would leave the video paused
462
+ const poll = setInterval(() => {
463
+ if (this._audioDescriptionUtterance !== utterance) clearInterval(poll);
464
+ else if (!this._audioDescriptionPaused && !window.speechSynthesis.speaking) {
465
+ clearInterval(poll);
466
+ finish();
467
+ }
468
+ }, AUDIO_DESCRIPTION_END_POLL_MS);
469
+ };
415
470
 
416
471
  const speak = () => {
417
472
  if (this._audioDescriptionUtterance !== utterance) return;
@@ -420,6 +475,19 @@ export const MediaPlayerAudioDescriptionsMixin = superclass => class extends sup
420
475
  utterance.volume = this.volume;
421
476
  utterance.rate = this._media?.playbackRate || 1;
422
477
  window.speechSynthesis.speak(utterance);
478
+ // Chrome's paused state is browser-wide and survives reloads, and resume() is ignored until something is queued
479
+ if (!this._audioDescriptionPaused) window.speechSynthesis.resume?.();
480
+ if (isRetry) return;
481
+
482
+ setTimeout(() => {
483
+ if (started || this._audioDescriptionUtterance !== utterance || this._audioDescriptionPaused) return;
484
+ const pausedVideo = this._audioDescriptionPausedVideo;
485
+ // Detach first so the cancelled utterance's error event doesn't resume the video
486
+ this._audioDescriptionUtterance = null;
487
+ window.speechSynthesis.cancel();
488
+ this.#speakAudioDescription(description, track, true);
489
+ if (pausedVideo) this._audioDescriptionPausedVideo = true;
490
+ }, AUDIO_DESCRIPTION_START_TIMEOUT_MS);
423
491
  };
424
492
 
425
493
  if (window.speechSynthesis.getVoices().length) {
@@ -659,11 +659,12 @@ class MediaPlayer extends LocalizeLabsElement(MediaPlayerTranscriptMixin(MediaPl
659
659
 
660
660
  render() {
661
661
  const fullscreenIcon = fullscreenApi.isFullscreen ? 'tier1:smallscreen' : 'tier1:fullscreen';
662
- const playIcon = this._playing ? 'tier1:pause' : 'tier1:play';
662
+ const playing = this._playing || this._audioDescriptionHoldingPlayback;
663
+ const playIcon = playing ? 'tier1:pause' : 'tier1:play';
663
664
  const volumeIcon = this._muted ? 'tier1:volume-muted' : 'tier1:volume';
664
665
 
665
666
  const fullscreenTooltip = `${fullscreenApi.isFullscreen ? this.localize('components:mediaPlayer:exitFullscreen') : this.localize('components:mediaPlayer:fullscreen')} (${KEY_BINDINGS.fullscreen})`;
666
- const playTooltip = `${this._playing ? this.localize('components:mediaPlayer:pause') : this.localize('components:mediaPlayer:play')} (${KEY_BINDINGS.play})`;
667
+ const playTooltip = `${playing ? this.localize('components:mediaPlayer:pause') : this.localize('components:mediaPlayer:play')} (${KEY_BINDINGS.play})`;
667
668
  const volumeTooltip = `${this._muted ? this.localize('components:mediaPlayer:unmute') : this.localize('components:mediaPlayer:mute')} (${KEY_BINDINGS.mute})`;
668
669
 
669
670
  const height = this._maintainHeight ? `${this._maintainHeight}px` : (this._heightPixels ? `${this._heightPixels}px` : '100%');
@@ -864,13 +865,13 @@ class MediaPlayer extends LocalizeLabsElement(MediaPlayerTranscriptMixin(MediaPl
864
865
  }
865
866
 
866
867
  pause() {
867
- if (this._media && !this._media.paused) {
868
+ if (this._media && (!this._media.paused || this._audioDescriptionHoldingPlayback)) {
868
869
  this._togglePlay();
869
870
  }
870
871
  }
871
872
 
872
873
  play() {
873
- if (this._media && this._media.paused) {
874
+ if (this._media && this._media.paused && !this._audioDescriptionHoldingPlayback) {
874
875
  this._togglePlay();
875
876
  }
876
877
  }
@@ -1039,8 +1040,9 @@ class MediaPlayer extends LocalizeLabsElement(MediaPlayerTranscriptMixin(MediaPl
1039
1040
  }
1040
1041
 
1041
1042
  _getMediaAreaView() {
1042
- const playIcon = `tier3:${this._playing ? 'pause' : 'play'}`;
1043
- const playTooltip = `${this._playing ? this.localize('components:mediaPlayer:pause') : this.localize('components:mediaPlayer:play')} (${KEY_BINDINGS.play})`;
1043
+ const playing = this._playing || this._audioDescriptionHoldingPlayback;
1044
+ const playIcon = `tier3:${playing ? 'pause' : 'play'}`;
1045
+ const playTooltip = `${playing ? this.localize('components:mediaPlayer:pause') : this.localize('components:mediaPlayer:play')} (${KEY_BINDINGS.play})`;
1044
1046
 
1045
1047
  switch (this.mediaType) {
1046
1048
  case SOURCE_TYPES.video:
@@ -1417,12 +1419,13 @@ class MediaPlayer extends LocalizeLabsElement(MediaPlayerTranscriptMixin(MediaPl
1417
1419
  }
1418
1420
 
1419
1421
  _onPause() {
1420
- if (!this._audioDescriptionPausedVideo) this._cancelAudioDescription();
1422
+ if (!this._audioDescriptionPausedVideo) this._pauseAudioDescription();
1421
1423
  this._playing = false;
1422
1424
  this.dispatchEvent(new CustomEvent('pause'));
1423
1425
  }
1424
1426
 
1425
1427
  _onPlay() {
1428
+ this._resumeAudioDescription();
1426
1429
  this.dispatchEvent(new CustomEvent('play'));
1427
1430
  }
1428
1431
 
@@ -1932,6 +1935,11 @@ class MediaPlayer extends LocalizeLabsElement(MediaPlayerTranscriptMixin(MediaPl
1932
1935
 
1933
1936
  _togglePlay() {
1934
1937
  this._posterVisible = false;
1938
+ this._unlockAudioDescriptionSpeech();
1939
+ if (this._audioDescriptionPausedVideo) {
1940
+ this._toggleAudioDescriptionPause();
1941
+ return;
1942
+ }
1935
1943
  if (this._media.paused) {
1936
1944
  if (this.playInView) {
1937
1945
  this._loadVisibilityObserver({ target: this._mediaContainer });