Merge pull request #1015 from friday/captions-fixes-again

Captions rewrite (use index internally to support missing or duplicate languages)
This commit is contained in:
Sam Potts
2018-06-11 13:21:05 +10:00
committed by GitHub
9 changed files with 224 additions and 209 deletions

View File

@ -407,7 +407,8 @@ player.fullscreen.active; // false;
| `source` | ✓ | ✓ | Gets or sets the current source for the player. The setter accepts an object. See [source setter](#source-setter) below for examples. | | `source` | ✓ | ✓ | Gets or sets the current source for the player. The setter accepts an object. See [source setter](#source-setter) below for examples. |
| `poster` | ✓ | ✓ | Gets or sets the current poster image for the player. The setter accepts a string; the URL for the updated poster image. | | `poster` | ✓ | ✓ | Gets or sets the current poster image for the player. The setter accepts a string; the URL for the updated poster image. |
| `autoplay` | ✓ | ✓ | Gets or sets the autoplay state of the player. The setter accepts a boolean. | | `autoplay` | ✓ | ✓ | Gets or sets the autoplay state of the player. The setter accepts a boolean. |
| `language` | ✓ | ✓ | Gets or sets the preferred captions language for the player. The setter accepts an ISO two-letter language code. Support for the languages is dependent on the captions you include. | | `currentTrack` | ✓ | ✓ | Gets or sets the caption track by index. `-1` means the track is missing or captions is not active |
| `language` | ✓ | ✓ | Gets or sets the preferred captions language for the player. The setter accepts an ISO two-letter language code. Support for the languages is dependent on the captions you include. If your captions don't have any language data, or if you have multiple tracks with the same language, you may want to use `currentTrack` instead. |
| `fullscreen.active` | ✓ | - | Returns a boolean indicating if the current player is in fullscreen mode. | | `fullscreen.active` | ✓ | - | Returns a boolean indicating if the current player is in fullscreen mode. |
| `fullscreen.enabled` | ✓ | - | Returns a boolean indicating if the current player has fullscreen enabled. | | `fullscreen.enabled` | ✓ | - | Returns a boolean indicating if the current player has fullscreen enabled. |
| `pip` | ✓ | ✓ | Gets or sets the picture-in-picture state of the player. The setter accepts a boolean. This currently only supported on Safari 10+ on MacOS Sierra+ and iOS 10+. | | `pip` | ✓ | ✓ | Gets or sets the picture-in-picture state of the player. The setter accepts a boolean. This currently only supported on Safari 10+ on MacOS Sierra+ and iOS 10+. |

View File

@ -69,12 +69,18 @@ const captions = {
({ active } = this.config.captions); ({ active } = this.config.captions);
} }
// Set toggled state // Get language from storage, fallback to config
this.toggleCaptions(active); 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);
// Watch changes to textTracks and update captions menu // Watch changes to textTracks and update captions menu
if (this.config.captions.update) { if (this.isHTML5) {
utils.on(this.media.textTracks, 'addtrack removetrack', captions.update.bind(this)); const trackEvents = this.config.captions.update ? 'addtrack removetrack' : 'removetrack';
utils.on(this.media.textTracks, trackEvents, captions.update.bind(this));
} }
// Update available languages in list next tick (the event must not be triggered before the listeners) // Update available languages in list next tick (the event must not be triggered before the listeners)
@ -82,21 +88,39 @@ const captions = {
}, },
update() { update() {
// Update tracks const tracks = captions.getTracks.call(this, true);
const tracks = captions.getTracks.call(this); // Get the wanted language
this.options.captions = tracks.map(({language}) => language); const { language, meta } = this.captions;
// Set language if it hasn't been set already // Handle tracks (add event listener and "pseudo"-default)
if (!this.language) { if (this.isHTML5 && this.isVideo) {
let { language } = this.config.captions; tracks
if (language === 'auto') { .filter(track => !meta.get(track))
[ language ] = (navigator.language || navigator.userLanguage).split('-'); .forEach(track => {
} this.debug.log('Track added', track);
this.language = this.storage.get('language') || (language || '').toLowerCase(); // 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
track.mode = 'hidden';
// Add event listener for cue changes
utils.on(track, 'cuechange', () => captions.updateCues.call(this));
});
} }
// Toggle the class hooks const trackRemoved = !tracks.find(track => track === this.captions.currentTrackNode);
utils.toggleClass(this.elements.container, this.config.classNames.captions.enabled, !utils.is.empty(captions.getTracks.call(this))); 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);
}
// Enable or disable captions based on track length
utils.toggleClass(this.elements.container, this.config.classNames.captions.enabled, !utils.is.empty(tracks));
// Update available languages in list // Update available languages in list
if ((this.config.controls || []).includes('settings') && this.config.settings.includes('captions')) { if ((this.config.controls || []).includes('settings') && this.config.settings.includes('captions')) {
@ -104,70 +128,94 @@ const captions = {
} }
}, },
// Set the captions language set(index, setLanguage = true, show = true) {
setLanguage() { const tracks = captions.getTracks.call(this);
// Setup HTML5 track rendering
if (this.isHTML5 && this.isVideo) {
captions.getTracks.call(this).forEach(track => {
// Show track
utils.on(track, 'cuechange', event => captions.setCue.call(this, event));
// Turn off native caption rendering to avoid double captions // Disable captions if setting to -1
// eslint-disable-next-line if (index === -1) {
track.mode = 'hidden'; this.toggleCaptions(false);
}); return;
}
// Get current track if (!utils.is.number(index)) {
const currentTrack = captions.getCurrentTrack.call(this); this.debug.warn('Invalid caption argument', index);
return;
}
// Check if suported kind if (!(index in tracks)) {
if (utils.is.track(currentTrack)) { this.debug.warn('Track not found', index);
// If we change the active track while a cue is already displayed we need to update it return;
if (Array.from(currentTrack.activeCues || []).length) { }
captions.setCue.call(this, currentTrack);
} if (this.captions.currentTrack !== index) {
this.captions.currentTrack = index;
const track = captions.getCurrentTrack.call(this);
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) {
this.captions.language = language;
} }
} else if (this.isVimeo && this.captions.active) {
this.embed.enableTextTrack(this.language); // Handle Vimeo captions
if (this.isVimeo) {
this.embed.enableTextTrack(language);
}
// Trigger event
utils.dispatchEvent.call(this, this.media, 'languagechange');
}
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);
}
// Show captions
if (show) {
this.toggleCaptions(true);
} }
}, },
// Get the tracks setLanguage(language, show = true) {
getTracks() { if (!utils.is.string(language)) {
// Return empty array at least this.debug.warn('Invalid language argument', language);
if (utils.is.nullOrUndefined(this.media)) { return;
return [];
} }
// Normalize
this.captions.language = language.toLowerCase();
// Only get accepted kinds // Set currentTrack
return Array.from(this.media.textTracks || []).filter(track => [ const tracks = captions.getTracks.call(this);
'captions', const track = captions.getCurrentTrack.call(this, true);
'subtitles', captions.set.call(this, tracks.indexOf(track), false, show);
].includes(track.kind)); },
// 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));
}, },
// Get the current track for the current language // Get the current track for the current language
getCurrentTrack() { getCurrentTrack(fromLanguage = false) {
const tracks = captions.getTracks.call(this); const tracks = captions.getTracks.call(this);
const sortIsDefault = track => Number((this.captions.meta.get(track) || {}).default);
if (!tracks.length) { const sorted = Array.from(tracks).sort((a, b) => sortIsDefault(b) - sortIsDefault(a));
return null; return (!fromLanguage && tracks[this.currentTrack]) || sorted.find(track => track.language === this.captions.language) || sorted[0];
}
// Get track based on current language
let track = tracks.find(track => track.language.toLowerCase() === this.language);
// Get the <track> with default attribute
if (!track) {
track = utils.getElement.call(this, 'track[default]');
}
// Get the first track
if (!track) {
[track] = tracks;
}
return track;
}, },
// Get UI label for track // Get UI label for track
@ -193,56 +241,48 @@ const captions = {
return i18n.get('disabled', this.config); return i18n.get('disabled', this.config);
}, },
// Display active caption if it contains text // Update captions using current track's active cues
setCue(input) { // Also optional array argument in case there isn't any track (ex: vimeo)
// Get the track from the event if needed updateCues(input) {
const track = utils.is.event(input) ? input.target : input;
const { activeCues } = track;
const active = activeCues.length && activeCues[0];
const currentTrack = captions.getCurrentTrack.call(this);
// Only display current track
if (track !== currentTrack) {
return;
}
// Display a cue, if there is one
if (utils.is.cue(active)) {
captions.setText.call(this, active.getCueAsHTML());
} else {
captions.setText.call(this, null);
}
utils.dispatchEvent.call(this, this.media, 'cuechange');
},
// Set the current caption
setText(input) {
// Requires UI // Requires UI
if (!this.supported.ui) { if (!this.supported.ui) {
return; return;
} }
if (utils.is.element(this.elements.captions)) { if (!utils.is.element(this.elements.captions)) {
const content = utils.createElement('span');
// Empty the container
utils.emptyElement(this.elements.captions);
// Default to empty
const caption = !utils.is.nullOrUndefined(input) ? input : '';
// Set the span content
if (utils.is.string(caption)) {
content.innerText = caption.trim();
} else {
content.appendChild(caption);
}
// Set new caption text
this.elements.captions.appendChild(content);
} else {
this.debug.warn('No captions element to render to'); this.debug.warn('No captions element to render to');
return;
}
// Only accept array or empty input
if (!utils.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(utils.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
utils.emptyElement(this.elements.captions);
const caption = utils.createElement('span', utils.getAttributesFromSelector(this.config.selectors.caption));
caption.innerHTML = content;
this.elements.captions.appendChild(caption);
// Trigger event
utils.dispatchEvent.call(this, this.media, 'cuechange');
} }
}, },
}; };

