aboutsummaryrefslogtreecommitdiffstats
path: root/src/js/captions.js
blob: e33fd81a085aa7a7ece084afa572b8704c570ca6 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
// ==========================================================================
// Plyr Captions
// TODO: Create as class
// ==========================================================================

import controls from './controls';
import support from './support';
import { dedupe } from './utils/arrays';
import browser from './utils/browser';
import {
    createElement,
    emptyElement,
    getAttributesFromSelector,
    insertAfter,
    removeElement,
    toggleClass,
} from './utils/elements';
import { on, triggerEvent } from './utils/events';
import fetch from './utils/fetch';
import i18n from './utils/i18n';
import is from './utils/is';
import { getHTML } from './utils/strings';
import { parseUrl } from './utils/urls';

const captions = {
    // Setup captions
    setup() {
        // Requires UI support
        if (!this.supported.ui) {
            return;
        }

        // Only Vimeo and HTML5 video supported at this point
        if (!this.isVideo || this.isYouTube || (this.isHTML5 && !support.textTracks)) {
            // Clear menu and hide
            if (
                is.array(this.config.controls) &&
                this.config.controls.includes('settings') &&
                this.config.settings.includes('captions')
            ) {
                controls.setCaptionsMenu.call(this);
            }

            return;
        }

        // Inject the container
        if (!is.element(this.elements.captions)) {
            this.elements.captions = createElement('div', getAttributesFromSelector(this.config.selectors.captions));

            insertAfter(this.elements.captions, this.elements.wrapper);
        }

        // Fix IE captions if CORS is used
        // Fetch captions and inject as blobs instead (data URIs not supported!)
        if (browser.isIE && window.URL) {
            const elements = this.media.querySelectorAll('track');

            Array.from(elements).forEach(track => {
                const src = track.getAttribute('src');
                const url = parseUrl(src);

                if (
                    url !== null &&
                    url.hostname !== window.location.href.hostname &&
                    ['http:', 'https:'].includes(url.protocol)
                ) {
                    fetch(src, 'blob')
                        .then(blob => {
                            track.setAttribute('src', window.URL.createObjectURL(blob));
                        })
                        .catch(() => {
                            removeElement(track);
                        });
                }
            });
        }

        // Get and set initial data
        // The "preferred" options are not realized unless / until the wanted language has a match
        // * languages: Array of user's browser languages.
        // * language:  The language preferred by user settings or config
        // * active:    The state preferred by user settings or config
        // * toggled:   The real captions state

        const browserLanguages = navigator.languages || [navigator.language || navigator.userLanguage || 'en'];
        const languages = dedupe(browserLanguages.map(language => language.split('-')[0]));
        let language = (this.storage.get('language') || this.config.captions.language || 'auto').toLowerCase();

        // Use first browser language when language is 'auto'
        if (language === 'auto') {
            [language] = languages;
        }

        let active = this.storage.get('captions');
        if (!is.boolean(active)) {
            ({ active } = this.config.captions);
        }

        Object.assign(this.captions, {
            toggled: false,
            active,
            language,
            languages,
        });

        // Watch changes to textTracks and update captions menu
        if (this.isHTML5) {
            const trackEvents = this.config.captions.update ? 'addtrack removetrack' : 'removetrack';
            on.call(this, this.media.textTracks, trackEvents, captions.update.bind(this));
        }

        // Update available languages in list next tick (the event must not be triggered before the listeners)
        setTimeout(captions.update.bind(this), 0);
    },

    // Update available language options in settings based on tracks
    update() {
        const tracks = captions.getTracks.call(this, true);
        // Get the wanted language
        const { active, language, meta, currentTrackNode } = this.captions;
        const languageExists = Boolean(tracks.find(track => track.language === language));

        // Handle tracks (add event listener and "pseudo"-default)
        if (this.isHTML5 && this.isVideo) {
            tracks
                .filter(track => !meta.get(track))
                .forEach(track => {
                    this.debug.log('Track added', track);
                    // Attempt to store if the original dom element was "default"
                    meta.set(track, {
                        default: track.mode === 'showing',
                    });

                    // Turn off native caption rendering to avoid double captions
                    // eslint-disable-next-line no-param-reassign
                    track.mode = 'hidden';

                    // Add event listener for cue changes
                    on.call(this, track, 'cuechange', () => captions.updateCues.call(this));
                });
        }

        // Update language first time it matches, or if the previous matching track was removed
        if ((languageExists && this.language !== language) || !tracks.includes(currentTrackNode)) {
            captions.setLanguage.call(this, language);
            captions.toggle.call(this, active && languageExists);
        }

        // Enable or disable captions based on track length
        toggleClass(this.elements.container, this.config.classNames.captions.enabled, !is.empty(tracks));

        // Update available languages in list
        if ((this.config.controls || []).includes('settings') && this.config.settings.includes('captions')) {
            controls.setCaptionsMenu.call(this);
        }
    },

    // Toggle captions display
    // Used internally for the toggleCaptions method, with the passive option forced to false
    toggle(input, passive = true) {
        // If there's no full support
        if (!this.supported.ui) {
            return;
        }

        const { toggled } = this.captions; // Current state
        const activeClass = this.config.classNames.captions.active;
        // Get the next state
        // If the method is called without parameter, toggle based on current value
        const active = is.nullOrUndefined(input) ? !toggled : input;

        // Update state and trigger event
        if (active !== toggled) {
            // When passive, don't override user preferences
            if (!passive) {
                this.captions.active = active;
                this.storage.set({ captions: active });
            }

            // Force language if the call isn't passive and there is no matching language to toggle to
            if (!this.language && active && !passive) {
                const tracks = captions.getTracks.call(this);
                const track = captions.findTrack.call(this, [this.captions.language, ...this.captions.languages], true);

                // Override user preferences to avoid switching languages if a matching track is added
                this.captions.language = track.language;

                // Set caption, but don't store in localStorage as user preference
                captions.set.call(this, tracks.indexOf(track));
                return;
            }

            // Toggle button if it's enabled
            if (this.elements.buttons.captions) {
                this.elements.buttons.captions.pressed = active;
            }

            // Add class hook
            toggleClass(this.elements.container, activeClass, active);

            this.captions.toggled = active;

            // Update settings menu
            controls.updateSetting.call(this, 'captions');

            // Trigger event (not used internally)
            triggerEvent.call(this, this.media, active ? 'captionsenabled' : 'captionsdisabled');
        }
    },

    // Set captions by track index
    // Used internally for the currentTrack setter with the passive option forced to false
    set(index, passive = true) {
        const tracks = captions.getTracks.call(this);

        // Disable captions if setting to -1
        if (index === -1) {
            captions.toggle.call(this, false, passive);
            return;
        }

        if (!is.number(index)) {
            this.debug.warn('Invalid caption argument', index);
            return;
        }

        if (!(index in tracks)) {
            this.debug.warn('Track not found', index);
            return;
        }

        if (this.captions.currentTrack !== index) {
            this.captions.currentTrack = index;
            const track = tracks[index];
            const { language } = track || {};

            // Store reference to node for invalidation on remove
            this.captions.currentTrackNode = track;

            // Update settings menu
            controls.updateSetting.call(this, 'captions');

            // When passive, don't override user preferences
            if (!passive) {
                this.captions.language = language;
                this.storage.set({ language });
            }

            // Handle Vimeo captions
            if (this.isVimeo) {
                this.embed.enableTextTrack(language);
            }

            // Trigger event
            triggerEvent.call(this, this.media, 'languagechange');
        }

        // Show captions
        captions.toggle.call(this, true, passive);

        if (this.isHTML5 && this.isVideo) {
            // If we change the active track while a cue is already displayed we need to update it
            captions.updateCues.call(this);
        }
    },

    // Set captions by language
    // Used internally for the language setter with the passive option forced to false
    setLanguage(input, passive = true) {
        if (!is.string(input)) {
            this.debug.warn('Invalid language argument', input);
            return;
        }
        // Normalize
        const language = input.toLowerCase();
        this.captions.language = language;

        // Set currentTrack
        const tracks = captions.getTracks.call(this);
        const track = captions.findTrack.call(this, [language]);
        captions.set.call(this, tracks.indexOf(track), passive);
    },

    // Get current valid caption tracks
    // If update is false it will also ignore tracks without metadata
    // This is used to "freeze" the language options when captions.update is false
    getTracks(update = false) {
        // Handle media or textTracks missing or null
        const tracks = Array.from((this.media || {}).textTracks || []);
        // For HTML5, use cache instead of current tracks when it exists (if captions.update is false)
        // Filter out removed tracks and tracks that aren't captions/subtitles (for example metadata)
        return tracks
            .filter(track => !this.isHTML5 || update || this.captions.meta.has(track))
            .filter(track => ['captions', 'subtitles'].includes(track.kind));
    },

    // Match tracks based on languages and get the first
    findTrack(languages, force = false) {
        const tracks = captions.getTracks.call(this);
        const sortIsDefault = track => Number((this.captions.meta.get(track) || {}).default);
        const sorted = Array.from(tracks).sort((a, b) => sortIsDefault(b) - sortIsDefault(a));
        let track;

        languages.every(language => {
            track = sorted.find(t => t.language === language);
            return !track; // Break iteration if there is a match
        });

        // If no match is found but is required, get first
        return track || (force ? sorted[0] : undefined);
    },

    // Get the current track
    getCurrentTrack() {
        return captions.getTracks.call(this)[this.currentTrack];
    },

    // Get UI label for track
    getLabel(track) {
        let currentTrack = track;

        if (!is.track(currentTrack) && support.textTracks && this.captions.toggled) {
            currentTrack = captions.getCurrentTrack.call(this);
        }

        if (is.track(currentTrack)) {
            if (!is.empty(currentTrack.label)) {
                return currentTrack.label;
            }

            if (!is.empty(currentTrack.language)) {
                return track.language.toUpperCase();
            }

            return i18n.get('enabled', this.config);
        }

        return i18n.get('disabled', this.config);
    },

    // Update captions using current track's active cues
    // Also optional array argument in case there isn't any track (ex: vimeo)
    updateCues(input) {
        // Requires UI
        if (!this.supported.ui) {
            return;
        }

        if (!is.element(this.elements.captions)) {
            this.debug.warn('No captions element to render to');
            return;
        }

        // Only accept array or empty input
        if (!is.nullOrUndefined(input) && !Array.isArray(input)) {
            this.debug.warn('updateCues: Invalid input', input);
            return;
        }

        let cues = input;

        // Get cues from track
        if (!cues) {
            const track = captions.getCurrentTrack.call(this);

            cues = Array.from((track || {}).activeCues || [])
                .map(cue => cue.getCueAsHTML())
                .map(getHTML);
        }

        // Set new caption text
        const content = cues.map(cueText => cueText.trim()).join('\n');
        const changed = content !== this.elements.captions.innerHTML;

        if (changed) {
            // Empty the container and create a new child element
            emptyElement(this.elements.captions);
            const caption = createElement('span', getAttributesFromSelector(this.config.selectors.caption));
            caption.innerHTML = content;
            this.elements.captions.appendChild(caption);

            // Trigger event
            triggerEvent.call(this, this.media, 'cuechange');
        }
    },
};

export default captions;