@brightspace-ui/labs 2.63.0 → 2.63.2
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/demo/components/media-player/static/sample-vtt-en-descriptions.vtt +7 -0
- package/demo/components/media-player/static/sample-vtt-fr-descriptions.vtt +7 -0
- package/package.json +1 -1
- package/src/components/media-player/README.md +29 -1
- package/src/components/media-player/media-player-audio-bars.js +8 -8
- package/src/components/media-player/media-player-audio-descriptions.js +415 -0
- package/src/components/media-player/media-player.js +170 -145
- package/src/lang/ar.js +6 -0
- package/src/lang/ca.js +6 -0
- package/src/lang/cy.js +6 -0
- package/src/lang/da.js +6 -0
- package/src/lang/de.js +6 -0
- package/src/lang/en-gb.js +6 -0
- package/src/lang/en.js +6 -0
- package/src/lang/es-es.js +6 -0
- package/src/lang/es.js +6 -0
- package/src/lang/fr-fr.js +6 -0
- package/src/lang/fr-on.js +6 -0
- package/src/lang/fr.js +6 -0
- package/src/lang/haw.js +6 -0
- package/src/lang/hi.js +6 -0
- package/src/lang/ja.js +6 -0
- package/src/lang/ko.js +6 -0
- package/src/lang/mi.js +6 -0
- package/src/lang/nl.js +6 -0
- package/src/lang/pl.js +6 -0
- package/src/lang/pt.js +6 -0
- package/src/lang/ru.js +6 -0
- package/src/lang/sv.js +6 -0
- package/src/lang/th.js +6 -0
- package/src/lang/tr.js +6 -0
- package/src/lang/ur.js +6 -0
- package/src/lang/vi.js +6 -0
- package/src/lang/zh-cn.js +6 -0
- package/src/lang/zh-tw.js +6 -0
package/package.json
CHANGED
|
@@ -42,6 +42,7 @@ A Lit element based media player component, designed for similarity across brows
|
|
|
42
42
|
| crossorigin | String | null | If set, will set the `crossorigin` attribute on the underlying media element to the set value.
|
|
43
43
|
| download-filename | String | null | If set along with `allow-download`, will use the provided value as the base of the filename (the extension will be automatically appended)
|
|
44
44
|
| duration-hint | Number | 1 | Measured in seconds. If set and the duration cannot be determined automatically, this value will be used instead.
|
|
45
|
+
| hide-audio-description-selection | Boolean | false | If set, the button to configure audio descriptions is hidden. |
|
|
45
46
|
| hide-captions-selection | Boolean | false | If set, the menu item to configure captions is hidden. |
|
|
46
47
|
| hide-seek-bar | Boolean | false | If set, the seek bar will not be shown. |
|
|
47
48
|
| loop | Boolean | false | If set, once the media has finished playing it will replay from the beginning. |
|
|
@@ -64,6 +65,7 @@ A Lit element based media player component, designed for similarity across brows
|
|
|
64
65
|
|--|--|--|--|
|
|
65
66
|
| currentTime | Number | Get & Set | Current time playback time of the media in seconds. |
|
|
66
67
|
| activeCue | Object | Get | VTTCue instance for the currently-displayed captions cue. If no cue is currently displayed, the value is null. |
|
|
68
|
+
| activeDescriptionCue | Object | Get | The most recently reached audio description cue (`{ text, time }`). Reset to null on seek or when the audio description language changes. |
|
|
67
69
|
| duration | Number | Get | Total duration of the media in seconds. |
|
|
68
70
|
| ended | Boolean | Get | Whether or not the video has ended. |
|
|
69
71
|
| paused | Boolean | Get | Whether or not the video is currently paused. |
|
|
@@ -96,6 +98,7 @@ this.document.querySelector('d2l-labs-media-player').pause();
|
|
|
96
98
|
| Event | Description |
|
|
97
99
|
|--|--|
|
|
98
100
|
| cuechange | Dispatched when the currently-displayed captions cue changes. |
|
|
101
|
+
| descriptioncuechange | Dispatched when a new audio description cue is reached. Read the cue from `activeDescriptionCue`. |
|
|
99
102
|
| durationchange | Dispatched when the video or media displayed has changed its duration |
|
|
100
103
|
| ended | Dispatched when the media has reached the end of its duration. |
|
|
101
104
|
| error | Dispatched when the media failed to load. |
|
|
@@ -216,9 +219,33 @@ The media player supports captions and subtitles, provided as `.srt` or `.vtt` f
|
|
|
216
219
|
| 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
220
|
| 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
221
|
|
|
222
|
+
## Audio Descriptions Using `<track>`
|
|
223
|
+
|
|
224
|
+
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.
|
|
225
|
+
|
|
226
|
+
```html
|
|
227
|
+
<script type="module">
|
|
228
|
+
import '@brightspace-ui/labs/components/media-player.js';
|
|
229
|
+
</script>
|
|
230
|
+
<d2l-labs-media-player src="/video.webm">
|
|
231
|
+
<track src="/english-descriptions.vtt" srclang="en" label="English" kind="descriptions">
|
|
232
|
+
<track src="/french-descriptions.vtt" srclang="fr" label="French" kind="descriptions" pause-video>
|
|
233
|
+
</d2l-labs-media-player>
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
**Attributes**
|
|
237
|
+
|
|
238
|
+
| Attribute | Type | Default | Description |
|
|
239
|
+
|--|--|--|--|
|
|
240
|
+
| kind | "descriptions", required | | Identifies the track as an audio description track. |
|
|
241
|
+
| label | String, required | | The label for the track, displayed to the user for selection. |
|
|
242
|
+
| src | String, required | | The URL of the `.vtt` source file. |
|
|
243
|
+
| srclang | String, required | | The language's code. Used as the SpeechSynthesis voice/language and stored as the selection preference. |
|
|
244
|
+
| 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. |
|
|
245
|
+
|
|
219
246
|
## Local Storage
|
|
220
247
|
|
|
221
|
-
The media player uses local storage to persist the user's playback speed, track selections, and volume.
|
|
248
|
+
The media player uses local storage to persist the user's playback speed, track selections, audio description selection, and volume.
|
|
222
249
|
|
|
223
250
|
**Items**
|
|
224
251
|
|
|
@@ -226,6 +253,7 @@ The media player uses local storage to persist the user's playback speed, track
|
|
|
226
253
|
| -- | -- |
|
|
227
254
|
| D2L.MediaPlayer.Preferences.Speed | Playback speed that was last selected.
|
|
228
255
|
| D2L.MediaPlayer.Preferences.Track | Identifier for the kind and language of the track that was last selected.
|
|
256
|
+
| D2L.MediaPlayer.Preferences.AudioDescriptionLanguage | Language of the audio description track that was last selected.
|
|
229
257
|
| D2L.MediaPlayer.Preferences.Volume | Volume that was last selected.
|
|
230
258
|
|
|
231
259
|
## Accessibility
|
|
@@ -64,7 +64,7 @@ class MediaPlayerAudioBars extends LitElement {
|
|
|
64
64
|
|
|
65
65
|
static properties = {
|
|
66
66
|
playing: { type: Boolean },
|
|
67
|
-
_visibleAudioBars: {
|
|
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
|
-
#
|
|
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
|
-
#
|
|
83
|
+
#audio-bar-container {
|
|
84
84
|
display: flex;
|
|
85
85
|
flex-direction: column;
|
|
86
86
|
height: 100%;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
.
|
|
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('
|
|
146
|
+
}).observe(this.shadowRoot.getElementById('audio-bars-row'));
|
|
147
147
|
}
|
|
148
148
|
|
|
149
149
|
render() {
|
|
150
150
|
return html`
|
|
151
|
-
<div id="
|
|
151
|
+
<div id="audio-bars-row">
|
|
152
152
|
${this._visibleAudioBars.map(audioBar => html`
|
|
153
|
-
<div id="
|
|
153
|
+
<div id="audio-bar-container">
|
|
154
154
|
<div style="flex: auto;"></div>
|
|
155
|
-
<div class="
|
|
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,415 @@
|
|
|
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
|
+
// macOS Eloquence and novelty voices, which Safari lists alphabetically ahead of the natural-sounding ones
|
|
20
|
+
const LOW_QUALITY_VOICE_NAMES = new Set([
|
|
21
|
+
'albert', 'bad news', 'bahh', 'bells', 'boing', 'bubbles', 'cellos', 'eddy', 'flo', 'fred', 'good news', 'grandma', 'grandpa',
|
|
22
|
+
'jester', 'junior', 'kathy', 'organ', 'ralph', 'reed', 'rocko', 'sandy', 'shelley', 'superstar', 'trinoids', 'whisper', 'wobble', 'zarvox'
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
export const MediaPlayerAudioDescriptionsMixin = superclass => class extends superclass {
|
|
26
|
+
|
|
27
|
+
static properties = {
|
|
28
|
+
_audioDescriptionControlsInMenu: { state: true },
|
|
29
|
+
_audioDescriptionPlaying: { state: true },
|
|
30
|
+
_audioDescriptionTracks: { state: true },
|
|
31
|
+
_canReplayAudioDescription: { state: true },
|
|
32
|
+
_selectedAudioDescriptionLanguage: { state: true },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
static styles = [bodySmallStyles, css`
|
|
36
|
+
#audio-description-skip-button {
|
|
37
|
+
margin-inline-end: 12px;
|
|
38
|
+
}
|
|
39
|
+
#audio-description-button {
|
|
40
|
+
margin-inline-end: 15px;
|
|
41
|
+
}
|
|
42
|
+
#audio-description-button[data-enabled] {
|
|
43
|
+
position: relative;
|
|
44
|
+
}
|
|
45
|
+
#audio-description-button d2l-icon-custom {
|
|
46
|
+
display: block;
|
|
47
|
+
height: calc(1rem - 7px);
|
|
48
|
+
margin: 0 auto;
|
|
49
|
+
width: calc(2rem - 9px);
|
|
50
|
+
}
|
|
51
|
+
.audio-description-header {
|
|
52
|
+
background-color: var(--d2l-color-tungsten);
|
|
53
|
+
border-bottom: 1px solid var(--d2l-color-chromite);
|
|
54
|
+
border-start-end-radius: var(--d2l-popover-border-radius, var(--d2l-popover-default-border-radius));
|
|
55
|
+
border-start-start-radius: var(--d2l-popover-border-radius, var(--d2l-popover-default-border-radius));
|
|
56
|
+
color: white;
|
|
57
|
+
padding: 1rem;
|
|
58
|
+
}
|
|
59
|
+
#audio-description-button[data-enabled]::after {
|
|
60
|
+
border: 2px solid var(--d2l-color-sylvite);
|
|
61
|
+
border-radius: var(--d2l-button-icon-border-radius, 0.3rem);
|
|
62
|
+
box-sizing: border-box;
|
|
63
|
+
content: "";
|
|
64
|
+
inset: 0;
|
|
65
|
+
pointer-events: none;
|
|
66
|
+
position: absolute;
|
|
67
|
+
}
|
|
68
|
+
`];
|
|
69
|
+
|
|
70
|
+
constructor() {
|
|
71
|
+
super();
|
|
72
|
+
|
|
73
|
+
this._activeDescriptionCue = null;
|
|
74
|
+
this._audioDescriptionTracks = [];
|
|
75
|
+
this._audioDescriptionIndex = 0;
|
|
76
|
+
this._audioDescriptionPreviousTime = -0.001;
|
|
77
|
+
this._audioDescriptionPausedVideo = false;
|
|
78
|
+
this._audioDescriptionPlaying = false;
|
|
79
|
+
this._audioDescriptionControlsInMenu = false;
|
|
80
|
+
this._canReplayAudioDescription = false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
get activeDescriptionCue() {
|
|
84
|
+
return this._activeDescriptionCue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
connectedCallback() {
|
|
88
|
+
super.connectedCallback();
|
|
89
|
+
this.#audioDescriptionResizeObserver.observe(this);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
disconnectedCallback() {
|
|
93
|
+
this.#audioDescriptionResizeObserver.disconnect();
|
|
94
|
+
this._cancelAudioDescription();
|
|
95
|
+
super.disconnectedCallback();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
#audioDescriptionResizeObserver = new ResizeObserver(entries => {
|
|
99
|
+
for (const entry of entries) {
|
|
100
|
+
this._audioDescriptionControlsInMenu = entry.contentRect.width < AUDIO_DESCRIPTION_CONTROLS_IN_MENU_MAX_WIDTH_PX;
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
_cancelAudioDescription() {
|
|
105
|
+
if (this._audioDescriptionUtterance && window.speechSynthesis) {
|
|
106
|
+
window.speechSynthesis.cancel();
|
|
107
|
+
}
|
|
108
|
+
this._audioDescriptionUtterance = null;
|
|
109
|
+
this._audioDescriptionPausedVideo = false;
|
|
110
|
+
this._audioDescriptionPlaying = false;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
_getAudioDescriptionsButtonView() {
|
|
114
|
+
if (!this._audioDescriptionTracks?.length || this.hideAudioDescriptionSelection) return null;
|
|
115
|
+
|
|
116
|
+
const tooltip = this.localize('components:mediaPlayer:audioDescription');
|
|
117
|
+
const replayTooltip = this.localize('components:mediaPlayer:replayAudioDescription');
|
|
118
|
+
const skipTooltip = this.localize('components:mediaPlayer:skipAudioDescription');
|
|
119
|
+
const showControls = !!this.#getSelectedAudioDescriptionTrack()?.pauseVideo;
|
|
120
|
+
|
|
121
|
+
return html`
|
|
122
|
+
${showControls && !this._audioDescriptionControlsInMenu ? html`
|
|
123
|
+
<d2l-button-icon
|
|
124
|
+
id="audio-description-replay-button"
|
|
125
|
+
text="${this.localize('components:mediaPlayer:replay')}"
|
|
126
|
+
theme="${ifDefined(this._getTheme())}"
|
|
127
|
+
disabled-tooltip="${replayTooltip}"
|
|
128
|
+
?disabled="${!this._audioDescriptionPlaying}"
|
|
129
|
+
@click="${this._replayAudioDescription}"
|
|
130
|
+
><d2l-icon-custom slot="icon"><svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
131
|
+
<g clip-path="url(#clip0_777_1621)">
|
|
132
|
+
<mask id="mask0_777_1621" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="2" width="18" height="14">
|
|
133
|
+
<path d="M0 2H18V16H0V2Z" fill="white"/>
|
|
134
|
+
</mask>
|
|
135
|
+
<g mask="url(#mask0_777_1621)">
|
|
136
|
+
<path d="M14.846 2C16.036 2 17 2.73967 17 3.65278V14.3472C17 14.957 16.563 15.517 15.862 15.8048C15.162 16.0926 14.312 16.0607 13.651 15.7223L3.958 10.3751C3.66426 10.2256 3.42291 10.0217 3.25568 9.78167C3.08845 9.54163 3.00059 9.27302 3 9C3 8.44778 3.36 7.93133 3.96 7.62489L13.651 2.27767C14.0059 2.09624 14.4213 1.9997 14.846 2Z" fill="#F1F5FB"/>
|
|
137
|
+
</g>
|
|
138
|
+
<path d="M2 1V17" stroke="#F1F5FB" stroke-width="2" stroke-linecap="round"/>
|
|
139
|
+
</g>
|
|
140
|
+
<defs>
|
|
141
|
+
<clipPath id="clip0_777_1621">
|
|
142
|
+
<rect width="18" height="18" fill="white"/>
|
|
143
|
+
</clipPath>
|
|
144
|
+
</defs>
|
|
145
|
+
</svg></d2l-icon-custom></d2l-button-icon>
|
|
146
|
+
<d2l-button-icon
|
|
147
|
+
id="audio-description-skip-button"
|
|
148
|
+
text="${this.localize('components:mediaPlayer:skip')}"
|
|
149
|
+
theme="${ifDefined(this._getTheme())}"
|
|
150
|
+
disabled-tooltip="${skipTooltip}"
|
|
151
|
+
?disabled="${!this._audioDescriptionPlaying}"
|
|
152
|
+
@click="${this._skipAudioDescription}"
|
|
153
|
+
><d2l-icon-custom slot="icon"><svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
154
|
+
<g clip-path="url(#clip0_777_1614)">
|
|
155
|
+
<mask id="mask0_777_1614" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="2" width="18" height="14">
|
|
156
|
+
<path d="M18 2H0V16H18V2Z" fill="white"/>
|
|
157
|
+
</mask>
|
|
158
|
+
<g mask="url(#mask0_777_1614)">
|
|
159
|
+
<path d="M3.154 2C1.964 2 1 2.73967 1 3.65278V14.3472C1 14.957 1.437 15.517 2.138 15.8048C2.838 16.0926 3.688 16.0607 4.349 15.7223L14.042 10.3751C14.3357 10.2256 14.5771 10.0217 14.7443 9.78167C14.9115 9.54163 14.9994 9.27302 15 9C15 8.44778 14.64 7.93133 14.04 7.62489L4.349 2.27767C3.9941 2.09624 3.5787 1.9997 3.154 2Z" fill="#F1F5FB"/>
|
|
160
|
+
</g>
|
|
161
|
+
<path d="M16 1V17" stroke="#F1F5FB" stroke-width="2" stroke-linecap="round"/>
|
|
162
|
+
</g>
|
|
163
|
+
<defs>
|
|
164
|
+
<clipPath id="clip0_777_1614">
|
|
165
|
+
<rect width="18" height="18" fill="white" transform="matrix(-1 0 0 1 18 0)"/>
|
|
166
|
+
</clipPath>
|
|
167
|
+
</defs>
|
|
168
|
+
</svg></d2l-icon-custom></d2l-button-icon>
|
|
169
|
+
` : null}
|
|
170
|
+
<d2l-dropdown>
|
|
171
|
+
<d2l-button-icon
|
|
172
|
+
class="d2l-dropdown-opener"
|
|
173
|
+
id="audio-description-button"
|
|
174
|
+
text="${tooltip}"
|
|
175
|
+
theme="${ifDefined(this._getTheme())}"
|
|
176
|
+
?data-enabled="${!!this._selectedAudioDescriptionLanguage}"
|
|
177
|
+
><d2l-icon-custom slot="icon"><svg width="28" height="14" viewBox="0 0 28 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
178
|
+
<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"/>
|
|
179
|
+
</svg></d2l-icon-custom></d2l-button-icon>
|
|
180
|
+
<d2l-dropdown-content id="audio-description-dropdown-content" class="vdiff-target" no-padding no-padding-header no-pointer theme="${ifDefined(this._getTheme())}">
|
|
181
|
+
${showControls ? html`<div class="d2l-body-small audio-description-header" slot="header">${this.localize('components:mediaPlayer:extendedAudioDescription')}</div>` : null}
|
|
182
|
+
<d2l-menu label="${tooltip}" @d2l-menu-item-change=${this.#onAudioDescriptionMenuItemChange} theme="${ifDefined(this._getTheme())}">
|
|
183
|
+
${showControls && this._audioDescriptionControlsInMenu ? html`
|
|
184
|
+
<d2l-menu-item
|
|
185
|
+
text="${this.localize('components:mediaPlayer:replay')}"
|
|
186
|
+
?disabled="${!this._audioDescriptionPlaying}"
|
|
187
|
+
@d2l-menu-item-select="${this._replayAudioDescription}"
|
|
188
|
+
></d2l-menu-item>
|
|
189
|
+
<d2l-menu-item
|
|
190
|
+
text="${this.localize('components:mediaPlayer:skip')}"
|
|
191
|
+
?disabled="${!this._audioDescriptionPlaying}"
|
|
192
|
+
@d2l-menu-item-select="${this._skipAudioDescription}"
|
|
193
|
+
></d2l-menu-item>
|
|
194
|
+
<d2l-menu-item-separator></d2l-menu-item-separator>
|
|
195
|
+
` : null}
|
|
196
|
+
<d2l-menu-item-radio
|
|
197
|
+
?selected="${!this._selectedAudioDescriptionLanguage}"
|
|
198
|
+
text="${this.localize('components:mediaPlayer:off')}"
|
|
199
|
+
></d2l-menu-item-radio>
|
|
200
|
+
${this._audioDescriptionTracks.map(track => html`
|
|
201
|
+
<d2l-menu-item-radio
|
|
202
|
+
?selected="${track.srclang === this._selectedAudioDescriptionLanguage}"
|
|
203
|
+
text="${track.label}"
|
|
204
|
+
value="${track.srclang}"
|
|
205
|
+
></d2l-menu-item-radio>
|
|
206
|
+
`)}
|
|
207
|
+
</d2l-menu>
|
|
208
|
+
</d2l-dropdown-content>
|
|
209
|
+
</d2l-dropdown>
|
|
210
|
+
`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async _loadAudioDescriptionTrack(node) {
|
|
214
|
+
for (const attr of ['label', 'src', 'srclang']) {
|
|
215
|
+
if (!node[attr]) {
|
|
216
|
+
console.warn(`d2l-labs-media-player component requires '${attr}' text on track`);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const res = await fetch(node.src);
|
|
222
|
+
if (res.status !== 200) {
|
|
223
|
+
console.warn(`d2l-labs-media-player component could not load track from '${node.src}'`);
|
|
224
|
+
this.dispatchEvent(new CustomEvent('trackloadfailed'));
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const text = await res.text();
|
|
229
|
+
|
|
230
|
+
// Audio descriptions are spoken via SpeechSynthesis rather than displayed, so only each cue's start time and text are needed
|
|
231
|
+
const { cues } = this._webVTTParser.parse(text, 'metadata');
|
|
232
|
+
const descriptions = cues
|
|
233
|
+
.map(cue => ({ text: cue.text, time: cue.startTime }))
|
|
234
|
+
.filter(description => Number.isFinite(description.time) && description.text)
|
|
235
|
+
.sort((a, b) => a.time - b.time);
|
|
236
|
+
|
|
237
|
+
if (descriptions.length === 0) return;
|
|
238
|
+
|
|
239
|
+
// Some browsers (e.g. Chrome) load voices asynchronously on the first getVoices() call
|
|
240
|
+
window.speechSynthesis?.getVoices?.();
|
|
241
|
+
|
|
242
|
+
this._audioDescriptionTracks.push({
|
|
243
|
+
descriptions,
|
|
244
|
+
label: node.label,
|
|
245
|
+
pauseVideo: node.hasAttribute('pause-video'),
|
|
246
|
+
srclang: node.srclang,
|
|
247
|
+
});
|
|
248
|
+
this.dispatchEvent(new CustomEvent('trackloaded'));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
_onAudioDescriptionTimeUpdate(currentTime) {
|
|
252
|
+
const track = this._audioDescriptionTracks?.find(track => track.srclang === this._selectedAudioDescriptionLanguage);
|
|
253
|
+
if (!track) return;
|
|
254
|
+
|
|
255
|
+
if (currentTime < this._audioDescriptionPreviousTime) {
|
|
256
|
+
this._audioDescriptionIndex = track.descriptions.findIndex(description => description.time >= currentTime);
|
|
257
|
+
if (this._audioDescriptionIndex < 0) this._audioDescriptionIndex = track.descriptions.length;
|
|
258
|
+
this._cancelAudioDescription();
|
|
259
|
+
this._activeDescriptionCue = null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const next = track.descriptions[this._audioDescriptionIndex];
|
|
263
|
+
if (next && this._audioDescriptionPreviousTime < next.time && currentTime >= next.time) {
|
|
264
|
+
this._audioDescriptionIndex += 1;
|
|
265
|
+
this._activeDescriptionCue = next;
|
|
266
|
+
this.dispatchEvent(new CustomEvent('descriptioncuechange'));
|
|
267
|
+
this.#speakAudioDescription(next, track);
|
|
268
|
+
}
|
|
269
|
+
this._audioDescriptionPreviousTime = currentTime;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
_replayAudioDescription() {
|
|
273
|
+
if (!this._audioDescriptionPlaying) return;
|
|
274
|
+
const track = this.#getSelectedAudioDescriptionTrack();
|
|
275
|
+
if (!track?.pauseVideo) return;
|
|
276
|
+
const description = track?.descriptions.findLast(description => description.time <= this.currentTime);
|
|
277
|
+
if (!description) return;
|
|
278
|
+
|
|
279
|
+
// Preserve the pause from the original playback so the video still resumes after the replay
|
|
280
|
+
const pausedVideo = this._audioDescriptionPausedVideo;
|
|
281
|
+
this.#speakAudioDescription(description, track);
|
|
282
|
+
if (pausedVideo) this._audioDescriptionPausedVideo = true;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
_resetAudioDescriptionCursor(time) {
|
|
286
|
+
this._cancelAudioDescription();
|
|
287
|
+
this._activeDescriptionCue = null;
|
|
288
|
+
const track = this._audioDescriptionTracks?.find(track => track.srclang === this._selectedAudioDescriptionLanguage);
|
|
289
|
+
this._audioDescriptionIndex = track
|
|
290
|
+
? track.descriptions.findIndex(description => description.time >= time)
|
|
291
|
+
: 0;
|
|
292
|
+
if (this._audioDescriptionIndex < 0) this._audioDescriptionIndex = track?.descriptions.length || 0;
|
|
293
|
+
this._audioDescriptionPreviousTime = time - 0.001;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
_restoreAudioDescriptionPreference() {
|
|
297
|
+
const audioDescriptionPreference = this._getPreference(PREFERENCES_AUDIO_DESCRIPTION_LANGUAGE_KEY);
|
|
298
|
+
this._selectedAudioDescriptionLanguage = this._audioDescriptionTracks.some(track => track.srclang === audioDescriptionPreference)
|
|
299
|
+
? audioDescriptionPreference
|
|
300
|
+
: null;
|
|
301
|
+
this._resetAudioDescriptionCursor(this.currentTime);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
_skipAudioDescription() {
|
|
305
|
+
if (!this._audioDescriptionPlaying || !this.#getSelectedAudioDescriptionTrack()?.pauseVideo) return;
|
|
306
|
+
const pausedVideo = this._audioDescriptionPausedVideo;
|
|
307
|
+
this._cancelAudioDescription();
|
|
308
|
+
if (pausedVideo) this._play();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
#getAudioDescriptionVoice(language) {
|
|
312
|
+
const normalizedLanguage = (language || '').toLowerCase();
|
|
313
|
+
if (!normalizedLanguage || !window.speechSynthesis?.getVoices) return null;
|
|
314
|
+
|
|
315
|
+
const voices = window.speechSynthesis.getVoices();
|
|
316
|
+
if (!voices.length) return null;
|
|
317
|
+
|
|
318
|
+
const baseLanguage = normalizedLanguage.split('-')[0];
|
|
319
|
+
const getLanguageScore = voice => {
|
|
320
|
+
const voiceLanguage = (voice.lang || '').toLowerCase();
|
|
321
|
+
if (voiceLanguage === normalizedLanguage) return 4;
|
|
322
|
+
if (voiceLanguage.startsWith(`${baseLanguage}-`) || voiceLanguage.startsWith(`${baseLanguage}_`)) return 3;
|
|
323
|
+
if (voiceLanguage.startsWith(baseLanguage)) return 2;
|
|
324
|
+
if (voiceLanguage.includes(baseLanguage)) return 1;
|
|
325
|
+
return 0;
|
|
326
|
+
};
|
|
327
|
+
const getQualityScore = voice => {
|
|
328
|
+
const uri = (voice.voiceURI || '').toLowerCase();
|
|
329
|
+
const name = (voice.name || '').toLowerCase().split(' (')[0];
|
|
330
|
+
if (uri.includes('eloquence') || uri.includes('speech.synthesis.voice') || LOW_QUALITY_VOICE_NAMES.has(name)) return 0;
|
|
331
|
+
if (uri.includes('premium') || name.includes('premium')) return 3;
|
|
332
|
+
if (uri.includes('enhanced') || name.includes('enhanced')) return 2;
|
|
333
|
+
return 1;
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
let bestMatch = voices[0];
|
|
337
|
+
let bestScore = -1;
|
|
338
|
+
for (const voice of voices) {
|
|
339
|
+
const score = getLanguageScore(voice) * 10 + getQualityScore(voice);
|
|
340
|
+
if (score > bestScore) {
|
|
341
|
+
bestMatch = voice;
|
|
342
|
+
bestScore = score;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return bestMatch;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
#getSelectedAudioDescriptionTrack() {
|
|
349
|
+
return this._audioDescriptionTracks?.find(track => track.srclang === this._selectedAudioDescriptionLanguage);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
#onAudioDescriptionMenuItemChange(e) {
|
|
353
|
+
this._cancelAudioDescription();
|
|
354
|
+
this._selectedAudioDescriptionLanguage = e.target.value || null;
|
|
355
|
+
this._resetAudioDescriptionCursor(this.currentTime);
|
|
356
|
+
if (this._selectedAudioDescriptionLanguage) {
|
|
357
|
+
this._setPreference(PREFERENCES_AUDIO_DESCRIPTION_LANGUAGE_KEY, this._selectedAudioDescriptionLanguage);
|
|
358
|
+
} else {
|
|
359
|
+
this._clearPreference(PREFERENCES_AUDIO_DESCRIPTION_LANGUAGE_KEY);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
this.shadowRoot?.querySelector('#audio-description-dropdown-content')?.close();
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
#speakAudioDescription(description, track) {
|
|
366
|
+
if (!window.speechSynthesis || !window.SpeechSynthesisUtterance) return;
|
|
367
|
+
|
|
368
|
+
this._cancelAudioDescription();
|
|
369
|
+
const utterance = new window.SpeechSynthesisUtterance(description.text);
|
|
370
|
+
utterance.lang = track.srclang;
|
|
371
|
+
this._audioDescriptionUtterance = utterance;
|
|
372
|
+
this._audioDescriptionPlaying = true;
|
|
373
|
+
|
|
374
|
+
if (track.pauseVideo && this._media && !this._media.paused) {
|
|
375
|
+
this._audioDescriptionPausedVideo = true;
|
|
376
|
+
this._media.pause();
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const finish = () => {
|
|
380
|
+
if (this._audioDescriptionUtterance !== utterance) return;
|
|
381
|
+
this._audioDescriptionUtterance = null;
|
|
382
|
+
this._audioDescriptionPlaying = false;
|
|
383
|
+
if (this._audioDescriptionPausedVideo) {
|
|
384
|
+
this._audioDescriptionPausedVideo = false;
|
|
385
|
+
this._play();
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
utterance.onend = finish;
|
|
389
|
+
utterance.onerror = finish;
|
|
390
|
+
|
|
391
|
+
const speak = () => {
|
|
392
|
+
if (this._audioDescriptionUtterance !== utterance) return;
|
|
393
|
+
const voice = this.#getAudioDescriptionVoice(track.srclang);
|
|
394
|
+
if (voice) utterance.voice = voice;
|
|
395
|
+
utterance.volume = this.volume;
|
|
396
|
+
utterance.rate = this._media?.playbackRate || 1;
|
|
397
|
+
window.speechSynthesis.speak(utterance);
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
if (window.speechSynthesis.getVoices().length) {
|
|
401
|
+
speak();
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Speaking before voices have loaded is silently dropped in some browsers
|
|
406
|
+
const controller = new AbortController();
|
|
407
|
+
const onVoicesChanged = () => {
|
|
408
|
+
controller.abort();
|
|
409
|
+
speak();
|
|
410
|
+
};
|
|
411
|
+
window.speechSynthesis.addEventListener('voiceschanged', onVoicesChanged, { signal: controller.signal });
|
|
412
|
+
const timeout = setTimeout(onVoicesChanged, VOICES_LOAD_TIMEOUT_MS);
|
|
413
|
+
controller.signal.addEventListener('abort', () => clearTimeout(timeout));
|
|
414
|
+
}
|
|
415
|
+
};
|