69
src/js/controls.js vendored
View File

@ -376,7 +376,7 @@ const controls = {
}, },
// Create a settings menu item // Create a settings menu item
createMenuItem(value, list, type, title, badge = null, checked = false) { createMenuItem({value, list, type, title, badge = null, checked = false}) {
const item = utils.createElement('li'); const item = utils.createElement('li');
const label = utils.createElement('label', { const label = utils.createElement('label', {
@ -680,8 +680,13 @@ const controls = {
return sorting.indexOf(a) > sorting.indexOf(b) ? 1 : -1; return sorting.indexOf(a) > sorting.indexOf(b) ? 1 : -1;
}) })
.forEach(quality => { .forEach(quality => {
const label = controls.getLabel.call(this, 'quality', quality); controls.createMenuItem.call(this, {
controls.createMenuItem.call(this, quality, list, type, label, getBadge(quality)); value: quality,
list,
type,
title: controls.getLabel.call(this, 'quality', quality),
badge: getBadge(quality),
});
}); });
controls.updateSetting.call(this, type, list); controls.updateSetting.call(this, type, list);
@ -722,16 +727,7 @@ const controls = {
switch (setting) { switch (setting) {
case 'captions': case 'captions':
if (this.captions.active) { value = this.currentTrack;
if (this.options.captions.length > 2 || !this.options.captions.some(lang => lang === 'enabled')) {
value = this.captions.language;
} else {
value = 'enabled';
}
} else {
value = '';
}
break; break;
default: default:
@ -831,10 +827,10 @@ const controls = {
// TODO: Captions or language? Currently it's mixed // TODO: Captions or language? Currently it's mixed
const type = 'captions'; const type = 'captions';
const list = this.elements.settings.panes.captions.querySelector('ul'); const list = this.elements.settings.panes.captions.querySelector('ul');
const tracks = captions.getTracks.call(this);
// Toggle the pane and tab // Toggle the pane and tab
const toggle = captions.getTracks.call(this).length; controls.toggleTab.call(this, type, tracks.length);
controls.toggleTab.call(this, type, toggle);
// Empty the menu // Empty the menu
utils.emptyElement(list); utils.emptyElement(list);
@ -843,34 +839,31 @@ const controls = {
controls.checkMenu.call(this); controls.checkMenu.call(this);
// If there's no captions, bail // If there's no captions, bail
if (!toggle) { if (!tracks.length) {
return; return;
} }
// Re-map the tracks into just the data we need // Generate options data
const tracks = captions.getTracks.call(this).map(track => ({ const options = tracks.map((track, value) => ({
language: !utils.is.empty(track.language) ? track.language : 'enabled', value,
label: captions.getLabel.call(this, track), checked: this.captions.active && this.currentTrack === value,
title: captions.getLabel.call(this, track),
badge: track.language && controls.createBadge.call(this, track.language.toUpperCase()),
list,
type: 'language',
})); }));
// Add the "Disabled" option to turn off captions // Add the "Disabled" option to turn off captions
tracks.unshift({ options.unshift({
language: '', value: -1,
label: i18n.get('disabled', this.config), checked: !this.captions.active,
title: i18n.get('disabled', this.config),
list,
type: 'language',
}); });
// Generate options // Generate options
tracks.forEach(track => { options.forEach(controls.createMenuItem.bind(this));
controls.createMenuItem.call(
this,
track.language,
list,
'language',
track.label,
track.language !== 'enabled' ? controls.createBadge.call(this, track.language.toUpperCase()) : null,
track.language.toLowerCase() === this.language,
);
});
controls.updateSetting.call(this, type, list); controls.updateSetting.call(this, type, list);
}, },
@ -927,8 +920,12 @@ const controls = {
// Create items // Create items
this.options.speed.forEach(speed => { this.options.speed.forEach(speed => {
const label = controls.getLabel.call(this, 'speed', speed); controls.createMenuItem.call(this, {
controls.createMenuItem.call(this, speed, list, type, label); value: speed,
list,
type,
title: controls.getLabel.call(this, 'speed', speed),
});
}); });
controls.updateSetting.call(this, type, list); controls.updateSetting.call(this, type, list);

View File

@ -328,6 +328,7 @@ const defaults = {
}, },
progress: '.plyr__progress', progress: '.plyr__progress',
captions: '.plyr__captions', captions: '.plyr__captions',
caption: '.plyr__caption',
menu: { menu: {
quality: '.js-plyr__menu__list--quality', quality: '.js-plyr__menu__list--quality',
}, },

View File

@ -523,7 +523,7 @@ class Listeners {
proxy( proxy(
event, event,
() => { () => {
this.player.language = event.target.value; this.player.currentTrack = Number(event.target.value);
showHomeTab(); showHomeTab();
}, },
'language', 'language',

View File

@ -305,14 +305,9 @@ const vimeo = {
captions.setup.call(player); captions.setup.call(player);
}); });
player.embed.on('cuechange', data => { player.embed.on('cuechange', ({ cues = [] }) => {
let cue = null; const strippedCues = cues.map(cue => utils.stripHTML(cue.text));
captions.updateCues.call(player, strippedCues);
if (data.cues.length) {
cue = utils.stripHTML(data.cues[0].text);
}
captions.setText.call(player, cue);
}); });
player.embed.on('loaded', () => { player.embed.on('loaded', () => {

View File

@ -84,7 +84,8 @@ class Plyr {
// Captions // Captions
this.captions = { this.captions = {
active: null, active: null,
currentTrack: null, currentTrack: -1,
meta: new WeakMap(),
}; };
// Fullscreen // Fullscreen
@ -96,7 +97,6 @@ class Plyr {
this.options = { this.options = {
speed: [], speed: [],
quality: [], quality: [],
captions: [],
}; };
// Debugging // Debugging
@ -854,61 +854,35 @@ class Plyr {
} }
/** /**
* Set the captions language * Set the caption track by index
* @param {string} - Two character ISO language code (e.g. EN, FR, PT, etc) * @param {number} - Caption index
*/ */
set language(input) { set currentTrack(input) {
// Nothing specified captions.set.call(this, input);
if (!utils.is.string(input)) {
return;
}
// If empty string is passed, assume disable captions
if (utils.is.empty(input)) {
this.toggleCaptions(false);
return;
}
// Normalize
const language = input.toLowerCase();
// Check for support
if (!this.options.captions.includes(language)) {
this.debug.warn(`Unsupported language option: ${language}`);
return;
}
// Ensure captions are enabled
this.toggleCaptions(true);
// Enabled only
if (language === 'enabled') {
return;
}
// If nothing to change, bail
if (this.language === language) {
return;
}
// Update config
this.captions.language = language;
// Clear caption
captions.setText.call(this, null);
// Update captions
captions.setLanguage.call(this);
// Trigger an event
utils.dispatchEvent.call(this, this.media, 'languagechange');
} }
/** /**
* Get the current captions language * Get the current caption track index (-1 if disabled)
*/
get currentTrack() {
const { active, currentTrack } = this.captions;
return active ? currentTrack : -1;
}
/**
* Set the wanted language for captions
* Since tracks can be added later it won't update the actual caption track until there is a matching track
* @param {string} - Two character ISO language code (e.g. EN, FR, PT, etc)
*/
set language(input) {
captions.setLanguage.call(this, input);
}
/**
* Get the current track's language
*/ */
get language() { get language() {
return this.captions.language; return (captions.getCurrentTrack.call(this) || {}).language;
} }
/** /**

View File

@ -831,6 +831,13 @@ const utils = {
return fragment.firstChild.innerText; return fragment.firstChild.innerText;
}, },
// Like outerHTML, but also works for DocumentFragment
getHTML(element) {
const wrapper = document.createElement('div');
wrapper.appendChild(element);
return wrapper.innerHTML;
},
// Get aspect ratio for dimensions // Get aspect ratio for dimensions
getAspectRatio(width, height) { getAspectRatio(width, height) {
const getRatio = (w, h) => (h === 0 ? w : getRatio(h, w % h)); const getRatio = (w, h) => (h === 0 ? w : getRatio(h, w % h));

View File

@ -21,7 +21,7 @@
transition: transform 0.4s ease-in-out; transition: transform 0.4s ease-in-out;
width: 100%; width: 100%;
span { .plyr__caption {
background: $plyr-captions-bg; background: $plyr-captions-bg;
border-radius: 2px; border-radius: 2px;
box-decoration-break: clone; box-decoration-break: clone;