diff --git a/CHANGELOG.md b/CHANGELOG.md index ba89845ae1..fac9605d54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ CHANGELOG * Fixed error due to undefined tech when no source is supported [[view](https://github.com/videojs/video.js/pull/1172)] * Fixed the progress bar not finishing when manual timeupdate events are used [[view](https://github.com/videojs/video.js/pull/1173)] * Added a more informative and styled fallback message for non-html5 browsers [[view](https://github.com/videojs/video.js/pull/1181)] +* Added the option to provide an array of child components instead of an object [[view](https://github.com/videojs/video.js/pull/1093)] +* Fixed casing on webkitRequestFullscreen [[view](https://github.com/videojs/video.js/pull/1101)] +* Made tap events on mobile less sensitive to touch moves [[view](https://github.com/videojs/video.js/pull/1111)] -------------------- diff --git a/build/source-loader.js b/build/source-loader.js index 2dfa497666..1223d0cf62 100644 --- a/build/source-loader.js +++ b/build/source-loader.js @@ -34,6 +34,7 @@ var sourceFiles = [ "src/js/control-bar/volume-control.js", "src/js/control-bar/mute-toggle.js", "src/js/control-bar/volume-menu-button.js", + "src/js/control-bar/playback-rate-menu-button.js", "src/js/poster.js", "src/js/loading-spinner.js", "src/js/big-play-button.js", diff --git a/src/css/video-js.less b/src/css/video-js.less index cec3f712a1..32a3f88142 100644 --- a/src/css/video-js.less +++ b/src/css/video-js.less @@ -260,6 +260,21 @@ fonts to show/hide properly. content: @pause-icon; } +/* Playback toggle +-------------------------------------------------------------------------------- +*/ +.vjs-default-skin .vjs-playback-rate .vjs-playback-rate-value { + font-size: 1.5em; + line-height: 2; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + text-align: center; + text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5); +} + /* Volume/Mute -------------------------------------------------------------------------------- */ .vjs-default-skin .vjs-mute-control, @@ -636,6 +651,12 @@ easily in the skin designer. http://designer.videojs.com/ .box-shadow(-0.2em -0.2em 0.3em rgba(255, 255, 255, 0.2)); } +.vjs-default-skin .vjs-playback-rate .vjs-menu .vjs-menu-content { + width: 4em; + left: -2em; + list-style: none; +} + .vjs-default-skin .vjs-menu-button:hover .vjs-menu { display: block; } diff --git a/src/js/button.js b/src/js/button.js index 71d8d346de..76c09efe1e 100644 --- a/src/js/button.js +++ b/src/js/button.js @@ -15,23 +15,9 @@ vjs.Button = vjs.Component.extend({ init: function(player, options){ vjs.Component.call(this, player, options); - var touchstart = false; - this.on('touchstart', function(event) { - // Stop click and other mouse events from triggering also - event.preventDefault(); - touchstart = true; - }); - this.on('touchmove', function() { - touchstart = false; - }); - var self = this; - this.on('touchend', function(event) { - if (touchstart) { - self.onClick(event); - } - event.preventDefault(); - }); + this.emitTapEvents(); + this.on('tap', this.onClick); this.on('click', this.onClick); this.on('focus', this.onFocus); this.on('blur', this.onBlur); diff --git a/src/js/component.js b/src/js/component.js index a6e1746d71..b421bdc28b 100644 --- a/src/js/component.js +++ b/src/js/component.js @@ -864,35 +864,61 @@ vjs.Component.prototype.onResize; * @private */ vjs.Component.prototype.emitTapEvents = function(){ - var touchStart, touchTime, couldBeTap, noTap; + var touchStart, firstTouch, touchTime, couldBeTap, noTap, + xdiff, ydiff, touchDistance, tapMovementThreshold; // Track the start time so we can determine how long the touch lasted touchStart = 0; + firstTouch = null; + + // Maximum movement allowed during a touch event to still be considered a tap + tapMovementThreshold = 22; this.on('touchstart', function(event) { - // Record start time so we can detect a tap vs. "touch and hold" - touchStart = new Date().getTime(); - // Reset couldBeTap tracking - couldBeTap = true; + // If more than one finger, don't consider treating this as a click + if (event.touches.length === 1) { + firstTouch = event.touches[0]; + // Record start time so we can detect a tap vs. "touch and hold" + touchStart = new Date().getTime(); + // Reset couldBeTap tracking + couldBeTap = true; + } + }); + + this.on('touchmove', function(event) { + // If more than one finger, don't consider treating this as a click + if (event.touches.length > 1) { + couldBeTap = false; + } else if (firstTouch) { + // Some devices will throw touchmoves for all but the slightest of taps. + // So, if we moved only a small distance, this could still be a tap + xdiff = event.touches[0].pageX - firstTouch.pageX; + ydiff = event.touches[0].pageY - firstTouch.pageY; + touchDistance = Math.sqrt(xdiff * xdiff + ydiff * ydiff); + if (touchDistance > tapMovementThreshold) { + couldBeTap = false; + } + } }); noTap = function(){ couldBeTap = false; }; // TODO: Listen to the original target. http://youtu.be/DujfpXOKUp8?t=13m8s - this.on('touchmove', noTap); this.on('touchleave', noTap); this.on('touchcancel', noTap); // When the touch ends, measure how long it took and trigger the appropriate // event this.on('touchend', function(event) { + firstTouch = null; // Proceed only if the touchmove/leave/cancel event didn't happen if (couldBeTap === true) { // Measure how long the touch lasted touchTime = new Date().getTime() - touchStart; // The touch needs to be quick in order to consider it a tap if (touchTime < 250) { + event.preventDefault(); // Don't let browser turn this into a click this.trigger('tap'); // It may be good to copy the touchend event object and change the // type to tap, if the other event properties aren't exact after diff --git a/src/js/control-bar/control-bar.js b/src/js/control-bar/control-bar.js index 0443acae04..000e9b558f 100644 --- a/src/js/control-bar/control-bar.js +++ b/src/js/control-bar/control-bar.js @@ -20,7 +20,8 @@ vjs.ControlBar.prototype.options_ = { 'progressControl': {}, 'fullscreenToggle': {}, 'volumeControl': {}, - 'muteToggle': {} + 'muteToggle': {}, + 'playbackRateMenuButton': {} // 'volumeMenuButton': {} } }; diff --git a/src/js/control-bar/playback-rate-menu-button.js b/src/js/control-bar/playback-rate-menu-button.js new file mode 100644 index 0000000000..333a86eda3 --- /dev/null +++ b/src/js/control-bar/playback-rate-menu-button.js @@ -0,0 +1,128 @@ +/** + * The component for controlling the playback rate + * + * @param {vjs.Player|Object} player + * @param {Object=} options + * @constructor + */ +vjs.PlaybackRateMenuButton = vjs.MenuButton.extend({ + /** @constructor */ + init: function(player, options){ + vjs.MenuButton.call(this, player, options); + + this.updateVisibility(); + this.updateLabel(); + + player.on('loadstart', vjs.bind(this, this.updateVisibility)); + player.on('ratechange', vjs.bind(this, this.updateLabel)); + } +}); + +vjs.PlaybackRateMenuButton.prototype.createEl = function(){ + var el = vjs.Component.prototype.createEl.call(this, 'div', { + className: 'vjs-playback-rate vjs-menu-button vjs-control' + }); + + this.contentEl_ = vjs.createEl('div', { + className: 'vjs-playback-rate-value', + innerHTML: 1.0 + }); + el.appendChild(this.contentEl_); + + return el; +}; + +// Menu creation +vjs.PlaybackRateMenuButton.prototype.createMenu = function(){ + var menu = new vjs.Menu(this.player()); + var rates = this.player().options().playbackRates; + + if (rates) { + for (var i = rates.length - 1; i >= 0; i--) { + menu.addChild( + new vjs.PlaybackRateMenuItem(this.player(), {rate: rates[i] + 'x'}) + ); + }; + } + + return menu; +}; + +vjs.PlaybackRateMenuButton.prototype.updateARIAAttributes = function(){ + // Current playback rate + this.el().setAttribute('aria-valuenow', this.player().playbackRate()); +}; + +vjs.PlaybackRateMenuButton.prototype.onClick = function(){ + // select next rate option + var currentRate = this.player().playbackRate(); + var rates = this.player().options().playbackRates; + // this will select first one if the last one currently selected + var newRate = rates[0]; + for (var i = 0; i currentRate) { + newRate = rates[i]; + break; + } + }; + this.player().playbackRate(newRate); +}; + +vjs.PlaybackRateMenuButton.prototype.playbackRateSupported = function(){ + return + this.player().tech + && this.player().tech.features['playbackRate'] + && this.player().options().playbackRates + && this.player().options().playbackRates.length > 0 + ; +}; + +/** + * Hide playback rate controls when they're no playback rate options to select + */ +vjs.PlaybackRateMenuButton.prototype.updateVisibility = function(){ + if (this.playbackRateSupported()) { + this.removeClass('vjs-hidden'); + } else { + this.addClass('vjs-hidden'); + } +}; + +/** + * Update button label when rate changed + */ +vjs.PlaybackRateMenuButton.prototype.updateLabel = function(){ + if (this.playbackRateSupported()) { + this.contentEl().innerHTML = this.player().playbackRate() + 'x'; + } +}; + +/** + * The specific menu item type for selecting a playback rate + * + * @constructor + */ +vjs.PlaybackRateMenuItem = vjs.MenuItem.extend({ + contentElType: 'button', + /** @constructor */ + init: function(player, options){ + var label = this.label = options['rate']; + var rate = this.rate = parseFloat(label, 10); + + // Modify options for parent MenuItem class's init. + options['label'] = label; + options['selected'] = rate === 1; + vjs.MenuItem.call(this, player, options); + + this.player().on('ratechange', vjs.bind(this, this.update)); + } +}); + +vjs.PlaybackRateMenuItem.prototype.onClick = function(){ + vjs.MenuItem.prototype.onClick.call(this); + this.player().playbackRate(this.rate); +}; + +vjs.PlaybackRateMenuItem.prototype.update = function(){ + this.selected(this.player().playbackRate() == this.rate); +}; diff --git a/src/js/core.js b/src/js/core.js index 933004a3d1..4d8e7b6507 100644 --- a/src/js/core.js +++ b/src/js/core.js @@ -88,6 +88,9 @@ vjs.options = { // defaultVolume: 0.85, 'defaultVolume': 0.00, // The freakin seaguls are driving me crazy! + // default playback rates + 'playbackRates': [0.5, 1, 1.5, 2], + // Included control sets 'children': { 'mediaLoader': {}, diff --git a/src/js/exports.js b/src/js/exports.js index 1f03147d37..a57da53a8b 100644 --- a/src/js/exports.js +++ b/src/js/exports.js @@ -101,6 +101,7 @@ goog.exportSymbol('videojs.PosterImage', vjs.PosterImage); goog.exportSymbol('videojs.Menu', vjs.Menu); goog.exportSymbol('videojs.MenuItem', vjs.MenuItem); goog.exportSymbol('videojs.MenuButton', vjs.MenuButton); +goog.exportSymbol('videojs.PlaybackRateMenuButton', vjs.PlaybackRateMenuButton); goog.exportProperty(vjs.MenuButton.prototype, 'createItems', vjs.MenuButton.prototype.createItems); goog.exportProperty(vjs.TextTrackButton.prototype, 'createItems', vjs.TextTrackButton.prototype.createItems); goog.exportProperty(vjs.ChaptersButton.prototype, 'createItems', vjs.ChaptersButton.prototype.createItems); @@ -135,6 +136,8 @@ goog.exportProperty(vjs.Html5.prototype, 'setAutoplay', vjs.Html5.prototype.setA goog.exportProperty(vjs.Html5.prototype, 'setLoop', vjs.Html5.prototype.setLoop); goog.exportProperty(vjs.Html5.prototype, 'enterFullScreen', vjs.Html5.prototype.enterFullScreen); goog.exportProperty(vjs.Html5.prototype, 'exitFullScreen', vjs.Html5.prototype.exitFullScreen); +goog.exportProperty(vjs.Html5.prototype, 'playbackRate', vjs.Html5.prototype.playbackRate); +goog.exportProperty(vjs.Html5.prototype, 'setPlaybackRate', vjs.Html5.prototype.setPlaybackRate); goog.exportSymbol('videojs.Flash', vjs.Flash); goog.exportProperty(vjs.Flash, 'isSupported', vjs.Flash.isSupported); diff --git a/src/js/media/flash.js b/src/js/media/flash.js index c0f47aedcb..d46cc5e997 100644 --- a/src/js/media/flash.js +++ b/src/js/media/flash.js @@ -317,7 +317,6 @@ vjs.Flash.prototype.enterFullScreen = function(){ return false; }; - // Create setters and getters for attributes var api = vjs.Flash.prototype, readWrite = 'rtmpConnection,rtmpStream,preload,defaultPlaybackRate,playbackRate,autoplay,loop,mediaGroup,controller,controls,volume,muted,defaultMuted'.split(','), diff --git a/src/js/media/html5.js b/src/js/media/html5.js index 3c47baaea6..31677efa2f 100644 --- a/src/js/media/html5.js +++ b/src/js/media/html5.js @@ -15,6 +15,9 @@ vjs.Html5 = vjs.MediaTechController.extend({ // volume cannot be changed from 1 on iOS this.features['volumeControl'] = vjs.Html5.canControlVolume(); + // just in case; or is it excessively... + this.features['playbackRate'] = vjs.Html5.canControlPlaybackRate(); + // In iOS, if you move a video element in the DOM, it breaks video playback. this.features['movingMediaElementInDOM'] = !vjs.IS_IOS; @@ -234,6 +237,9 @@ vjs.Html5.prototype.seeking = function(){ return this.el_.seeking; }; vjs.Html5.prototype.ended = function(){ return this.el_.ended; }; vjs.Html5.prototype.defaultMuted = function(){ return this.el_.defaultMuted; }; +vjs.Html5.prototype.playbackRate = function(){ return this.el_.playbackRate; }; +vjs.Html5.prototype.setPlaybackRate = function(val){ this.el_.playbackRate = val; }; + /* HTML5 Support Testing ---------------------------------------------------- */ vjs.Html5.isSupported = function(){ @@ -266,6 +272,12 @@ vjs.Html5.canControlVolume = function(){ return volume !== vjs.TEST_VID.volume; }; +vjs.Html5.canControlPlaybackRate = function(){ + var playbackRate = vjs.TEST_VID.playbackRate; + vjs.TEST_VID.playbackRate = (playbackRate / 2) + 0.1; + return playbackRate !== vjs.TEST_VID.playbackRate; +}; + // HTML5 Feature detection and Device Fixes --------------------------------- // (function() { var canPlayType, diff --git a/src/js/media/media.js b/src/js/media/media.js index 842c759677..47a69eb1e9 100644 --- a/src/js/media/media.js +++ b/src/js/media/media.js @@ -152,6 +152,7 @@ vjs.MediaTechController.prototype.features = { // Resizing plugins using request fullscreen reloads the plugin 'fullscreenResize': false, + 'playbackRate': false, // Optional events that we can manually mimic with timers // currently not triggered by video-js-swf diff --git a/src/js/player.js b/src/js/player.js index c8a3a56ffb..4d8266567d 100644 --- a/src/js/player.js +++ b/src/js/player.js @@ -1403,6 +1403,20 @@ vjs.Player.prototype.listenForUserActivity = function(){ }); }; +vjs.Player.prototype.playbackRate = function(rate) { + if (rate !== undefined) { + this.techCall('setPlaybackRate', rate); + return this; + } + + if (this.tech && this.tech.features && this.tech.features['playbackRate']) { + return this.techGet('playbackRate'); + } else { + return 1.0; + } + +}; + // Methods to add support for // networkState: function(){ return this.techCall('networkState'); }, // readyState: function(){ return this.techCall('readyState'); }, @@ -1415,7 +1429,6 @@ vjs.Player.prototype.listenForUserActivity = function(){ // videoWidth: function(){ return this.techCall('videoWidth'); }, // videoHeight: function(){ return this.techCall('videoHeight'); }, // defaultPlaybackRate: function(){ return this.techCall('defaultPlaybackRate'); }, -// playbackRate: function(){ return this.techCall('playbackRate'); }, // mediaGroup: function(){ return this.techCall('mediaGroup'); }, // controller: function(){ return this.techCall('controller'); }, // defaultMuted: function(){ return this.techCall('defaultMuted'); } @@ -1452,13 +1465,14 @@ vjs.Player.prototype.listenForUserActivity = function(){ if (document.mozCancelFullScreen) { prefix = 'moz'; requestFS.isFullScreen = prefix + 'FullScreen'; + requestFS.requestFn = prefix + 'RequestFullScreen'; } else { prefix = 'webkit'; requestFS.isFullScreen = prefix + 'IsFullScreen'; + requestFS.requestFn = prefix + 'RequestFullscreen'; } if (div[prefix + 'RequestFullScreen']) { - requestFS.requestFn = prefix + 'RequestFullScreen'; requestFS.cancelFn = prefix + 'CancelFullScreen'; } requestFS.eventName = prefix + 'fullscreenchange'; diff --git a/test/unit/api.js b/test/unit/api.js index 6e4a7b5d8c..309a0b1f0f 100644 --- a/test/unit/api.js +++ b/test/unit/api.js @@ -27,6 +27,7 @@ test('should be able to access expected player API methods', function() { ok(player.textTracks, 'textTracks exists'); ok(player.requestFullScreen, 'requestFullScreen exists'); ok(player.cancelFullScreen, 'cancelFullScreen exists'); + ok(player.playbackRate, 'playbackRate exists'); // Unsupported Native HTML5 Methods // ok(player.canPlayType, 'canPlayType exists'); @@ -138,6 +139,7 @@ test('should export useful components to the public', function () { ok(videojs.Menu, 'Menu should be public'); ok(videojs.MenuItem, 'MenuItem should be public'); ok(videojs.MenuButton, 'MenuButton should be public'); + ok(videojs.PlaybackRateMenuButton, 'PlaybackRateMenuButton should be public'); ok(videojs.util, 'util namespace should be public'); ok(videojs.util.mergeOptions, 'mergeOptions should be public'); diff --git a/test/unit/component.js b/test/unit/component.js index d44d88f2e6..c8c0a7f783 100644 --- a/test/unit/component.js +++ b/test/unit/component.js @@ -255,7 +255,7 @@ test('should use a defined content el for appending children', function(){ }); test('should emit a tap event', function(){ - expect(1); + expect(2); // Fake touch support. Real touch support isn't needed for this test. var origTouch = vjs.TOUCH_ENABLED; @@ -267,13 +267,27 @@ test('should emit a tap event', function(){ comp.on('tap', function(){ ok(true, 'Tap event emitted'); }); - comp.trigger('touchstart'); + + // A touchstart followed by touchend should trigger a tap + vjs.trigger(comp.el(), {type: 'touchstart', touches: [{}]}); + comp.trigger('touchend'); + + // A touchmove with a lot of movement should not trigger a tap + vjs.trigger(comp.el(), {type: 'touchstart', touches: [ + { pageX: 0, pageY: 0 } + ]}); + vjs.trigger(comp.el(), {type: 'touchmove', touches: [ + { pageX: 100, pageY: 100 } + ]}); comp.trigger('touchend'); - // This second test should not trigger another tap event because - // a touchmove is happening - comp.trigger('touchstart'); - comp.trigger('touchmove'); + // A touchmove with not much movement should still allow a tap + vjs.trigger(comp.el(), {type: 'touchstart', touches: [ + { pageX: 0, pageY: 0 } + ]}); + vjs.trigger(comp.el(), {type: 'touchmove', touches: [ + { pageX: 10, pageY: 10 } + ]}); comp.trigger('touchend'); // Reset to orignial value diff --git a/test/unit/controls.js b/test/unit/controls.js index be690e9948..7a144b8926 100644 --- a/test/unit/controls.js +++ b/test/unit/controls.js @@ -103,3 +103,12 @@ test('calculateDistance should use changedTouches, if available', function() { equal(slider.calculateDistance(event), 0.5, 'we should have touched exactly in the center, so, the ratio should be half'); }); + +test('should hide playback rate control if it\'s not supported', function(){ + expect(1); + + var player = PlayerTest.makePlayer(); + var playbackRate = new vjs.PlaybackRateMenuButton(player); + + ok(playbackRate.el().className.indexOf('vjs-hidden') >= 0, 'playbackRate is not hidden'); +}); diff --git a/test/unit/media.html5.js b/test/unit/media.html5.js index 6982115296..a8e3780ebc 100644 --- a/test/unit/media.html5.js +++ b/test/unit/media.html5.js @@ -92,3 +92,30 @@ test('should return a maybe for mp4 on OLD ANDROID', function() { vjs.IS_OLD_ANDROID = isOldAndroid; vjs.Html5.unpatchCanPlayType(); }); + +test('test playbackRate', function() { + var el, player, playbackRate, tech; + + el = document.createElement('div'); + el.innerHTML = '
'; + + player = { + id: function(){ return 'id'; }, + el: function(){ return el; }, + options_: {}, + options: function(){ return {}; }, + controls: function(){ return false; }, + usingNativeControls: function(){ return false; }, + on: function(){ return this; }, + ready: function(){} + }; + + tech = new vjs.Html5(player, {}); + tech.createEl(); + + tech.el_.playbackRate = 1.25; + strictEqual(tech.playbackRate(), 1.25); + + tech['setPlaybackRate'](0.75); + strictEqual(tech.playbackRate(), 0.75); +});