diff options
Diffstat (limited to 'javascript/videojs/src/js/tracks/text-track-display.js')
| -rw-r--r-- | javascript/videojs/src/js/tracks/text-track-display.js | 534 |
1 files changed, 534 insertions, 0 deletions
diff --git a/javascript/videojs/src/js/tracks/text-track-display.js b/javascript/videojs/src/js/tracks/text-track-display.js new file mode 100644 index 0000000..a0dc527 --- /dev/null +++ b/javascript/videojs/src/js/tracks/text-track-display.js @@ -0,0 +1,534 @@ +/** + * @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 * as browser from '../utils/browser'; + +/** @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('useractive', updateDisplayTextHandler); + player.on('userinactive', updateDisplayTextHandler); + 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 {@link TextTrack} when either a {@link Player#texttrackchange}, + * a {@link Player#fullscreenchange}, a {@link Player#useractive}, or a + * {@link Player#userinactive} is fired. + * + * @listens Player#texttrackchange + * @listens Player#fullscreenchange + * @listens Player#useractive + * @listens Player#userinactive + */ + 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); + } + + if (!(window.CSS !== undefined && window.CSS.supports('inset', '10px'))) { + const textTrackDisplay = this.el_; + const vjsTextTrackCues = textTrackDisplay.querySelectorAll('.vjs-text-track-cue'); + const controlBarHeight = this.player_.controlBar.el_.getBoundingClientRect().height; + const playerHeight = this.player_.el_.getBoundingClientRect().height; + + // Clear inline style before getting actual height of textTrackDisplay + textTrackDisplay.style = ''; + + // textrack style updates, this styles are required to be inline + tryUpdateStyle(textTrackDisplay, 'position', 'relative'); + tryUpdateStyle(textTrackDisplay, 'height', (playerHeight - controlBarHeight) + 'px'); + tryUpdateStyle(textTrackDisplay, 'top', 'unset'); + + if (browser.IS_SMART_TV) { + tryUpdateStyle(textTrackDisplay, 'bottom', playerHeight + 'px'); + } else { + tryUpdateStyle(textTrackDisplay, 'bottom', '0px'); + } + + // vjsTextTrackCue style updates + if (vjsTextTrackCues.length > 0) { + vjsTextTrackCues.forEach((vjsTextTrackCue) => { + // verify if inset styles are inline + if (vjsTextTrackCue.style.inset) { + const insetStyles = vjsTextTrackCue.style.inset.split(' '); + + // expected value is always 3 + if (insetStyles.length === 3) { + Object.assign(vjsTextTrackCue.style, { top: insetStyles[0], right: insetStyles[1], bottom: insetStyles[2], left: 'unset' }); + } + } + }); + } + } + } + + /** + * 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 !== undefined && 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; |
