<?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>