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
commit 7d26f41d64
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
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. |
| `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. |
| `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.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+. |

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;