/**
* @file text-track-display.js
*/
import Component from '../component';
import * as Fn from '../utils/fn.js';
import * as Dom from '../utils/dom.js';
import window from 'global/window';
/** @import Player from '../player' */
const darkGray = '#222';
const lightGray = '#ccc';
const fontMap = {
monospace: 'monospace',
sansSerif: 'sans-serif',
serif: 'serif',
monospaceSansSerif: '"Andale Mono", "Lucida Console", monospace',
monospaceSerif: '"Courier New", monospace',
proportionalSansSerif: 'sans-serif',
proportionalSerif: 'serif',
casual: '"Comic Sans MS", Impact, fantasy',
script: '"Monotype Corsiva", cursive',
smallcaps: '"Andale Mono", "Lucida Console", monospace, sans-serif'
};
/**
* Construct an rgba color from a given hex color code.
*
* @param {number} color
* Hex number for color, like #f0e or #f604e2.
*
* @param {number} opacity
* Value for opacity, 0.0 - 1.0.
*
* @return {string}
* The rgba color that was created, like 'rgba(255, 0, 0, 0.3)'.
*/
export function constructColor(color, opacity) {
let hex;
if (color.length === 4) {
// color looks like "#f0e"
hex = color[1] + color[1] + color[2] + color[2] + color[3] + color[3];
} else if (color.length === 7) {
// color looks like "#f604e2"
hex = color.slice(1);
} else {
throw new Error('Invalid color code provided, ' + color + '; must be formatted as e.g. #f0e or #f604e2.');
}
return 'rgba(' +
parseInt(hex.slice(0, 2), 16) + ',' +
parseInt(hex.slice(2, 4), 16) + ',' +
parseInt(hex.slice(4, 6), 16) + ',' +
opacity + ')';
}
/**
* Try to update the style of a DOM element. Some style changes will throw an error,
* particularly in IE8. Those should be noops.
*
* @param {Element} el
* The DOM element to be styled.
*
* @param {string} style
* The CSS property on the element that should be styled.
*
* @param {string} rule
* The style rule that should be applied to the property.
*
* @private
*/
function tryUpdateStyle(el, style, rule) {
try {
el.style[style] = rule;
} catch (e) {
// Satisfies linter.
return;
}
}
/**
* Converts the CSS top/right/bottom/left property numeric value to string in pixels.
*
* @param {number} position
* The CSS top/right/bottom/left property value.
*
* @return {string}
* The CSS property value that was created, like '10px'.
*
* @private
*/
function getCSSPositionValue(position) {
return position ? `${position}px` : '';
}
/**
* The component for displaying text track cues.
*
* @extends Component
*/
class TextTrackDisplay extends Component {
/**
* Creates an instance of this class.
*
* @param {Player} player
* The `Player` that this class should be attached to.
*
* @param {Object} [options]
* The key/value store of player options.
*
* @param {Function} [ready]
* The function to call when `TextTrackDisplay` is ready.
*/
constructor(player, options, ready) {
super(player, options, ready);
const updateDisplayTextHandler = (e) => this.updateDisplay(e);
const updateDisplayHandler = (e) => {
this.updateDisplayOverlay();
this.updateDisplay(e);
};
player.on('loadstart', (e) => this.toggleDisplay(e));
player.on('texttrackchange', updateDisplayTextHandler);
player.on('loadedmetadata', (e) => {
this.updateDisplayOverlay();
this.preselectTrack(e);
});
// This used to be called during player init, but was causing an error
// if a track should show by default and the display hadn't loaded yet.
// Should probably be moved to an external track loader when we support
// tracks that don't need a display.
player.ready(Fn.bind_(this, function() {
if (player.tech_ && player.tech_.featuresNativeTextTracks) {
this.hide();
return;
}
player.on('fullscreenchange', updateDisplayHandler);
player.on('playerresize', updateDisplayHandler);
const screenOrientation = window.screen.orientation || window;
const changeOrientationEvent = window.screen.orientation ? 'change' : 'orientationchange';
screenOrientation.addEventListener(changeOrientationEvent, updateDisplayHandler);
player.on('dispose', () => screenOrientation.removeEventListener(changeOrientationEvent, updateDisplayHandler));
const tracks = this.options_.playerOptions.tracks || [];
for (let i = 0; i < tracks.length; i++) {
this.player_.addRemoteTextTrack(tracks[i], true);
}
this.preselectTrack();
}));
}
/**
* Preselect a track following this precedence:
* - matches the previously selected {@link TextTrack}'s language and kind
* - matches the previously selected {@link TextTrack}'s language only
* - is the first default captions track
* - is the first default descriptions track
*
* @listens Player#loadstart
*/
preselectTrack() {
const modes = {captions: 1, subtitles: 1};
const trackList = this.player_.textTracks();
const userPref = this.player_.cache_.selectedLanguage;
let firstDesc;
let firstCaptions;
let preferredTrack;
for (let i = 0; i < trackList.length; i++) {
const track = trackList[i];
if (
userPref && userPref.enabled &&
userPref.language && userPref.language === track.language &&
track.kind in modes
) {
// Always choose the track that matches both language and kind
if (track.kind === userPref.kind) {
preferredTrack = track;
// or choose the first track that matches language
} else if (!preferredTrack) {
preferredTrack = track;
}
// clear everything if offTextTrackMenuItem was clicked
} else if (userPref && !userPref.enabled) {
preferredTrack = null;
firstDesc = null;
firstCaptions = null;
} else if (track.default) {
if (track.kind === 'descriptions' && !firstDesc) {
firstDesc = track;
} else if (track.kind in modes && !firstCaptions) {
firstCaptions = track;
}
}
}
// The preferredTrack matches the user preference and takes
// precedence over all the other tracks.
// So, display the preferredTrack before the first default track
// and the subtitles/captions track before the descriptions track
if (preferredTrack) {
preferredTrack.mode = 'showing';
} else if (firstCaptions) {
firstCaptions.mode = 'showing';
} else if (firstDesc) {
firstDesc.mode = 'showing';
}
}
/**
* Turn display of {@link TextTrack}'s from the current state into the other state.
* There are only two states:
* - 'shown'
* - 'hidden'
*
* @listens Player#loadstart
*/
toggleDisplay() {
if (this.player_.tech_ && this.player_.tech_.featuresNativeTextTracks) {
this.hide();
} else {
this.show();
}
}
/**
* Create the {@link Component}'s DOM element.
*
* @return {Element}
* The element that was created.
*/
createEl() {
return super.createEl('div', {
className: 'vjs-text-track-display'
}, {
'translate': 'yes',
'aria-live': 'off',
'aria-atomic': 'true'
});
}
/**
* Clear all displayed {@link TextTrack}s.
*/
clearDisplay() {
if (typeof window.WebVTT === 'function') {
window.WebVTT.processCues(window, [], this.el_);
}
}
/**
* Update the displayed TextTrack when a either a {@link Player#texttrackchange} or
* a {@link Player#fullscreenchange} is fired.
*
* @listens Player#texttrackchange
* @listens Player#fullscreenchange
*/
updateDisplay() {
const tracks = this.player_.textTracks();
const allowMultipleShowingTracks = this.options_.allowMultipleShowingTracks;
this.clearDisplay();
if (allowMultipleShowingTracks) {
const showingTracks = [];
for (let i = 0; i < tracks.length; ++i) {
const track = tracks[i];
if (track.mode !== 'showing') {
continue;
}
showingTracks.push(track);
}
this.updateForTrack(showingTracks);
return;
}
// Track display prioritization model: if multiple tracks are 'showing',
// display the first 'subtitles' or 'captions' track which is 'showing',
// otherwise display the first 'descriptions' track which is 'showing'
let descriptionsTrack = null;
let captionsSubtitlesTrack = null;
let i = tracks.length;
while (i--) {
const track = tracks[i];
if (track.mode === 'showing') {
if (track.kind === 'descriptions') {
descriptionsTrack = track;
} else {
captionsSubtitlesTrack = track;
}
}
}
if (captionsSubtitlesTrack) {
if (this.getAttribute('aria-live') !== 'off') {
this.setAttribute('aria-live', 'off');
}
this.updateForTrack(captionsSubtitlesTrack);
} else if (descriptionsTrack) {
if (this.getAttribute('aria-live') !== 'assertive') {
this.setAttribute('aria-live', 'assertive');
}
this.updateForTrack(descriptionsTrack);
}
}
/**
* Updates the displayed TextTrack to be sure it overlays the video when a either
* a {@link Player#texttrackchange} or a {@link Player#fullscreenchange} is fired.
*/
updateDisplayOverlay() {
// inset-inline and inset-block are not supprted on old chrome, but these are
// only likely to be used on TV devices
if (!this.player_.videoHeight() || !window.CSS.supports('inset-inline: 10px')) {
return;
}
const playerWidth = this.player_.currentWidth();
const playerHeight = this.player_.currentHeight();
const playerAspectRatio = playerWidth / playerHeight;
const videoAspectRatio = this.player_.videoWidth() / this.player_.videoHeight();
let insetInlineMatch = 0;
let insetBlockMatch = 0;
if (Math.abs(playerAspectRatio - videoAspectRatio) > 0.1) {
if (playerAspectRatio > videoAspectRatio) {
insetInlineMatch = Math.round((playerWidth - playerHeight * videoAspectRatio) / 2);
} else {
insetBlockMatch = Math.round((playerHeight - playerWidth / videoAspectRatio) / 2);
}
}
tryUpdateStyle(this.el_, 'insetInline', getCSSPositionValue(insetInlineMatch));
tryUpdateStyle(this.el_, 'insetBlock', getCSSPositionValue(insetBlockMatch));
}
/**
* Style {@Link TextTrack} activeCues according to {@Link TextTrackSettings}.
*
* @param {TextTrack} track
* Text track object containing active cues to style.
*/
updateDisplayState(track) {
const overrides = this.player_.textTrackSettings.getValues();
const cues = track.activeCues;
let i = cues.length;
while (i--) {
const cue = cues[i];
if (!cue) {
continue;
}
const cueDiv = cue.displayState;
if (overrides.color) {
cueDiv.firstChild.style.color = overrides.color;
}
if (overrides.textOpacity) {
tryUpdateStyle(
cueDiv.firstChild,
'color',
constructColor(
overrides.color || '#fff',
overrides.textOpacity
)
);
}
if (overrides.backgroundColor) {
cueDiv.firstChild.style.backgroundColor = overrides.backgroundColor;
}
if (overrides.backgroundOpacity) {
tryUpdateStyle(
cueDiv.firstChild,
'backgroundColor',
constructColor(
overrides.backgroundColor || '#000',
overrides.backgroundOpacity
)
);
}
if (overrides.windowColor) {
if (overrides.windowOpacity) {
tryUpdateStyle(
cueDiv,
'backgroundColor',
constructColor(overrides.windowColor, overrides.windowOpacity)
);
} else {
cueDiv.style.backgroundColor = overrides.windowColor;
}
}
if (overrides.edgeStyle) {
if (overrides.edgeStyle === 'dropshadow') {
cueDiv.firstChild.style.textShadow = `2px 2px 3px ${darkGray}, 2px 2px 4px ${darkGray}, 2px 2px 5px ${darkGray}`;
} else if (overrides.edgeStyle === 'raised') {
cueDiv.firstChild.style.textShadow = `1px 1px ${darkGray}, 2px 2px ${darkGray}, 3px 3px ${darkGray}`;
} else if (overrides.edgeStyle === 'depressed') {
cueDiv.firstChild.style.textShadow = `1px 1px ${lightGray}, 0 1px ${lightGray}, -1px -1px ${darkGray}, 0 -1px ${darkGray}`;
} else if (overrides.edgeStyle === 'uniform') {
cueDiv.firstChild.style.textShadow = `0 0 4px ${darkGray}, 0 0 4px ${darkGray}, 0 0 4px ${darkGray}, 0 0 4px ${darkGray}`;
}
}
if (overrides.fontPercent && overrides.fontPercent !== 1) {
const fontSize = window.parseFloat(cueDiv.style.fontSize);
cueDiv.style.fontSize = (fontSize * overrides.fontPercent) + 'px';
cueDiv.style.height = 'auto';
cueDiv.style.top = 'auto';
}
if (overrides.fontFamily && overrides.fontFamily !== 'default') {
if (overrides.fontFamily === 'small-caps') {
cueDiv.firstChild.style.fontVariant = 'small-caps';
} else {
cueDiv.firstChild.style.fontFamily = fontMap[overrides.fontFamily];
}
}
}
}
/**
* Add an {@link TextTrack} to to the {@link Tech}s {@link TextTrackList}.
*
* @param {TextTrack|TextTrack[]} tracks
* Text track object or text track array to be added to the list.
*/
updateForTrack(tracks) {
if (!Array.isArray(tracks)) {
tracks = [tracks];
}
if (typeof window.WebVTT !== 'function' ||
tracks.every((track)=> {
return !track.activeCues;
})) {
return;
}
const cues = [];
// push all active track cues
for (let i = 0; i < tracks.length; ++i) {
const track = tracks[i];
for (let j = 0; j < track.activeCues.length; ++j) {
cues.push(track.activeCues[j]);
}
}
// removes all cues before it processes new ones
window.WebVTT.processCues(window, cues, this.el_);
// add unique class to each language text track & add settings styling if necessary
for (let i = 0; i < tracks.length; ++i) {
const track = tracks[i];
for (let j = 0; j < track.activeCues.length; ++j) {
const cueEl = track.activeCues[j].displayState;
Dom.addClass(cueEl, 'vjs-text-track-cue', 'vjs-text-track-cue-' + ((track.language) ? track.language : i));
if (track.language) {
Dom.setAttribute(cueEl, 'lang', track.language);
}
}
if (this.player_.textTrackSettings) {
this.updateDisplayState(track);
}
}
}
}
Component.registerComponent('TextTrackDisplay', TextTrackDisplay);
export default TextTrackDisplay;