diff --git a/.babelrc b/.babelrc index 103072c3..2dcd89b5 100644 --- a/.babelrc +++ b/.babelrc @@ -1,3 +1,12 @@ { - "presets": [ "@babel/preset-env" ] -} \ No newline at end of file + "presets": [ + [ + "@babel/preset-env", { + "targets": { + "ie": 11 + }, + "useBuiltIns": "usage" + } + ] + ] +} diff --git a/package-lock.json b/package-lock.json index 6cfb9ba2..4685c85e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -608,6 +608,16 @@ "regexpu-core": "4.2.0" } }, + "@babel/polyfill": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@babel/polyfill/-/polyfill-7.0.0.tgz", + "integrity": "sha512-dnrMRkyyr74CRelJwvgnnSUDh2ge2NCTyHVwpOdvRMHtJUyxLtMAfhBN3s64pY41zdw0kgiLPh6S20eb1NcX6Q==", + "dev": true, + "requires": { + "core-js": "2.5.7", + "regenerator-runtime": "0.11.1" + } + }, "@babel/preset-env": { "version": "7.0.0-beta.56", "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.0.0-beta.56.tgz", diff --git a/package.json b/package.json index 39b4604c..b835c856 100644 --- a/package.json +++ b/package.json @@ -13,11 +13,12 @@ "test": "jest" }, "devDependencies": { - "browserify": "16.2.2", - "babelify": "^9.0.0", "@babel/core": "^7.0.0-beta.51", + "@babel/polyfill": "^7.0.0", "@babel/preset-env": "^7.0.0-beta.51", "babel-core": "^7.0.0-bridge.0", + "babelify": "^9.0.0", + "browserify": "16.2.2", "expect.js": "0.3.1", "file-lister": "^1.1.0", "gulp": "3.9.1", @@ -30,11 +31,11 @@ "jest-cli": "23.4.2", "jquery": "3.3.1", "jsdoc": "^3.3.3", + "sinon": "^6.0.0", "uglify": "^0.1.5", "underscore": "1.9.1", "vinyl-buffer": "^1.0.0", - "vinyl-source-stream": "^2.0.0", - "sinon": "^6.0.0" + "vinyl-source-stream": "^2.0.0" }, "repository": { "type": "git", diff --git a/src/main/js/constants/constants.js b/src/main/js/constants/constants.js new file mode 100644 index 00000000..b0cd0542 --- /dev/null +++ b/src/main/js/constants/constants.js @@ -0,0 +1,21 @@ + +const CONSTANTS = { + + ID_PREFIX: { + INTERNAL: 'CC', + EXTERNAL: 'VTT', + }, + + TEXT_TRACK: { + KIND: { + SUBTITLES: 'subtitles', + CAPTIONS: 'captions', + DESCRIPTIONS: 'descriptions', + CHAPTERS: 'chapters', + METADATA: 'metadata', + } + }, + +}; + +export default CONSTANTS; diff --git a/src/main/js/main_html5.js b/src/main/js/main_html5.js index 60e3c2b9..4db25f69 100644 --- a/src/main/js/main_html5.js +++ b/src/main/js/main_html5.js @@ -10,6 +10,10 @@ require("../../../html5-common/js/utils/constants.js"); require("../../../html5-common/js/utils/utils.js"); require("../../../html5-common/js/utils/environment.js"); +import TextTrackMap from "./text_track/text_track_map"; +import TextTrackHelper from "./text_track/text_track_helper"; +import CONSTANTS from "./constants/constants"; + (function(_, $) { var pluginName = "ooyalaHtml5VideoTech"; var currentInstances = {}; @@ -205,7 +209,6 @@ require("../../../html5-common/js/utils/environment.js"); var currentTimeShift = 0; var currentVolumeSet = 0; var isM3u8 = false; - var TRACK_CLASS = "track_cc"; var firstPlay = true; var videoDimension = {height: 0, width: 0}; var initialTime = { value: 0, reached: true }; @@ -213,22 +216,14 @@ require("../../../html5-common/js/utils/environment.js"); var isPriming = false; var isLive = false; var lastCueText = null; - var availableClosedCaptions = {}; - var textTrackModes = {}; var originalPreloadValue = $(_video).attr("preload") || "none"; var currentPlaybackSpeed = 1.0; - /** - * Keeps track of the ids of all of the text tracks that were added by - * the plugin (as opposed to in-manifest/in-stream tracks) for the current stream. - * @type {Array} - */ - var externalTextTrackIds = []; - /** - * Keeps track of the number of in-manifest/in-stream tracks that have been - * detected for the current stream. - * @type {Number} - */ - var streamTextTrackCount = 0; + + let currentCCKey = ''; + let setClosedCaptionsQueue = []; + let externalCaptionsLanguages = {}; + const textTrackMap = new TextTrackMap(); + const textTrackHelper = new TextTrackHelper(_video); // Watch for underflow on Chrome var underflowWatcherTimer = null; @@ -381,18 +376,15 @@ require("../../../html5-common/js/utils/environment.js"); isPriming = false; stopUnderflowWatcher(); lastCueText = null; - textTrackModes = {}; + currentCCKey = ''; + setClosedCaptionsQueue = []; + externalCaptionsLanguages = {}; + textTrackHelper.removeExternalTracks(textTrackMap); + textTrackMap.clear(); // Restore the preload attribute to the value it had when the video // element was created $(_video).attr("preload", originalPreloadValue); - // [PLAYER-212] - // Closed captions persist across discovery videos unless they are cleared - // when a new video is set - $(_video).find('.' + TRACK_CLASS).remove(); - availableClosedCaptions = {}; ignoreFirstPlayingEvent = false; - externalTextTrackIds = []; - streamTextTrackCount = 0; }, this); /** @@ -467,7 +459,7 @@ require("../../../html5-common/js/utils/environment.js"); var queuedSeekRequired = OO.isSafari && videoEnded && time === 0; initialTime.value = time; initialTime.reached = false; - + // [PBW-3866] Some Android devices (mostly Nexus) cannot be seeked too early or the seeked event is // never raised, even if the seekable property returns an endtime greater than the seek time. // To avoid this, save seeking information for use later. @@ -759,150 +751,104 @@ require("../../../html5-common/js/utils/environment.js"); }; /** - * Sets the closed captions on the video element. + * Creates text tracks for any external VTT sources provided and sets the + * mode of the track that matches the specified language to the specified mode. + * In a general sense this method is used for enabling the captions of a + * particular language. * @public * @method OoyalaVideoWrapper#setClosedCaptions - * @param {string} language The language of the closed captions. If null, the current closed captions will be removed. - * @param {object} closedCaptions The closedCaptions object - * @param {object} params The params to set with closed captions - */ - this.setClosedCaptions = _.bind(function(language, closedCaptions, params) { - var iosVersion = OO.iosMajorVersion; - var macOsSafariVersion = OO.macOsSafariVersion; - var useOldLogic = (iosVersion && iosVersion < 10) || (macOsSafariVersion && macOsSafariVersion < 10); - if (useOldLogic) { // XXX HACK! PLAYER-54 iOS and OSX Safari versions < 10 require re-creation of textTracks every time this function is called - $(_video).find('.' + TRACK_CLASS).remove(); - textTrackModes = {}; - if (language == null) { - return; - } - } else { - if (language == null) { - $(_video).find('.' + TRACK_CLASS).remove(); - textTrackModes = {}; - return; - } - // Remove captions before setting new ones if they are different, otherwise we may see native closed captions - if (closedCaptions) { - $(_video).children('.' + TRACK_CLASS).each(function() { - if ($(this).label != closedCaptions.locale[language] || - $(this).srclang != language || - $(this).kind != "subtitles") { - $(this).remove(); - } - }); + * @param {String} language The key of the text track that we want to enable/change. + * Usually a language code, but can also be the track id in the case of in-manifest + * or in-stream text tracks. + * @param {Object} closedCaptions An object containing a list of external VTT captions + * that the player should display to the end user. + * @param {Object} params An object containing additional parameters: + * - mode: (String) The mode to set on the track that matches the language parameter + */ + this.setClosedCaptions = _.bind(function(language, closedCaptions = {}, params = {}) { + OO.log("MainHtml5: setClosedCaptions called", language, closedCaptions, params); + const vttClosedCaptions = closedCaptions.closed_captions_vtt || {}; + const externalCaptionsProvided = !!Object.keys(vttClosedCaptions).length; + // Most browsers will require crossorigin=anonymous in order to be able to + // load VTT files from a different domain. This needs to happen before any + // tracks are added and, on Firefox, it also needs to be as early as possible + // (hence why don't queue this part of the operation). Note that we only do this + // if we're actually adding external tracks + if (externalCaptionsProvided) { + this.setCrossorigin('anonymous'); + + for (let language in vttClosedCaptions) { + externalCaptionsLanguages[language] = true; } } - - //Add the new closed captions if they are valid. - var captionsFormat = "closed_captions_vtt"; - if (closedCaptions && closedCaptions[captionsFormat]) { - _.each(closedCaptions[captionsFormat], function(captions, languageKey) { - var captionInfo = { - label: captions.name, - src: captions.url, - language: languageKey, - inStream: false - }; - addClosedCaptions(captionInfo); - }); + // Browsers tend to glitch when text tracks are added before metadata is + // loaded and in some cases fail to trigger the first cue if a track is + // added before canplay event is fired + if (canPlay) { + dequeueSetClosedCaptions(); + executeSetClosedCaptions.apply(this, arguments); + } else { + OO.log('MainHtml5: setClosedCaptions called before load, queing operation.'); + setClosedCaptionsQueue.push(arguments); } + }, this); - var trackId = OO.getRandomString(); - var captionMode = (params && params.mode) || OO.CONSTANTS.CLOSED_CAPTIONS.SHOWING; - //Set the closed captions based on the language and our available closed captions - if (availableClosedCaptions[language]) { - var captions = availableClosedCaptions[language]; - //If the captions are in-stream, we just need to enable them; Otherwise we must add them to the video ourselves. - if (captions.inStream == true && _video.textTracks) { - for (var i = 0; i < _video.textTracks.length; i++) { - if ( - (((OO.isSafari || OO.isEdge) && isLive) || _video.textTracks[i].kind === "captions") && - // Enable only the track that matches the active language. For - // in-manifest/in-stream tracks the language will actually be the - // track id (something like CC1, CC2, etc.) - _video.textTracks[i].trackId === language - ) { - _video.textTracks[i].mode = captionMode; - _video.textTracks[i].oncuechange = onClosedCaptionCueChange; - } else { - _video.textTracks[i].mode = OO.CONSTANTS.CLOSED_CAPTIONS.DISABLED; - _video.textTracks[i].oncuechange = null; - } - // [PLAYER-327], [PLAYER-73] - // We keep track of all text track modes in order to prevent Safari from randomly - // changing them. We can't set the id of inStream tracks, so we use a custom - // trackId property instead - trackId = trySetStreamTextTrackId(_video.textTracks[i]); - textTrackModes[trackId] = _video.textTracks[i].mode; - } - } else if (!captions.inStream) { - this.setClosedCaptionsMode(OO.CONSTANTS.CLOSED_CAPTIONS.DISABLED); - if (useOldLogic) { // XXX HACK! PLAYER-54 create video element unconditionally as it was removed - $(_video).append(""); - if (_video.textTracks && _video.textTracks[0]) { - _video.textTracks[0].mode = captionMode; - //We only want to let the controller know of cue change if we aren't rendering cc from the plugin. - if (captionMode == OO.CONSTANTS.CLOSED_CAPTIONS.HIDDEN) { - _video.textTracks[0].oncuechange = onClosedCaptionCueChange; - } - } - } else { - if ($(_video).children('.' + TRACK_CLASS).length == 0) { - $(_video).append(""); - } - if (_video.textTracks && _video.textTracks.length > 0) { - for (var i = 0; i < _video.textTracks.length; i++) { - _video.textTracks[i].mode = captionMode; - //We only want to let the controller know of cue change if we aren't rendering cc from the plugin. - if (captionMode == OO.CONSTANTS.CLOSED_CAPTIONS.HIDDEN) { - _video.textTracks[i].oncuechange = onClosedCaptionCueChange; - } - } - } - } - // [PLAYER-327], [PLAYER-73] - // Store mode of newly added tracks for future use in workaround - textTrackModes[trackId] = captionMode; - // Keep track of the fact that this track was added manually and is - // not an in-manifest/in-stream track - externalTextTrackIds.push(trackId); - //Sometimes there is a delay before the textTracks are accessible. This is a workaround. - _.delay(function(captionMode) { - if (_video.textTracks && _video.textTracks[0]) { - _video.textTracks[0].mode = captionMode; - if (OO.isFirefox) { - for (var i=0; i < _video.textTracks[0].cues.length; i++) { - _video.textTracks[0].cues[i].line = 15; - } - } - } - }, 100, captionMode); - } + /** + * The actual logic of setClosedCaptions() above. This is separated in order to + * allow us to queue any calls to setClosedCaptions() that happen before metadata + * is loaded. + * @private + * @method OoyalaVideoWrapper#executeSetClosedCaptions + * @param {String} language The key of the text track that we want to enable/change. + * Usually a language code, but can also be the track id in the case of in-manifest + * or in-stream text tracks. + * @param {Object} closedCaptions An object containing a list of external VTT captions + * that the player should display to the end user. + * @param {Object} params An object containing additional parameters: + * - mode: (String) The mode to set on the track that matches the language parameter + */ + const executeSetClosedCaptions = (language, closedCaptions = {}, params = {}) => { + const vttClosedCaptions = closedCaptions.closed_captions_vtt || {}; + const targetMode = params.mode || OO.CONSTANTS.CLOSED_CAPTIONS.SHOWING; + const targetTrack = textTrackHelper.findTrackByKey(language, textTrackMap); + // Clear current CC cue if track is about to change + if (currentCCKey !== language) { + raiseClosedCaptionCueChanged(''); + } + currentCCKey = language; + // Start by disabling all tracks, except for the one whose mode we want to set + disableTextTracksExcept(targetTrack); + // Create tracks for all VTT captions from content tree that we haven't + // added before. If the track with the specified language is added, it + // will be created with the desired mode automatically + const wasTargetTrackAdded = addExternalVttCaptions( + vttClosedCaptions, + language, + targetMode + ); + // If the desired track is not one of the newly added tracks then we set + // the target mode on the pre-existing track that matches the target language + if (!wasTargetTrackAdded) { + setTextTrackMode(targetTrack, targetMode); } - }, this); + }; /** - * Sets the closed captions mode on the video element. + * Sets the given text track mode for ALL existing tracks. * @public * @method OoyalaVideoWrapper#setClosedCaptionsMode - * @param {string} mode The mode to set the text tracks element. + * @param {string} mode The mode to set on the text tracks. * One of (OO.CONSTANTS.CLOSED_CAPTIONS.DISABLED, OO.CONSTANTS.CLOSED_CAPTIONS.HIDDEN, OO.CONSTANTS.CLOSED_CAPTIONS.SHOWING). */ - this.setClosedCaptionsMode = _.bind(function(mode) { - if (_video.textTracks) { - for (var i = 0; i < _video.textTracks.length; i++) { - _video.textTracks[i].mode = mode; - // [PLAYER-327], [PLAYER-73] - // Store newly set track mode for future use in workaround - var trackId = _video.textTracks[i].id || _video.textTracks[i].trackId; - textTrackModes[trackId] = mode; - } - } - if (mode == OO.CONSTANTS.CLOSED_CAPTIONS.DISABLED) { - raiseClosedCaptionCueChanged(""); + this.setClosedCaptionsMode = (mode) => { + textTrackHelper.forEach(textTrack => + setTextTrackMode(textTrack, mode) + ); + + if (mode === OO.CONSTANTS.CLOSED_CAPTIONS.DISABLED) { + raiseClosedCaptionCueChanged(''); } - }, this); + }; /** * Sets the crossorigin attribute on the video element. @@ -1046,10 +992,8 @@ require("../../../html5-common/js/utils/environment.js"); * @method OoyalaVideoWrapper#onLoadedMetadata */ var onLoadedMetadata = _.bind(function() { - // [PLAYER-327], [PLAYER-73] - // We need to monitor track change in Safari in order to prevent - // it from overriding our settings - if (OO.isSafari && _video && _video.textTracks) { + if (_video.textTracks) { + _video.textTracks.onaddtrack = onTextTracksAddTrack; _video.textTracks.onchange = onTextTracksChange; } @@ -1093,30 +1037,77 @@ require("../../../html5-common/js/utils/environment.js"); }, this); /** - * Fired when there is a change on a text track. + * Fired by the browser when new text tracks are added. + * @method OoyalaVideoWrapper#onTextTracksAddTrack * @private - * @method OoyalaVideoWrapper#onTextTracksChange - * @param {object} event The event from the track change */ - var onTextTracksChange = _.bind(function(event) { - for (var i = 0; i < _video.textTracks.length; i++) { - var trackId = _video.textTracks[i].id || _video.textTracks[i].trackId; + const onTextTracksAddTrack = () => { + // Update our internal map of available text tracks + tryMapTextTracks(); + // Notify core about closed captions available after the change + checkForAvailableClosedCaptions(); + }; - if (typeof textTrackModes[trackId] === 'undefined') { + /** + * Fired by the browser when there is a change on a text track. We use this + * handler in order to compare text track modes against our own records in + * order to determine whether changes have been made by the native UI (mostly + * for iOS fullscreen mode). + * @private + * @method OoyalaVideoWrapper#onTextTracksChange + */ + const onTextTracksChange = () => { + let newLanguage; + const changedTracks = textTrackHelper.filterChangedTracks(textTrackMap); + // Changed tracks are any whose mode is different from the one we last + // recorded on our text track map (i.e. the ones changed by the native UI) + for (let changedTrack of changedTracks) { + const trackMetadata = textTrackMap.findEntry({ + textTrack: changedTrack + }); + // We assume that any changes that occur prior to playback are browser + // defaults since the native UI couldn't have been displayed yet + if (!canPlay) { + OO.log('MainHtml5: Native CC changes detected before playback, ignoring.'); + changedTrack.mode = trackMetadata.mode; continue; } - // [PLAYER-327], [PLAYER-73] - // Safari (desktop and iOS) sometimes randomly switches a track's mode. As a - // workaround, we force our own value if we detect that we have switched - // to a mode that we didn't set ourselves - if (_video.textTracks[i].mode !== textTrackModes[trackId]) { - OO.log("main_html5: Forcing text track mode for track " + trackId + ". Expected: '" - + textTrackModes[trackId] + "', received: '" + _video.textTracks[i].mode + "'"); - - _video.textTracks[i].mode = textTrackModes[trackId]; + // Changed tracks will come in pairs (one disabled, one enabled), except when + // captions are turned off, in which case there should be a single disabled track + if (changedTrack.mode === OO.CONSTANTS.CLOSED_CAPTIONS.DISABLED) { + // This will be none when all changed tracks are disabled + newLanguage = newLanguage || 'none'; + // A single enabled track (without a corresponding disabled track) indicates + // that the browser is forcing its default language. The exception to this is + // when all tracks were previously disabled, which means that captions were + // enabled by the user via the native UI + } else if (!textTrackMap.areAllDisabled() && changedTracks.length === 1) { + OO.log('MainHtml5: Default browser CC language detected, ignoring in favor of plugin default'); + } else { + const useLanguageAsKey = !!( + trackMetadata.isExternal || + externalCaptionsLanguages[trackMetadata.language] + ); + // We give priority to external VTT captions but Safari might pick an + // in-stream/in-manifest track when a CC language is chosen using the + // native UI. We make sure to enable the equivalent external track + // whenever both internal and external tracks exist for the same language + newLanguage = useLanguageAsKey ? trackMetadata.language : trackMetadata.id; } + // Whether we're ignoring or propagating the changes we revert the track to + // it's last known mode. If there's a need for a language change it will + // happen as a result of the notification below + changedTrack.mode = trackMetadata.mode; + } + // Native text track change detected, update our own UI + if (newLanguage) { + this.controller.notify( + this.controller.EVENTS.CAPTIONS_LANGUAGE_CHANGE, + { language: newLanguage } + ); + OO.log(`MainHtml5: CC track has been changed to "${newLanguage}" by the native UI`); } - }, this); + }; /** * Callback for when a closed caption track cue has changed. @@ -1136,131 +1127,6 @@ require("../../../html5-common/js/utils/environment.js"); raiseClosedCaptionCueChanged(cueText); }, this); - /** - * Workaround for Firefox only. - * Check for active closed caption cues and relay them to the controller. - * @private - * @method OoyalaVideoWrapper#checkForClosedCaptionsCueChange - */ - var checkForClosedCaptionsCueChange = _.bind(function() { - var cueText = ""; - if (_video.textTracks) { - for (var i = 0; i < _video.textTracks.length; i++) { - if (_video.textTracks[i].activeCues) { - for (var j = 0; j < _video.textTracks[i].activeCues.length; j++) { - if (_video.textTracks[i].activeCues[j].text) { - cueText += _video.textTracks[i].activeCues[j].text + "\n"; - } - } - break; - } - } - } - raiseClosedCaptionCueChanged(cueText); - }, this); - - /** - * Check for in-stream and in manifest closed captions. - * @private - * @method OoyalaVideoWrapper#checkForClosedCaptions - */ - var checkForClosedCaptions = _.bind(function() { - if (!_video.textTracks || !_video.textTracks.length) { - return; - } - var expectingStreamTextTracks = (OO.isSafari || OO.isEdge) && isLive; - - Array.prototype.forEach.call(_video.textTracks, function(currentTrack) { - if ( - (expectingStreamTextTracks || currentTrack.kind === 'captions') && - // Manually added tracks have already been (or will be) added and notified - // when setClosedCaptions() is called, avoid mixing them up with in-manifest/in-stream tracks - !isExternalTextTrackId(currentTrack.id || currentTrack.trackId) - ) { - // For in-manifest/in-stream captions we use the id of the track (e.g. - // CC1, CC2, etc.) as a language in order to avoid conflicts with - // external captions. - var trackId = trySetStreamTextTrackId(currentTrack); - var label = currentTrack.label || currentTrack.language || 'Captions (' + trackId + ')'; - var captionInfo = { - language: trackId, - inStream: true, - label: label - }; - // Don't overwrite other closed captions of this language. They have priority. - if (!availableClosedCaptions[captionInfo.language]) { - addClosedCaptions(captionInfo); - } - } - }); - }, this); - - /** - * Determines whether or not the given track id belongs to an "external" text - * track that was added manually by the plugin (as opposed to an in-manifest - * or in-stream text track). - * @private - * @method OoyalaVideoWrapper#isExternalTextTrackId - * @param {String} trackId The id of the text track we want to check - * @return {Boolean} True if the track id belongs to an external track, false otherwise - */ - var isExternalTextTrackId = _.bind(function(trackId) { - var isExternalId = externalTextTrackIds.indexOf(trackId) >= 0; - return isExternalId; - }); - - /** - * Tries to set a custom id on a in-manifest/in-stream TextTrack object which - * allows us to identify it for selection and workaround purposes. IDs set are - * stored on a custom trackId property and are of the type 'CCn', where n is the - * index of the track relative to the order in which it was found. Existing track - * ids will not be overriten. - * @private - * @method OoyalaVideoWrapper#trySetStreamTextTrackId - * @param {TextTrack} textTrack The TextTrack object whose id we want to set. - * @return {String} The new or pre-existing id of the track, null if textTrack is invalid - */ - var trySetStreamTextTrackId = _.bind(function(textTrack) { - if (!textTrack) { - return null; - } - if (!textTrack.trackId) { - streamTextTrackCount++; - textTrack.trackId = 'CC' + streamTextTrackCount; - } - return textTrack.trackId; - }); - - /** - * Add new closed captions and relay them to the controller. - * @private - * @method OoyalaVideoWrapper#addClosedCaptions - */ - var addClosedCaptions = _.bind(function(captionInfo) { - //Don't add captions if argument is null or we already have added these captions. - if (captionInfo == null || captionInfo.language == null || (availableClosedCaptions[captionInfo.language] && - availableClosedCaptions[captionInfo.language].src == captionInfo.src)) return; - availableClosedCaptions[captionInfo.language] = captionInfo; - raiseCaptionsFoundOnPlaying(); - }, this); - - /** - * Notify the controller with new available closed captions. - * @private - * @method OoyalaVideoWrapper#raiseCaptionsFoundOnPlaying - */ - var raiseCaptionsFoundOnPlaying = _.bind(function() { - var closedCaptionInfo = { - languages: [], - locale: {} - }; - _.each(availableClosedCaptions, function(value, key) { - closedCaptionInfo.languages.push(key); - closedCaptionInfo.locale[key] = value.label; - }); - this.controller.notify(this.controller.EVENTS.CAPTIONS_FOUND_ON_PLAYING, closedCaptionInfo); - }, this); - /** * Notify the controller with new closed caption cue text. * @private @@ -1340,6 +1206,10 @@ require("../../../html5-common/js/utils/environment.js"); //Notify controller of video width and height. if (firstPlay) { + // Dequeue any calls to setClosedCaptions() that occurred before + // the video was loaded + dequeueSetClosedCaptions(); + this.controller.notify(this.controller.EVENTS.ASSET_DIMENSION, {width: _video.videoWidth, height: _video.videoHeight}); var availableAudio = this.getAvailableAudio(); @@ -1395,7 +1265,6 @@ require("../../../html5-common/js/utils/environment.js"); } startUnderflowWatcher(); - checkForClosedCaptions(); ignoreFirstPlayingEvent = false; firstPlay = false; @@ -1527,11 +1396,6 @@ require("../../../html5-common/js/utils/environment.js"); // iOS has issues seeking so if we queue a seek handle it here dequeueSeek(); - //Workaround for Firefox because it doesn't support the oncuechange event on a text track - if (OO.isFirefox) { - checkForClosedCaptionsCueChange(); - } - forceEndOnTimeupdateIfRequired(event); }, this); @@ -1631,6 +1495,273 @@ require("../../../html5-common/js/utils/environment.js"); // Helper methods /************************************************************************************/ + /** + * Sequentially executes all the setClosedCaptions() calls that have + * been queued. The queue is cleared as a result of this operation. + * @private + * @method OoyalaVideoWrapper#dequeueSetClosedCaptions + */ + const dequeueSetClosedCaptions = _.bind(function() { + let queuedArguments; + + while (queuedArguments = setClosedCaptionsQueue.shift()) { + executeSetClosedCaptions.apply(this, queuedArguments); + } + }, this); + + /** + * Sets the mode of all text tracks to 'disabled' except for targetTrack. + * @private + * @method OoyalaVideoWrapper#disableTextTracksExcept + * @param {TextTrack} The text track which we want to exclude from the disable operation. + */ + const disableTextTracksExcept = (targetTrack) => { + // Start by disabling all tracks, except for the one whose mode we want to set + textTrackHelper.forEach(textTrack => { + // Note: Edge will get stuck on 'disabled' mode if you disable a track right + // before setting another mode on it, so we avoid disabling the target track + if (textTrack !== targetTrack) { + setTextTrackMode(textTrack, OO.CONSTANTS.CLOSED_CAPTIONS.DISABLED); + } + }); + }; + + /** + * Creates text tracks for all of the given external VTT captions. If any of + * the newly added tracks matches the targetLanguage then its mode will be set + * to targetMode. Note that the mode can't be set at creation time, so this + * happens when the addtrack event is fired. + * @private + * @method OoyalaVideoWrapper#addExternalVttCaptions + * @param {Object} vttClosedCaptions A metadata object that containing a list of external VTT captions + * that the player should display to the end user. + * @param {String} targetLanguage The language or key of the track that should be set to targetMode + * (usually the language that should be active). + * @param {String} targetMode The mode that should be set on the track that matches targetLanguage. + * @return {Boolean} True if a track that matches targetLanguage was added as a result of this call, false otherwise. + */ + const addExternalVttCaptions = (vttClosedCaptions = {}, targetLanguage, targetMode) => { + let wasTargetTrackAdded = false; + + for (let language in vttClosedCaptions) { + const trackData = Object.assign( + { language: language }, + vttClosedCaptions[language] + ); + const existsTrack = textTrackMap.existsEntry({ + src: trackData.url + }); + // Only add tracks whose source url hasn't been added before + if (!existsTrack) { + addExternalCaptionsTrack(trackData, targetLanguage, targetMode); + + if (language === targetLanguage) { + wasTargetTrackAdded = true; + } + } + } + return wasTargetTrackAdded; + }; + + /** + * Creates a single TextTrack object using the values provided in trackData. + * The new track's mode will be set to targetMode after creation if the track + * matches targetLanguage. Tracks that don't match targetLanguage will have a + * 'disabled' mode by default. + * @private + * @method OoyalaVideoWrapper#addExternalCaptionsTrack + * @param {Object} trackData An object with the following properties: + * - url: {String} The url of a source VTT file + * - name: {String} The label to display for this track + * - language: {String} The language code of the closed captions + * @param {String} targetLanguage The language or key of the track that should be set to targetMode + * (usually the language that should be active). + * @param {String} targetMode The mode that should be set on the track that matches targetLanguage. + */ + const addExternalCaptionsTrack = (trackData = {}, targetLanguage, targetMode) => { + let trackMode; + // Disable new tracks by default unless their language matches the language + // that is meant to be active + if (trackData.language === targetLanguage) { + trackMode = targetMode; + } else { + trackMode = OO.CONSTANTS.CLOSED_CAPTIONS.DISABLED; + } + // Keep a record of all the tracks that we add + const trackId = textTrackMap.addEntry({ + src: trackData.url, + label: trackData.name, + language: trackData.language, + mode: trackMode + }, true); + // Create the actual TextTrack object + textTrackHelper.addTrack({ + id: trackId, + kind: CONSTANTS.TEXT_TRACK.KIND.SUBTITLES, + // IMPORTANT: + // We initially set the label to trackId since it's the only + // cross-browser way to indentify the track after it's created + label: trackId, + srclang: trackData.language, + src: trackData.url + }); + // MS Edge doesn't fire the addtrack event for manually added tracks + if (OO.isEdge) { + onTextTracksAddTrack(); + } + }; + + /** + * Registers unknown text tracks in our text track map and ensures that + * any tracks that we add have the track mode that corresponds to them. + * This method is called when there are text track changes such as when the + * addtrack or removetrack events are fired. + * @private + * @method OoyalaVideoWrapper#tryMapTextTracks + */ + const tryMapTextTracks = () => { + textTrackHelper.forEach(textTrack => { + // Any tracks that have a track id as a label are known to be external + // VTT tracks that we recently added. We rely on the label as the only + // cross-browser way to identify a TextTrack object after its creation + const trackMetadata = textTrackMap.findEntry({ + id: textTrack.label + }); + + if (trackMetadata) { + OO.log("MainHtml5: Registering newly added text track:", trackMetadata.id); + // Store a reference to the track on our track map in order to link + // related metadata + textTrackMap.tryUpdateEntry( + { id: trackMetadata.id }, + { textTrack: textTrack } + ); + // Now that we've linked the TextTrack object to our map, we no longer + // need the label in order to identify the track. We can set the actual + // label on the track at this point + textTrackHelper.updateTrackLabel(trackMetadata.id, trackMetadata.label); + // Tracks are added as 'disabled' by default so we make sure to set + // the mode that we had previously stored for the newly added track. + // Note that track mode can't be set during creation that's why we + // need to wait until the browser reports the track addition. + setTextTrackMode(textTrack, trackMetadata.mode); + } + // Add in-manifest/in-stream tracks to our text track map. All external + // tracks are already known to us, so any unrecognized tracks are assumed + // to be in-manifest/in-stream + mapTextTrackIfUnknown(textTrack); + }); + }; + + /** + * Adds in-manifest/in-stream tracks to our text track map in order to allow + * us to keep track of their state and identify them by ids that we assign to them. + * @private + * @method OoyalaVideoWrapper#mapTextTrackIfUnknown + * @param {TextTrack} textTrack The TextTrack object which we want to try to map. + */ + const mapTextTrackIfUnknown = (textTrack) => { + // Any unkown track is assumed to be an in-manifest/in-stream track since + // we map external tracks when they are added + const isKnownTrack = textTrackMap.existsEntry({ + textTrack: textTrack + }); + // Avoid mapping metadata and other non-subtitle track kinds + const isTextTrack = ( + textTrack.kind === CONSTANTS.TEXT_TRACK.KIND.CAPTIONS || + textTrack.kind === CONSTANTS.TEXT_TRACK.KIND.SUBTITLES + ); + // Add an entry to our text track map in order to be able to keep track of + // the in-manifest/in-stream track's mode + if (!isKnownTrack && isTextTrack) { + OO.log("MainHtml5: Registering internal text track:", textTrack); + + textTrackMap.addEntry({ + label: textTrack.label, + language: textTrack.language, + mode: textTrack.mode, + textTrack: textTrack + }, false); + } + }; + + /** + * Translates the tracks from the text track map into the format that the core + * uses in order to determine available closed captions languages (or tracks). + * Calling this function results in CAPTIONS_FOUND_ON_PLAYING being notified + * with the current state of our text track map. + * @method OoyalaVideoWrapper#checkForAvailableClosedCaptions + * @private + */ + const checkForAvailableClosedCaptions = () => { + const closedCaptionInfo = { + languages: [], + locale: {} + }; + const externalEntries = textTrackMap.getExternalEntries(); + const internalEntries = textTrackMap.getInternalEntries(); + // External tracks will override in-manifest/in-stream captions when languages + // collide, so we add their info first + for (let externalEntry of externalEntries) { + closedCaptionInfo.languages.push(externalEntry.language); + closedCaptionInfo.locale[externalEntry.language] = externalEntry.label; + } + // In-manifest/in-stream captions are reported with an id such as CC1 instead + // of language in order to avoid conflicts with external VTTs + for (let internalEntry of internalEntries) { + // Either the language was already added to the info above or it is one + // of the external captions that will be added after the video loads + const isLanguageDefined = ( + !!closedCaptionInfo.locale[internalEntry.language] || + !!externalCaptionsLanguages[internalEntry.language] + ); + // We do not report an in-manifest/in-stream track when its language is + // already in use by external VTT captions + if (!isLanguageDefined) { + const key = internalEntry.id; + const label = ( + internalEntry.label || + internalEntry.language || + `Captions (${key})` + ); + // For in-manifest/in-stream we use id instead of language in order to + // account for cases in which language metadata is unavailable and also + // to avoid conflicts with external VTT captions + closedCaptionInfo.languages.push(key); + closedCaptionInfo.locale[key] = label; + } + } + this.controller.notify(this.controller.EVENTS.CAPTIONS_FOUND_ON_PLAYING, closedCaptionInfo); + }; + + /** + * Sets the given track mode on the given text track. The new mode is also + * updated in the relevant text track map entry in order for us to be able to + * detect native changes. + * @private + * @method OoyalaVideoWrapper#setTextTrackMode + * @param {TextTrack} textTrack The TextTrack object whose mode we want to set. + * @param {String} mode The mode that we want to set on the text track. + */ + const setTextTrackMode = (textTrack, mode) => { + if (textTrack && textTrack.mode !== mode) { + textTrack.mode = mode; + // Keep track of the latest mode that was set in order to be able to + // detect any changes triggered by the native UI + textTrackMap.tryUpdateEntry( + { textTrack: textTrack }, + { mode: mode } + ); + // Make sure to listen to cue changes on active tracks + if (mode === OO.CONSTANTS.CLOSED_CAPTIONS.DISABLED) { + textTrack.oncuechange = null; + } else { + textTrack.oncuechange = onClosedCaptionCueChange; + } + OO.log('MainHtml5: Text track mode set:', textTrack.language, mode); + } + }; + /** * If any plays are queued up, execute them. * @private diff --git a/src/main/js/text_track/text_track_helper.js b/src/main/js/text_track/text_track_helper.js new file mode 100644 index 00000000..e370b4ec --- /dev/null +++ b/src/main/js/text_track/text_track_helper.js @@ -0,0 +1,167 @@ +import TextTrackMap from "./text_track_map"; + +/** + * Extends the functionality of the TextTrackList object in order to simplify + * adding, searching, updating and removing text tracks. + */ +export default class TextTrackHelper { + + constructor(video) { + this.video = video; + } + + /** + * Creates a new TextTrack object by appending Track element to the video element. + * @public + * @param {Object} trackData An object with the relevant properties to set on the text track object. + */ + addTrack(trackData = {}) { + if (!this.video) { + return; + } + const track = document.createElement('track'); + track.id = trackData.id; + track.kind = trackData.kind; + track.label = trackData.label; + track.srclang = trackData.srclang; + track.src = trackData.src; + this.video.appendChild(track); + } + + /** + * Finds a text track by id and sets its label to the given value. This operation + * is only possible for tracks that were manually added by the plugin. + * @public + * @param {String} trackId The dom id of the text track to update + * @param {String} label The new label to be set on the text track + */ + updateTrackLabel(trackId, label = '') { + if (this.video && trackId) { + const trackElement = this.video.querySelector(`#${trackId}`); + + if (trackElement) { + trackElement.setAttribute('label', label); + } + } + } + + /** + * Allows executing Array.prototype.forEach on the video's TextTrackList. + * @public + * @param {Function} callback A function to execute for existing text track. + */ + forEach(callback) { + if (!this.video || !this.video.textTracks) { + return; + } + Array.prototype.forEach.call(this.video.textTracks, callback); + } + + /** + * Allows executing Array.prototype.filter on the video's TextTrackList. + * @public + * @param {Function} callback A predicate function to test each element of the array. + * @return {Array} An array with all the TextTrack objects that match the filter criteria. + */ + filter(callback) { + if (!this.video || !this.video.textTracks) { + return []; + } + return Array.prototype.filter.call(this.video.textTracks, callback); + } + + /** + * Allows executing Array.prototype.find on the video's TextTrackList. + * @public + * @param {Function} callback A function to execute on each value in the array. + * @return {TextTrack} The first TextTrack object that matches the search criteria or undefined if there are no matches. + */ + find(callback) { + if (!this.video || !this.video.textTracks) { + return; + } + let track = Array.prototype.find.call(this.video.textTracks, callback); + return track; + } + + /** + * Finds the TextTrack object that matches a key, which can be either the language + * code of the track or a track id associated with the track on a TextTrackMap. + * Important: + * It is assumed that internal tracks are matched by id and external tracks are + * matched by language. This function will not return internal tracks that match + * a language key, for example. This limitation is imposed by the fact that the + * core uses language as key for closed captions. Ideally all tracks should be + * matched by id. + * @public + * @param {String} languageOrId The language or track id of the track we want to find. Note that + * language matches only internal tracks and track id only external tracks. + * @param {TextTrackMap} textTrackMap A TextTrackMap that contains metadata for all of the video's TextTrack objects. + * @return {TextTrack} The first TextTrack object that matches the given key or undefined if there are no matches. + */ + findTrackByKey(languageOrId, textTrackMap = new TextTrackMap()) { + let track = this.find(currentTrack => { + const trackMetadata = textTrackMap.findEntry({ + textTrack: currentTrack + }); + // Note: We don't match tracks that are unknown to us + const matchesTrackId = ( + !!trackMetadata && + !trackMetadata.isExternal && // We use track id as key for internal tracks + trackMetadata.id === languageOrId + ); + const matchesLanguage = ( + !!trackMetadata && + trackMetadata.isExternal && // We use language as key for external tracks + currentTrack.language === languageOrId + ); + const keyMatchesTrack = matchesTrackId || matchesLanguage; + + return keyMatchesTrack; + }); + return track; + } + + /** + * Returns a list with all of the TextTrack objects whose track mode is different + * from the value stored in the given TextTrackMap. + * @public + * @param {TextTrackMap} textTrackMap A TextTrackMap that contains metadata for all of the video's TextTrack objects. + * @return {Array} An array with all of the TextTrack objects that match the search + * criteria or an empty array if there are no matches. + */ + filterChangedTracks(textTrackMap = new TextTrackMap()) { + const changedTracks = this.filter(currentTrack => { + const trackMetadata = textTrackMap.findEntry({ + textTrack: currentTrack + }); + const hasTrackChanged = ( + trackMetadata && + currentTrack.mode !== trackMetadata.mode + ); + + return hasTrackChanged; + }); + return changedTracks; + } + + /** + * Finds and removes any TextTracks that marked as external on the given + * TextTrackMap. + * @public + * @param {TextTrackMap} textTrackMap A TextTrackMap that contains metadata for all of the video's TextTrack objects. + */ + removeExternalTracks(textTrackMap = TextTrackMap()) { + if (!this.video) { + return; + } + + for (let trackMetadata of textTrackMap.getExternalEntries()) { + const trackElement = this.video.querySelector(`#${trackMetadata.id}`); + + if (trackElement) { + trackElement.remove(); + } + } + } +} diff --git a/src/main/js/text_track/text_track_map.js b/src/main/js/text_track/text_track_map.js new file mode 100644 index 00000000..6d7ce386 --- /dev/null +++ b/src/main/js/text_track/text_track_map.js @@ -0,0 +1,145 @@ +import CONSTANTS from "../constants/constants"; + +/** + * Allows us to store and associate metadata with a TextTrack object since we + * can't store any data on the object itself. Automatically generates an id for + * registered tracks which can be used identify the object later. + */ +export default class TextTrackMap { + + constructor() { + this.textTracks = []; + } + + /** + * Creates an entry in the TextTrackMap which represents a TextTrack object that + * has been added to or found in the video element. Automatically generates an id + * that can be used to identify the TextTrack later on. + * @public + * @param {Object} metadata An object with metadata related to a TextTrack + * @param {Boolean} isExternal Determines whether or not the TextTrack was added by the plugin (i.e. is external) + * @return {String} The auto-generated id assigned to the newly registered track + */ + addEntry(metadata = {}, isExternal = false) { + let idPrefix, trackCount; + + if (isExternal) { + idPrefix = CONSTANTS.ID_PREFIX.EXTERNAL; + trackCount = this.getExternalEntries().length; + } else { + idPrefix = CONSTANTS.ID_PREFIX.INTERNAL; + trackCount = this.getInternalEntries().length; + } + // Generate new id based on the track count for the given track type + // (i.e. internal vs external) + const newTextTrack = Object.assign({}, metadata, { + id: `${idPrefix}${trackCount + 1}`, + isExternal: !!isExternal + }); + + this.textTracks.push(newTextTrack); + return newTextTrack.id; + } + + /** + * Finds the metadata that matches the given search options. + * @public + * @param {Object} searchOptions An object whose key value pairs will be matched against + * the existing entries. All existing properties in searchOptions need to match in order + * for a given entry to be matched. + * @return {Object} The metadata object that matches the given search options or undefined if there are no matches. + */ + findEntry(searchOptions = {}) { + const textTrack = this.textTracks.find(currentTrack => { + let isFound = true; + + for (let property in searchOptions) { + if ( + searchOptions.hasOwnProperty(property) && + searchOptions[property] !== currentTrack[property] + ) { + isFound = false; + break; + } + } + return isFound; + }); + return textTrack; + }; + + /** + * Determines whether or not there exists an entry that matches the given search options. + * @public + * @param {Object} searchOptions An object whose key value pairs will be matched against + * the existing entries. All existing properties in searchOptions need to match in order + * for a given entry to be matched. + * @return {Boolean} True if the entry exists, false otherwise + */ + existsEntry(searchOptions) { + const exists = !!this.findEntry(searchOptions); + return exists; + } + + /** + * Finds an entry with the given search options and merges the provided metadata + * with the existing object/ + * @public + * @param {Object} searchOptions An object whose key value pairs will be matched against + * the existing entries. All existing properties in searchOptions need to match in order + * for a given entry to be matched. + * @param {Object} metadata An object containing the properties to be merged with the existing object + * @return {Object} The updated metadata entry or undefined if there were no matches + */ + tryUpdateEntry(searchOptions, metadata = {}) { + let entry = this.findEntry(searchOptions); + + if (entry) { + entry = Object.assign(entry, metadata); + } + return entry; + } + + /** + * Gets all of the entries associated with internal in-manifest/in-stream text tracks. + * @public + * @return {Array} An array with all the internal TextTrack objects. + */ + getInternalEntries() { + const internalEntries = this.textTracks.filter(trackMetadata => + !trackMetadata.isExternal + ); + return internalEntries; + } + + /** + * Gets all of the entries associated with external, manually added text tracks. + * @public + * @return {Array} An array with all the external TextTrack objects. + */ + getExternalEntries() { + const externalEntries = this.textTracks.filter(trackMetadata => + trackMetadata.isExternal + ); + return externalEntries; + } + + /** + * Determines whether or not all of the track entries are currently in 'disabled' mode. + * @public + * @return {Boolean} True if all tracks have 'disabled' mode, false otherwise + */ + areAllDisabled() { + const allDisabled = this.textTracks.reduce((result, trackMetadata) => + result && trackMetadata.mode === OO.CONSTANTS.CLOSED_CAPTIONS.DISABLED + , true); + return allDisabled; + } + + /** + * Clears all the text track metadata and resets id generation. + * @public + */ + clear() { + this.textTracks = []; + }; +} diff --git a/tests/setup.js b/tests/setup.js index 09702d63..c038f2e8 100644 --- a/tests/setup.js +++ b/tests/setup.js @@ -56,4 +56,43 @@ readOnlyVideoProperties.forEach((prop) => { writable: true, configurable: true }); -}); \ No newline at end of file +}); + +// Simulate behavior in which appending a track element to the video element +// results in a TextTrack object being created and added to the video's +// textTracks property. This is not currently handled by jsdom out of the box. +jest.mock('../src/main/js/text_track/text_track_helper', () => { + const TextTrackHelper = require.requireActual( + '../src/main/js/text_track/text_track_helper' + ).default; + const textTrackHelperProto = TextTrackHelper.prototype; + + const originalAddTrack = textTrackHelperProto.addTrack; + // Override the class' addTrack method in order automatically create TextTrack + // objects when a Track element is created. The rest of the implementation is + // not mocked + Object.assign(textTrackHelperProto, { + addTrack: function(trackData) { + if (!this.video) { + return; + } + // Execute original logic first + originalAddTrack.apply(this, arguments); + // Add TextTrack object matching provided properties + if (!this.video.textTracks) { + this.video.textTracks = []; + } + this.video.textTracks.push({ + id: trackData.id, + language: trackData.srclang, + // Note that the implementation initially sets the label to track id in + // order to be able to recognize the TextTrack object on the addtrack event + label: trackData.id, + kind: trackData.kind, + mode: 'disabled' + }); + } + }); + + return TextTrackHelper; +}); diff --git a/tests/unit/main_html5/wrapper-api-tests.js b/tests/unit/main_html5/wrapper-api-tests.js index 3e226acf..d9d716a1 100644 --- a/tests/unit/main_html5/wrapper-api-tests.js +++ b/tests/unit/main_html5/wrapper-api-tests.js @@ -51,6 +51,7 @@ describe('main_html5 wrapper tests', function () { parentElement = $("
"); wrapper = pluginFactory.create(parentElement, "test", vtc.interface, {}); element = parentElement.children()[0]; + element.textTracks = []; originalTimeout = jasmine.DEFAULT_TIMEOUT_INTERVAL; jasmine.DEFAULT_TIMEOUT_INTERVAL = 3000; }); @@ -118,7 +119,10 @@ describe('main_html5 wrapper tests', function () { it('should clear closed captions when setting a new url', function(){ wrapper.setVideoUrl("url"); + $(element).triggerHandler("loadedmetadata"); + $(element).triggerHandler("canplay"); wrapper.setClosedCaptions(language, closedCaptions, params); + element.textTracks.onaddtrack(); // Make sure tracks are actually there before we remove them expect(element.children.length > 0).to.be(true); expect(element.children[0].tagName).to.eql("TRACK"); @@ -128,8 +132,13 @@ describe('main_html5 wrapper tests', function () { it('should not clear closed captions when setting the same url', function(){ wrapper.setVideoUrl("url"); + $(element).triggerHandler("loadedmetadata"); + $(element).triggerHandler("canplay"); + element.textTracks.onaddtrack(); wrapper.setClosedCaptions(language, closedCaptions, params); wrapper.setVideoUrl("url"); + $(element).triggerHandler("loadedmetadata"); + element.textTracks.onaddtrack(); expect(element.children.length > 0).to.be(true); expect(element.children[0].tagName).to.eql("TRACK"); }); @@ -1142,10 +1151,13 @@ describe('main_html5 wrapper tests', function () { }); it('should set external closed captions', function(){ + wrapper.setVideoUrl("url"); + $(element).triggerHandler("loadedmetadata"); + $(element).triggerHandler("canplay"); wrapper.setClosedCaptions(language, closedCaptions, params); + element.textTracks.onaddtrack(); expect(element.children.length > 0).to.be(true); expect(element.children[0].tagName).to.eql("TRACK"); - expect(element.children[0].getAttribute("class")).to.eql(TRACK_CLASS); expect(element.children[0].getAttribute("kind")).to.eql("subtitles"); expect(element.children[0].getAttribute("label")).to.eql("English"); expect(element.children[0].getAttribute("src")).to.eql("http://ooyala.com"); @@ -1154,138 +1166,68 @@ describe('main_html5 wrapper tests', function () { it('should set closed captions mode for in-stream captions', function(){ element.textTracks = [{ mode: OO.CONSTANTS.CLOSED_CAPTIONS.DISABLED, kind: "captions" }]; - $(element).triggerHandler("playing"); + wrapper.setVideoUrl("url"); + $(element).triggerHandler("loadedmetadata"); + $(element).triggerHandler("canplay"); + element.textTracks.onaddtrack(); expect(element.textTracks[0].mode).to.eql(OO.CONSTANTS.CLOSED_CAPTIONS.DISABLED); - wrapper.setClosedCaptions("CC1", null, { mode: "showing" }); + wrapper.setClosedCaptions("CC1", {}, { mode: "showing" }); expect(element.textTracks[0].mode).to.eql(OO.CONSTANTS.CLOSED_CAPTIONS.SHOWING); }); - it('should replace French text tracks by English text tracks for iOS versions < 10 ', function(){ - OO.iosMajorVersion = 9; - $(element).append(""); - wrapper.setClosedCaptions(language, closedCaptions, { mode: OO.CONSTANTS.CLOSED_CAPTIONS.HIDDEN }); - expect(element.children.length).to.eql(1); - expect(element.children[0].tagName).to.eql("TRACK"); - expect(element.children[0].getAttribute("label")).to.eql("English"); - expect(element.children[0].getAttribute("kind")).to.eql("subtitles"); - expect(element.children[0].getAttribute("src")).to.eql("http://ooyala.com"); - expect(element.children[0].getAttribute("srclang")).to.eql("en"); - }); - - it('should replace French text tracks by English text tracks for OSX/Safari versions < 10 ', function(){ - OO.macOsSafariVersion = 9; - $(element).append(""); - wrapper.setClosedCaptions(language, closedCaptions, { mode: OO.CONSTANTS.CLOSED_CAPTIONS.HIDDEN }); - expect(element.children.length).to.eql(1); - expect(element.children[0].tagName).to.eql("TRACK"); - expect(element.children[0].getAttribute("label")).to.eql("English"); - expect(element.children[0].getAttribute("kind")).to.eql("subtitles"); - expect(element.children[0].getAttribute("src")).to.eql("http://ooyala.com"); - expect(element.children[0].getAttribute("srclang")).to.eql("en"); - }); - - it('should replace French subtitles by English ones on Safari version >= 10 and other platforms', function(){ - OO.isChrome = true; - var closedCaptions2 = { - locale: { fr: "French" }, - closed_captions_vtt: { - fr: { - name: "French", - url: "http://french.ooyala.com" - } - } - }; - - wrapper.setClosedCaptions('fr', closedCaptions2, { mode: OO.CONSTANTS.CLOSED_CAPTIONS.HIDDEN }); - wrapper.setClosedCaptions(language, closedCaptions, { mode: OO.CONSTANTS.CLOSED_CAPTIONS.HIDDEN }); - expect(element.children.length).to.eql(1); - expect(element.children[0].getAttribute("label")).to.eql("English"); - expect(element.children[0].getAttribute("kind")).to.eql("subtitles"); - expect(element.children[0].getAttribute("src")).to.eql("http://ooyala.com"); - expect(element.children[0].getAttribute("srclang")).to.eql("en"); - }); - it('should set both in-stream and external closed captions and switches between them', function(){ element.textTracks = [{ kind: "captions" }, { kind: "captions" }]; - $(element).triggerHandler("playing"); // this adds in-stream captions + wrapper.setVideoUrl("url"); + $(element).triggerHandler("loadedmetadata"); + $(element).triggerHandler("canplay"); + element.textTracks.onaddtrack(); wrapper.setClosedCaptionsMode(OO.CONSTANTS.CLOSED_CAPTIONS.DISABLED); expect(element.textTracks[0].mode).to.eql(OO.CONSTANTS.CLOSED_CAPTIONS.DISABLED); expect(element.textTracks[1].mode).to.eql(OO.CONSTANTS.CLOSED_CAPTIONS.DISABLED); - wrapper.setClosedCaptions("CC1", null, {mode: OO.CONSTANTS.CLOSED_CAPTIONS.SHOWING}); + wrapper.setClosedCaptions("CC1", {}, {mode: OO.CONSTANTS.CLOSED_CAPTIONS.SHOWING}); expect(element.textTracks[0].mode).to.eql(OO.CONSTANTS.CLOSED_CAPTIONS.SHOWING); expect(element.textTracks[1].mode).to.eql(OO.CONSTANTS.CLOSED_CAPTIONS.DISABLED); - wrapper.setClosedCaptions("CC2", null, {mode: OO.CONSTANTS.CLOSED_CAPTIONS.SHOWING}); + wrapper.setClosedCaptions("CC2", {}, {mode: OO.CONSTANTS.CLOSED_CAPTIONS.SHOWING}); expect(element.textTracks[0].mode).to.eql(OO.CONSTANTS.CLOSED_CAPTIONS.DISABLED); expect(element.textTracks[1].mode).to.eql(OO.CONSTANTS.CLOSED_CAPTIONS.SHOWING); wrapper.setClosedCaptions("en", closedCaptions, {mode: OO.CONSTANTS.CLOSED_CAPTIONS.HIDDEN}); // this adds external captions - expect(element.textTracks[0].mode).to.eql(OO.CONSTANTS.CLOSED_CAPTIONS.HIDDEN); - expect(element.textTracks[1].mode).to.eql(OO.CONSTANTS.CLOSED_CAPTIONS.HIDDEN); + element.textTracks.onaddtrack(); + expect(element.textTracks[0].mode).to.eql(OO.CONSTANTS.CLOSED_CAPTIONS.DISABLED); + expect(element.textTracks[1].mode).to.eql(OO.CONSTANTS.CLOSED_CAPTIONS.DISABLED); + expect(element.textTracks[2].mode).to.eql(OO.CONSTANTS.CLOSED_CAPTIONS.HIDDEN); }); it('should only enable the in-manifest/in-stream track that matches language parameter', function() { - var isLive = true; - OO.isSafari = true; element.textTracks = [ { language: "", label: "", kind: "subtitles" }, { language: "", label: "", kind: "subtitles" }, { language: "", label: "", kind: "subtitles" } ]; - wrapper.setVideoUrl("url", OO.VIDEO.ENCODING.HLS, isLive); - $(element).triggerHandler("playing"); - wrapper.setClosedCaptions("CC3", null, { mode: OO.CONSTANTS.CLOSED_CAPTIONS.SHOWING }); + wrapper.setVideoUrl("url", OO.VIDEO.ENCODING.HLS); + $(element).triggerHandler("loadedmetadata"); + $(element).triggerHandler("canplay"); + element.textTracks.onaddtrack(); + wrapper.setClosedCaptions("CC3", {}, { mode: OO.CONSTANTS.CLOSED_CAPTIONS.SHOWING }); expect(element.textTracks[0].mode).to.eql(OO.CONSTANTS.CLOSED_CAPTIONS.DISABLED); expect(element.textTracks[1].mode).to.eql(OO.CONSTANTS.CLOSED_CAPTIONS.DISABLED); expect(element.textTracks[2].mode).to.eql(OO.CONSTANTS.CLOSED_CAPTIONS.SHOWING); }); - it('should set custom ids on in-manifest/in-stream tracks when stream tracks are checked', function() { - var isLive = true; - OO.isSafari = true; - element.textTracks = [ - { language: "", label: "", kind: "subtitles" }, - { language: "", label: "", kind: "subtitles" }, - { language: "", label: "", kind: "subtitles" }, - { language: "", label: "", kind: "subtitles" } - ]; - wrapper.setVideoUrl("url", OO.VIDEO.ENCODING.HLS, isLive); - $(element).triggerHandler("playing"); - expect(element.textTracks[0].trackId).to.eql('CC1'); - expect(element.textTracks[1].trackId).to.eql('CC2'); - expect(element.textTracks[2].trackId).to.eql('CC3'); - expect(element.textTracks[3].trackId).to.eql('CC4'); - }); - - it('should set custom ids on in-manifest/in-stream tracks when closed captions are set if ids haven\'t been added yet', function() { - var isLive = true; - OO.isSafari = true; - element.textTracks = [ - { language: "", label: "", kind: "subtitles" }, - { language: "", label: "", kind: "subtitles" } - ]; - wrapper.setVideoUrl("url", OO.VIDEO.ENCODING.HLS, isLive); - $(element).triggerHandler("playing"); - expect(element.textTracks[0].trackId).to.eql('CC1'); - expect(element.textTracks[1].trackId).to.eql('CC2'); - // Add some new tracks - element.textTracks.push({ language: "", label: "", kind: "subtitles" }); - element.textTracks.push({ language: "", label: "", kind: "subtitles" }); - wrapper.setClosedCaptions("CC2", null, { mode: OO.CONSTANTS.CLOSED_CAPTIONS.SHOWING }); - expect(element.textTracks[0].trackId).to.eql('CC1'); - expect(element.textTracks[1].trackId).to.eql('CC2'); - expect(element.textTracks[2].trackId).to.eql('CC3'); - expect(element.textTracks[3].trackId).to.eql('CC4'); - }); - - it('should remove closed captions if language is null', function(){ + it('should disable closed captions if language is null', function() { + wrapper.setVideoUrl("url", OO.VIDEO.ENCODING.HLS); + $(element).triggerHandler("loadedmetadata"); + $(element).triggerHandler("canplay"); wrapper.setClosedCaptions(language, closedCaptions, params); + element.textTracks.onaddtrack(); expect(element.children.length > 0).to.be(true); expect(element.children[0].tagName).to.eql("TRACK"); + expect(element.textTracks[0].mode).to.eql(OO.CONSTANTS.CLOSED_CAPTIONS.HIDDEN); wrapper.setClosedCaptions(null, closedCaptions, params); - expect(element.children.length).to.eql(0); + expect(element.textTracks[0].mode).to.eql(OO.CONSTANTS.CLOSED_CAPTIONS.DISABLED); }); it('should set the closed captions mode', function(){ diff --git a/tests/unit/main_html5/wrapper-event-tests.js b/tests/unit/main_html5/wrapper-event-tests.js index d68f7080..2a9d5a92 100644 --- a/tests/unit/main_html5/wrapper-event-tests.js +++ b/tests/unit/main_html5/wrapper-event-tests.js @@ -10,12 +10,17 @@ describe('main_html5 wrapper tests', function () { // Setup OO.Video = { plugin: function(plugin) { pluginFactory = plugin; } }; + if (!OO.log) { + OO.log = function() {}; + } + // Load file under test jest.dontMock('../../../src/main/js/main_html5'); + jest.dontMock('../../../src/main/js/text_track/text_track_map'); require('../../../src/main/js/main_html5'); var closedCaptions = { - locale: {en: "English"}, + locale: { en: "English" }, closed_captions_vtt: { en: { name: "English", @@ -36,6 +41,7 @@ describe('main_html5 wrapper tests', function () { parentElement = $("
"); wrapper = pluginFactory.create(parentElement, "test", vtc.interface, {}); element = parentElement.children()[0]; + element.textTracks = []; }); afterEach(function() { @@ -422,9 +428,10 @@ describe('main_html5 wrapper tests', function () { expect(_.contains(vtc.notified, vtc.interface.EVENTS.MULTI_AUDIO_CHANGED)).to.eql(true); }); - it('should notify CAPTIONS_FOUND_ON_PLAYING on first video \'playing\' event if video has cc', function(){ + it('should notify CAPTIONS_FOUND_ON_PLAYING on \'onaddtrack\' event if video has cc', function() { element.textTracks = [{ kind: "captions" }]; - $(element).triggerHandler("playing"); // this adds in-stream captions + $(element).triggerHandler("loadedmetadata"); + element.textTracks.onaddtrack(); expect(vtc.notifyParameters).to.eql([vtc.interface.EVENTS.CAPTIONS_FOUND_ON_PLAYING, { languages: ['CC1'], locale: { @@ -433,12 +440,13 @@ describe('main_html5 wrapper tests', function () { }]); }); - it('should notify CAPTIONS_FOUND_ON_PLAYING on first video \'playing\' event for both live and external CCs on Safari (or Edge)', function(){ - OO.isSafari = true; - element.textTracks = [{ language: "", label: "", kind: "subtitles" }]; // this is external CC - wrapper.setVideoUrl("url", OO.VIDEO.ENCODING.HLS, true); // sets isLive flag to true - wrapper.setClosedCaptions("en", closedCaptions, {mode: "hidden"}); // creates text tracks for external CCs - $(element).triggerHandler("playing"); // adds in-stream captions + it('should notify CAPTIONS_FOUND_ON_PLAYING on \'onaddtrack\' event for both internal and external CCs', function(){ + element.textTracks = [{ language: "", label: "", kind: "subtitles" }]; // this is internal CC + wrapper.setVideoUrl("url", OO.VIDEO.ENCODING.HLS); + $(element).triggerHandler("loadedmetadata"); + $(element).triggerHandler("canplay"); + wrapper.setClosedCaptions("en", closedCaptions, { mode: "hidden" }); // creates text tracks for external CCs + element.textTracks.onaddtrack(); expect(vtc.notifyParameters).to.eql([vtc.interface.EVENTS.CAPTIONS_FOUND_ON_PLAYING, { languages: ['en', 'CC1'], locale: { @@ -448,27 +456,17 @@ describe('main_html5 wrapper tests', function () { }]); }); - it('should notify CAPTIONS_FOUND_ON_PLAYING for live in-stream captions for Edge in a different way', function(){ - OO.isEdge = true; - element.textTracks = [{}]; - wrapper.setVideoUrl("url", OO.VIDEO.ENCODING.HLS, true); - $(element).triggerHandler("playing"); // this adds in-stream captions - - expect(vtc.notifyParameters).to.eql([vtc.interface.EVENTS.CAPTIONS_FOUND_ON_PLAYING, { - languages: ['CC1'], locale: { CC1: 'Captions (CC1)' }}]); - }); - it('should notify CAPTIONS_FOUND_ON_PLAYING with multiple in-manifest/in-stream captions', function() { - var isLive = true; - OO.isSafari = true; element.textTracks = [ { language: "", label: "", kind: "subtitles" }, { language: "", label: "", kind: "subtitles" }, { language: "", label: "", kind: "subtitles" } ]; - wrapper.setVideoUrl("url", OO.VIDEO.ENCODING.HLS, isLive); + wrapper.setVideoUrl("url", OO.VIDEO.ENCODING.HLS); + $(element).triggerHandler("loadedmetadata"); + $(element).triggerHandler("canplay"); wrapper.setClosedCaptions("en", closedCaptions, { mode: "hidden" }); - $(element).triggerHandler("playing"); + element.textTracks.onaddtrack(); expect(vtc.notifyParameters).to.eql([vtc.interface.EVENTS.CAPTIONS_FOUND_ON_PLAYING, { languages: ['en', 'CC1', 'CC2', 'CC3'], locale: { @@ -481,16 +479,16 @@ describe('main_html5 wrapper tests', function () { }); it('should notify CAPTIONS_FOUND_ON_PLAYING with multiple in-manifest/in-stream captions using label and language metadata when available', function() { - var isLive = true; - OO.isSafari = true; element.textTracks = [ { language: "", label: "", kind: "subtitles" }, { language: "es", label: "", kind: "subtitles" }, { language: "de", label: "German", kind: "subtitles" } ]; - wrapper.setVideoUrl("url", OO.VIDEO.ENCODING.HLS, isLive); + wrapper.setVideoUrl("url", OO.VIDEO.ENCODING.HLS); + $(element).triggerHandler("loadedmetadata"); + $(element).triggerHandler("canplay"); wrapper.setClosedCaptions("en", closedCaptions, { mode: "hidden" }); - $(element).triggerHandler("playing"); + element.textTracks.onaddtrack(); expect(vtc.notifyParameters).to.eql([vtc.interface.EVENTS.CAPTIONS_FOUND_ON_PLAYING, { languages: ['en', 'CC1', 'CC2', 'CC3'], locale: { @@ -503,14 +501,13 @@ describe('main_html5 wrapper tests', function () { }); it('should reset in-manifest/in-stream track ids when a new source is set', function() { - var isLive = true; - OO.isSafari = true; element.textTracks = [ { language: "", label: "", kind: "subtitles" }, { language: "", label: "", kind: "subtitles" }, ]; - wrapper.setVideoUrl("url1", OO.VIDEO.ENCODING.HLS, isLive); - $(element).triggerHandler("playing"); + wrapper.setVideoUrl("url1", OO.VIDEO.ENCODING.HLS); + $(element).triggerHandler("loadedmetadata"); + element.textTracks.onaddtrack(); expect(vtc.notifyParameters).to.eql([vtc.interface.EVENTS.CAPTIONS_FOUND_ON_PLAYING, { languages: ['CC1', 'CC2'], locale: { @@ -523,8 +520,9 @@ describe('main_html5 wrapper tests', function () { { language: "", label: "", kind: "subtitles" }, { language: "", label: "", kind: "subtitles" }, ]; - wrapper.setVideoUrl("url2", OO.VIDEO.ENCODING.HLS, isLive); - $(element).triggerHandler("playing"); + wrapper.setVideoUrl("url2", OO.VIDEO.ENCODING.HLS); + $(element).triggerHandler("loadedmetadata"); + element.textTracks.onaddtrack(); expect(vtc.notifyParameters).to.eql([vtc.interface.EVENTS.CAPTIONS_FOUND_ON_PLAYING, { languages: ['CC1', 'CC2'], locale: { @@ -535,25 +533,16 @@ describe('main_html5 wrapper tests', function () { }); it('should NOT re-add manually added tracks to available captions when in-manifest/in-stream tracks are checked', function() { - var isLive = true; - OO.isSafari = true; element.textTracks = [ { language: "", label: "", kind: "subtitles" }, { language: "", label: "", kind: "subtitles" }, { language: "", label: "", kind: "subtitles" } ]; - wrapper.setVideoUrl("url", OO.VIDEO.ENCODING.HLS, isLive); + wrapper.setVideoUrl("url", OO.VIDEO.ENCODING.HLS); + $(element).triggerHandler("loadedmetadata"); + $(element).triggerHandler("canplay"); wrapper.setClosedCaptions("en", closedCaptions, { mode: "hidden" }); - // Simulate manually added track showing up on video element's textTracks property - var newTrackId = $(element).find('track').get(0).id; - element.textTracks.push({ - id: newTrackId, - language: "en", - label: "English", - kind: "subtitles" - }); - // Check for stream captions - $(element).triggerHandler("playing"); + element.textTracks.onaddtrack(); expect(vtc.notifyParameters).to.eql([vtc.interface.EVENTS.CAPTIONS_FOUND_ON_PLAYING, { languages: ['en', 'CC1', 'CC2', 'CC3'], locale: { @@ -573,10 +562,11 @@ describe('main_html5 wrapper tests', function () { }] } }; - element.textTracks = [{ - oncuechange: null - }]; + wrapper.setVideoUrl("url", OO.VIDEO.ENCODING.HLS); + $(element).triggerHandler("loadedmetadata"); + $(element).triggerHandler("canplay"); wrapper.setClosedCaptions("en", closedCaptions, {mode: "hidden"}); + element.textTracks.onaddtrack(); element.textTracks[0].oncuechange(event); expect(vtc.notifyParameters).to.eql([vtc.interface.EVENTS.CLOSED_CAPTION_CUE_CHANGED, event.currentTarget.activeCues[0].text]); }); @@ -591,10 +581,11 @@ describe('main_html5 wrapper tests', function () { }] } }; - element.textTracks = [{ - oncuechange: null - }]; + wrapper.setVideoUrl("url", OO.VIDEO.ENCODING.HLS); + $(element).triggerHandler("loadedmetadata"); + $(element).triggerHandler("canplay"); wrapper.setClosedCaptions("en", closedCaptions, {mode: "hidden"}); + element.textTracks.onaddtrack(); element.textTracks[0].oncuechange(event); expect(vtc.notifyParameters).to.eql([ vtc.interface.EVENTS.CLOSED_CAPTION_CUE_CHANGED, @@ -607,60 +598,6 @@ describe('main_html5 wrapper tests', function () { expect(vtc.notifyParameters).to.eql([vtc.interface.EVENTS.CLOSED_CAPTION_CUE_CHANGED, ""]); }); - it('should notify CLOSED_CAPTION_CUE_CHANGED on \'timeupdate\' event in Firefox', function(){ - element.textTracks = [{ - activeCues: [{ - text: "This is cue text." - }] - }]; - OO.isFirefox = true; - $(element).triggerHandler("timeupdate"); - expect(vtc.notifyParameters).to.eql([vtc.interface.EVENTS.CLOSED_CAPTION_CUE_CHANGED, element.textTracks[0].activeCues[0].text]); - }); - - it('should notify CLOSED_CAPTION_CUE_CHANGED on \'timeupdate\' event in Firefox with all active cues', function(){ - element.textTracks = [{ - activeCues: [{ - text: "This is cue text." - }, { - text: "This is more text." - }] - }]; - OO.isFirefox = true; - $(element).triggerHandler("timeupdate"); - expect(vtc.notifyParameters).to.eql([ - vtc.interface.EVENTS.CLOSED_CAPTION_CUE_CHANGED, - element.textTracks[0].activeCues[0].text + "\n" + element.textTracks[0].activeCues[1].text - ]); - }); - - it('should notify CLOSED_CAPTION_CUE_CHANGED with an empty string on \'timeupdate\' event in Firefox if there are no active cues', function(){ - element.textTracks = [{ - activeCues: [{ - text: "This is cue text." - }] - }]; - OO.isFirefox = true; - $(element).triggerHandler("timeupdate"); - expect(vtc.notifyParameters).to.eql([vtc.interface.EVENTS.CLOSED_CAPTION_CUE_CHANGED, element.textTracks[0].activeCues[0].text]); - element.textTracks = null; - $(element).triggerHandler("timeupdate"); - expect(vtc.notifyParameters).to.eql([vtc.interface.EVENTS.CLOSED_CAPTION_CUE_CHANGED, ""]); - }); - - it('should not notify CLOSED_CAPTION_CUE_CHANGED on \'timeupdate\' if the cue text has not changed', function(){ - element.textTracks = [{ - activeCues: [{ - text: "This is cue text." - }] - }]; - OO.isFirefox = true; - $(element).triggerHandler("timeupdate"); - expect(vtc.notifyParameters).to.eql([vtc.interface.EVENTS.CLOSED_CAPTION_CUE_CHANGED, element.textTracks[0].activeCues[0].text]); - $(element).triggerHandler("timeupdate"); - expect(vtc.notifyParameters[0]).to.eql(vtc.interface.EVENTS.TIME_UPDATE); - }); - it('should notify WAITING on video \'waiting\' event', function(){ element.currentSrc = "url"; $(element).triggerHandler("waiting");