diff options
Diffstat (limited to 'toolkit/content/widgets/videocontrols.xml')
-rw-r--r-- | toolkit/content/widgets/videocontrols.xml | 2027 |
1 files changed, 2027 insertions, 0 deletions
diff --git a/toolkit/content/widgets/videocontrols.xml b/toolkit/content/widgets/videocontrols.xml new file mode 100644 index 000000000..630f5a022 --- /dev/null +++ b/toolkit/content/widgets/videocontrols.xml @@ -0,0 +1,2027 @@ +<?xml version="1.0"?> +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + + +<!DOCTYPE bindings [ + <!ENTITY % videocontrolsDTD SYSTEM "chrome://global/locale/videocontrols.dtd"> + %videocontrolsDTD; +]> + +<bindings id="videoControlBindings" + xmlns="http://www.mozilla.org/xbl" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:xbl="http://www.mozilla.org/xbl" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns:html="http://www.w3.org/1999/xhtml"> + + <binding id="timeThumb" + extends="chrome://global/content/bindings/scale.xml#scalethumb"> + <xbl:content xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <xbl:children/> + <hbox class="timeThumb" xbl:inherits="showhours"> + <label class="timeLabel"/> + </hbox> + </xbl:content> + <implementation> + + <constructor> + <![CDATA[ + this.timeLabel = document.getAnonymousElementByAttribute(this, "class", "timeLabel"); + this.timeLabel.setAttribute("value", "0:00"); + ]]> + </constructor> + + <property name="showHours"> + <getter> + <![CDATA[ + return this.getAttribute("showhours") == "true"; + ]]> + </getter> + <setter> + <![CDATA[ + this.setAttribute("showhours", val); + // If the duration becomes known while we're still showing the value + // for time=0, immediately update the value to show or hide the hours. + // It's less intrusive to do it now than when the user clicks play and + // is looking right next to the thumb. + var displayedTime = this.timeLabel.getAttribute("value"); + if (val && displayedTime == "0:00") + this.timeLabel.setAttribute("value", "0:00:00"); + else if (!val && displayedTime == "0:00:00") + this.timeLabel.setAttribute("value", "0:00"); + ]]> + </setter> + </property> + + <method name="setTime"> + <parameter name="time"/> + <body> + <![CDATA[ + var timeString; + time = Math.round(time / 1000); + var hours = Math.floor(time / 3600); + var mins = Math.floor((time % 3600) / 60); + var secs = Math.floor(time % 60); + if (secs < 10) + secs = "0" + secs; + if (hours || this.showHours) { + if (mins < 10) + mins = "0" + mins; + timeString = hours + ":" + mins + ":" + secs; + } else { + timeString = mins + ":" + secs; + } + + this.timeLabel.setAttribute("value", timeString); + ]]> + </body> + </method> + </implementation> + </binding> + + <binding id="suppressChangeEvent" + extends="chrome://global/content/bindings/scale.xml#scale"> + <implementation implements="nsIXBLAccessible"> + <!-- nsIXBLAccessible --> + <property name="accessibleName" readonly="true"> + <getter> + if (this.type != "scrubber") + return ""; + + var currTime = this.thumb.timeLabel.getAttribute("value"); + var totalTime = this.durationValue; + + return this.scrubberNameFormat.replace(/#1/, currTime). + replace(/#2/, totalTime); + </getter> + </property> + + <constructor> + <![CDATA[ + this.scrubberNameFormat = ]]>"&scrubberScale.nameFormat;"<![CDATA[; + this.durationValue = ""; + this.valueBar = null; + this.isDragging = false; + this.isPausedByDragging = false; + + this.thumb = document.getAnonymousElementByAttribute(this, "class", "scale-thumb"); + this.type = this.getAttribute("class"); + this.Utils = document.getBindingParent(this.parentNode).Utils; + if (this.type == "scrubber") + this.valueBar = this.Utils.progressBar; + ]]> + </constructor> + + <method name="valueChanged"> + <parameter name="which"/> + <parameter name="newValue"/> + <parameter name="userChanged"/> + <body> + <![CDATA[ + // This method is a copy of the base binding's valueChanged(), except that it does + // not dispatch a |change| event (to avoid exposing the event to web content), and + // just calls the videocontrol's seekToPosition() method directly. + switch (which) { + case "curpos": + if (this.type == "scrubber") { + // Update the time shown in the thumb. + this.thumb.setTime(newValue); + this.Utils.positionLabel.setAttribute("value", this.thumb.timeLabel.value); + // Update the value bar to match the thumb position. + let percent = newValue / this.max; + this.valueBar.value = Math.round(percent * 10000); // has max=10000 + } + + // The value of userChanged is true when changing the position with the mouse, + // but not when pressing an arrow key. However, the base binding sets + // ._userChanged in its keypress handlers, so we just need to check both. + if (!userChanged && !this._userChanged) + return; + this.setAttribute("value", newValue); + + if (this.type == "scrubber") + this.Utils.seekToPosition(newValue); + else if (this.type == "volumeControl") + this.Utils.setVolume(newValue / 100); + break; + + case "minpos": + this.setAttribute("min", newValue); + break; + + case "maxpos": + if (this.type == "scrubber") { + // Update the value bar to match the thumb position. + let percent = this.value / newValue; + this.valueBar.value = Math.round(percent * 10000); // has max=10000 + } + this.setAttribute("max", newValue); + break; + } + ]]> + </body> + </method> + + <method name="dragStateChanged"> + <parameter name="isDragging"/> + <body> + <![CDATA[ + if (this.type == "scrubber") { + this.Utils.log("--- dragStateChanged: " + isDragging + " ---"); + this.isDragging = isDragging; + if (this.isPausedByDragging && !isDragging) { + // After the drag ends, resume playing. + this.Utils.video.play(); + this.isPausedByDragging = false; + } + } + ]]> + </body> + </method> + + <method name="pauseVideoDuringDragging"> + <body> + <![CDATA[ + if (this.isDragging && + !this.Utils.video.paused && !this.isPausedByDragging) { + this.isPausedByDragging = true; + this.Utils.video.pause(); + } + ]]> + </body> + </method> + + </implementation> + </binding> + + <binding id="videoControls"> + + <resources> + <stylesheet src="chrome://global/content/bindings/videocontrols.css"/> + <stylesheet src="chrome://global/skin/media/videocontrols.css"/> + </resources> + + <xbl:content xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + class="mediaControlsFrame"> + <stack flex="1"> + <vbox flex="1" class="statusOverlay" hidden="true"> + <box class="statusIcon"/> + <label class="errorLabel" anonid="errorAborted">&error.aborted;</label> + <label class="errorLabel" anonid="errorNetwork">&error.network;</label> + <label class="errorLabel" anonid="errorDecode">&error.decode;</label> + <label class="errorLabel" anonid="errorSrcNotSupported">&error.srcNotSupported;</label> + <label class="errorLabel" anonid="errorNoSource">&error.noSource2;</label> + <label class="errorLabel" anonid="errorGeneric">&error.generic;</label> + </vbox> + + <vbox class="controlsOverlay"> + <stack flex="1"> + <spacer class="controlsSpacer" flex="1"/> + <box class="clickToPlay" hidden="true" flex="1"/> + <vbox class="textTrackList" hidden="true" offlabel="&closedCaption.off;"></vbox> + </stack> + <hbox class="controlBar" hidden="true"> + <button class="playButton" + playlabel="&playButton.playLabel;" + pauselabel="&playButton.pauseLabel;"/> + <stack class="scrubberStack" flex="1"> + <box class="backgroundBar"/> + <progressmeter class="bufferBar"/> + <progressmeter class="progressBar" max="10000"/> + <scale class="scrubber" movetoclick="true"/> + </stack> + <vbox class="durationBox"> + <label class="positionLabel" role="presentation"/> + <label class="durationLabel" role="presentation"/> + </vbox> + <button class="muteButton" + mutelabel="&muteButton.muteLabel;" + unmutelabel="&muteButton.unmuteLabel;"/> + <stack class="volumeStack"> + <box class="volumeBackground"/> + <box class="volumeForeground" anonid="volumeForeground"/> + <scale class="volumeControl" movetoclick="true"/> + </stack> + <button class="closedCaptionButton"/> + <button class="fullscreenButton" + enterfullscreenlabel="&fullscreenButton.enterfullscreenlabel;" + exitfullscreenlabel="&fullscreenButton.exitfullscreenlabel;"/> + </hbox> + </vbox> + </stack> + </xbl:content> + + <implementation> + + <constructor> + <![CDATA[ + this.isTouchControl = false; + this.randomID = 0; + + this.Utils = { + debug : false, + video : null, + videocontrols : null, + controlBar : null, + playButton : null, + muteButton : null, + volumeControl : null, + durationLabel : null, + positionLabel : null, + scrubberThumb : null, + scrubber : null, + progressBar : null, + bufferBar : null, + statusOverlay : null, + controlsSpacer : null, + clickToPlay : null, + controlsOverlay : null, + fullscreenButton : null, + currentTextTrackIndex: 0, + + textTracksCount: 0, + randomID : 0, + videoEvents : ["play", "pause", "ended", "volumechange", "loadeddata", + "loadstart", "timeupdate", "progress", + "playing", "waiting", "canplay", "canplaythrough", + "seeking", "seeked", "emptied", "loadedmetadata", + "error", "suspend", "stalled", + "mozinterruptbegin", "mozinterruptend" ], + + firstFrameShown : false, + timeUpdateCount : 0, + maxCurrentTimeSeen : 0, + _isAudioOnly : false, + get isAudioOnly() { return this._isAudioOnly; }, + set isAudioOnly(val) { + this._isAudioOnly = val; + this.setFullscreenButtonState(); + + if (!this.isTopLevelSyntheticDocument) + return; + if (this._isAudioOnly) { + this.video.style.height = this._controlBarHeight + "px"; + this.video.style.width = "66%"; + } else { + this.video.style.removeProperty("height"); + this.video.style.removeProperty("width"); + } + }, + suppressError : false, + + setupStatusFader : function(immediate) { + // Since the play button will be showing, we don't want to + // show the throbber behind it. The throbber here will + // only show if needed after the play button has been pressed. + if (!this.clickToPlay.hidden) { + this.startFadeOut(this.statusOverlay, true); + return; + } + + var show = false; + if (this.video.seeking || + (this.video.error && !this.suppressError) || + this.video.networkState == this.video.NETWORK_NO_SOURCE || + (this.video.networkState == this.video.NETWORK_LOADING && + (this.video.paused || this.video.ended + ? this.video.readyState < this.video.HAVE_CURRENT_DATA + : this.video.readyState < this.video.HAVE_FUTURE_DATA)) || + (this.timeUpdateCount <= 1 && !this.video.ended && + this.video.readyState < this.video.HAVE_FUTURE_DATA && + this.video.networkState == this.video.NETWORK_LOADING)) + show = true; + + // Explicitly hide the status fader if this + // is audio only until bug 619421 is fixed. + if (this.isAudioOnly) + show = false; + + this.log("Status overlay: seeking=" + this.video.seeking + + " error=" + this.video.error + " readyState=" + this.video.readyState + + " paused=" + this.video.paused + " ended=" + this.video.ended + + " networkState=" + this.video.networkState + + " timeUpdateCount=" + this.timeUpdateCount + + " --> " + (show ? "SHOW" : "HIDE")); + this.startFade(this.statusOverlay, show, immediate); + }, + + /* + * Set the initial state of the controls. The binding is normally created along + * with video element, but could be attached at any point (eg, if the video is + * removed from the document and then reinserted). Thus, some one-time events may + * have already fired, and so we'll need to explicitly check the initial state. + */ + setupInitialState : function() { + this.randomID = Math.random(); + this.videocontrols.randomID = this.randomID; + + this.setPlayButtonState(this.video.paused); + + this.setFullscreenButtonState(); + + var duration = Math.round(this.video.duration * 1000); // in ms + var currentTime = Math.round(this.video.currentTime * 1000); // in ms + this.log("Initial playback position is at " + currentTime + " of " + duration); + // It would be nice to retain maxCurrentTimeSeen, but it would be difficult + // to determine if the media source changed while we were detached. + this.maxCurrentTimeSeen = currentTime; + this.showPosition(currentTime, duration); + + // If we have metadata, check if this is a <video> without + // video data, or a video with no audio track. + if (this.video.readyState >= this.video.HAVE_METADATA) { + if (this.video instanceof HTMLVideoElement && + (this.video.videoWidth == 0 || this.video.videoHeight == 0)) + this.isAudioOnly = true; + + // We have to check again if the media has audio here, + // because of bug 718107: switching to fullscreen may + // cause the bindings to detach and reattach, hence + // unsetting the attribute. + if (!this.isAudioOnly && !this.video.mozHasAudio) { + this.muteButton.setAttribute("noAudio", "true"); + this.muteButton.setAttribute("disabled", "true"); + } + } + + if (this.isAudioOnly) + this.clickToPlay.hidden = true; + + // If the first frame hasn't loaded, kick off a throbber fade-in. + if (this.video.readyState >= this.video.HAVE_CURRENT_DATA) + this.firstFrameShown = true; + + // We can't determine the exact buffering status, but do know if it's + // fully loaded. (If it's still loading, it will fire a progress event + // and we'll figure out the exact state then.) + this.bufferBar.setAttribute("max", 100); + if (this.video.readyState >= this.video.HAVE_METADATA) + this.showBuffered(); + else + this.bufferBar.setAttribute("value", 0); + + // Set the current status icon. + if (this.hasError()) { + this.clickToPlay.hidden = true; + this.statusIcon.setAttribute("type", "error"); + this.updateErrorText(); + this.setupStatusFader(true); + } + + // An event handler for |onresize| should be added when bug 227495 is fixed. + this.controlBar.hidden = false; + this._playButtonWidth = this.playButton.clientWidth; + this._durationLabelWidth = this.durationLabel.clientWidth; + this._muteButtonWidth = this.muteButton.clientWidth; + this._volumeControlWidth = this.volumeControl.clientWidth; + this._closedCaptionButtonWidth = this.closedCaptionButton.clientWidth; + this._fullscreenButtonWidth = this.fullscreenButton.clientWidth; + this._controlBarHeight = this.controlBar.clientHeight; + this.controlBar.hidden = true; + this.adjustControlSize(); + + // Can only update the volume controls once we've computed + // _volumeControlWidth, since the volume slider implementation + // depends on it. + this.updateVolumeControls(); + }, + + setupNewLoadState : function() { + // videocontrols.css hides the control bar by default, because if script + // is disabled our binding's script is disabled too (bug 449358). Thus, + // the controls are broken and we don't want them shown. But if script is + // enabled, the code here will run and can explicitly unhide the controls. + // + // For videos with |autoplay| set, we'll leave the controls initially hidden, + // so that they don't get in the way of the playing video. Otherwise we'll + // go ahead and reveal the controls now, so they're an obvious user cue. + // + // (Note: the |controls| attribute is already handled via layout/style/html.css) + var shouldShow = !this.dynamicControls || + (this.video.paused && + !(this.video.autoplay && this.video.mozAutoplayEnabled)); + // Hide the overlay if the video time is non-zero or if an error occurred to workaround bug 718107. + this.startFade(this.clickToPlay, shouldShow && !this.isAudioOnly && + this.video.currentTime == 0 && !this.hasError(), true); + this.startFade(this.controlBar, shouldShow, true); + }, + + get dynamicControls() { + // Don't fade controls for <audio> elements. + var enabled = !this.isAudioOnly; + + // Allow tests to explicitly suppress the fading of controls. + if (this.video.hasAttribute("mozNoDynamicControls")) + enabled = false; + + // If the video hits an error, suppress controls if it + // hasn't managed to do anything else yet. + if (!this.firstFrameShown && this.hasError()) + enabled = false; + + return enabled; + }, + + updateVolumeControls() { + var volume = this.video.muted ? 0 : this.video.volume; + var volumePercentage = Math.round(volume * 100); + this.updateMuteButtonState(); + this.volumeControl.value = volumePercentage; + this.volumeForeground.style.paddingRight = (1 - volume) * this._volumeControlWidth + "px"; + }, + + handleEvent : function (aEvent) { + this.log("Got media event ----> " + aEvent.type); + + // If the binding is detached (or has been replaced by a + // newer instance of the binding), nuke our event-listeners. + if (this.videocontrols.randomID != this.randomID) { + this.terminateEventListeners(); + return; + } + + switch (aEvent.type) { + case "play": + this.setPlayButtonState(false); + this.setupStatusFader(); + if (!this._triggeredByControls && this.dynamicControls && this.videocontrols.isTouchControl) + this.startFadeOut(this.controlBar); + if (!this._triggeredByControls) + this.clickToPlay.hidden = true; + this._triggeredByControls = false; + break; + case "pause": + // Little white lie: if we've internally paused the video + // while dragging the scrubber, don't change the button state. + if (!this.scrubber.isDragging) + this.setPlayButtonState(true); + this.setupStatusFader(); + break; + case "ended": + this.setPlayButtonState(true); + // We throttle timechange events, so the thumb might not be + // exactly at the end when the video finishes. + this.showPosition(Math.round(this.video.currentTime * 1000), + Math.round(this.video.duration * 1000)); + this.startFadeIn(this.controlBar); + this.setupStatusFader(); + break; + case "volumechange": + this.updateVolumeControls(); + // Show the controls to highlight the changing volume, + // but only if the click-to-play overlay has already + // been hidden (we don't hide controls when the overlay is visible). + if (this.clickToPlay.hidden && !this.isAudioOnly) { + this.startFadeIn(this.controlBar); + clearTimeout(this._hideControlsTimeout); + this._hideControlsTimeout = setTimeout(this._hideControlsFn, this.HIDE_CONTROLS_TIMEOUT_MS); + } + break; + case "loadedmetadata": + this.adjustControlSize(); + // If a <video> doesn't have any video data, treat it as <audio> + // and show the controls (they won't fade back out) + if (this.video instanceof HTMLVideoElement && + (this.video.videoWidth == 0 || this.video.videoHeight == 0)) { + this.isAudioOnly = true; + this.clickToPlay.hidden = true; + this.startFadeIn(this.controlBar); + this.setFullscreenButtonState(); + } + this.showDuration(Math.round(this.video.duration * 1000)); + if (!this.isAudioOnly && !this.video.mozHasAudio) { + this.muteButton.setAttribute("noAudio", "true"); + this.muteButton.setAttribute("disabled", "true"); + } + break; + case "loadeddata": + this.firstFrameShown = true; + this.setupStatusFader(); + break; + case "loadstart": + this.maxCurrentTimeSeen = 0; + this.controlsSpacer.removeAttribute("aria-label"); + this.statusOverlay.removeAttribute("error"); + this.statusIcon.setAttribute("type", "throbber"); + this.isAudioOnly = (this.video instanceof HTMLAudioElement); + this.setPlayButtonState(true); + this.setupNewLoadState(); + this.setupStatusFader(); + break; + case "progress": + this.statusIcon.removeAttribute("stalled"); + this.showBuffered(); + this.setupStatusFader(); + break; + case "stalled": + this.statusIcon.setAttribute("stalled", "true"); + this.statusIcon.setAttribute("type", "throbber"); + this.setupStatusFader(); + break; + case "suspend": + this.setupStatusFader(); + break; + case "timeupdate": + var currentTime = Math.round(this.video.currentTime * 1000); // in ms + var duration = Math.round(this.video.duration * 1000); // in ms + + // If playing/seeking after the video ended, we won't get a "play" + // event, so update the button state here. + if (!this.video.paused) + this.setPlayButtonState(false); + + this.timeUpdateCount++; + // Whether we show the statusOverlay sometimes depends + // on whether we've seen more than one timeupdate + // event (if we haven't, there hasn't been any + // "playback activity" and we may wish to show the + // statusOverlay while we wait for HAVE_ENOUGH_DATA). + // If we've seen more than 2 timeupdate events, + // the count is no longer relevant to setupStatusFader. + if (this.timeUpdateCount <= 2) + this.setupStatusFader(); + + // If the user is dragging the scrubber ignore the delayed seek + // responses (don't yank the thumb away from the user) + if (this.scrubber.isDragging) + return; + + this.showPosition(currentTime, duration); + break; + case "emptied": + this.bufferBar.value = 0; + this.showPosition(0, 0); + break; + case "seeking": + this.showBuffered(); + this.statusIcon.setAttribute("type", "throbber"); + this.setupStatusFader(); + break; + case "waiting": + this.statusIcon.setAttribute("type", "throbber"); + this.setupStatusFader(); + break; + case "seeked": + case "playing": + case "canplay": + case "canplaythrough": + this.setupStatusFader(); + break; + case "error": + // We'll show the error status icon when we receive an error event + // under either of the following conditions: + // 1. The video has its error attribute set; this means we're loading + // from our src attribute, and the load failed, or we we're loading + // from source children and the decode or playback failed after we + // determined our selected resource was playable. + // 2. The video's networkState is NETWORK_NO_SOURCE. This means we we're + // loading from child source elements, but we were unable to select + // any of the child elements for playback during resource selection. + if (this.hasError()) { + this.suppressError = false; + this.clickToPlay.hidden = true; + this.statusIcon.setAttribute("type", "error"); + this.updateErrorText(); + this.setupStatusFader(true); + // If video hasn't shown anything yet, disable the controls. + if (!this.firstFrameShown) + this.startFadeOut(this.controlBar); + this.controlsSpacer.removeAttribute("hideCursor"); + } + break; + case "mozinterruptbegin": + case "mozinterruptend": + // Nothing to do... + break; + default: + this.log("!!! event " + aEvent.type + " not handled!"); + } + }, + + terminateEventListeners : function () { + if (this.videoEvents) { + for (let event of this.videoEvents) { + this.video.removeEventListener(event, this, { + capture: true, + mozSystemGroup: true + }); + } + } + + if (this.controlListeners) { + for (let element of this.controlListeners) { + element.item.removeEventListener(element.event, element.func, + { mozSystemGroup: true }); + } + + delete this.controlListeners; + } + + this.log("--- videocontrols terminated ---"); + }, + + hasError : function () { + // We either have an explicit error, or the resource selection + // algorithm is running and we've tried to load something and failed. + // Note: we don't consider the case where we've tried to load but + // there's no sources to load as an error condition, as sites may + // do this intentionally to work around requires-user-interaction to + // play restrictions, and we don't want to display a debug message + // if that's the case. + return this.video.error != null || + (this.video.networkState == this.video.NETWORK_NO_SOURCE && + this.hasSources()); + }, + + hasSources : function() { + if (this.video.hasAttribute('src') && + this.video.getAttribute('src') !== "") { + return true; + } + for (var child = this.video.firstChild; + child !== null; + child = child.nextElementSibling) { + if (child instanceof HTMLSourceElement) { + return true; + } + } + return false; + }, + + updateErrorText : function () { + let error; + let v = this.video; + // It is possible to have both v.networkState == NETWORK_NO_SOURCE + // as well as v.error being non-null. In this case, we will show + // the v.error.code instead of the v.networkState error. + if (v.error) { + switch (v.error.code) { + case v.error.MEDIA_ERR_ABORTED: + error = "errorAborted"; + break; + case v.error.MEDIA_ERR_NETWORK: + error = "errorNetwork"; + break; + case v.error.MEDIA_ERR_DECODE: + error = "errorDecode"; + break; + case v.error.MEDIA_ERR_SRC_NOT_SUPPORTED: + error = "errorSrcNotSupported"; + break; + default: + error = "errorGeneric"; + break; + } + } else if (v.networkState == v.NETWORK_NO_SOURCE) { + error = "errorNoSource"; + } else { + return; // No error found. + } + + let label = document.getAnonymousElementByAttribute(this.videocontrols, "anonid", error); + this.controlsSpacer.setAttribute("aria-label", label.textContent); + this.statusOverlay.setAttribute("error", error); + }, + + formatTime : function(aTime) { + // Format the duration as "h:mm:ss" or "m:ss" + aTime = Math.round(aTime / 1000); + let hours = Math.floor(aTime / 3600); + let mins = Math.floor((aTime % 3600) / 60); + let secs = Math.floor(aTime % 60); + let timeString; + if (secs < 10) + secs = "0" + secs; + if (hours) { + if (mins < 10) + mins = "0" + mins; + timeString = hours + ":" + mins + ":" + secs; + } else { + timeString = mins + ":" + secs; + } + return timeString; + }, + + showDuration : function (duration) { + let isInfinite = (duration == Infinity); + this.log("Duration is " + duration + "ms.\n"); + + if (isNaN(duration) || isInfinite) + duration = this.maxCurrentTimeSeen; + + // Format the duration as "h:mm:ss" or "m:ss" + let timeString = isInfinite ? "" : this.formatTime(duration); + this.durationLabel.setAttribute("value", timeString); + + // "durationValue" property is used by scale binding to + // generate accessible name. + this.scrubber.durationValue = timeString; + + // If the duration is over an hour, thumb should show h:mm:ss instead of mm:ss + this.scrubberThumb.showHours = (duration >= 3600000); + + this.scrubber.max = duration; + // XXX Can't set increment here, due to bug 473103. Also, doing so causes + // snapping when dragging with the mouse, so we can't just set a value for + // the arrow-keys. + this.scrubber.pageIncrement = Math.round(duration / 10); + }, + + seekToPosition : function(newPosition) { + newPosition /= 1000; // convert from ms + this.log("+++ seeking to " + newPosition); + if (this.videocontrols.isGonk) { + // We use fastSeek() on B2G, and an accurate (but slower) + // seek on other platforms (that are likely to be higher + // perf). + this.video.fastSeek(newPosition); + } else { + this.video.currentTime = newPosition; + } + }, + + setVolume : function(newVolume) { + this.log("*** setting volume to " + newVolume); + this.video.volume = newVolume; + this.video.muted = false; + }, + + showPosition : function(currentTime, duration) { + // If the duration is unknown (because the server didn't provide + // it, or the video is a stream), then we want to fudge the duration + // by using the maximum playback position that's been seen. + if (currentTime > this.maxCurrentTimeSeen) + this.maxCurrentTimeSeen = currentTime; + this.showDuration(duration); + + this.log("time update @ " + currentTime + "ms of " + duration + "ms"); + + this.positionLabel.setAttribute("value", this.formatTime(currentTime)); + this.scrubber.value = currentTime; + }, + + showBuffered : function() { + function bsearch(haystack, needle, cmp) { + var length = haystack.length; + var low = 0; + var high = length; + while (low < high) { + var probe = low + ((high - low) >> 1); + var r = cmp(haystack, probe, needle); + if (r == 0) { + return probe; + } else if (r > 0) { + low = probe + 1; + } else { + high = probe; + } + } + return -1; + } + + function bufferedCompare(buffered, i, time) { + if (time > buffered.end(i)) { + return 1; + } else if (time >= buffered.start(i)) { + return 0; + } + return -1; + } + + var duration = Math.round(this.video.duration * 1000); + if (isNaN(duration)) + duration = this.maxCurrentTimeSeen; + + // Find the range that the current play position is in and use that + // range for bufferBar. At some point we may support multiple ranges + // displayed in the bar. + var currentTime = this.video.currentTime; + var buffered = this.video.buffered; + var index = bsearch(buffered, currentTime, bufferedCompare); + var endTime = 0; + if (index >= 0) { + endTime = Math.round(buffered.end(index) * 1000); + } + this.bufferBar.max = duration; + this.bufferBar.value = endTime; + }, + + _controlsHiddenByTimeout : false, + _showControlsTimeout : 0, + SHOW_CONTROLS_TIMEOUT_MS: 500, + _showControlsFn : function () { + if (Utils.video.matches("video:hover")) { + Utils.startFadeIn(Utils.controlBar, false); + Utils._showControlsTimeout = 0; + Utils._controlsHiddenByTimeout = false; + } + }, + + _hideControlsTimeout : 0, + _hideControlsFn : function () { + if (!Utils.scrubber.isDragging) { + Utils.startFade(Utils.controlBar, false); + Utils._hideControlsTimeout = 0; + Utils._controlsHiddenByTimeout = true; + } + }, + HIDE_CONTROLS_TIMEOUT_MS : 2000, + onMouseMove : function (event) { + // Pause playing video when the mouse is dragging over the control bar. + if (this.scrubber.isDragging) { + this.scrubber.pauseVideoDuringDragging(); + } + + // If the controls are static, don't change anything. + if (!this.dynamicControls) + return; + + clearTimeout(this._hideControlsTimeout); + + // Suppress fading out the controls until the video has rendered + // its first frame. But since autoplay videos start off with no + // controls, let them fade-out so the controls don't get stuck on. + if (!this.firstFrameShown && + !(this.video.autoplay && this.video.mozAutoplayEnabled)) + return; + + if (this._controlsHiddenByTimeout) + this._showControlsTimeout = setTimeout(this._showControlsFn, this.SHOW_CONTROLS_TIMEOUT_MS); + else + this.startFade(this.controlBar, true); + + // Hide the controls if the mouse cursor is left on top of the video + // but above the control bar and if the click-to-play overlay is hidden. + if ((this._controlsHiddenByTimeout || + event.clientY < this.controlBar.getBoundingClientRect().top) && + this.clickToPlay.hidden) { + this._hideControlsTimeout = setTimeout(this._hideControlsFn, this.HIDE_CONTROLS_TIMEOUT_MS); + } + }, + + onMouseInOut : function (event) { + // If the controls are static, don't change anything. + if (!this.dynamicControls) + return; + + clearTimeout(this._hideControlsTimeout); + + // Ignore events caused by transitions between child nodes. + // Note that the videocontrols element is the same + // size as the *content area* of the video element, + // but this is not the same as the video element's + // border area if the video has border or padding. + if (this.isEventWithin(event, this.videocontrols)) + return; + + var isMouseOver = (event.type == "mouseover"); + + var controlRect = this.controlBar.getBoundingClientRect(); + var isMouseInControls = event.clientY > controlRect.top && + event.clientY < controlRect.bottom && + event.clientX > controlRect.left && + event.clientX < controlRect.right; + + // Suppress fading out the controls until the video has rendered + // its first frame. But since autoplay videos start off with no + // controls, let them fade-out so the controls don't get stuck on. + if (!this.firstFrameShown && !isMouseOver && + !(this.video.autoplay && this.video.mozAutoplayEnabled)) + return; + + if (!isMouseOver && !isMouseInControls) { + this.adjustControlSize(); + + // Keep the controls visible if the click-to-play is visible. + if (!this.clickToPlay.hidden) + return; + + this.startFadeOut(this.controlBar, false); + this.textTrackList.setAttribute("hidden", "true"); + clearTimeout(this._showControlsTimeout); + Utils._controlsHiddenByTimeout = false; + } + }, + + startFadeIn : function (element, immediate) { + this.startFade(element, true, immediate); + }, + + startFadeOut : function (element, immediate) { + this.startFade(element, false, immediate); + }, + + startFade : function (element, fadeIn, immediate) { + if (element.classList.contains("controlBar") && fadeIn) { + // Bug 493523, the scrubber doesn't call valueChanged while hidden, + // so our dependent state (eg, timestamp in the thumb) will be stale. + // As a workaround, update it manually when it first becomes unhidden. + if (element.hidden) + this.scrubber.valueChanged("curpos", this.video.currentTime * 1000, false); + } + + if (immediate) + element.setAttribute("immediate", true); + else + element.removeAttribute("immediate"); + + if (fadeIn) { + element.hidden = false; + // force style resolution, so that transition begins + // when we remove the attribute. + element.clientTop; + element.removeAttribute("fadeout"); + if (element.classList.contains("controlBar")) + this.controlsSpacer.removeAttribute("hideCursor"); + } else { + element.setAttribute("fadeout", true); + if (element.classList.contains("controlBar") && !this.hasError() && + document.mozFullScreenElement == this.video) + this.controlsSpacer.setAttribute("hideCursor", true); + + } + }, + + onTransitionEnd : function (event) { + // Ignore events for things other than opacity changes. + if (event.propertyName != "opacity") + return; + + var element = event.originalTarget; + + // Nothing to do when a fade *in* finishes. + if (!element.hasAttribute("fadeout")) + return; + + this.scrubber.dragStateChanged(false); + element.hidden = true; + }, + + _triggeredByControls: false, + + startPlay : function () { + this._triggeredByControls = true; + this.hideClickToPlay(); + this.video.play(); + }, + + togglePause : function () { + if (this.video.paused || this.video.ended) { + this.startPlay(); + } else { + this.video.pause(); + } + + // We'll handle style changes in the event listener for + // the "play" and "pause" events, same as if content + // script was controlling video playback. + }, + + isVideoWithoutAudioTrack : function() { + return this.video.readyState >= this.video.HAVE_METADATA && + !this.isAudioOnly && + !this.video.mozHasAudio; + }, + + toggleMute : function () { + if (this.isVideoWithoutAudioTrack()) { + return; + } + this.video.muted = !this.isEffectivelyMuted(); + if (this.video.volume === 0) { + this.video.volume = 0.5; + } + + // We'll handle style changes in the event listener for + // the "volumechange" event, same as if content script was + // controlling volume. + }, + + isVideoInFullScreen : function () { + return document.mozFullScreenElement == this.video; + }, + + toggleFullscreen : function () { + this.isVideoInFullScreen() ? + document.mozCancelFullScreen() : + this.video.mozRequestFullScreen(); + }, + + setFullscreenButtonState : function () { + if (this.isAudioOnly || !document.mozFullScreenEnabled) { + this.controlBar.setAttribute("fullscreen-unavailable", true); + this.adjustControlSize(); + return; + } + this.controlBar.removeAttribute("fullscreen-unavailable"); + this.adjustControlSize(); + + var attrName = this.isVideoInFullScreen() ? "exitfullscreenlabel" : "enterfullscreenlabel"; + var value = this.fullscreenButton.getAttribute(attrName); + this.fullscreenButton.setAttribute("aria-label", value); + + if (this.isVideoInFullScreen()) + this.fullscreenButton.setAttribute("fullscreened", "true"); + else + this.fullscreenButton.removeAttribute("fullscreened"); + }, + + onFullscreenChange: function () { + if (this.isVideoInFullScreen()) { + Utils._hideControlsTimeout = setTimeout(this._hideControlsFn, this.HIDE_CONTROLS_TIMEOUT_MS); + } + this.setFullscreenButtonState(); + }, + + clickToPlayClickHandler : function(e) { + if (e.button != 0) + return; + if (this.hasError() && !this.suppressError) { + // Errors that can be dismissed should be placed here as we discover them. + if (this.video.error.code != this.video.error.MEDIA_ERR_ABORTED) + return; + this.statusOverlay.hidden = true; + this.suppressError = true; + return; + } + if (e.defaultPrevented) + return; + if (this.playButton.hasAttribute("paused")) { + this.startPlay(); + } else { + this.video.pause(); + } + }, + hideClickToPlay : function () { + let videoHeight = this.video.clientHeight; + let videoWidth = this.video.clientWidth; + + // The play button will animate to 3x its size. This + // shows the animation unless the video is too small + // to show 2/3 of the animation. + let animationScale = 2; + if (this._overlayPlayButtonHeight * animationScale > (videoHeight - this._controlBarHeight)|| + this._overlayPlayButtonWidth * animationScale > videoWidth) { + this.clickToPlay.setAttribute("immediate", "true"); + this.clickToPlay.hidden = true; + } else { + this.clickToPlay.removeAttribute("immediate"); + } + this.clickToPlay.setAttribute("fadeout", "true"); + }, + + setPlayButtonState : function(aPaused) { + if (aPaused) + this.playButton.setAttribute("paused", "true"); + else + this.playButton.removeAttribute("paused"); + + var attrName = aPaused ? "playlabel" : "pauselabel"; + var value = this.playButton.getAttribute(attrName); + this.playButton.setAttribute("aria-label", value); + }, + + isEffectivelyMuted : function() { + return this.video.muted || !this.video.volume; + }, + + updateMuteButtonState : function() { + var muted = this.isEffectivelyMuted(); + + if (muted) + this.muteButton.setAttribute("muted", "true"); + else + this.muteButton.removeAttribute("muted"); + + var attrName = muted ? "unmutelabel" : "mutelabel"; + var value = this.muteButton.getAttribute(attrName); + this.muteButton.setAttribute("aria-label", value); + }, + + _getComputedPropertyValueAsInt : function(element, property) { + let value = getComputedStyle(element, null).getPropertyValue(property); + return parseInt(value, 10); + }, + + keyHandler : function(event) { + // Ignore keys when content might be providing its own. + if (!this.video.hasAttribute("controls")) + return; + + var keystroke = ""; + if (event.altKey) + keystroke += "alt-"; + if (event.shiftKey) + keystroke += "shift-"; + if (navigator.platform.startsWith("Mac")) { + if (event.metaKey) + keystroke += "accel-"; + if (event.ctrlKey) + keystroke += "control-"; + } else { + if (event.metaKey) + keystroke += "meta-"; + if (event.ctrlKey) + keystroke += "accel-"; + } + switch (event.keyCode) { + case KeyEvent.DOM_VK_UP: + keystroke += "upArrow"; + break; + case KeyEvent.DOM_VK_DOWN: + keystroke += "downArrow"; + break; + case KeyEvent.DOM_VK_LEFT: + keystroke += "leftArrow"; + break; + case KeyEvent.DOM_VK_RIGHT: + keystroke += "rightArrow"; + break; + case KeyEvent.DOM_VK_HOME: + keystroke += "home"; + break; + case KeyEvent.DOM_VK_END: + keystroke += "end"; + break; + } + + if (String.fromCharCode(event.charCode) == ' ') + keystroke += "space"; + + this.log("Got keystroke: " + keystroke); + var oldval, newval; + + try { + switch (keystroke) { + case "space": /* Play */ + this.togglePause(); + break; + case "downArrow": /* Volume decrease */ + oldval = this.video.volume; + this.video.volume = (oldval < 0.1 ? 0 : oldval - 0.1); + this.video.muted = false; + break; + case "upArrow": /* Volume increase */ + oldval = this.video.volume; + this.video.volume = (oldval > 0.9 ? 1 : oldval + 0.1); + this.video.muted = false; + break; + case "accel-downArrow": /* Mute */ + this.video.muted = true; + break; + case "accel-upArrow": /* Unmute */ + this.video.muted = false; + break; + case "leftArrow": /* Seek back 15 seconds */ + case "accel-leftArrow": /* Seek back 10% */ + oldval = this.video.currentTime; + if (keystroke == "leftArrow") + newval = oldval - 15; + else + newval = oldval - (this.video.duration || this.maxCurrentTimeSeen / 1000) / 10; + this.video.currentTime = (newval >= 0 ? newval : 0); + break; + case "rightArrow": /* Seek forward 15 seconds */ + case "accel-rightArrow": /* Seek forward 10% */ + oldval = this.video.currentTime; + var maxtime = (this.video.duration || this.maxCurrentTimeSeen / 1000); + if (keystroke == "rightArrow") + newval = oldval + 15; + else + newval = oldval + maxtime / 10; + this.video.currentTime = (newval <= maxtime ? newval : maxtime); + break; + case "home": /* Seek to beginning */ + this.video.currentTime = 0; + break; + case "end": /* Seek to end */ + if (this.video.currentTime != this.video.duration) + this.video.currentTime = (this.video.duration || this.maxCurrentTimeSeen / 1000); + break; + default: + return; + } + } catch (e) { /* ignore any exception from setting .currentTime */ } + + event.preventDefault(); // Prevent page scrolling + }, + + isSupportedTextTrack : function(textTrack) { + return textTrack.kind == "subtitles" || + textTrack.kind == "captions"; + }, + + get overlayableTextTracks() { + return Array.prototype.filter.call(this.video.textTracks, this.isSupportedTextTrack); + }, + + isClosedCaptionOn : function () { + for (let tt of this.overlayableTextTracks) { + if (tt.mode === "showing") { + return true; + } + } + + return false; + }, + + setClosedCaptionButtonState : function () { + if (!this.overlayableTextTracks.length || this.videocontrols.isTouchControl) { + this.closedCaptionButton.setAttribute("hidden", "true"); + return; + } + + this.closedCaptionButton.removeAttribute("hidden"); + + if (this.isClosedCaptionOn()) { + this.closedCaptionButton.setAttribute("enabled", "true"); + } else { + this.closedCaptionButton.removeAttribute("enabled"); + } + + let ttItems = this.textTrackList.childNodes; + + for (let tti of ttItems) { + const idx = +tti.getAttribute("index"); + + if (idx == this.currentTextTrackIndex) { + tti.setAttribute("on", "true"); + } else { + tti.removeAttribute("on"); + } + } + }, + + addNewTextTrack : function (tt) { + if (!this.isSupportedTextTrack(tt)) { + return; + } + + if (tt.index && tt.index < this.textTracksCount) { + // Don't create items for initialized tracks. However, we + // still need to care about mode since TextTrackManager would + // turn on the first available track automatically. + if (tt.mode === "showing") { + this.changeTextTrack(tt.index); + } + return; + } + + tt.index = this.textTracksCount++; + + const label = tt.label || ""; + const ttText = document.createTextNode(label); + const ttBtn = document.createElement("button"); + + ttBtn.classList.add("textTrackItem"); + ttBtn.setAttribute("index", tt.index); + + ttBtn.addEventListener("click", function (event) { + event.stopPropagation(); + + this.changeTextTrack(tt.index); + }.bind(this)); + + ttBtn.appendChild(ttText); + + this.textTrackList.appendChild(ttBtn); + + if (tt.mode === "showing" && tt.index) { + this.changeTextTrack(tt.index); + } + }, + + changeTextTrack : function (index) { + for (let tt of this.overlayableTextTracks) { + if (tt.index === index) { + tt.mode = "showing"; + + this.currentTextTrackIndex = tt.index; + } else { + tt.mode = "disabled"; + } + } + + // should fallback to off + if (this.currentTextTrackIndex !== index) { + this.currentTextTrackIndex = 0; + } + + this.textTrackList.setAttribute("hidden", "true"); + this.setClosedCaptionButtonState(); + }, + + onControlBarTransitioned : function () { + this.textTrackList.setAttribute("hidden", "true"); + this.video.dispatchEvent(new CustomEvent("controlbarchange")); + }, + + toggleClosedCaption : function () { + if (this.overlayableTextTracks.length === 1) { + const lastTTIdx = this.overlayableTextTracks[0].index; + this.changeTextTrack(this.isClosedCaptionOn() ? 0 : lastTTIdx); + return; + } + + if (this.textTrackList.hasAttribute("hidden")) { + this.textTrackList.removeAttribute("hidden"); + } else { + this.textTrackList.setAttribute("hidden", "true"); + } + + let maxButtonWidth = 0; + + for (let tti of this.textTrackList.childNodes) { + if (tti.clientWidth > maxButtonWidth) { + maxButtonWidth = tti.clientWidth; + } + } + + if (maxButtonWidth > this.video.clientWidth) { + maxButtonWidth = this.video.clientWidth; + } + + for (let tti of this.textTrackList.childNodes) { + tti.style.width = maxButtonWidth + "px"; + } + }, + + onTextTrackAdd : function (trackEvent) { + this.addNewTextTrack(trackEvent.track); + this.setClosedCaptionButtonState(); + }, + + onTextTrackRemove : function (trackEvent) { + const toRemoveIndex = trackEvent.track.index; + const ttItems = this.textTrackList.childNodes; + + if (!ttItems) { + return; + } + + for (let tti of ttItems) { + const idx = +tti.getAttribute("index"); + + if (idx === toRemoveIndex) { + tti.remove(); + this.textTracksCount--; + } + + if (idx === this.currentTextTrackIndex) { + this.currentTextTrackIndex = 0; + + this.video.dispatchEvent(new CustomEvent("texttrackchange")); + } + } + + this.setClosedCaptionButtonState(); + }, + + initTextTracks : function () { + // add 'off' button anyway as new text track might be + // dynamically added after initialization. + const offLabel = this.textTrackList.getAttribute("offlabel"); + + this.addNewTextTrack({ + label: offLabel, + kind: "subtitles" + }); + + for (let tt of this.overlayableTextTracks) { + this.addNewTextTrack(tt); + } + + this.setClosedCaptionButtonState(); + }, + + isEventWithin : function (event, parent1, parent2) { + function isDescendant (node) { + while (node) { + if (node == parent1 || node == parent2) + return true; + node = node.parentNode; + } + return false; + } + return isDescendant(event.target) && isDescendant(event.relatedTarget); + }, + + log : function (msg) { + if (this.debug) + console.log("videoctl: " + msg + "\n"); + }, + + get isTopLevelSyntheticDocument() { + let doc = this.video.ownerDocument; + let win = doc.defaultView; + return doc.mozSyntheticDocument && win === win.top; + }, + + _playButtonWidth : 0, + _durationLabelWidth : 0, + _muteButtonWidth : 0, + _volumeControlWidth : 0, + _closedCaptionButtonWidth : 0, + _fullscreenButtonWidth : 0, + _controlBarHeight : 0, + _overlayPlayButtonHeight : 64, + _overlayPlayButtonWidth : 64, + _controlBarPaddingEnd: 8, + adjustControlSize : function adjustControlSize() { + let doc = this.video.ownerDocument; + + // The scrubber has |flex=1|, therefore |minScrubberWidth| + // was generated by empirical testing. + let minScrubberWidth = 25; + let minWidthAllControls = this._playButtonWidth + + minScrubberWidth + + this._durationLabelWidth + + this._muteButtonWidth + + this._volumeControlWidth + + this._closedCaptionButtonWidth + + this._fullscreenButtonWidth; + + let isFullscreenUnavailable = this.controlBar.hasAttribute("fullscreen-unavailable"); + if (isFullscreenUnavailable) { + // When the fullscreen button is hidden we add margin-end to the volume stack. + minWidthAllControls -= this._fullscreenButtonWidth - this._controlBarPaddingEnd; + } + + let minHeightForControlBar = this._controlBarHeight; + let minWidthOnlyPlayPause = this._playButtonWidth + this._muteButtonWidth; + + let isAudioOnly = this.isAudioOnly; + let videoHeight = isAudioOnly ? minHeightForControlBar : this.video.clientHeight; + let videoWidth = isAudioOnly ? minWidthAllControls : this.video.clientWidth; + + // Adapt the size of the controls to the size of the video + if (this.video.readyState >= this.video.HAVE_METADATA) { + if (!this.isAudioOnly && this.video.videoWidth && this.video.videoHeight) { + var rect = this.video.getBoundingClientRect(); + var widthRatio = rect.width / this.video.videoWidth; + var heightRatio = rect.height / this.video.videoHeight; + var width = this.video.videoWidth * Math.min(widthRatio, heightRatio); + + this.controlsOverlay.setAttribute("scaled", true); + this.controlsOverlay.style.width = width + "px"; + this.controlsSpacer.style.width = width + "px"; + this.controlBar.style.width = width + "px"; + } else { + this.controlsOverlay.removeAttribute("scaled"); + this.controlsOverlay.style.width = ""; + this.controlsSpacer.style.width = ""; + this.controlBar.style.width = ""; + } + } + + if ((this._overlayPlayButtonHeight + this._controlBarHeight) > videoHeight || + this._overlayPlayButtonWidth > videoWidth) { + this.clickToPlay.hidden = true; + } else if (this.clickToPlay.hidden && + !this.video.played.length && + this.video.paused) { + // Check this.video.paused to handle when a video is + // playing but hasn't processed any frames yet + this.clickToPlay.hidden = false; + } + + let size = "normal"; + if (videoHeight < minHeightForControlBar) + size = "hidden"; + else if (videoWidth < minWidthOnlyPlayPause) + size = "hidden"; + else if (videoWidth < minWidthAllControls) + size = "small"; + this.controlBar.setAttribute("size", size); + }, + + init : function (binding) { + this.video = binding.parentNode; + this.videocontrols = binding; + + this.statusIcon = document.getAnonymousElementByAttribute(binding, "class", "statusIcon"); + this.controlBar = document.getAnonymousElementByAttribute(binding, "class", "controlBar"); + this.playButton = document.getAnonymousElementByAttribute(binding, "class", "playButton"); + this.muteButton = document.getAnonymousElementByAttribute(binding, "class", "muteButton"); + this.volumeControl = document.getAnonymousElementByAttribute(binding, "class", "volumeControl"); + this.progressBar = document.getAnonymousElementByAttribute(binding, "class", "progressBar"); + this.bufferBar = document.getAnonymousElementByAttribute(binding, "class", "bufferBar"); + this.scrubber = document.getAnonymousElementByAttribute(binding, "class", "scrubber"); + this.scrubberThumb = document.getAnonymousElementByAttribute(this.scrubber, "class", "scale-thumb"); + this.durationLabel = document.getAnonymousElementByAttribute(binding, "class", "durationLabel"); + this.positionLabel = document.getAnonymousElementByAttribute(binding, "class", "positionLabel"); + this.statusOverlay = document.getAnonymousElementByAttribute(binding, "class", "statusOverlay"); + this.controlsOverlay = document.getAnonymousElementByAttribute(binding, "class", "controlsOverlay"); + this.controlsSpacer = document.getAnonymousElementByAttribute(binding, "class", "controlsSpacer"); + this.clickToPlay = document.getAnonymousElementByAttribute(binding, "class", "clickToPlay"); + this.fullscreenButton = document.getAnonymousElementByAttribute(binding, "class", "fullscreenButton"); + this.volumeForeground = document.getAnonymousElementByAttribute(binding, "anonid", "volumeForeground"); + this.closedCaptionButton = document.getAnonymousElementByAttribute(binding, "class", "closedCaptionButton"); + this.textTrackList = document.getAnonymousElementByAttribute(binding, "class", "textTrackList"); + + this.isAudioOnly = (this.video instanceof HTMLAudioElement); + this.setupInitialState(); + this.setupNewLoadState(); + this.initTextTracks(); + + // Use the handleEvent() callback for all media events. + // Only the "error" event listener must capture, so that it can trap error + // events from <source> children, which don't bubble. But we use capture + // for all events in order to simplify the event listener add/remove. + for (let event of this.videoEvents) { + this.video.addEventListener(event, this, { + capture: true, + mozSystemGroup: true + }); + } + + var self = this; + this.controlListeners = []; + + // Helper function to add an event listener to the given element + function addListener(elem, eventName, func) { + let boundFunc = func.bind(self); + self.controlListeners.push({ item: elem, event: eventName, func: boundFunc }); + elem.addEventListener(eventName, boundFunc, { mozSystemGroup: true }); + } + + addListener(this.muteButton, "command", this.toggleMute); + addListener(this.closedCaptionButton, "command", this.toggleClosedCaption); + addListener(this.playButton, "click", this.clickToPlayClickHandler); + addListener(this.fullscreenButton, "command", this.toggleFullscreen); + addListener(this.clickToPlay, "click", this.clickToPlayClickHandler); + addListener(this.controlsSpacer, "click", this.clickToPlayClickHandler); + addListener(this.controlsSpacer, "dblclick", this.toggleFullscreen); + + addListener(this.videocontrols, "resizevideocontrols", this.adjustControlSize); + addListener(this.videocontrols, "transitionend", this.onTransitionEnd); + addListener(this.video.ownerDocument, "mozfullscreenchange", this.onFullscreenChange); + addListener(this.videocontrols, "transitionend", this.onControlBarTransitioned); + addListener(this.video.ownerDocument, "fullscreenchange", this.onFullscreenChange); + addListener(this.video, "keypress", this.keyHandler); + addListener(this.video.textTracks, "addtrack", this.onTextTrackAdd); + addListener(this.video.textTracks, "removetrack", this.onTextTrackRemove); + + addListener(this.videocontrols, "dragstart", function(event) { + event.preventDefault(); // prevent dragging of controls image (bug 517114) + }); + + this.log("--- videocontrols initialized ---"); + } + }; + this.Utils.init(this); + ]]> + </constructor> + <destructor> + <![CDATA[ + this.Utils.terminateEventListeners(); + // randomID used to be a <field>, which meant that the XBL machinery + // undefined the property when the element was unbound. The code in + // this file actually depends on this, so now that randomID is an + // expando, we need to make sure to explicitly delete it. + delete this.randomID; + ]]> + </destructor> + + </implementation> + + <handlers> + <handler event="mouseover"> + if (!this.isTouchControl) + this.Utils.onMouseInOut(event); + </handler> + <handler event="mouseout"> + if (!this.isTouchControl) + this.Utils.onMouseInOut(event); + </handler> + <handler event="mousemove"> + if (!this.isTouchControl) + this.Utils.onMouseMove(event); + </handler> + </handlers> + </binding> + + <binding id="touchControls" extends="chrome://global/content/bindings/videocontrols.xml#videoControls"> + + <xbl:content xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" class="mediaControlsFrame"> + <stack flex="1"> + <vbox flex="1" class="statusOverlay" hidden="true"> + <box class="statusIcon"/> + <label class="errorLabel" anonid="errorAborted">&error.aborted;</label> + <label class="errorLabel" anonid="errorNetwork">&error.network;</label> + <label class="errorLabel" anonid="errorDecode">&error.decode;</label> + <label class="errorLabel" anonid="errorSrcNotSupported">&error.srcNotSupported;</label> + <label class="errorLabel" anonid="errorNoSource">&error.noSource2;</label> + <label class="errorLabel" anonid="errorGeneric">&error.generic;</label> + </vbox> + + <vbox class="controlsOverlay"> + <spacer class="controlsSpacer" flex="1"/> + <box flex="1" hidden="true"> + <box class="clickToPlay" hidden="true" flex="1"/> + <vbox class="textTrackList" hidden="true" offlabel="&closedCaption.off;"></vbox> + </box> + <vbox class="controlBar" hidden="true"> + <hbox class="buttonsBar"> + <button class="playButton" + playlabel="&playButton.playLabel;" + pauselabel="&playButton.pauseLabel;"/> + <label class="positionLabel" role="presentation"/> + <stack class="scrubberStack"> + <box class="backgroundBar"/> + <progressmeter class="flexibleBar" value="100"/> + <progressmeter class="bufferBar"/> + <progressmeter class="progressBar" max="10000"/> + <scale class="scrubber" movetoclick="true"/> + </stack> + <label class="durationLabel" role="presentation"/> + <button class="muteButton" + mutelabel="&muteButton.muteLabel;" + unmutelabel="&muteButton.unmuteLabel;"/> + <stack class="volumeStack"> + <box class="volumeBackground"/> + <box class="volumeForeground" anonid="volumeForeground"/> + <scale class="volumeControl" movetoclick="true"/> + </stack> + <button class="castingButton" hidden="true" + aria-label="&castingButton.castingLabel;"/> + <button class="closedCaptionButton" hidden="true"/> + <button class="fullscreenButton" + enterfullscreenlabel="&fullscreenButton.enterfullscreenlabel;" + exitfullscreenlabel="&fullscreenButton.exitfullscreenlabel;"/> + </hbox> + </vbox> + </vbox> + </stack> + </xbl:content> + + <implementation> + + <constructor> + <![CDATA[ + this.isTouchControl = true; + this.TouchUtils = { + videocontrols: null, + video: null, + controlsTimer: null, + controlsTimeout: 5000, + positionLabel: null, + castingButton: null, + + get Utils() { + return this.videocontrols.Utils; + }, + + get visible() { + return !this.Utils.controlBar.hasAttribute("fadeout") && + !(this.Utils.controlBar.getAttribute("hidden") == "true"); + }, + + _firstShow: false, + get firstShow() { return this._firstShow; }, + set firstShow(val) { + this._firstShow = val; + this.Utils.controlBar.setAttribute("firstshow", val); + }, + + toggleControls: function() { + if (!this.Utils.dynamicControls || !this.visible) + this.showControls(); + else + this.delayHideControls(0); + }, + + showControls : function() { + if (this.Utils.dynamicControls) { + this.Utils.startFadeIn(this.Utils.controlBar); + this.delayHideControls(this.controlsTimeout); + } + }, + + clearTimer: function() { + if (this.controlsTimer) { + clearTimeout(this.controlsTimer); + this.controlsTimer = null; + } + }, + + delayHideControls : function(aTimeout) { + this.clearTimer(); + let self = this; + this.controlsTimer = setTimeout(function() { + self.hideControls(); + }, aTimeout); + }, + + hideControls : function() { + if (!this.Utils.dynamicControls) + return; + this.Utils.startFadeOut(this.Utils.controlBar); + if (this.firstShow) + this.videocontrols.addEventListener("transitionend", this, false); + }, + + handleEvent : function (aEvent) { + if (aEvent.type == "transitionend") { + this.firstShow = false; + this.videocontrols.removeEventListener("transitionend", this, false); + return; + } + + if (this.videocontrols.randomID != this.Utils.randomID) + this.terminateEventListeners(); + + }, + + terminateEventListeners : function () { + for (var event of this.videoEvents) + this.Utils.video.removeEventListener(event, this, false); + }, + + isVideoCasting : function () { + if (this.video.mozIsCasting) + return true; + return false; + }, + + updateCasting : function (eventDetail) { + let castingData = JSON.parse(eventDetail); + if ("allow" in castingData) { + this.video.mozAllowCasting = !!castingData.allow; + } + + if ("active" in castingData) { + this.video.mozIsCasting = !!castingData.active; + } + this.setCastButtonState(); + }, + + startCasting : function () { + this.videocontrols.dispatchEvent(new CustomEvent("VideoBindingCast")); + }, + + setCastButtonState : function () { + if (this.isAudioOnly || !this.video.mozAllowCasting) { + this.castingButton.hidden = true; + return; + } + + if (this.video.mozIsCasting) { + this.castingButton.setAttribute("active", "true"); + } else { + this.castingButton.removeAttribute("active"); + } + + this.castingButton.hidden = false; + }, + + init : function (binding) { + this.videocontrols = binding; + this.video = binding.parentNode; + + let self = this; + this.Utils.playButton.addEventListener("command", function() { + if (!self.video.paused) + self.delayHideControls(0); + else + self.showControls(); + }, false); + this.Utils.scrubber.addEventListener("touchstart", function() { + self.clearTimer(); + }, false); + this.Utils.scrubber.addEventListener("touchend", function() { + self.delayHideControls(self.controlsTimeout); + }, false); + this.Utils.muteButton.addEventListener("click", function() { self.delayHideControls(self.controlsTimeout); }, false); + + this.castingButton = document.getAnonymousElementByAttribute(binding, "class", "castingButton"); + this.castingButton.addEventListener("command", function() { + self.startCasting(); + }, false); + + this.video.addEventListener("media-videoCasting", function (e) { + if (!e.isTrusted) + return; + self.updateCasting(e.detail); + }, false, true); + + // The first time the controls appear we want to just display + // a play button that does not fade away. The firstShow property + // makes that happen. But because of bug 718107 this init() method + // may be called again when we switch in or out of fullscreen + // mode. So we only set firstShow if we're not autoplaying and + // if we are at the beginning of the video and not already playing + if (!this.video.autoplay && this.Utils.dynamicControls && this.video.paused && + this.video.currentTime === 0) + this.firstShow = true; + + // If the video is not at the start, then we probably just + // transitioned into or out of fullscreen mode, and we don't want + // the controls to remain visible. this.controlsTimeout is a full + // 5s, which feels too long after the transition. + if (this.video.currentTime !== 0) { + this.delayHideControls(this.Utils.HIDE_CONTROLS_TIMEOUT_MS); + } + } + }; + this.TouchUtils.init(this); + this.dispatchEvent(new CustomEvent("VideoBindingAttached")); + ]]> + </constructor> + <destructor> + <![CDATA[ + // XBL destructors don't appear to be inherited properly, so we need + // to do this here in addition to the videoControls destructor. :-( + delete this.randomID; + ]]> + </destructor> + + </implementation> + + <handlers> + <handler event="mouseup"> + if (event.originalTarget.nodeName == "vbox") { + if (this.TouchUtils.firstShow) + this.Utils.video.play(); + this.TouchUtils.toggleControls(); + } + </handler> + </handlers> + + </binding> + + <binding id="touchControlsGonk" extends="chrome://global/content/bindings/videoControls.xml#touchControls"> + <implementation> + <constructor> + this.isGonk = true; + </constructor> + </implementation> + </binding> + + <binding id="noControls"> + + <resources> + <stylesheet src="chrome://global/content/bindings/videocontrols.css"/> + <stylesheet src="chrome://global/skin/media/videocontrols.css"/> + </resources> + + <xbl:content xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" class="mediaControlsFrame"> + <vbox flex="1" class="statusOverlay" hidden="true"> + <box flex="1"> + <box class="clickToPlay" flex="1"/> + </box> + </vbox> + </xbl:content> + + <implementation> + <constructor> + <![CDATA[ + this.randomID = 0; + this.Utils = { + randomID : 0, + videoEvents : ["play", + "playing"], + controlListeners: [], + terminateEventListeners : function () { + for (let event of this.videoEvents) + this.video.removeEventListener(event, this, { mozSystemGroup: true }); + + for (let element of this.controlListeners) { + element.item.removeEventListener(element.event, element.func, + { mozSystemGroup: true }); + } + + delete this.controlListeners; + }, + + hasError : function () { + return (this.video.error != null || this.video.networkState == this.video.NETWORK_NO_SOURCE); + }, + + handleEvent : function (aEvent) { + // If the binding is detached (or has been replaced by a + // newer instance of the binding), nuke our event-listeners. + if (this.binding.randomID != this.randomID) { + this.terminateEventListeners(); + return; + } + + switch (aEvent.type) { + case "play": + this.noControlsOverlay.hidden = true; + break; + case "playing": + this.noControlsOverlay.hidden = true; + break; + } + }, + + blockedVideoHandler : function () { + if (this.binding.randomID != this.randomID) { + this.terminateEventListeners(); + return; + } else if (this.hasError()) { + this.noControlsOverlay.hidden = true; + return; + } + this.noControlsOverlay.hidden = false; + }, + + clickToPlayClickHandler : function (e) { + if (this.binding.randomID != this.randomID) { + this.terminateEventListeners(); + return; + } else if (e.button != 0) { + return; + } + + this.noControlsOverlay.hidden = true; + this.video.play(); + }, + + init : function (binding) { + this.binding = binding; + this.randomID = Math.random(); + this.binding.randomID = this.randomID; + this.video = binding.parentNode; + this.clickToPlay = document.getAnonymousElementByAttribute(binding, "class", "clickToPlay"); + this.noControlsOverlay = document.getAnonymousElementByAttribute(binding, "class", "statusOverlay"); + + let self = this; + function addListener(elem, eventName, func) { + let boundFunc = func.bind(self); + self.controlListeners.push({ item: elem, event: eventName, func: boundFunc }); + elem.addEventListener(eventName, boundFunc, { mozSystemGroup: true }); + } + addListener(this.clickToPlay, "click", this.clickToPlayClickHandler); + addListener(this.video, "MozNoControlsBlockedVideo", this.blockedVideoHandler); + + for (let event of this.videoEvents) { + this.video.addEventListener(event, this, { mozSystemGroup: true }); + } + + if (this.video.autoplay && !this.video.mozAutoplayEnabled) { + this.blockedVideoHandler(); + } + } + }; + this.Utils.init(this); + this.Utils.video.dispatchEvent(new CustomEvent("MozNoControlsVideoBindingAttached")); + ]]> + </constructor> + <destructor> + <![CDATA[ + this.Utils.terminateEventListeners(); + // randomID used to be a <field>, which meant that the XBL machinery + // undefined the property when the element was unbound. The code in + // this file actually depends on this, so now that randomID is an + // expando, we need to make sure to explicitly delete it. + delete this.randomID; + ]]> + </destructor> + </implementation> + </binding> + +</bindings> |