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

@ -69,12 +69,18 @@ const captions = {
({ active } = this.config.captions);
}
// Set toggled state
this.toggleCaptions(active);
// 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);
// Watch changes to textTracks and update captions menu
if (this.config.captions.update) {
utils.on(this.media.textTracks, 'addtrack removetrack', captions.update.bind(this));
if (this.isHTML5) {
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)
@ -82,21 +88,39 @@ const captions = {
},
update() {
// Update tracks
const tracks = captions.getTracks.call(this);
this.options.captions = tracks.map(({language}) => language);
const tracks = captions.getTracks.call(this, true);
// Get the wanted language
const { language, meta } = this.captions;
// Set language if it hasn't been set already
if (!this.language) {
let { language } = this.config.captions;
if (language === 'auto') {
[ language ] = (navigator.language || navigator.userLanguage).split('-');
}
this.language = this.storage.get('language') || (language || '').toLowerCase();
// 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
track.mode = 'hidden';
// Add event listener for cue changes
utils.on(track, 'cuechange', () => captions.updateCues.call(this));
});
}
// Toggle the class hooks
utils.toggleClass(this.elements.container, this.config.classNames.captions.enabled, !utils.is.empty(captions.getTracks.call(this)));
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);
}
// 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
if ((this.config.controls || []).includes('settings') && this.config.settings.includes('captions')) {
@ -104,70 +128,94 @@ const captions = {
}
},
// Set the captions language
setLanguage() {
// 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));
set(index, setLanguage = true, show = true) {
const tracks = captions.getTracks.call(this);
// Turn off native caption rendering to avoid double captions
// eslint-disable-next-line
track.mode = 'hidden';
});
// Disable captions if setting to -1
if (index === -1) {
this.toggleCaptions(false);
return;
}
// Get current track
const currentTrack = captions.getCurrentTrack.call(this);
if (!utils.is.number(index)) {
this.debug.warn('Invalid caption argument', index);
return;
}
// Check if suported kind
if (utils.is.track(currentTrack)) {
// If we change the active track while a cue is already displayed we need to update it
if (Array.from(currentTrack.activeCues || []).length) {
captions.setCue.call(this, currentTrack);
}
if (!(index in tracks)) {
this.debug.warn('Track not found', index);
return;
}
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
getTracks() {
// Return empty array at least
if (utils.is.nullOrUndefined(this.media)) {
return [];
setLanguage(language, show = true) {
if (!utils.is.string(language)) {
this.debug.warn('Invalid language argument', language);
return;
}
// Normalize
this.captions.language = language.toLowerCase();
// Only get accepted kinds
return Array.from(this.media.textTracks || []).filter(track => [
'captions',
'subtitles',
].includes(track.kind));
// Set currentTrack
const tracks = captions.getTracks.call(this);
const track = captions.getCurrentTrack.call(this, true);
captions.set.call(this, tracks.indexOf(track), false, show);
},
// 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
getCurrentTrack() {
getCurrentTrack(fromLanguage = false) {
const tracks = captions.getTracks.call(this);
if (!tracks.length) {
return null;
}
// 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;
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];
},
// Get UI label for track
@ -193,56 +241,48 @@ const captions = {
return i18n.get('disabled', this.config);
},
// Display active caption if it contains text
setCue(input) {
// Get the track from the event if needed
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) {
// 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 (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 {
if (!utils.is.element(this.elements.captions)) {
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
createMenuItem(value, list, type, title, badge = null, checked = false) {
createMenuItem({value, list, type, title, badge = null, checked = false}) {
const item = utils.createElement('li');
const label = utils.createElement('label', {
@ -680,8 +680,13 @@ const controls = {
return sorting.indexOf(a) > sorting.indexOf(b) ? 1 : -1;
})
.forEach(quality => {
const label = controls.getLabel.call(this, 'quality', quality);
controls.createMenuItem.call(this, quality, list, type, label, getBadge(quality));
controls.createMenuItem.call(this, {
value: quality,
list,
type,
title: controls.getLabel.call(this, 'quality', quality),
badge: getBadge(quality),
});
});
controls.updateSetting.call(this, type, list);
@ -722,16 +727,7 @@ const controls = {
switch (setting) {
case 'captions':
if (this.captions.active) {
if (this.options.captions.length > 2 || !this.options.captions.some(lang => lang === 'enabled')) {
value = this.captions.language;
} else {
value = 'enabled';
}
} else {
value = '';
}
value = this.currentTrack;
break;
default:
@ -831,10 +827,10 @@ const controls = {
// TODO: Captions or language? Currently it's mixed
const type = 'captions';
const list = this.elements.settings.panes.captions.querySelector('ul');
const tracks = captions.getTracks.call(this);
// Toggle the pane and tab
const toggle = captions.getTracks.call(this).length;
controls.toggleTab.call(this, type, toggle);
controls.toggleTab.call(this, type, tracks.length);
// Empty the menu
utils.emptyElement(list);
@ -843,34 +839,31 @@ const controls = {
controls.checkMenu.call(this);
// If there's no captions, bail
if (!toggle) {
if (!tracks.length) {
return;
}
// Re-map the tracks into just the data we need
const tracks = captions.getTracks.call(this).map(track => ({
language: !utils.is.empty(track.language) ? track.language : 'enabled',
label: captions.getLabel.call(this, track),
// Generate options data
const options = tracks.map((track, value) => ({
value,
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
tracks.unshift({
language: '',
label: i18n.get('disabled', this.config),
options.unshift({
value: -1,
checked: !this.captions.active,
title: i18n.get('disabled', this.config),
list,
type: 'language',
});
// Generate options
tracks.forEach(track => {
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,
);
});
options.forEach(controls.createMenuItem.bind(this));
controls.updateSetting.call(this, type, list);
},
@ -927,8 +920,12 @@ const controls = {
// Create items
this.options.speed.forEach(speed => {
const label = controls.getLabel.call(this, 'speed', speed);
controls.createMenuItem.call(this, speed, list, type, label);
controls.createMenuItem.call(this, {
value: speed,
list,
type,
title: controls.getLabel.call(this, 'speed', speed),
});
});
controls.updateSetting.call(this, type, list);

View File

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

View File

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

View File

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

View File

@ -84,7 +84,8 @@ class Plyr {
// Captions
this.captions = {
active: null,
currentTrack: null,
currentTrack: -1,
meta: new WeakMap(),
};
// Fullscreen
@ -96,7 +97,6 @@ class Plyr {
this.options = {
speed: [],
quality: [],
captions: [],
};
// Debugging
@ -854,61 +854,35 @@ class Plyr {
}
/**
* Set the captions language
* @param {string} - Two character ISO language code (e.g. EN, FR, PT, etc)
* Set the caption track by index
* @param {number} - Caption index
*/
set language(input) {
// Nothing specified
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');
set currentTrack(input) {
captions.set.call(this, input);
}
/**
* 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() {
return this.captions.language;
return (captions.getCurrentTrack.call(this) || {}).language;
}
/**

View File

@ -831,6 +831,13 @@ const utils = {
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
getAspectRatio(width, height) {
const getRatio = (w, h) => (h === 0 ? w : getRatio(h, w % h));

View File

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