/** * @file html5.js */ import Tech from './tech.js'; import * as Dom from '../utils/dom.js'; import * as Url from '../utils/url.js'; import log from '../utils/log.js'; import * as browser from '../utils/browser.js'; import document from 'global/document'; import window from 'global/window'; import {defineLazyProperty, merge} from '../utils/obj'; import {toTitleCase} from '../utils/str.js'; import {NORMAL as TRACK_TYPES, REMOTE} from '../tracks/track-types'; import setupSourceset from './setup-sourceset'; import {silencePromise} from '../utils/promise'; /** * HTML5 Media Controller - Wrapper for HTML5 Media API * * @mixes Tech~SourceHandlerAdditions * @extends Tech */ class Html5 extends Tech { /** * Create an instance of this Tech. * * @param {Object} [options] * The key/value store of player options. * * @param {Function} [ready] * Callback function to call when the `HTML5` Tech is ready. */ constructor(options, ready) { super(options, ready); const source = options.source; let crossoriginTracks = false; this.featuresVideoFrameCallback = this.featuresVideoFrameCallback && this.el_.tagName === 'VIDEO'; // Set the source if one is provided // 1) Check if the source is new (if not, we want to keep the original so playback isn't interrupted) // 2) Check to see if the network state of the tag was failed at init, and if so, reset the source // anyway so the error gets fired. if (source && (this.el_.currentSrc !== source.src || (options.tag && options.tag.initNetworkState_ === 3))) { this.setSource(source); } else { this.handleLateInit_(this.el_); } // setup sourceset after late sourceset/init if (options.enableSourceset) { this.setupSourcesetHandling_(); } this.isScrubbing_ = false; if (this.el_.hasChildNodes()) { const nodes = this.el_.childNodes; let nodesLength = nodes.length; const removeNodes = []; while (nodesLength--) { const node = nodes[nodesLength]; const nodeName = node.nodeName.toLowerCase(); if (nodeName === 'track') { if (!this.featuresNativeTextTracks) { // Empty video tag tracks so the built-in player doesn't use them also. // This may not be fast enough to stop HTML5 browsers from reading the tags // so we'll need to turn off any default tracks if we're manually doing // captions and subtitles. videoElement.textTracks removeNodes.push(node); } else { // store HTMLTrackElement and TextTrack to remote list this.remoteTextTrackEls().addTrackElement_(node); this.remoteTextTracks().addTrack(node.track); this.textTracks().addTrack(node.track); if (!crossoriginTracks && !this.el_.hasAttribute('crossorigin') && Url.isCrossOrigin(node.src)) { crossoriginTracks = true; } } } } for (let i = 0; i < removeNodes.length; i++) { this.el_.removeChild(removeNodes[i]); } } this.proxyNativeTracks_(); if (this.featuresNativeTextTracks && crossoriginTracks) { log.warn('Text Tracks are being loaded from another origin but the crossorigin attribute isn\'t used.\n' + 'This may prevent text tracks from loading.'); } // prevent iOS Safari from disabling metadata text tracks during native playback this.restoreMetadataTracksInIOSNativePlayer_(); // Determine if native controls should be used // Our goal should be to get the custom controls on mobile solid everywhere // so we can remove this all together. Right now this will block custom // controls on touch enabled laptops like the Chrome Pixel if ((browser.TOUCH_ENABLED || browser.IS_IPHONE) && options.nativeControlsForTouch === true) { this.setControls(true); } // on iOS, we want to proxy `webkitbeginfullscreen` and `webkitendfullscreen` // into a `fullscreenchange` event this.proxyWebkitFullscreen_(); this.triggerReady(); } /** * Dispose of `HTML5` media element and remove all tracks. */ dispose() { if (this.el_ && this.el_.resetSourceset_) { this.el_.resetSourceset_(); } Html5.disposeMediaElement(this.el_); this.options_ = null; // tech will handle clearing of the emulated track list super.dispose(); } /** * Modify the media element so that we can detect when * the source is changed. Fires `sourceset` just after the source has changed */ setupSourcesetHandling_() { setupSourceset(this); } /** * When a captions track is enabled in the iOS Safari native player, all other * tracks are disabled (including metadata tracks), which nulls all of their * associated cue points. This will restore metadata tracks to their pre-fullscreen * state in those cases so that cue points are not needlessly lost. * * @private */ restoreMetadataTracksInIOSNativePlayer_() { const textTracks = this.textTracks(); let metadataTracksPreFullscreenState; // captures a snapshot of every metadata track's current state const takeMetadataTrackSnapshot = () => { metadataTracksPreFullscreenState = []; for (let i = 0; i < textTracks.length; i++) { const track = textTracks[i]; if (track.kind === 'metadata') { metadataTracksPreFullscreenState.push({ track, storedMode: track.mode }); } } }; // snapshot each metadata track's initial state, and update the snapshot // each time there is a track 'change' event takeMetadataTrackSnapshot(); textTracks.addEventListener('change', takeMetadataTrackSnapshot); this.on('dispose', () => textTracks.removeEventListener('change', takeMetadataTrackSnapshot)); const restoreTrackMode = () => { for (let i = 0; i < metadataTracksPreFullscreenState.length; i++) { const storedTrack = metadataTracksPreFullscreenState[i]; if (storedTrack.track.mode === 'disabled' && storedTrack.track.mode !== storedTrack.storedMode) { storedTrack.track.mode = storedTrack.storedMode; } } // we only want this handler to be executed on the first 'change' event textTracks.removeEventListener('change', restoreTrackMode); }; // when we enter fullscreen playback, stop updating the snapshot and // restore all track modes to their pre-fullscreen state this.on('webkitbeginfullscreen', () => { textTracks.removeEventListener('change', takeMetadataTrackSnapshot); // remove the listener before adding it just in case it wasn't previously removed textTracks.removeEventListener('change', restoreTrackMode); textTracks.addEventListener('change', restoreTrackMode); }); // start updating the snapshot again after leaving fullscreen this.on('webkitendfullscreen', () => { // remove the listener before adding it just in case it wasn't previously removed textTracks.removeEventListener('change', takeMetadataTrackSnapshot); textTracks.addEventListener('change', takeMetadataTrackSnapshot); // remove the restoreTrackMode handler in case it wasn't triggered during fullscreen playback textTracks.removeEventListener('change', restoreTrackMode); }); } /** * Attempt to force override of tracks for the given type * * @param {string} type - Track type to override, possible values include 'Audio', * 'Video', and 'Text'. * @param {boolean} override - If set to true native audio/video will be overridden, * otherwise native audio/video will potentially be used. * @private */ overrideNative_(type, override) { // If there is no behavioral change don't add/remove listeners if (override !== this[`featuresNative${type}Tracks`]) { return; } const lowerCaseType = type.toLowerCase(); if (this[`${lowerCaseType}TracksListeners_`]) { Object.keys(this[`${lowerCaseType}TracksListeners_`]).forEach((eventName) => { const elTracks = this.el()[`${lowerCaseType}Tracks`]; elTracks.removeEventListener(eventName, this[`${lowerCaseType}TracksListeners_`][eventName]); }); } this[`featuresNative${type}Tracks`] = !override; this[`${lowerCaseType}TracksListeners_`] = null; this.proxyNativeTracksForType_(lowerCaseType); } /** * Attempt to force override of native audio tracks. * * @param {boolean} override - If set to true native audio will be overridden, * otherwise native audio will potentially be used. */ overrideNativeAudioTracks(override) { this.overrideNative_('Audio', override); } /** * Attempt to force override of native video tracks. * * @param {boolean} override - If set to true native video will be overridden, * otherwise native video will potentially be used. */ overrideNativeVideoTracks(override) { this.overrideNative_('Video', override); } /** * Proxy native track list events for the given type to our track * lists if the browser we are playing in supports that type of track list. * * @param {string} name - Track type; values include 'audio', 'video', and 'text' * @private */ proxyNativeTracksForType_(name) { const props = TRACK_TYPES[name]; const elTracks = this.el()[props.getterName]; const techTracks = this[props.getterName](); if (!this[`featuresNative${props.capitalName}Tracks`] || !elTracks || !elTracks.addEventListener) { return; } const listeners = { change: (e) => { const event = { type: 'change', target: techTracks, currentTarget: techTracks, srcElement: techTracks }; techTracks.trigger(event); // if we are a text track change event, we should also notify the // remote text track list. This can potentially cause a false positive // if we were to get a change event on a non-remote track and // we triggered the event on the remote text track list which doesn't // contain that track. However, best practices mean looping through the // list of tracks and searching for the appropriate mode value, so, // this shouldn't pose an issue if (name === 'text') { this[REMOTE.remoteText.getterName]().trigger(event); } }, addtrack(e) { techTracks.addTrack(e.track); }, removetrack(e) { techTracks.removeTrack(e.track); } }; const removeOldTracks = function() { const removeTracks = []; for (let i = 0; i < techTracks.length; i++) { let found = false; for (let j = 0; j < elTracks.length; j++) { if (elTracks[j] === techTracks[i]) { found = true; break; } } if (!found) { removeTracks.push(techTracks[i]); } } while (removeTracks.length) { techTracks.removeTrack(removeTracks.shift()); } }; this[props.getterName + 'Listeners_'] = listeners; Object.keys(listeners).forEach((eventName) => { const listener = listeners[eventName]; elTracks.addEventListener(eventName, listener); this.on('dispose', (e) => elTracks.removeEventListener(eventName, listener)); }); // Remove (native) tracks that are not used anymore this.on('loadstart', removeOldTracks); this.on('dispose', (e) => this.off('loadstart', removeOldTracks)); } /** * Proxy all native track list events to our track lists if the browser we are playing * in supports that type of track list. * * @private */ proxyNativeTracks_() { TRACK_TYPES.names.forEach((name) => { this.proxyNativeTracksForType_(name); }); } /** * Create the `Html5` Tech's DOM element. * * @return {Element} * The element that gets created. */ createEl() { let el = this.options_.tag; // Check if this browser supports moving the element into the box. // On the iPhone video will break if you move the element, // So we have to create a brand new element. // If we ingested the player div, we do not need to move the media element. if (!el || !(this.options_.playerElIngest || this.movingMediaElementInDOM)) { // If the original tag is still there, clone and remove it. if (el) { const clone = el.cloneNode(true); if (el.parentNode) { el.parentNode.insertBefore(clone, el); } Html5.disposeMediaElement(el); el = clone; } else { el = document.createElement('video'); // determine if native controls should be used const tagAttributes = this.options_.tag && Dom.getAttributes(this.options_.tag); const attributes = merge({}, tagAttributes); if (!browser.TOUCH_ENABLED || this.options_.nativeControlsForTouch !== true) { delete attributes.controls; } Dom.setAttributes( el, Object.assign(attributes, { id: this.options_.techId, class: 'vjs-tech' }) ); } el.playerId = this.options_.playerId; } if (typeof this.options_.preload !== 'undefined') { Dom.setAttribute(el, 'preload', this.options_.preload); } if (this.options_.disablePictureInPicture !== undefined) { el.disablePictureInPicture = this.options_.disablePictureInPicture; } // Update specific tag settings, in case they were overridden // `autoplay` has to be *last* so that `muted` and `playsinline` are present // when iOS/Safari or other browsers attempt to autoplay. const settingsAttrs = ['loop', 'muted', 'playsinline', 'autoplay']; for (let i = 0; i < settingsAttrs.length; i++) { const attr = settingsAttrs[i]; const value = this.options_[attr]; if (typeof value !== 'undefined') { if (value) { Dom.setAttribute(el, attr, attr); } else { Dom.removeAttribute(el, attr); } el[attr] = value; } } return el; } /** * This will be triggered if the loadstart event has already fired, before videojs was * ready. Two known examples of when this can happen are: * 1. If we're loading the playback object after it has started loading * 2. The media is already playing the (often with autoplay on) then * * This function will fire another loadstart so that videojs can catchup. * * @fires Tech#loadstart * * @return {undefined} * returns nothing. */ handleLateInit_(el) { if (el.networkState === 0 || el.networkState === 3) { // The video element hasn't started loading the source yet // or didn't find a source return; } if (el.readyState === 0) { // NetworkState is set synchronously BUT loadstart is fired at the // end of the current stack, usually before setInterval(fn, 0). // So at this point we know loadstart may have already fired or is // about to fire, and either way the player hasn't seen it yet. // We don't want to fire loadstart prematurely here and cause a // double loadstart so we'll wait and see if it happens between now // and the next loop, and fire it if not. // HOWEVER, we also want to make sure it fires before loadedmetadata // which could also happen between now and the next loop, so we'll // watch for that also. let loadstartFired = false; const setLoadstartFired = function() { loadstartFired = true; }; this.on('loadstart', setLoadstartFired); const triggerLoadstart = function() { // We did miss the original loadstart. Make sure the player // sees loadstart before loadedmetadata if (!loadstartFired) { this.trigger('loadstart'); } }; this.on('loadedmetadata', triggerLoadstart); this.ready(function() { this.off('loadstart', setLoadstartFired); this.off('loadedmetadata', triggerLoadstart); if (!loadstartFired) { // We did miss the original native loadstart. Fire it now. this.trigger('loadstart'); } }); return; } // From here on we know that loadstart already fired and we missed it. // The other readyState events aren't as much of a problem if we double // them, so not going to go to as much trouble as loadstart to prevent // that unless we find reason to. const eventsToTrigger = ['loadstart']; // loadedmetadata: newly equal to HAVE_METADATA (1) or greater eventsToTrigger.push('loadedmetadata'); // loadeddata: newly increased to HAVE_CURRENT_DATA (2) or greater if (el.readyState >= 2) { eventsToTrigger.push('loadeddata'); } // canplay: newly increased to HAVE_FUTURE_DATA (3) or greater if (el.readyState >= 3) { eventsToTrigger.push('canplay'); } // canplaythrough: newly equal to HAVE_ENOUGH_DATA (4) if (el.readyState >= 4) { eventsToTrigger.push('canplaythrough'); } // We still need to give the player time to add event listeners this.ready(function() { eventsToTrigger.forEach(function(type) { this.trigger(type); }, this); }); } /** * Set whether we are scrubbing or not. * This is used to decide whether we should use `fastSeek` or not. * `fastSeek` is used to provide trick play on Safari browsers. * * @param {boolean} isScrubbing * - true for we are currently scrubbing * - false for we are no longer scrubbing */ setScrubbing(isScrubbing) { this.isScrubbing_ = isScrubbing; } /** * Get whether we are scrubbing or not. * * @return {boolean} isScrubbing * - true for we are currently scrubbing * - false for we are no longer scrubbing */ scrubbing() { return this.isScrubbing_; } /** * Set current time for the `HTML5` tech. * * @param {number} seconds * Set the current time of the media to this. */ setCurrentTime(seconds) { try { if (this.isScrubbing_ && this.el_.fastSeek && browser.IS_ANY_SAFARI) { this.el_.fastSeek(seconds); } else { this.el_.currentTime = seconds; } } catch (e) { log(e, 'Video is not ready. (Video.js)'); // this.warning(VideoJS.warnings.videoNotReady); } } /** * Get the current duration of the HTML5 media element. * * @return {number} * The duration of the media or 0 if there is no duration. */ duration() { // Android Chrome will report duration as Infinity for VOD HLS until after // playback has started, which triggers the live display erroneously. // Return NaN if playback has not started and trigger a durationupdate once // the duration can be reliably known. if ( this.el_.duration === Infinity && browser.IS_ANDROID && browser.IS_CHROME && this.el_.currentTime === 0 ) { // Wait for the first `timeupdate` with currentTime > 0 - there may be // several with 0 const checkProgress = () => { if (this.el_.currentTime > 0) { // Trigger durationchange for genuinely live video if (this.el_.duration === Infinity) { this.trigger('durationchange'); } this.off('timeupdate', checkProgress); } }; this.on('timeupdate', checkProgress); return NaN; } return this.el_.duration || NaN; } /** * Get the current width of the HTML5 media element. * * @return {number} * The width of the HTML5 media element. */ width() { return this.el_.offsetWidth; } /** * Get the current height of the HTML5 media element. * * @return {number} * The height of the HTML5 media element. */ height() { return this.el_.offsetHeight; } /** * Proxy iOS `webkitbeginfullscreen` and `webkitendfullscreen` into * `fullscreenchange` event. * * @private * @fires fullscreenchange * @listens webkitendfullscreen * @listens webkitbeginfullscreen * @listens webkitbeginfullscreen */ proxyWebkitFullscreen_() { if (!('webkitDisplayingFullscreen' in this.el_)) { return; } const endFn = function() { this.trigger('fullscreenchange', { isFullscreen: false }); // Safari will sometimes set controls on the videoelement when existing fullscreen. if (this.el_.controls && !this.options_.nativeControlsForTouch && this.controls()) { this.el_.controls = false; } }; const beginFn = function() { if ('webkitPresentationMode' in this.el_ && this.el_.webkitPresentationMode !== 'picture-in-picture') { this.one('webkitendfullscreen', endFn); this.trigger('fullscreenchange', { isFullscreen: true, // set a flag in case another tech triggers fullscreenchange nativeIOSFullscreen: true }); } }; this.on('webkitbeginfullscreen', beginFn); this.on('dispose', () => { this.off('webkitbeginfullscreen', beginFn); this.off('webkitendfullscreen', endFn); }); } /** * Check if fullscreen is supported on the video el. * * @return {boolean} * - True if fullscreen is supported. * - False if fullscreen is not supported. */ supportsFullScreen() { return typeof this.el_.webkitEnterFullScreen === 'function'; } /** * Request that the `HTML5` Tech enter fullscreen. */ enterFullScreen() { const video = this.el_; if (video.paused && video.networkState <= video.HAVE_METADATA) { // attempt to prime the video element for programmatic access // this isn't necessary on the desktop but shouldn't hurt silencePromise(this.el_.play()); // playing and pausing synchronously during the transition to fullscreen // can get iOS ~6.1 devices into a play/pause loop this.setTimeout(function() { video.pause(); try { video.webkitEnterFullScreen(); } catch (e) { this.trigger('fullscreenerror', e); } }, 0); } else { try { video.webkitEnterFullScreen(); } catch (e) { this.trigger('fullscreenerror', e); } } } /** * Request that the `HTML5` Tech exit fullscreen. */ exitFullScreen() { if (!this.el_.webkitDisplayingFullscreen) { this.trigger('fullscreenerror', new Error('The video is not fullscreen')); return; } this.el_.webkitExitFullScreen(); } /** * Create a floating video window always on top of other windows so that users may * continue consuming media while they interact with other content sites, or * applications on their device. * * @see [Spec]{@link https://wicg.github.io/picture-in-picture} * * @return {Promise} * A promise with a Picture-in-Picture window. */ requestPictureInPicture() { return this.el_.requestPictureInPicture(); } /** * Native requestVideoFrameCallback if supported by browser/tech, or fallback * Don't use rVCF on Safari when DRM is playing, as it doesn't fire * Needs to be checked later than the constructor * This will be a false positive for clear sources loaded after a Fairplay source * * @param {function} cb function to call * @return {number} id of request */ requestVideoFrameCallback(cb) { if (this.featuresVideoFrameCallback && !this.el_.webkitKeys) { return this.el_.requestVideoFrameCallback(cb); } return super.requestVideoFrameCallback(cb); } /** * Native or fallback requestVideoFrameCallback * * @param {number} id request id to cancel */ cancelVideoFrameCallback(id) { if (this.featuresVideoFrameCallback && !this.el_.webkitKeys) { this.el_.cancelVideoFrameCallback(id); } else { super.cancelVideoFrameCallback(id); } } /** * A getter/setter for the `Html5` Tech's source object. * > Note: Please use {@link Html5#setSource} * * @param {Tech~SourceObject} [src] * The source object you want to set on the `HTML5` techs element. * * @return {Tech~SourceObject|undefined} * - The current source object when a source is not passed in. * - undefined when setting * * @deprecated Since version 5. */ src(src) { if (src === undefined) { return this.el_.src; } // Setting src through `src` instead of `setSrc` will be deprecated this.setSrc(src); } /** * Add a element to the