shaka-player
Version:
DASH/EME video player library
793 lines (637 loc) • 23.3 kB
JavaScript
/**
* @license
* Copyright 2016 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* A container for custom video controls.
* @constructor
* @suppress {missingProvide}
*/
function ShakaControls() {
/** @private {boolean} */
this.enabled_ = true;
/** @private {shaka.cast.CastProxy} */
this.castProxy_ = null;
/** @private {boolean} */
this.castAllowed_ = true;
/** @private {?function(!shaka.util.Error)} */
this.onError_ = null;
/** @private {HTMLMediaElement} */
this.video_ = null;
/** @private {shaka.Player} */
this.player_ = null;
/** @private {Element} */
this.videoContainer_ = document.getElementById('videoContainer');
/** @private {Element} */
this.controls_ = document.getElementById('controls');
/** @private {Element} */
this.playPauseButton_ = document.getElementById('playPauseButton');
/** @private {Element} */
this.seekBar_ = document.getElementById('seekBar');
/** @private {Element} */
this.muteButton_ = document.getElementById('muteButton');
/** @private {Element} */
this.volumeBar_ = document.getElementById('volumeBar');
/** @private {Element} */
this.captionButton_ = document.getElementById('captionButton');
/** @private {Element} */
this.fullscreenButton_ = document.getElementById('fullscreenButton');
/** @private {Element} */
this.currentTime_ = document.getElementById('currentTime');
/** @private {Element} */
this.rewindButton_ = document.getElementById('rewindButton');
/** @private {Element} */
this.fastForwardButton_ = document.getElementById('fastForwardButton');
/** @private {Element} */
this.castButton_ = document.getElementById('castButton');
/** @private {Element} */
this.castReceiverName_ = document.getElementById('castReceiverName');
/** @private {Element} */
this.bufferingSpinner_ = document.getElementById('bufferingSpinner');
/** @private {Element} */
this.giantPlayButtonContainer_ =
document.getElementById('giantPlayButtonContainer');
/** @private {boolean} */
this.isSeeking_ = false;
/** @private {number} */
this.trickPlayRate_ = 1;
/** @private {?number} */
this.seekTimeoutId_ = null;
/** @private {?number} */
this.mouseStillTimeoutId_ = null;
/** @private {?number} */
this.lastTouchEventTime_ = null;
}
/**
* Initializes the player controls.
* @param {shaka.cast.CastProxy} castProxy
* @param {function(!shaka.util.Error)} onError
* @param {function(boolean)} notifyCastStatus
*/
ShakaControls.prototype.init = function(castProxy, onError, notifyCastStatus) {
this.castProxy_ = castProxy;
this.onError_ = onError;
this.notifyCastStatus_ = notifyCastStatus;
this.initMinimal(castProxy.getVideo(), castProxy.getPlayer());
this.playPauseButton_.addEventListener(
'click', this.onPlayPauseClick_.bind(this));
this.video_.addEventListener(
'play', this.onPlayStateChange_.bind(this));
this.video_.addEventListener(
'pause', this.onPlayStateChange_.bind(this));
// Since videos go into a paused state at the end, Chrome and Edge both fire
// the 'pause' event when a video ends. IE 11 only fires the 'ended' event.
this.video_.addEventListener(
'ended', this.onPlayStateChange_.bind(this));
this.seekBar_.addEventListener(
'mousedown', this.onSeekStart_.bind(this));
this.seekBar_.addEventListener(
'touchstart', this.onSeekStart_.bind(this));
this.seekBar_.addEventListener(
'input', this.onSeekInput_.bind(this));
this.seekBar_.addEventListener(
'touchend', this.onSeekEnd_.bind(this));
this.seekBar_.addEventListener(
'mouseup', this.onSeekEnd_.bind(this));
this.muteButton_.addEventListener(
'click', this.onMuteClick_.bind(this));
this.volumeBar_.addEventListener(
'input', this.onVolumeInput_.bind(this));
this.video_.addEventListener(
'volumechange', this.onVolumeStateChange_.bind(this));
// initialize volume display with a fake event
this.onVolumeStateChange_();
this.captionButton_.addEventListener(
'click', this.onCaptionClick_.bind(this));
this.player_.addEventListener(
'texttrackvisibility', this.onCaptionStateChange_.bind(this));
this.player_.addEventListener(
'trackschanged', this.onTracksChange_.bind(this));
// initialize caption state with a fake event
this.onCaptionStateChange_();
this.fullscreenButton_.addEventListener(
'click', this.onFullscreenClick_.bind(this));
this.currentTime_.addEventListener(
'click', this.onCurrentTimeClick_.bind(this));
this.rewindButton_.addEventListener(
'click', this.onRewindClick_.bind(this));
this.fastForwardButton_.addEventListener(
'click', this.onFastForwardClick_.bind(this));
this.castButton_.addEventListener(
'click', this.onCastClick_.bind(this));
this.videoContainer_.addEventListener(
'touchstart', this.onContainerTouch_.bind(this));
this.videoContainer_.addEventListener(
'click', this.onContainerClick_.bind(this));
// Clicks in the controls should not propagate up to the video container.
this.controls_.addEventListener(
'click', function(event) { event.stopPropagation(); });
this.videoContainer_.addEventListener(
'mousemove', this.onMouseMove_.bind(this));
this.videoContainer_.addEventListener(
'touchmove', this.onMouseMove_.bind(this));
this.videoContainer_.addEventListener(
'touchend', this.onMouseMove_.bind(this));
this.videoContainer_.addEventListener(
'mouseout', this.onMouseOut_.bind(this));
this.castProxy_.addEventListener(
'caststatuschanged', this.onCastStatusChange_.bind(this));
if (screen.orientation) {
screen.orientation.addEventListener(
'change', this.onScreenRotation_.bind(this));
}
};
/**
* When mobile device is rotated to landscape layout, and the video is loaded,
* the demo app goes into fullscreen.
* Exit fullscreen when the device is rotated to portrait layout.
* @private
*/
ShakaControls.prototype.onScreenRotation_ = function() {
if (!this.video_ ||
this.video_.readyState == 0 ||
this.castProxy_.isCasting()) return;
if (screen.orientation.type.indexOf('landscape') >= 0 &&
!document.fullscreenElement) {
this.videoContainer_.requestFullscreen();
} else if (screen.orientation.type.indexOf('portrait') >= 0 &&
document.fullscreenElement) {
document.exitFullscreen();
}
};
/**
* Initializes minimal player controls. Used on both sender (indirectly) and
* receiver (directly).
* @param {HTMLMediaElement} video
* @param {shaka.Player} player
*/
ShakaControls.prototype.initMinimal = function(video, player) {
this.video_ = video;
this.player_ = player;
this.player_.addEventListener(
'buffering', this.onBufferingStateChange_.bind(this));
window.setInterval(this.updateTimeAndSeekRange_.bind(this), 125);
};
/**
* This allows the application to inhibit casting.
*
* @param {boolean} allow
*/
ShakaControls.prototype.allowCast = function(allow) {
this.castAllowed_ = allow;
this.onCastStatusChange_(null);
};
/**
* Used by the application to notify the controls that a load operation is
* complete. This allows the controls to recalculate play/paused state, which
* is important for platforms like Android where autoplay is disabled.
*/
ShakaControls.prototype.loadComplete = function() {
// If we are on Android or if autoplay is false, video.paused should be true.
// Otherwise, video.paused is false and the content is autoplaying.
this.onPlayStateChange_();
};
/**
* Enable or disable the custom controls.
* Disabling custom controls enables native controls.
*
* @param {boolean} enabled
*/
ShakaControls.prototype.setEnabled = function(enabled) {
this.enabled_ = enabled;
if (enabled) {
this.controls_.parentElement.style.display = 'inherit';
this.video_.controls = false;
} else {
this.controls_.parentElement.style.display = 'none';
this.video_.controls = true;
}
// The effects of play state changes are inhibited while showing native
// browser controls. Recalculate that state now.
this.onPlayStateChange_();
};
/**
* Hiding the cursor when the mouse stops moving seems to be the only decent UX
* in fullscreen mode. Since we can't use pure CSS for that, we use events both
* in and out of fullscreen mode.
* @param {!Event} event
* @private
*/
ShakaControls.prototype.onMouseMove_ = function(event) {
if (event.type == 'touchstart' || event.type == 'touchmove' ||
event.type == 'touchend') {
this.lastTouchEventTime_ = Date.now();
} else if (this.lastTouchEventTime_ + 1000 < Date.now()) {
// It has been a while since the last touch event, this is probably a real
// mouse moving, so treat it like a mouse.
this.lastTouchEventTime_ = null;
}
// When there is a touch, we can get a 'mousemove' event after touch events.
// This should be treated as part of the touch, which has already been handled
if (this.lastTouchEventTime_ && event.type == 'mousemove')
return;
// Use the cursor specified in the CSS file.
this.videoContainer_.style.cursor = '';
// Show the controls.
this.controls_.style.opacity = 1;
this.updateTimeAndSeekRange_();
// Hide the cursor when the mouse stops moving.
// Only applies while the cursor is over the video container.
if (this.mouseStillTimeoutId_) {
// Reset the timer.
window.clearTimeout(this.mouseStillTimeoutId_);
}
// Only start a timeout on 'touchend' or for 'mousemove' with no touch events.
if (event.type == 'touchend' || !this.lastTouchEventTime_) {
this.mouseStillTimeoutId_ = window.setTimeout(
this.onMouseStill_.bind(this), 3000);
}
};
/** @private */
ShakaControls.prototype.onMouseOut_ = function() {
// We sometimes get 'mouseout' events with touches. Since we can never leave
// the video element when touching, ignore.
if (this.lastTouchEventTime_) return;
// Expire the timer early.
if (this.mouseStillTimeoutId_) {
window.clearTimeout(this.mouseStillTimeoutId_);
}
// Run the timeout callback to hide the controls.
// If we don't, the opacity style we set in onMouseMove_ will continue to
// override the opacity in CSS and force the controls to stay visible.
this.onMouseStill_();
};
/** @private */
ShakaControls.prototype.onMouseStill_ = function() {
// The mouse has stopped moving.
this.mouseStillTimeoutId_ = null;
// Hide the cursor. (NOTE: not supported on IE)
this.videoContainer_.style.cursor = 'none';
// Revert opacity control to CSS. Hovering directly over the controls will
// keep them showing, even in fullscreen mode. Unless there were touch events,
// then override the hover and hide the controls.
this.controls_.style.opacity = this.lastTouchEventTime_ ? '0' : '';
};
/**
* @param {!Event} event
* @private
*/
ShakaControls.prototype.onContainerTouch_ = function(event) {
if (!this.video_.duration) {
// Can't play yet. Ignore.
return;
}
if (this.controls_.style.opacity == 1) {
this.lastTouchEventTime_ = Date.now();
// The controls are showing.
// Let this event continue and become a click.
} else {
// The controls are hidden, so show them.
this.onMouseMove_(event);
// Stop this event from becoming a click event.
event.preventDefault();
}
};
/**
* @param {!Event} event
* @private
*/
ShakaControls.prototype.onContainerClick_ = function(event) {
if (!this.enabled_) return;
this.onPlayPauseClick_();
};
/** @private */
ShakaControls.prototype.onPlayPauseClick_ = function() {
if (!this.enabled_) return;
if (!this.video_.duration) {
// Can't play yet. Ignore.
return;
}
this.player_.cancelTrickPlay();
this.trickPlayRate_ = 1;
if (this.video_.paused) {
this.video_.play();
} else {
this.video_.pause();
}
};
/** @private */
ShakaControls.prototype.onPlayStateChange_ = function() {
// Video is paused during seek, so don't show the play arrow while seeking:
if (this.enabled_ && this.video_.paused && !this.isSeeking_) {
this.playPauseButton_.textContent = 'play_arrow';
this.giantPlayButtonContainer_.style.display = 'inline';
} else {
this.playPauseButton_.textContent = 'pause';
this.giantPlayButtonContainer_.style.display = 'none';
}
};
/** @private */
ShakaControls.prototype.onSeekStart_ = function() {
if (!this.enabled_) return;
this.isSeeking_ = true;
this.video_.pause();
};
/** @private */
ShakaControls.prototype.onSeekInput_ = function() {
if (!this.enabled_) return;
if (!this.video_.duration) {
// Can't seek yet. Ignore.
return;
}
// Update the UI right away.
this.updateTimeAndSeekRange_();
// Collect input events and seek when things have been stable for 125ms.
if (this.seekTimeoutId_ != null) {
window.clearTimeout(this.seekTimeoutId_);
}
this.seekTimeoutId_ = window.setTimeout(
this.onSeekInputTimeout_.bind(this), 125);
};
/** @private */
ShakaControls.prototype.onSeekInputTimeout_ = function() {
this.seekTimeoutId_ = null;
this.video_.currentTime = parseFloat(this.seekBar_.value);
};
/** @private */
ShakaControls.prototype.onSeekEnd_ = function() {
if (!this.enabled_) return;
if (this.seekTimeoutId_ != null) {
// They just let go of the seek bar, so end the timer early.
window.clearTimeout(this.seekTimeoutId_);
this.onSeekInputTimeout_();
}
this.isSeeking_ = false;
this.video_.play();
};
/** @private */
ShakaControls.prototype.onMuteClick_ = function() {
if (!this.enabled_) return;
this.video_.muted = !this.video_.muted;
};
/**
* Updates the controls to reflect volume changes.
* @private
*/
ShakaControls.prototype.onVolumeStateChange_ = function() {
if (this.video_.muted) {
this.muteButton_.textContent = 'volume_off';
this.volumeBar_.value = 0;
} else {
this.muteButton_.textContent = 'volume_up';
this.volumeBar_.value = this.video_.volume;
}
var gradient = ['to right'];
gradient.push('#ccc ' + (this.volumeBar_.value * 100) + '%');
gradient.push('#000 ' + (this.volumeBar_.value * 100) + '%');
gradient.push('#000 100%');
this.volumeBar_.style.background =
'linear-gradient(' + gradient.join(',') + ')';
};
/** @private */
ShakaControls.prototype.onVolumeInput_ = function() {
this.video_.volume = parseFloat(this.volumeBar_.value);
this.video_.muted = false;
};
/** @private */
ShakaControls.prototype.onCaptionClick_ = function() {
if (!this.enabled_) return;
this.player_.setTextTrackVisibility(!this.player_.isTextTrackVisible());
};
/** @private */
ShakaControls.prototype.onTracksChange_ = function() {
var hasText = this.player_.getTextTracks().length;
this.captionButton_.style.display = hasText ? 'inherit' : 'none';
};
/** @private */
ShakaControls.prototype.onCaptionStateChange_ = function() {
if (this.player_.isTextTrackVisible()) {
this.captionButton_.style.color = 'white';
} else {
// Make the button look darker to show that the text track is inactive.
this.captionButton_.style.color = 'rgba(255, 255, 255, 0.3)';
}
};
/** @private */
ShakaControls.prototype.onFullscreenClick_ = function() {
if (!this.enabled_) return;
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
this.videoContainer_.requestFullscreen();
}
};
/** @private */
ShakaControls.prototype.onCurrentTimeClick_ = function() {
if (!this.enabled_) return;
// Jump to LIVE if the user clicks on the current time.
if (this.player_.isLive()) {
this.video_.currentTime = this.seekBar_.max;
}
};
/**
* Cycles trick play rate between -1, -2, -4, and -8.
* @private
*/
ShakaControls.prototype.onRewindClick_ = function() {
if (!this.enabled_) return;
if (!this.video_.duration) {
return;
}
this.trickPlayRate_ = (this.trickPlayRate_ > 0 || this.trickPlayRate_ < -4) ?
-1 : this.trickPlayRate_ * 2;
this.player_.trickPlay(this.trickPlayRate_);
};
/**
* Cycles trick play rate between 1, 2, 4, and 8.
* @private
*/
ShakaControls.prototype.onFastForwardClick_ = function() {
if (!this.enabled_) return;
if (!this.video_.duration) {
return;
}
this.trickPlayRate_ = (this.trickPlayRate_ < 0 || this.trickPlayRate_ > 4) ?
1 : this.trickPlayRate_ * 2;
this.player_.trickPlay(this.trickPlayRate_);
};
/** @private */
ShakaControls.prototype.onCastClick_ = function() {
if (!this.enabled_) return;
if (this.castProxy_.isCasting()) {
this.castProxy_.suggestDisconnect();
} else {
this.castButton_.disabled = true;
// Disable the load button, to prevent the users from trying to load an
// asset while the cast proxy is connecting.
// That can lead to strange, erratic behavior.
document.getElementById('loadButton').disabled = true;
this.castProxy_.cast().then(function() {
document.getElementById('loadButton').disabled = false;
this.castButton_.disabled = false;
// Success!
}.bind(this), function(error) {
this.castButton_.disabled = false;
document.getElementById('loadButton').disabled = false;
if (error.code != shaka.util.Error.Code.CAST_CANCELED_BY_USER) {
this.onError_(error);
}
}.bind(this));
}
};
/**
* @param {Event} event
* @private
*/
ShakaControls.prototype.onCastStatusChange_ = function(event) {
var canCast = this.castProxy_.canCast() && this.castAllowed_;
var isCasting = this.castProxy_.isCasting();
this.notifyCastStatus_(isCasting);
this.castButton_.style.display = canCast ? 'inherit' : 'none';
this.castButton_.textContent = isCasting ? 'cast_connected' : 'cast';
this.castReceiverName_.style.display =
isCasting ? 'inherit' : 'none';
this.castReceiverName_.textContent =
isCasting ? 'Casting to ' + this.castProxy_.receiverName() : '';
if (this.castProxy_.isCasting()) {
this.controls_.classList.add('casting');
} else {
this.controls_.classList.remove('casting');
}
};
/**
* @param {Event} event
* @private
*/
ShakaControls.prototype.onBufferingStateChange_ = function(event) {
this.bufferingSpinner_.style.display =
event.buffering ? 'inherit' : 'none';
};
/**
* @param {boolean} show True to show trick play controls, false to show seek
* bar.
*/
ShakaControls.prototype.showTrickPlay = function(show) {
this.seekBar_.parentElement.style.width = show ? 'auto' : '100%';
this.seekBar_.style.display = show ? 'none' : 'flex';
this.rewindButton_.style.display = show ? 'inline' : 'none';
this.fastForwardButton_.style.display = show ? 'inline' : 'none';
};
/**
* @return {boolean}
* @private
*/
ShakaControls.prototype.isOpaque_ = function() {
if (!this.enabled_) return false;
// While you are casting, the UI is always opaque.
if (this.castProxy_ && this.castProxy_.isCasting()) return true;
var parentElement = this.controls_.parentElement;
// The controls are opaque if either:
// 1. We have explicitly made them so in JavaScript
// 2. The browser has made them so via css and the hover state
return (this.controls_.style.opacity == 1 ||
parentElement.querySelector('#controls:hover') == this.controls_);
};
/**
* Called when the seek range or current time need to be updated.
* @private
*/
ShakaControls.prototype.updateTimeAndSeekRange_ = function() {
// Suppress updates if the controls are hidden.
if (!this.isOpaque_()) {
return;
}
var displayTime = this.isSeeking_ ?
this.seekBar_.value : this.video_.currentTime;
var duration = this.video_.duration;
var bufferedLength = this.video_.buffered.length;
var bufferedStart = bufferedLength ? this.video_.buffered.start(0) : 0;
var bufferedEnd =
bufferedLength ? this.video_.buffered.end(bufferedLength - 1) : 0;
var seekRange = this.player_.seekRange();
var seekRangeSize = seekRange.end - seekRange.start;
this.seekBar_.min = seekRange.start;
this.seekBar_.max = seekRange.end;
if (this.player_.isLive()) {
// The amount of time we are behind the live edge.
var behindLive = Math.floor(seekRange.end - displayTime);
displayTime = Math.max(0, behindLive);
var showHour = seekRangeSize >= 3600;
// Consider "LIVE" when less than 1 second behind the live-edge. Always
// show the full time string when seeking, including the leading '-';
// otherwise, the time string "flickers" near the live-edge.
if ((displayTime >= 1) || this.isSeeking_) {
this.currentTime_.textContent =
'- ' + this.buildTimeString_(displayTime, showHour);
this.currentTime_.style.cursor = 'pointer';
} else {
this.currentTime_.textContent = 'LIVE';
this.currentTime_.style.cursor = '';
}
if (!this.isSeeking_) {
this.seekBar_.value = seekRange.end - displayTime;
}
} else {
var showHour = duration >= 3600;
this.currentTime_.textContent =
this.buildTimeString_(displayTime, showHour);
if (!this.isSeeking_) {
this.seekBar_.value = displayTime;
}
this.currentTime_.style.cursor = '';
}
var gradient = ['to right'];
if (bufferedLength == 0) {
gradient.push('#000 0%');
} else {
var clampedBufferStart = Math.max(bufferedStart, seekRange.start);
var clampedBufferEnd = Math.min(bufferedEnd, seekRange.end);
var bufferStartDistance = clampedBufferStart - seekRange.start;
var bufferEndDistance = clampedBufferEnd - seekRange.start;
var playheadDistance = displayTime - seekRange.start;
// NOTE: the fallback to zero eliminates NaN.
var bufferStartFraction = (bufferStartDistance / seekRangeSize) || 0;
var bufferEndFraction = (bufferEndDistance / seekRangeSize) || 0;
var playheadFraction = (playheadDistance / seekRangeSize) || 0;
gradient.push('#000 ' + (bufferStartFraction * 100) + '%');
gradient.push('#ccc ' + (bufferStartFraction * 100) + '%');
gradient.push('#ccc ' + (playheadFraction * 100) + '%');
gradient.push('#444 ' + (playheadFraction * 100) + '%');
gradient.push('#444 ' + (bufferEndFraction * 100) + '%');
gradient.push('#000 ' + (bufferEndFraction * 100) + '%');
}
this.seekBar_.style.background =
'linear-gradient(' + gradient.join(',') + ')';
};
/**
* Builds a time string, e.g., 01:04:23, from |displayTime|.
*
* @param {number} displayTime
* @param {boolean} showHour
* @return {string}
* @private
*/
ShakaControls.prototype.buildTimeString_ = function(displayTime, showHour) {
var h = Math.floor(displayTime / 3600);
var m = Math.floor((displayTime / 60) % 60);
var s = Math.floor(displayTime % 60);
if (s < 10) s = '0' + s;
var text = m + ':' + s;
if (showHour) {
if (m < 10) text = '0' + text;
text = h + ':' + text;
}
return text;
};