@brightspace-ui/labs 2.63.0 → 2.63.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.
@@ -0,0 +1,7 @@
1
+ WEBVTT
2
+
3
+ 00:00:03.000 --> 00:00:04.000
4
+ A person enters the scene.
5
+
6
+ 00:00:07.500 --> 00:00:08.500
7
+ The camera pans across the nearby landscape.
@@ -0,0 +1,7 @@
1
+ WEBVTT
2
+
3
+ 00:00:03.000 --> 00:00:04.000
4
+ Une personne entre dans la scène.
5
+
6
+ 00:00:07.500 --> 00:00:08.500
7
+ La caméra balaie le paysage environnant.
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.0"
115
+ "version": "2.63.1"
116
116
  }
@@ -216,9 +216,33 @@ The media player supports captions and subtitles, provided as `.srt` or `.vtt` f
216
216
  | default | Boolean | false | The track to be selected by default. If D2L.MediaPlayer.Preferences.Track is defined in local storage, then it will take precedence over this attribute.
217
217
  | default-ignore-preferences | Boolean | false | Same as default, but if D2L.MediaPlayer.Preferences.Track is defined, it will be ignored and this track will be selected instead.
218
218
 
219
+ ## Audio Descriptions Using `<track>`
220
+
221
+ The media player supports audio descriptions, provided as `.vtt` files, one per language. Audio descriptions are not displayed as burned-in text; instead, when the video's current time crosses a cue's start timestamp (the cue's end timestamp is ignored), the cue text is spoken using the browser's SpeechSynthesis API. If any valid audio description tracks are present, an "Audio descriptions" menu item will be presented in the settings menu with an item for each track.
222
+
223
+ ```html
224
+ <script type="module">
225
+ import '@brightspace-ui/labs/components/media-player.js';
226
+ </script>
227
+ <d2l-labs-media-player src="/video.webm">
228
+ <track src="/english-descriptions.vtt" srclang="en" label="English" kind="descriptions">
229
+ <track src="/french-descriptions.vtt" srclang="fr" label="French" kind="descriptions" pause-video>
230
+ </d2l-labs-media-player>
231
+ ```
232
+
233
+ **Attributes**
234
+
235
+ | Attribute | Type | Default | Description |
236
+ |--|--|--|--|
237
+ | kind | "descriptions", required | | Identifies the track as an audio description track. |
238
+ | label | String, required | | The label for the track, displayed to the user for selection. |
239
+ | src | String, required | | The URL of the `.vtt` source file. |
240
+ | srclang | String, required | | The language's code. Used as the SpeechSynthesis voice/language and stored as the selection preference. |
241
+ | pause-video | Boolean | false | If set, playback is paused for the duration of each spoken description and resumes automatically once the description finishes, and replay/skip controls are shown. If not set, descriptions play inline with the video and the replay/skip controls are hidden. |
242
+
219
243
  ## Local Storage
220
244
 
221
- The media player uses local storage to persist the user's playback speed, track selections, and volume.
245
+ The media player uses local storage to persist the user's playback speed, track selections, audio description selection, and volume.
222
246
 
223
247
  **Items**
224
248
 
@@ -226,6 +250,7 @@ The media player uses local storage to persist the user's playback speed, track
226
250
  | -- | -- |
227
251
  | D2L.MediaPlayer.Preferences.Speed | Playback speed that was last selected.
228
252
  | D2L.MediaPlayer.Preferences.Track | Identifier for the kind and language of the track that was last selected.
253
+ | D2L.MediaPlayer.Preferences.AudioDescriptionLanguage | Language of the audio description track that was last selected.
229
254
  | D2L.MediaPlayer.Preferences.Volume | Volume that was last selected.
230
255
 
231
256
  ## Accessibility
@@ -64,7 +64,7 @@ class MediaPlayerAudioBars extends LitElement {
64
64
 
65
65
  static properties = {
66
66
  playing: { type: Boolean },
67
- _visibleAudioBars: { type: Array, attribute: false },
67
+ _visibleAudioBars: { state: true },
68
68
  };
69
69
 
70
70
  static styles = css`
@@ -72,7 +72,7 @@ class MediaPlayerAudioBars extends LitElement {
72
72
  width: 100%;
73
73
  }
74
74
 
75
- #d2l-labs-media-player-audio-bars-row {
75
+ #audio-bars-row {
76
76
  align-items: center;
77
77
  display: flex;
78
78
  flex-direction: row;
@@ -80,13 +80,13 @@ class MediaPlayerAudioBars extends LitElement {
80
80
  justify-content: center;
81
81
  }
82
82
 
83
- #d2l-labs-media-player-audio-bar-container {
83
+ #audio-bar-container {
84
84
  display: flex;
85
85
  flex-direction: column;
86
86
  height: 100%;
87
87
  }
88
88
 
89
- .d2l-labs-media-player-audio-bar {
89
+ .audio-bar {
90
90
  border-top-left-radius: 0.075rem;
91
91
  border-top-right-radius: 0.075rem;
92
92
  margin: 0 ${AUDIO_BAR_HORIZONTAL_MARGIN_REM}rem;
@@ -143,16 +143,16 @@ class MediaPlayerAudioBars extends LitElement {
143
143
 
144
144
  this._startChangingAudioBars();
145
145
  }
146
- }).observe(this.shadowRoot.getElementById('d2l-labs-media-player-audio-bars-row'));
146
+ }).observe(this.shadowRoot.getElementById('audio-bars-row'));
147
147
  }
148
148
 
149
149
  render() {
150
150
  return html`
151
- <div id="d2l-labs-media-player-audio-bars-row">
151
+ <div id="audio-bars-row">
152
152
  ${this._visibleAudioBars.map(audioBar => html`
153
- <div id="d2l-labs-media-player-audio-bar-container">
153
+ <div id="audio-bar-container">
154
154
  <div style="flex: auto;"></div>
155
- <div class="d2l-labs-media-player-audio-bar" style=${styleMap({ backgroundColor: `rgba(${audioBar.red}, ${audioBar.green}, ${audioBar.blue}, 1)`, height: `${audioBar.height}%` })}></div>
155
+ <div class="audio-bar" style=${styleMap({ backgroundColor: `rgba(${audioBar.red}, ${audioBar.green}, ${audioBar.blue}, 1)`, height: `${audioBar.height}%` })}></div>
156
156
  </div>
157
157
  `)}
158
158
  </div>
@@ -0,0 +1,350 @@
1
+ import '@brightspace-ui/core/components/button/button-icon.js';
2
+ import '@brightspace-ui/core/components/dropdown/dropdown.js';
3
+ import '@brightspace-ui/core/components/dropdown/dropdown-content.js';
4
+ import '@brightspace-ui/core/components/icons/icon-custom.js';
5
+ import '@brightspace-ui/core/components/menu/menu.js';
6
+ import '@brightspace-ui/core/components/menu/menu-item-radio.js';
7
+ import '@brightspace-ui/core/components/menu/menu-item.js';
8
+ import '@brightspace-ui/core/components/menu/menu-item-separator.js';
9
+ import { css, html } from 'lit';
10
+ import { bodySmallStyles } from '@brightspace-ui/core/components/typography/styles.js';
11
+ import { ifDefined } from 'lit/directives/if-defined.js';
12
+
13
+ export const AUDIO_DESCRIPTION_REPLAY_KEY = 'r';
14
+ export const AUDIO_DESCRIPTION_SKIP_KEY = 's';
15
+ export const AUDIO_DESCRIPTION_TRACK_KIND = 'descriptions';
16
+ const PREFERENCES_AUDIO_DESCRIPTION_LANGUAGE_KEY = 'D2L.MediaPlayer.Preferences.AudioDescriptionLanguage';
17
+ const VOICES_LOAD_TIMEOUT_MS = 1000;
18
+ const AUDIO_DESCRIPTION_CONTROLS_IN_MENU_MAX_WIDTH_PX = 768;
19
+
20
+ export const MediaPlayerAudioDescriptionsMixin = superclass => class extends superclass {
21
+
22
+ static properties = {
23
+ _audioDescriptionControlsInMenu: { state: true },
24
+ _audioDescriptionPlaying: { state: true },
25
+ _audioDescriptionTracks: { state: true },
26
+ _canReplayAudioDescription: { state: true },
27
+ _selectedAudioDescriptionLanguage: { state: true },
28
+ };
29
+
30
+ static styles = [bodySmallStyles, css`
31
+ #audio-description-skip-button {
32
+ margin-inline-end: 12px;
33
+ }
34
+ #audio-description-button {
35
+ margin-inline-end: 15px;
36
+ }
37
+ #audio-description-button[data-enabled] {
38
+ position: relative;
39
+ }
40
+ #audio-description-button d2l-icon-custom {
41
+ display: block;
42
+ height: calc(1rem - 7px);
43
+ margin: 0 auto;
44
+ width: calc(2rem - 9px);
45
+ }
46
+ .audio-description-header {
47
+ background-color: var(--d2l-color-tungsten);
48
+ border-bottom: 1px solid var(--d2l-color-mica);
49
+ border-start-end-radius: var(--d2l-popover-border-radius, var(--d2l-popover-default-border-radius));
50
+ border-start-start-radius: var(--d2l-popover-border-radius, var(--d2l-popover-default-border-radius));
51
+ color: white;
52
+ padding: 1rem;
53
+ }
54
+ #audio-description-button[data-enabled]::after {
55
+ border: 2px solid var(--d2l-color-sylvite);
56
+ border-radius: var(--d2l-button-icon-border-radius, 0.3rem);
57
+ box-sizing: border-box;
58
+ content: "";
59
+ inset: 0;
60
+ pointer-events: none;
61
+ position: absolute;
62
+ }
63
+ `];
64
+
65
+ constructor() {
66
+ super();
67
+
68
+ this._audioDescriptionTracks = [];
69
+ this._audioDescriptionIndex = 0;
70
+ this._audioDescriptionPreviousTime = -0.001;
71
+ this._audioDescriptionPausedVideo = false;
72
+ this._audioDescriptionPlaying = false;
73
+ this._audioDescriptionControlsInMenu = false;
74
+ this._canReplayAudioDescription = false;
75
+ }
76
+
77
+ connectedCallback() {
78
+ super.connectedCallback();
79
+ this.#audioDescriptionResizeObserver.observe(this);
80
+ }
81
+
82
+ disconnectedCallback() {
83
+ this.#audioDescriptionResizeObserver.disconnect();
84
+ this._cancelAudioDescription();
85
+ super.disconnectedCallback();
86
+ }
87
+
88
+ #audioDescriptionResizeObserver = new ResizeObserver(entries => {
89
+ for (const entry of entries) {
90
+ this._audioDescriptionControlsInMenu = entry.contentRect.width < AUDIO_DESCRIPTION_CONTROLS_IN_MENU_MAX_WIDTH_PX;
91
+ }
92
+ });
93
+
94
+ _cancelAudioDescription() {
95
+ if (this._audioDescriptionUtterance && window.speechSynthesis) {
96
+ window.speechSynthesis.cancel();
97
+ }
98
+ this._audioDescriptionUtterance = null;
99
+ this._audioDescriptionPausedVideo = false;
100
+ this._audioDescriptionPlaying = false;
101
+ }
102
+
103
+ _getAudioDescriptionsButtonView() {
104
+ if (!this._audioDescriptionTracks?.length) return null;
105
+
106
+ const tooltip = this.localize('components:mediaPlayer:audioDescription');
107
+ const replayTooltip = this.localize('components:mediaPlayer:replayAudioDescription');
108
+ const skipTooltip = this.localize('components:mediaPlayer:skipAudioDescription');
109
+ const showControls = !!this.#getSelectedAudioDescriptionTrack()?.pauseVideo;
110
+
111
+ return html`
112
+ ${showControls && !this._audioDescriptionControlsInMenu ? html`
113
+ <d2l-button-icon
114
+ icon="tier1:undo"
115
+ id="audio-description-replay-button"
116
+ theme="${ifDefined(this._getTheme())}"
117
+ disabled-tooltip="${replayTooltip}"
118
+ ?disabled="${!this._audioDescriptionPlaying}"
119
+ @click="${this._replayAudioDescription}"
120
+ ></d2l-button-icon>
121
+ <d2l-button-icon
122
+ icon="tier1:redo"
123
+ id="audio-description-skip-button"
124
+ theme="${ifDefined(this._getTheme())}"
125
+ disabled-tooltip="${skipTooltip}"
126
+ ?disabled="${!this._audioDescriptionPlaying}"
127
+ @click="${this._skipAudioDescription}"
128
+ ></d2l-button-icon>
129
+ ` : null}
130
+ <d2l-dropdown>
131
+ <d2l-button-icon
132
+ aria-label="${tooltip}"
133
+ class="d2l-dropdown-opener"
134
+ id="audio-description-button"
135
+ title="${tooltip}"
136
+ theme="${ifDefined(this._getTheme())}"
137
+ ?data-enabled="${!!this._selectedAudioDescriptionLanguage}"
138
+ ><d2l-icon-custom slot="icon"><svg width="28" height="14" viewBox="0 0 28 14" fill="none" xmlns="http://www.w3.org/2000/svg">
139
+ <path d="M8.89199 8.55L7.21999 3.9805C7.13766 3.77783 7.05216 3.53717 6.96349 3.2585C6.87483 2.97983 6.78616 2.679 6.69749 2.356C6.61516 2.679 6.52966 2.983 6.44099 3.268C6.35233 3.54667 6.26683 3.7905 6.18449 3.9995L4.52199 8.55H8.89199ZM13.4045 13.737H11.4285C11.2068 13.737 11.0263 13.6832 10.887 13.5755C10.7477 13.4615 10.6432 13.3222 10.5735 13.1575L9.54749 10.355H3.85699L2.83099 13.1575C2.78033 13.3032 2.68216 13.4362 2.53649 13.5565C2.39083 13.6768 2.21033 13.737 1.99499 13.737H-7.82078e-06L5.40549 -1.43051e-06H8.00849L13.4045 13.737ZM27.2244 6.8685C27.2244 7.8755 27.0565 8.80017 26.7209 9.6425C26.3852 10.4848 25.9134 11.21 25.3054 11.818C24.6974 12.426 23.9659 12.8978 23.1109 13.2335C22.2559 13.5692 21.3059 13.737 20.2609 13.737H15.0264V-1.43051e-06H20.2609C21.3059 -1.43051e-06 22.2559 0.170999 23.1109 0.512999C23.9659 0.848665 24.6974 1.3205 25.3054 1.9285C25.9134 2.53017 26.3852 3.25217 26.7209 4.0945C27.0565 4.93683 27.2244 5.8615 27.2244 6.8685ZM24.6024 6.8685C24.6024 6.11483 24.501 5.44033 24.2984 4.845C24.102 4.24333 23.8139 3.73667 23.4339 3.325C23.0602 2.907 22.6042 2.58717 22.0659 2.3655C21.5339 2.14383 20.9322 2.033 20.2609 2.033H17.5914V11.704H20.2609C20.9322 11.704 21.5339 11.5932 22.0659 11.3715C22.6042 11.1498 23.0602 10.8332 23.4339 10.4215C23.8139 10.0035 24.102 9.49683 24.2984 8.9015C24.501 8.29983 24.6024 7.62217 24.6024 6.8685Z" fill="white"/>
140
+ </svg></d2l-icon-custom></d2l-button-icon>
141
+ <d2l-dropdown-content id="audio-description-dropdown-content" class="vdiff-target" no-padding no-padding-header no-pointer theme="${ifDefined(this._getTheme())}">
142
+ ${showControls ? html`<div class="d2l-body-small audio-description-header" slot="header">${this.localize('components:mediaPlayer:extendedAudioDescription')}</div>` : null}
143
+ <d2l-menu label="${tooltip}" @d2l-menu-item-change=${this.#onAudioDescriptionMenuItemChange} theme="${ifDefined(this._getTheme())}">
144
+ ${showControls && this._audioDescriptionControlsInMenu ? html`
145
+ <d2l-menu-item
146
+ text="${this.localize('components:mediaPlayer:replay')}"
147
+ ?disabled="${!this._audioDescriptionPlaying}"
148
+ @d2l-menu-item-select="${this._replayAudioDescription}"
149
+ ></d2l-menu-item>
150
+ <d2l-menu-item
151
+ text="${this.localize('components:mediaPlayer:skip')}"
152
+ ?disabled="${!this._audioDescriptionPlaying}"
153
+ @d2l-menu-item-select="${this._skipAudioDescription}"
154
+ ></d2l-menu-item>
155
+ <d2l-menu-item-separator></d2l-menu-item-separator>
156
+ ` : null}
157
+ <d2l-menu-item-radio
158
+ ?selected="${!this._selectedAudioDescriptionLanguage}"
159
+ text="${this.localize('components:mediaPlayer:off')}"
160
+ ></d2l-menu-item-radio>
161
+ ${this._audioDescriptionTracks.map(track => html`
162
+ <d2l-menu-item-radio
163
+ ?selected="${track.srclang === this._selectedAudioDescriptionLanguage}"
164
+ text="${track.label}"
165
+ value="${track.srclang}"
166
+ ></d2l-menu-item-radio>
167
+ `)}
168
+ </d2l-menu>
169
+ </d2l-dropdown-content>
170
+ </d2l-dropdown>
171
+ `;
172
+ }
173
+
174
+ async _loadAudioDescriptionTrack(node) {
175
+ for (const attr of ['label', 'src', 'srclang']) {
176
+ if (!node[attr]) {
177
+ console.warn(`d2l-labs-media-player component requires '${attr}' text on track`);
178
+ return;
179
+ }
180
+ }
181
+
182
+ const res = await fetch(node.src);
183
+ if (res.status !== 200) {
184
+ console.warn(`d2l-labs-media-player component could not load track from '${node.src}'`);
185
+ this.dispatchEvent(new CustomEvent('trackloadfailed'));
186
+ return;
187
+ }
188
+
189
+ const text = await res.text();
190
+
191
+ // Audio descriptions are spoken via SpeechSynthesis rather than displayed, so only each cue's start time and text are needed
192
+ const { cues } = this._webVTTParser.parse(text, 'metadata');
193
+ const descriptions = cues
194
+ .map(cue => ({ text: cue.text, time: cue.startTime }))
195
+ .filter(description => Number.isFinite(description.time) && description.text)
196
+ .sort((a, b) => a.time - b.time);
197
+
198
+ if (descriptions.length === 0) return;
199
+
200
+ // Some browsers (e.g. Chrome) load voices asynchronously on the first getVoices() call
201
+ window.speechSynthesis?.getVoices?.();
202
+
203
+ this._audioDescriptionTracks.push({
204
+ descriptions,
205
+ label: node.label,
206
+ pauseVideo: node.hasAttribute('pause-video'),
207
+ srclang: node.srclang,
208
+ });
209
+ this.dispatchEvent(new CustomEvent('trackloaded'));
210
+ }
211
+
212
+ _onAudioDescriptionTimeUpdate(currentTime) {
213
+ const track = this._audioDescriptionTracks?.find(track => track.srclang === this._selectedAudioDescriptionLanguage);
214
+ if (!track) return;
215
+
216
+ if (currentTime < this._audioDescriptionPreviousTime) {
217
+ this._audioDescriptionIndex = track.descriptions.findIndex(description => description.time >= currentTime);
218
+ if (this._audioDescriptionIndex < 0) this._audioDescriptionIndex = track.descriptions.length;
219
+ this._cancelAudioDescription();
220
+ }
221
+
222
+ const next = track.descriptions[this._audioDescriptionIndex];
223
+ if (next && this._audioDescriptionPreviousTime < next.time && currentTime >= next.time) {
224
+ this._audioDescriptionIndex += 1;
225
+ this.#speakAudioDescription(next, track);
226
+ }
227
+ this._audioDescriptionPreviousTime = currentTime;
228
+ }
229
+
230
+ _replayAudioDescription() {
231
+ if (!this._audioDescriptionPlaying) return;
232
+ const track = this.#getSelectedAudioDescriptionTrack();
233
+ if (!track?.pauseVideo) return;
234
+ const description = track?.descriptions.findLast(description => description.time <= this.currentTime);
235
+ if (!description) return;
236
+
237
+ // Preserve the pause from the original playback so the video still resumes after the replay
238
+ const pausedVideo = this._audioDescriptionPausedVideo;
239
+ this.#speakAudioDescription(description, track);
240
+ if (pausedVideo) this._audioDescriptionPausedVideo = true;
241
+ }
242
+
243
+ _resetAudioDescriptionCursor(time) {
244
+ this._cancelAudioDescription();
245
+ const track = this._audioDescriptionTracks?.find(track => track.srclang === this._selectedAudioDescriptionLanguage);
246
+ this._audioDescriptionIndex = track
247
+ ? track.descriptions.findIndex(description => description.time >= time)
248
+ : 0;
249
+ if (this._audioDescriptionIndex < 0) this._audioDescriptionIndex = track?.descriptions.length || 0;
250
+ this._audioDescriptionPreviousTime = time - 0.001;
251
+ }
252
+
253
+ _restoreAudioDescriptionPreference() {
254
+ const audioDescriptionPreference = this._getPreference(PREFERENCES_AUDIO_DESCRIPTION_LANGUAGE_KEY);
255
+ this._selectedAudioDescriptionLanguage = this._audioDescriptionTracks.some(track => track.srclang === audioDescriptionPreference)
256
+ ? audioDescriptionPreference
257
+ : null;
258
+ this._resetAudioDescriptionCursor(this.currentTime);
259
+ }
260
+
261
+ _skipAudioDescription() {
262
+ if (!this._audioDescriptionPlaying || !this.#getSelectedAudioDescriptionTrack()?.pauseVideo) return;
263
+ const pausedVideo = this._audioDescriptionPausedVideo;
264
+ this._cancelAudioDescription();
265
+ if (pausedVideo) this._play();
266
+ }
267
+
268
+ #getAudioDescriptionVoice(language) {
269
+ const normalizedLanguage = (language || '').toLowerCase();
270
+ if (!normalizedLanguage || !window.speechSynthesis?.getVoices) return null;
271
+
272
+ const voices = window.speechSynthesis.getVoices();
273
+ if (!voices.length) return null;
274
+
275
+ const bestMatch = voices.find(voice => (voice.lang || '').toLowerCase() === normalizedLanguage)
276
+ || voices.find(voice => (voice.lang || '').toLowerCase().startsWith(`${normalizedLanguage.split('-')[0]}-`))
277
+ || voices.find(voice => (voice.lang || '').toLowerCase().startsWith(`${normalizedLanguage.split('-')[0]}_`))
278
+ || voices.find(voice => (voice.lang || '').toLowerCase().startsWith(normalizedLanguage.split('-')[0]))
279
+ || voices.find(voice => (voice.lang || '').toLowerCase().includes(normalizedLanguage.split('-')[0]))
280
+ || voices[0];
281
+ return bestMatch || null;
282
+ }
283
+
284
+ #getSelectedAudioDescriptionTrack() {
285
+ return this._audioDescriptionTracks?.find(track => track.srclang === this._selectedAudioDescriptionLanguage);
286
+ }
287
+
288
+ #onAudioDescriptionMenuItemChange(e) {
289
+ this._cancelAudioDescription();
290
+ this._selectedAudioDescriptionLanguage = e.target.value || null;
291
+ this._resetAudioDescriptionCursor(this.currentTime);
292
+ if (this._selectedAudioDescriptionLanguage) {
293
+ this._setPreference(PREFERENCES_AUDIO_DESCRIPTION_LANGUAGE_KEY, this._selectedAudioDescriptionLanguage);
294
+ } else {
295
+ this._clearPreference(PREFERENCES_AUDIO_DESCRIPTION_LANGUAGE_KEY);
296
+ }
297
+
298
+ this.shadowRoot?.querySelector('#audio-description-dropdown-content')?.close();
299
+ }
300
+
301
+ #speakAudioDescription(description, track) {
302
+ if (!window.speechSynthesis || !window.SpeechSynthesisUtterance) return;
303
+
304
+ this._cancelAudioDescription();
305
+ const utterance = new window.SpeechSynthesisUtterance(description.text);
306
+ utterance.lang = track.srclang;
307
+ this._audioDescriptionUtterance = utterance;
308
+ this._audioDescriptionPlaying = true;
309
+
310
+ if (track.pauseVideo && this._media && !this._media.paused) {
311
+ this._audioDescriptionPausedVideo = true;
312
+ this._media.pause();
313
+ }
314
+
315
+ const finish = () => {
316
+ if (this._audioDescriptionUtterance !== utterance) return;
317
+ this._audioDescriptionUtterance = null;
318
+ this._audioDescriptionPlaying = false;
319
+ if (this._audioDescriptionPausedVideo) {
320
+ this._audioDescriptionPausedVideo = false;
321
+ this._play();
322
+ }
323
+ };
324
+ utterance.onend = finish;
325
+ utterance.onerror = finish;
326
+
327
+ const speak = () => {
328
+ if (this._audioDescriptionUtterance !== utterance) return;
329
+ const voice = this.#getAudioDescriptionVoice(track.srclang);
330
+ if (voice) utterance.voice = voice;
331
+ utterance.volume = this.volume;
332
+ window.speechSynthesis.speak(utterance);
333
+ };
334
+
335
+ if (window.speechSynthesis.getVoices().length) {
336
+ speak();
337
+ return;
338
+ }
339
+
340
+ // Speaking before voices have loaded is silently dropped in some browsers
341
+ const controller = new AbortController();
342
+ const onVoicesChanged = () => {
343
+ controller.abort();
344
+ speak();
345
+ };
346
+ window.speechSynthesis.addEventListener('voiceschanged', onVoicesChanged, { signal: controller.signal });
347
+ const timeout = setTimeout(onVoicesChanged, VOICES_LOAD_TIMEOUT_MS);
348
+ controller.signal.addEventListener('abort', () => clearTimeout(timeout));
349
+ }
350
+ };