/**
* @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