diff --git a/src/js/captions.js b/src/js/captions.js index 6682d6f0..63674b95 100644 --- a/src/js/captions.js +++ b/src/js/captions.js @@ -7,10 +7,11 @@ import controls from './controls'; import i18n from './i18n'; import support from './support'; import browser from './utils/browser'; -import { createElement, emptyElement, getAttributesFromSelector, insertAfter, removeElement, toggleClass } from './utils/elements'; +import { createElement, emptyElement, getAttributesFromSelector, insertAfter, removeElement, toggleClass, toggleState } from './utils/elements'; import { on, triggerEvent } from './utils/events'; import fetch from './utils/fetch'; import is from './utils/is'; +import { dedupe } from './utils/arrays'; import { getHTML } from './utils/strings'; import { parseUrl } from './utils/urls'; @@ -63,21 +64,34 @@ const captions = { }); } - // Try to load the value from storage - let active = this.storage.get('captions'); + // 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 - // Otherwise fall back to the default config + const languages = dedupe(Array.from(navigator.languages || navigator.userLanguage) + .map(language => language.split('-')[0])); + + let language = this.storage.get('language') || this.config.captions.language; + + // 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); } - // Get language from storage, fallback to config - let language = this.storage.get('language') || this.config.captions.language; - if (language === 'auto') { - [language] = (navigator.language || navigator.userLanguage).split('-'); - } - // Set language and show if active - captions.setLanguage.call(this, language, active); + Object.assign(this.captions, { + toggled: false, + active, + language, + languages, + }); // Watch changes to textTracks and update captions menu if (this.isHTML5) { @@ -89,10 +103,12 @@ const captions = { 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 { language, meta } = this.captions; + 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) { @@ -111,12 +127,10 @@ const captions = { }); } - const trackRemoved = !tracks.find(track => track === this.captions.currentTrackNode); - const firstMatch = this.language !== language && tracks.find(track => track.language === language); - - // Update language if removed or first matching track added - if (trackRemoved || firstMatch) { - captions.setLanguage.call(this, language, this.config.captions.active); + // 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 @@ -128,12 +142,69 @@ const captions = { } }, - set(index, setLanguage = true, show = true) { + // 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) { + // 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 state + toggleState(this.elements.buttons.captions, active); + + // Add class hook + toggleClass(this.elements.container, activeClass, active); + + this.captions.toggled = active; + + // Update settings menu + controls.updateSetting.call(this, 'captions'); + + // When passive, don't override user preferences + if (!passive) { + this.captions.active = active; + this.storage.set({ captions: active }); + } + + // 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) { - this.toggleCaptions(false); + captions.toggle.call(this, false, passive); return; } @@ -149,15 +220,19 @@ const captions = { if (this.captions.currentTrack !== index) { this.captions.currentTrack = index; - const track = captions.getCurrentTrack.call(this); + const track = tracks[index]; const { language } = track || {}; // Store reference to node for invalidation on remove this.captions.currentTrackNode = track; - // Prevent setting language in some cases, since it can violate user's intentions - if (setLanguage) { + // 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 @@ -175,12 +250,12 @@ const captions = { } // Show captions - if (show) { - this.toggleCaptions(true); - } + captions.toggle.call(this, true, passive); }, - setLanguage(language, show = true) { + // Set captions by language + // Used internally for the language setter with the passive option forced to false + setLanguage(language, passive = true) { if (!is.string(language)) { this.debug.warn('Invalid language argument', language); return; @@ -190,8 +265,8 @@ const captions = { // Set currentTrack const tracks = captions.getTracks.call(this); - const track = captions.getCurrentTrack.call(this, true); - captions.set.call(this, tracks.indexOf(track), false, show); + const track = captions.findTrack.call(this, [language]); + captions.set.call(this, tracks.indexOf(track), passive); }, // Get current valid caption tracks @@ -208,19 +283,30 @@ const captions = { ].includes(track.kind)); }, - // Get the current track for the current language - getCurrentTrack(fromLanguage = false) { + // 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)); - return (!fromLanguage && tracks[this.currentTrack]) || sorted.find(track => track.language === this.captions.language) || sorted[0]; + let track; + languages.every(language => { + track = sorted.find(track => track.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.active) { + if (!is.track(currentTrack) && support.textTracks && this.captions.toggled) { currentTrack = captions.getCurrentTrack.call(this); } diff --git a/src/js/controls.js b/src/js/controls.js index 0e28c222..e601a03a 100644 --- a/src/js/controls.js +++ b/src/js/controls.js @@ -10,7 +10,7 @@ import { repaint, transitionEndEvent } from './utils/animation'; import { dedupe } from './utils/arrays'; import browser from './utils/browser'; import { createElement, emptyElement, getAttributesFromSelector, getElement, getElements, hasClass, removeElement, setAttributes, toggleClass, toggleHidden, toggleState } from './utils/elements'; -import { once } from './utils/events'; +import { on, off } from './utils/events'; import is from './utils/is'; import loadSprite from './utils/loadSprite'; import { extend } from './utils/objects'; @@ -848,7 +848,7 @@ const controls = { // Generate options data const options = tracks.map((track, value) => ({ value, - checked: this.captions.active && this.currentTrack === value, + checked: this.captions.toggled && this.currentTrack === value, title: captions.getLabel.call(this, track), badge: track.language && controls.createBadge.call(this, track.language.toUpperCase()), list, @@ -858,7 +858,7 @@ const controls = { // Add the "Disabled" option to turn off captions options.unshift({ value: -1, - checked: !this.captions.active, + checked: !this.captions.toggled, title: i18n.get('disabled', this.config), list, type: 'language', @@ -1026,7 +1026,7 @@ const controls = { return; } - // Are we targetting a tab? If not, bail + // Are we targeting a tab? If not, bail const isTab = pane.getAttribute('role') === 'tabpanel'; if (!isTab) { return; @@ -1065,10 +1065,12 @@ const controls = { container.style.width = ''; container.style.height = ''; + // Only listen once + off.call(this, container, transitionEndEvent, restore); }; // Listen for the transition finishing and restore auto height/width - once.call(this, container, transitionEndEvent, restore); + on.call(this, container, transitionEndEvent, restore); // Set dimensions to target container.style.width = `${size.width}px`; diff --git a/src/js/listeners.js b/src/js/listeners.js index 283bd4a2..34cdc6fb 100644 --- a/src/js/listeners.js +++ b/src/js/listeners.js @@ -387,24 +387,6 @@ class Listeners { controls.updateSetting.call(this.player, 'quality', null, event.detail.quality); }); - // Caption language change - on.call(this.player, this.player.media, 'languagechange', () => { - // Update UI - controls.updateSetting.call(this.player, 'captions'); - - // Save to storage - this.player.storage.set({ language: this.player.language }); - }); - - // Captions toggle - on.call(this.player, this.player.media, 'captionsenabled captionsdisabled', () => { - // Update UI - controls.updateSetting.call(this.player, 'captions'); - - // Save to storage - this.player.storage.set({ captions: this.player.captions.active }); - }); - // Proxy events to container // Bubble up key events for Edge on.call(this.player, this.player.media, this.player.config.events.concat([ @@ -477,7 +459,7 @@ class Listeners { ); // Captions toggle - bind(this.player.elements.buttons.captions, 'click', this.player.toggleCaptions); + bind(this.player.elements.buttons.captions, 'click', () => this.player.toggleCaptions()); // Fullscreen toggle bind( diff --git a/src/js/plyr.js b/src/js/plyr.js index 543291e7..80555829 100644 --- a/src/js/plyr.js +++ b/src/js/plyr.js @@ -19,7 +19,7 @@ import Storage from './storage'; import support from './support'; import ui from './ui'; import { closest } from './utils/arrays'; -import { createElement, hasClass, removeElement, replaceElement, toggleClass, toggleState, wrap } from './utils/elements'; +import { createElement, hasClass, removeElement, replaceElement, toggleClass, wrap } from './utils/elements'; import { off, on, once, triggerEvent, unbindListeners } from './utils/events'; import is from './utils/is'; import loadSprite from './utils/loadSprite'; @@ -833,25 +833,7 @@ class Plyr { * @param {boolean} input - Whether to enable captions */ toggleCaptions(input) { - // If there's no full support - if (!this.supported.ui) { - return; - } - - // If the method is called without parameter, toggle based on current value - const active = is.boolean(input) ? input : !this.elements.container.classList.contains(this.config.classNames.captions.active); - - // Toggle state - toggleState(this.elements.buttons.captions, active); - - // Add class hook - toggleClass(this.elements.container, this.config.classNames.captions.active, active); - - // Update state and trigger event - if (active !== this.captions.active) { - this.captions.active = active; - triggerEvent.call(this, this.media, this.captions.active ? 'captionsenabled' : 'captionsdisabled'); - } + captions.toggle.call(this, input, false); } /** @@ -859,15 +841,15 @@ class Plyr { * @param {number} - Caption index */ set currentTrack(input) { - captions.set.call(this, input); + captions.set.call(this, input, false); } /** * Get the current caption track index (-1 if disabled) */ get currentTrack() { - const { active, currentTrack } = this.captions; - return active ? currentTrack : -1; + const { toggled, currentTrack } = this.captions; + return toggled ? currentTrack : -1; } /** @@ -876,7 +858,7 @@ class Plyr { * @param {string} - Two character ISO language code (e.g. EN, FR, PT, etc) */ set language(input) { - captions.setLanguage.call(this, input); + captions.setLanguage.call(this, input, false); } /**