aboutsummaryrefslogtreecommitdiffstats
path: root/src/js/captions.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/js/captions.js')
-rw-r--r--src/js/captions.js743
1 files changed, 380 insertions, 363 deletions
diff --git a/src/js/captions.js b/src/js/captions.js
index 724def9e..ebb678f8 100644
--- a/src/js/captions.js
+++ b/src/js/captions.js
@@ -8,12 +8,12 @@ import support from './support';
import { dedupe } from './utils/arrays';
import browser from './utils/browser';
import {
- createElement,
- emptyElement,
- getAttributesFromSelector,
- insertAfter,
- removeElement,
- toggleClass,
+ createElement,
+ emptyElement,
+ getAttributesFromSelector,
+ insertAfter,
+ removeElement,
+ toggleClass,
} from './utils/elements';
import { on, triggerEvent } from './utils/events';
import fetch from './utils/fetch';
@@ -23,368 +23,385 @@ 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);
- });
- }
+ // 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,
+ });
+ }
+
+ // 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
+ // Note: mode='hidden' forces a track to download. To ensure every track
+ // isn't downloaded at once, only 'showing' tracks should be reassigned
+ // eslint-disable-next-line no-param-reassign
+ if (track.mode === 'showing') {
+ // 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));
});
-
- // 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 ((is.array(this.config.controls) && 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) {
+ }
+
+ // 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 (
+ is.array(this.config.controls) &&
+ 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);
-
- // 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();
+ 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');
+ }
+
+ // Wait for the call stack to clear before setting mode='hidden'
+ // on the active track - forcing the browser to download it
+ setTimeout(() => {
+ if (active && this.captions.toggled) {
+ this.captions.currentTrackNode.mode = 'hidden';
+ }
+ });
+ },
+
+ // 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;
-
- // 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');
- }
- },
+ 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;