27 | Add external plugins: 28 | 29 | 30 |
31 |├── .babelrc ├── .codeclimate.yml ├── .editorconfig ├── .eslintignore ├── .eslintrc.json ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── question.md ├── pull_request_template.md ├── stale.yml └── workflow │ └── actions-ci.yml ├── .gitignore ├── .hound.yml ├── .yarnclean ├── .yarnrc ├── AUTHORS ├── CONTRIBUTORS ├── LICENSE ├── README.md ├── bump ├── jest.config.js ├── package.json ├── postcss.config.js ├── public ├── i │ ├── clappr_logo_black.png │ └── favico.png ├── index.html ├── j │ ├── add-external.js │ ├── clappr-config.js │ ├── editor │ │ ├── ace.js │ │ ├── mode-javascript.js │ │ ├── theme-katzenmilch.js │ │ └── worker-javascript.js │ └── main.js ├── stats.html └── stylesheets │ ├── bootstrap-theme.min.css │ ├── bootstrap.min.css │ └── style.css ├── rollup.config.js ├── src ├── __mocks__ │ ├── htmlMock.js │ └── styleMock.js ├── base │ ├── adaptive_playback │ │ ├── adaptive_playback.js │ │ └── adaptive_playback.test.js │ ├── base_object │ │ ├── base_object.js │ │ └── base_object.test.js │ ├── container_plugin │ │ ├── container_plugin.js │ │ └── container_plugin.test.js │ ├── core_plugin │ │ ├── core_plugin.js │ │ └── core_plugin.test.js │ ├── error_mixin │ │ ├── error_mixin.js │ │ └── error_mixin.test.js │ ├── events │ │ ├── events.js │ │ └── events.test.js │ ├── media.js │ ├── playback │ │ ├── playback.js │ │ └── playback.test.js │ ├── polyfills.js │ ├── scss │ │ ├── _fontsmoothing.scss │ │ ├── _noselect.scss │ │ └── _reset.scss │ ├── styler │ │ ├── styler.js │ │ └── styler.test.js │ ├── template.js │ ├── ui_container_plugin │ │ ├── ui_container_plugin.js │ │ └── ui_container_plugin.test.js │ ├── ui_core_plugin │ │ ├── ui_core_plugin.js │ │ └── ui_core_plugin.test.js │ └── ui_object │ │ ├── ui_object.js │ │ └── ui_object.test.js ├── components │ ├── browser │ │ ├── browser.js │ │ ├── browser.test.js │ │ ├── browser_data.js │ │ └── os_data.js │ ├── container │ │ ├── container.js │ │ ├── container.test.js │ │ └── public │ │ │ └── style.scss │ ├── container_factory │ │ ├── container_factory.js │ │ └── container_factory.test.js │ ├── core │ │ ├── core.js │ │ ├── core.test.js │ │ └── public │ │ │ ├── optional_reset.scss │ │ │ └── style.scss │ ├── core_factory │ │ ├── core_factory.js │ │ └── core_factory.test.js │ ├── error │ │ ├── error.js │ │ └── error.test.js │ ├── loader │ │ ├── loader.js │ │ └── loader.test.js │ ├── log │ │ ├── log.js │ │ └── log.test.js │ └── player │ │ ├── player.js │ │ └── player.test.js ├── external_plugin.test.js ├── main.js ├── playbacks │ ├── html5_audio │ │ ├── html5_audio.js │ │ └── html5_audio.test.js │ ├── html5_video │ │ ├── html5_video.js │ │ ├── html5_video.test.js │ │ └── public │ │ │ ├── style.scss │ │ │ └── tracks.html │ ├── html_img │ │ ├── html_img.js │ │ └── public │ │ │ └── style.scss │ └── no_op │ │ ├── no_op.js │ │ └── public │ │ ├── error.html │ │ └── style.scss ├── plugins │ ├── sources │ │ ├── sources.js │ │ └── sources.test.js │ └── strings │ │ ├── strings.js │ │ └── strings.test.js └── utils │ ├── utils.js │ ├── utils.test.js │ ├── version.js │ └── version.test.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/preset-env", { "modules": false }] 4 | ], 5 | "env": { 6 | "test": { 7 | "presets": [["@babel/preset-env"]] 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | engines: 2 | csslint: 3 | enabled: true 4 | duplication: 5 | enabled: true 6 | config: 7 | languages: 8 | - javascript 9 | eslint: 10 | enabled: true 11 | channel: "eslint-2" 12 | fixme: 13 | enabled: true 14 | ratings: 15 | paths: 16 | - "**.css" 17 | - "**.js" 18 | exclude_paths: 19 | - dist/ 20 | - test/ 21 | - node_modules/ 22 | - public/ 23 | - src/vendor/ 24 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | charset = utf-8 8 | end_of_line = lf 9 | insert_final_newline = true 10 | 11 | indent_style = space 12 | indent_size = 2 13 | 14 | trim_trailing_whitespace = true 15 | 16 | max_line_length = 120 17 | 18 | [*.md] 19 | # add Markdown specifics if needed 20 | 21 | [*json] 22 | # add JSON specifics if needed 23 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | public/ 3 | src/base/polyfills.js 4 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "commonjs": true, 5 | "es6": true 6 | }, 7 | "globals": { 8 | "_gaq": false, 9 | "process": false, 10 | "ActiveXObject": false, 11 | "VERSION": false, 12 | "__dirname": false, 13 | "after": false, 14 | "afterEach": false, 15 | "assert": false, 16 | "before": false, 17 | "beforeEach": false, 18 | "describe": false, 19 | "expect": false, 20 | "it": false, 21 | "sinon": false, 22 | "xit": false, 23 | "jest": false, 24 | "test": false, 25 | "module": false, 26 | "require": false 27 | }, 28 | "extends": "eslint:recommended", 29 | "parserOptions": { 30 | "sourceType": "module", 31 | "ecmaVersion": 2018 32 | }, 33 | "rules": { 34 | "indent": [ 35 | "error", 36 | 2 37 | ], 38 | "linebreak-style": [ 39 | "error", 40 | "unix" 41 | ], 42 | "quotes": [ 43 | "error", 44 | "single" 45 | ], 46 | "semi": [ 47 | "error", 48 | "never" 49 | ], 50 | "no-var": "error", 51 | "block-spacing": "error", 52 | "curly": ["error", "multi-or-nest", "consistent"], 53 | "object-curly-spacing": ["error", "always"], 54 | "brace-style": ["error", "1tbs", { "allowSingleLine": true }], 55 | "keyword-spacing": "error", 56 | "space-before-blocks": "error", 57 | "arrow-spacing": "error", 58 | "max-len": 0, 59 | "max-statements": 0 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | dist/* -diff 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F41C Bug report" 3 | about: Create a report to help us improve 4 | labels: bug 5 | --- 6 | 7 | Please, try to follow this to open new bugs (questions, suggestions and others are welcome) 8 | 9 | Before you open the bug please follow the [common steps to verify issues]( https://github.com/clappr/clappr/blob/master/doc/TROUBLESHOOTING.md#common-steps-to-verify-issues) 10 | 11 | For the **issue title**: A **meaningful title** (like: HLS doesn't work at windows 10). Try to **avoid helpless title** (like: it doesn't work, IE10, bug, problem) 12 | 13 | **Be sure to**: 14 | 15 | * Reproduce the bug at http://cdn.clappr.io/ 16 | * Search for similar open/closed issues on this matter before open a new one. 17 | 18 | For the **issue body**: 19 |
27 | Add external plugins: 28 | 29 | 30 |
31 |here
')[0] 139 | class MySpecialButton extends UIObject { 140 | constructor(options) { 141 | super(options) 142 | } 143 | render() { this.$el.append(insideComponent) } 144 | } 145 | 146 | const myButton = new MySpecialButton() 147 | myButton.render() 148 | 149 | expect(myButton.$('#special-id')[0]).toEqual(insideComponent) 150 | }) 151 | 152 | test('uses the existent element if _ensureElement method is called after one component is created', () => { 153 | class MyButton extends UIObject { get tagName() { return 'button' } } 154 | const myButton = new MyButton() 155 | const component = $('') 156 | 157 | expect(myButton.el).toEqual(component[0]) 158 | expect(myButton.$el).toEqual(component) 159 | 160 | myButton._ensureElement() 161 | 162 | expect(myButton.el).toEqual(component[0]) 163 | expect(myButton.$el).toEqual(component) 164 | }) 165 | 166 | test('removes it from DOM', () => { 167 | class FullscreenButton extends UIObject { 168 | constructor(options) { 169 | super(options) 170 | } 171 | get attributes() { return { id: 'my-0-button' } } 172 | } 173 | 174 | const myButton = new FullscreenButton() 175 | $(document.body).append(myButton.$el) 176 | 177 | expect($('#my-0-button').length).toEqual(1) 178 | 179 | myButton.destroy() 180 | 181 | expect($('#my-0-button').length).toEqual(0) 182 | }) 183 | 184 | test('stops listening', () => { 185 | class FullscreenButton extends UIObject { 186 | constructor(options) { 187 | super(options) 188 | this.myId = 0 189 | } 190 | get events() { return { 'click': 'myClick' } } 191 | myClick() { this.myId += 1 } 192 | } 193 | 194 | const myButton = new FullscreenButton() 195 | 196 | myButton.$el.trigger('click') 197 | expect(myButton.myId).toEqual(1) 198 | 199 | myButton.destroy() 200 | myButton.$el.trigger('click') 201 | myButton.$el.trigger('click') 202 | 203 | expect(myButton.myId).toEqual(1) 204 | }) 205 | }) 206 | -------------------------------------------------------------------------------- /src/components/browser/browser.js: -------------------------------------------------------------------------------- 1 | import $ from 'clappr-zepto' 2 | import BROWSER_DATA from './browser_data' 3 | import OS_DATA from './os_data' 4 | 5 | const Browser = {} 6 | 7 | const hasLocalstorage = function() { 8 | try { 9 | localStorage.setItem('clappr', 'clappr') 10 | localStorage.removeItem('clappr') 11 | return true 12 | } catch (e) { 13 | return false 14 | } 15 | } 16 | 17 | const hasFlash = function() { 18 | try { 19 | const fo = new ActiveXObject('ShockwaveFlash.ShockwaveFlash') 20 | return !!fo 21 | } catch (e) { 22 | return !!(navigator.mimeTypes && navigator.mimeTypes['application/x-shockwave-flash'] !== undefined && 23 | navigator.mimeTypes['application/x-shockwave-flash'].enabledPlugin) 24 | } 25 | } 26 | 27 | export const getBrowserInfo = function(ua) { 28 | let parts = ua.match(/\b(playstation 4|nx|opera|chrome|safari|firefox|msie|trident(?=\/))\/?\s*(\d+)/i) || [], 29 | extra 30 | if (/trident/i.test(parts[1])) { 31 | extra = /\brv[ :]+(\d+)/g.exec(ua) || [] 32 | return { 33 | name: 'IE', 34 | version: parseInt(extra[1] || '') 35 | } 36 | } else if (parts[1] === 'Chrome') { 37 | extra = ua.match(/\bOPR\/(\d+)/) 38 | if (extra != null) return { name: 'Opera', version: parseInt(extra[1]) } 39 | 40 | extra = ua.match(/\bEdge\/(\d+)/) 41 | if (extra != null) return { name: 'Edge', version: parseInt(extra[1]) } 42 | 43 | } else if (/android/i.test(ua) && (extra = ua.match(/version\/(\d+)/i))) { 44 | parts.splice(1, 1, 'Android WebView') 45 | parts.splice(2, 1, extra[1]) 46 | } 47 | parts = parts[2] ? [parts[1], parts[2]] : [navigator.appName, navigator.appVersion, '-?'] 48 | 49 | return { 50 | name: parts[0], 51 | version: parseInt(parts[1]) 52 | } 53 | } 54 | 55 | // Get browser data 56 | export const getBrowserData = function() { 57 | let browserObject = {} 58 | let userAgent = Browser.userAgent.toLowerCase() 59 | 60 | // Check browser type 61 | for (let browser of BROWSER_DATA) { 62 | let browserRegExp = new RegExp(browser.identifier.toLowerCase()) 63 | let browserRegExpResult = browserRegExp.exec(userAgent) 64 | 65 | if (browserRegExpResult != null && browserRegExpResult[1]) { 66 | browserObject.name = browser.name 67 | browserObject.group = browser.group 68 | 69 | // Check version 70 | if (browser.versionIdentifier) { 71 | let versionRegExp = new RegExp(browser.versionIdentifier.toLowerCase()) 72 | let versionRegExpResult = versionRegExp.exec(userAgent) 73 | 74 | if (versionRegExpResult != null && versionRegExpResult[1]) 75 | setBrowserVersion(versionRegExpResult[1], browserObject) 76 | 77 | } else { 78 | setBrowserVersion(browserRegExpResult[1], browserObject) 79 | } 80 | break 81 | } 82 | } 83 | return browserObject 84 | } 85 | 86 | // Set browser version 87 | const setBrowserVersion = function(version, browserObject) { 88 | let splitVersion = version.split('.', 2) 89 | browserObject.fullVersion = version 90 | 91 | // Major version 92 | if (splitVersion[0]) browserObject.majorVersion = parseInt(splitVersion[0]) 93 | 94 | // Minor version 95 | if (splitVersion[1]) browserObject.minorVersion = parseInt(splitVersion[1]) 96 | } 97 | 98 | // Get OS data 99 | export const getOsData = function() { 100 | let osObject = {} 101 | let userAgent = Browser.userAgent.toLowerCase() 102 | 103 | // Check browser type 104 | for (let os of OS_DATA) { 105 | let osRegExp = new RegExp(os.identifier.toLowerCase()) 106 | let osRegExpResult = osRegExp.exec(userAgent) 107 | 108 | if (osRegExpResult != null) { 109 | osObject.name = os.name 110 | osObject.group = os.group 111 | 112 | // Version defined 113 | if (os.version) { 114 | setOsVersion(os.version, (os.versionSeparator) ? os.versionSeparator : '.', osObject) 115 | 116 | // Version detected 117 | } else if (osRegExpResult[1]) { 118 | setOsVersion(osRegExpResult[1], (os.versionSeparator) ? os.versionSeparator : '.', osObject) 119 | 120 | // Version identifier 121 | } else if (os.versionIdentifier) { 122 | let versionRegExp = new RegExp(os.versionIdentifier.toLowerCase()) 123 | let versionRegExpResult = versionRegExp.exec(userAgent) 124 | 125 | if (versionRegExpResult != null && versionRegExpResult[1]) 126 | setOsVersion(versionRegExpResult[1], (os.versionSeparator) ? os.versionSeparator : '.', osObject) 127 | 128 | } 129 | break 130 | } 131 | } 132 | return osObject 133 | } 134 | 135 | // Set OS version 136 | const setOsVersion = function(version, separator, osObject) { 137 | let finalSeparator = separator.substr(0, 1) == '[' ? new RegExp(separator, 'g') : separator 138 | const splitVersion = version.split(finalSeparator, 2) 139 | 140 | if (separator != '.') version = version.replace(new RegExp(separator, 'g'), '.') 141 | 142 | osObject.fullVersion = version 143 | 144 | // Major version 145 | if (splitVersion && splitVersion[0]) 146 | osObject.majorVersion = parseInt(splitVersion[0]) 147 | 148 | // Minor version 149 | if (splitVersion && splitVersion[1]) 150 | osObject.minorVersion = parseInt(splitVersion[1]) 151 | } 152 | 153 | // Set viewport size 154 | export const getViewportSize = function() { 155 | let viewportObject = {} 156 | 157 | viewportObject.width = $(window).width() 158 | viewportObject.height = $(window).height() 159 | 160 | return viewportObject 161 | } 162 | 163 | // Set viewport orientation 164 | const setViewportOrientation = function() { 165 | switch (window.orientation) { 166 | case -90: 167 | case 90: 168 | Browser.viewport.orientation = 'landscape' 169 | break 170 | default: 171 | Browser.viewport.orientation = 'portrait' 172 | break 173 | } 174 | } 175 | 176 | export const getDevice = function(ua) { 177 | let platformRegExp = /\((iP(?:hone|ad|od))?(?:[^;]*; ){0,2}([^)]+(?=\)))/ 178 | let matches = platformRegExp.exec(ua) 179 | let device = matches && (matches[1] || matches[2]) || '' 180 | return device 181 | } 182 | 183 | const browserInfo = getBrowserInfo(navigator.userAgent) 184 | 185 | Browser.isEdge = /Edg|EdgiOS|EdgA/i.test(navigator.userAgent) 186 | Browser.isChrome = /Chrome|CriOS/i.test(navigator.userAgent) && !Browser.isEdge 187 | Browser.isSafari = /Safari/i.test(navigator.userAgent) && !Browser.isChrome && !Browser.isEdge 188 | Browser.isFirefox = /Firefox/i.test(navigator.userAgent) 189 | Browser.isLegacyIE = !!(window.ActiveXObject) 190 | Browser.isIE = Browser.isLegacyIE || /trident.*rv:1\d/i.test(navigator.userAgent) 191 | Browser.isIE11 = /trident.*rv:11/i.test(navigator.userAgent) 192 | Browser.isChromecast = Browser.isChrome && /CrKey/i.test(navigator.userAgent) 193 | Browser.isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|Windows Phone|IEMobile|Mobile Safari|Opera Mini/i.test(navigator.userAgent) 194 | Browser.isiOS = /iPad|iPhone|iPod/i.test(navigator.userAgent) 195 | Browser.isAndroid = /Android/i.test(navigator.userAgent) 196 | Browser.isWindowsPhone = /Windows Phone/i.test(navigator.userAgent) 197 | Browser.isWin8App = /MSAppHost/i.test(navigator.userAgent) 198 | Browser.isWiiU = /WiiU/i.test(navigator.userAgent) 199 | Browser.isPS4 = /PlayStation 4/i.test(navigator.userAgent) 200 | Browser.hasLocalstorage = hasLocalstorage() 201 | Browser.hasFlash = hasFlash() 202 | 203 | /** 204 | * @deprecated 205 | * This parameter currently exists for retrocompatibility reasons. 206 | * Use Browser.data.name instead. 207 | */ 208 | Browser.name = browserInfo.name 209 | 210 | /** 211 | * @deprecated 212 | * This parameter currently exists for retrocompatibility reasons. 213 | * Use Browser.data.fullVersion instead. 214 | */ 215 | Browser.version = browserInfo.version 216 | 217 | Browser.userAgent = navigator.userAgent 218 | Browser.data = getBrowserData() 219 | Browser.os = getOsData() 220 | 221 | Browser.isWindows = /^Windows$/i.test(Browser.os.group) 222 | Browser.isMacOS = /^Mac OS$/i.test(Browser.os.group) 223 | Browser.isLinux = /^Linux$/i.test(Browser.os.group) 224 | 225 | Browser.viewport = getViewportSize() 226 | Browser.device = getDevice(Browser.userAgent) 227 | typeof window.orientation !== 'undefined' && setViewportOrientation() 228 | 229 | export default Browser 230 | -------------------------------------------------------------------------------- /src/components/browser/browser.test.js: -------------------------------------------------------------------------------- 1 | import Browser from './browser' 2 | 3 | import { getBrowserData, getBrowserInfo, getDevice, getOsData } from './browser' 4 | 5 | describe('Browser', function() { 6 | test('checks localstorage support', () => { 7 | expect(Browser.hasLocalstorage).toEqual(true) 8 | }) 9 | 10 | describe('environment information', () => { 11 | test('reports correctly Android WebView (prior to KitKat)', () => { 12 | const userAgent = 'Mozilla/5.0 (Linux; U; Android 4.1.1; en-gb; Build/KLP) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Safari/534.30' 13 | const browserInfo = getBrowserInfo(userAgent) 14 | expect(browserInfo.name).toEqual('Android WebView') 15 | expect(browserInfo.version).toEqual(4) 16 | }) 17 | 18 | test('reports correctly Android Chrome WebView (KitKat to Lollipop)', () => { 19 | const userAgent = 'Mozilla/5.0 (Linux; Android 4.4; Nexus 5 Build/_BuildID_) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/30.0.0.0 Mobile Safari/537.36' 20 | const browserInfo = getBrowserInfo(userAgent) 21 | expect(browserInfo.name).toEqual('Chrome') 22 | expect(browserInfo.version).toEqual(30) 23 | }) 24 | 25 | test('reports correctly Android Chrome WebView (Lollipop and Above)', () => { 26 | const userAgent = 'Mozilla/5.0 (Linux; Android 5.1.1; Nexus 5 Build/LMY48B; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/43.0.2357.65 Mobile Safari/537.36' 27 | const browserInfo = getBrowserInfo(userAgent) 28 | expect(browserInfo.name).toEqual('Chrome') 29 | expect(browserInfo.version).toEqual(43) 30 | }) 31 | 32 | test('reports correctly operational system data', () => { 33 | Browser.userAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.139 Safari/537.36' 34 | const osData = getOsData() 35 | expect(osData.name).toEqual('Mac OS X Sierra') 36 | expect(osData.majorVersion).toEqual(10) 37 | expect(osData.minorVersion).toEqual(12) 38 | }) 39 | 40 | test('reports correctly browser data', () => { 41 | Browser.userAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.139 Safari/537.36' 42 | const browserData = getBrowserData() 43 | expect(browserData.group).toEqual('Chrome') 44 | expect(browserData.majorVersion).toEqual(66) 45 | expect(browserData.minorVersion).toEqual(0) 46 | expect(browserData.fullVersion).toEqual('66.0.3359.139') 47 | }) 48 | 49 | describe('device', () => { 50 | test('reports correctly android devices', () => { 51 | const userAgent = 'Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.139 Mobile Safari/537.36' 52 | const device = getDevice(userAgent) 53 | expect(device).toEqual('Pixel 2 XL Build/OPD1.170816.004') 54 | }) 55 | 56 | test('reports correctly iPhone devices', function () { 57 | const userAgent = 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1' 58 | const device = getDevice(userAgent) 59 | expect(device).toEqual('iPhone') 60 | }) 61 | 62 | test('reports full platform string if no separator is found', function () { 63 | const userAgent = 'Mozilla/5.0 (CrKey armv7l 1.5.16041) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/31.0.1650.0 Safari/537.36' 64 | const device = getDevice(userAgent) 65 | expect(device).toEqual('CrKey armv7l 1.5.16041') 66 | }) 67 | 68 | test('reports empty string for missing platform detail', () => { 69 | const userAgent = 'AppleTV6,2/11.1' 70 | const device = getDevice(userAgent) 71 | expect(device).toEqual('') 72 | }) 73 | }) 74 | }) 75 | }) 76 | -------------------------------------------------------------------------------- /src/components/browser/browser_data.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-useless-escape */ 2 | // The order of the following arrays is important, be careful if you change it. 3 | 4 | const BROWSER_DATA = [{ 5 | name: 'Chromium', 6 | group: 'Chrome', 7 | identifier: 'Chromium/([0-9\.]*)' 8 | }, { 9 | name: 'Chrome Mobile', 10 | group: 'Chrome', 11 | identifier: 'Chrome/([0-9\.]*) Mobile', 12 | versionIdentifier: 'Chrome/([0-9\.]*)' 13 | }, { 14 | name: 'Chrome', 15 | group: 'Chrome', 16 | identifier: 'Chrome/([0-9\.]*)' 17 | }, { 18 | name: 'Chrome for iOS', 19 | group: 'Chrome', 20 | identifier: 'CriOS/([0-9\.]*)' 21 | }, { 22 | name: 'Android Browser', 23 | group: 'Chrome', 24 | identifier: 'CrMo/([0-9\.]*)' 25 | }, { 26 | name: 'Firefox', 27 | group: 'Firefox', 28 | identifier: 'Firefox/([0-9\.]*)' 29 | }, { 30 | name: 'Opera Mini', 31 | group: 'Opera', 32 | identifier: 'Opera Mini/([0-9\.]*)' 33 | }, { 34 | name: 'Opera', 35 | group: 'Opera', 36 | identifier: 'Opera ([0-9\.]*)' 37 | }, { 38 | name: 'Opera', 39 | group: 'Opera', 40 | identifier: 'Opera/([0-9\.]*)', 41 | versionIdentifier: 'Version/([0-9\.]*)' 42 | }, { 43 | name: 'IEMobile', 44 | group: 'Explorer', 45 | identifier: 'IEMobile/([0-9\.]*)' 46 | }, { 47 | name: 'Internet Explorer', 48 | group: 'Explorer', 49 | identifier: 'MSIE ([a-zA-Z0-9\.]*)' 50 | }, { 51 | name: 'Internet Explorer', 52 | group: 'Explorer', 53 | identifier: 'Trident/([0-9\.]*)', 54 | versionIdentifier: 'rv:([0-9\.]*)' 55 | }, { 56 | name: 'Spartan', 57 | group: 'Spartan', 58 | identifier: 'Edge/([0-9\.]*)', 59 | versionIdentifier: 'Edge/([0-9\.]*)' 60 | }, { 61 | name: 'Safari', 62 | group: 'Safari', 63 | identifier: 'Safari/([0-9\.]*)', 64 | versionIdentifier: 'Version/([0-9\.]*)' 65 | }] 66 | 67 | export default BROWSER_DATA 68 | -------------------------------------------------------------------------------- /src/components/browser/os_data.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-useless-escape */ 2 | // The order of the following arrays is important, be careful if you change it. 3 | 4 | const OS_DATA = [{ 5 | name: 'Windows 2000', 6 | group: 'Windows', 7 | identifier: 'Windows NT 5.0', 8 | version: '5.0' 9 | }, { 10 | name: 'Windows XP', 11 | group: 'Windows', 12 | identifier: 'Windows NT 5.1', 13 | version: '5.1' 14 | }, { 15 | name: 'Windows Vista', 16 | group: 'Windows', 17 | identifier: 'Windows NT 6.0', 18 | version: '6.0' 19 | }, { 20 | name: 'Windows 7', 21 | group: 'Windows', 22 | identifier: 'Windows NT 6.1', 23 | version: '7.0' 24 | }, { 25 | name: 'Windows 8', 26 | group: 'Windows', 27 | identifier: 'Windows NT 6.2', 28 | version: '8.0' 29 | }, { 30 | name: 'Windows 8.1', 31 | group: 'Windows', 32 | identifier: 'Windows NT 6.3', 33 | version: '8.1' 34 | }, { 35 | name: 'Windows 10', 36 | group: 'Windows', 37 | identifier: 'Windows NT 10.0', 38 | version: '10.0' 39 | }, { 40 | name: 'Windows Phone', 41 | group: 'Windows Phone', 42 | identifier: 'Windows Phone ([0-9\.]*)' 43 | }, { 44 | name: 'Windows Phone', 45 | group: 'Windows Phone', 46 | identifier: 'Windows Phone OS ([0-9\.]*)' 47 | }, { 48 | name: 'Windows', 49 | group: 'Windows', 50 | identifier: 'Windows' 51 | }, { 52 | name: 'Chrome OS', 53 | group: 'Chrome OS', 54 | identifier: 'CrOS' 55 | }, { 56 | name: 'Android', 57 | group: 'Android', 58 | identifier: 'Android', 59 | versionIdentifier: 'Android ([a-zA-Z0-9\.-]*)' 60 | }, { 61 | name: 'iPad', 62 | group: 'iOS', 63 | identifier: 'iPad', 64 | versionIdentifier: 'OS ([0-9_]*)', 65 | versionSeparator: '[_|\.]' 66 | }, { 67 | name: 'iPod', 68 | group: 'iOS', 69 | identifier: 'iPod', 70 | versionIdentifier: 'OS ([0-9_]*)', 71 | versionSeparator: '[_|\.]' 72 | }, { 73 | name: 'iPhone', 74 | group: 'iOS', 75 | identifier: 'iPhone OS', 76 | versionIdentifier: 'OS ([0-9_]*)', 77 | versionSeparator: '[_|\.]' 78 | }, { 79 | name: 'Mac OS X High Sierra', 80 | group: 'Mac OS', 81 | identifier: 'Mac OS X (10([_|\.])13([0-9_\.]*))', 82 | versionSeparator: '[_|\.]' 83 | }, { 84 | name: 'Mac OS X Sierra', 85 | group: 'Mac OS', 86 | identifier: 'Mac OS X (10([_|\.])12([0-9_\.]*))', 87 | versionSeparator: '[_|\.]' 88 | }, { 89 | name: 'Mac OS X El Capitan', 90 | group: 'Mac OS', 91 | identifier: 'Mac OS X (10([_|\.])11([0-9_\.]*))', 92 | versionSeparator: '[_|\.]' 93 | }, { 94 | name: 'Mac OS X Yosemite', 95 | group: 'Mac OS', 96 | identifier: 'Mac OS X (10([_|\.])10([0-9_\.]*))', 97 | versionSeparator: '[_|\.]' 98 | }, { 99 | name: 'Mac OS X Mavericks', 100 | group: 'Mac OS', 101 | identifier: 'Mac OS X (10([_|\.])9([0-9_\.]*))', 102 | versionSeparator: '[_|\.]' 103 | }, { 104 | name: 'Mac OS X Mountain Lion', 105 | group: 'Mac OS', 106 | identifier: 'Mac OS X (10([_|\.])8([0-9_\.]*))', 107 | versionSeparator: '[_|\.]' 108 | }, { 109 | name: 'Mac OS X Lion', 110 | group: 'Mac OS', 111 | identifier: 'Mac OS X (10([_|\.])7([0-9_\.]*))', 112 | versionSeparator: '[_|\.]' 113 | }, { 114 | name: 'Mac OS X Snow Leopard', 115 | group: 'Mac OS', 116 | identifier: 'Mac OS X (10([_|\.])6([0-9_\.]*))', 117 | versionSeparator: '[_|\.]' 118 | }, { 119 | name: 'Mac OS X Leopard', 120 | group: 'Mac OS', 121 | identifier: 'Mac OS X (10([_|\.])5([0-9_\.]*))', 122 | versionSeparator: '[_|\.]' 123 | }, { 124 | name: 'Mac OS X Tiger', 125 | group: 'Mac OS', 126 | identifier: 'Mac OS X (10([_|\.])4([0-9_\.]*))', 127 | versionSeparator: '[_|\.]' 128 | }, { 129 | name: 'Mac OS X Panther', 130 | group: 'Mac OS', 131 | identifier: 'Mac OS X (10([_|\.])3([0-9_\.]*))', 132 | versionSeparator: '[_|\.]' 133 | }, { 134 | name: 'Mac OS X Jaguar', 135 | group: 'Mac OS', 136 | identifier: 'Mac OS X (10([_|\.])2([0-9_\.]*))', 137 | versionSeparator: '[_|\.]' 138 | }, { 139 | name: 'Mac OS X Puma', 140 | group: 'Mac OS', 141 | identifier: 'Mac OS X (10([_|\.])1([0-9_\.]*))', 142 | versionSeparator: '[_|\.]' 143 | }, { 144 | name: 'Mac OS X Cheetah', 145 | group: 'Mac OS', 146 | identifier: 'Mac OS X (10([_|\.])0([0-9_\.]*))', 147 | versionSeparator: '[_|\.]' 148 | }, { 149 | name: 'Mac OS', 150 | group: 'Mac OS', 151 | identifier: 'Mac OS' 152 | }, { 153 | name: 'Ubuntu', 154 | group: 'Linux', 155 | identifier: 'Ubuntu', 156 | versionIdentifier: 'Ubuntu/([0-9\.]*)' 157 | }, { 158 | name: 'Debian', 159 | group: 'Linux', 160 | identifier: 'Debian' 161 | }, { 162 | name: 'Gentoo', 163 | group: 'Linux', 164 | identifier: 'Gentoo' 165 | }, { 166 | name: 'Linux', 167 | group: 'Linux', 168 | identifier: 'Linux' 169 | }, { 170 | name: 'BlackBerry', 171 | group: 'BlackBerry', 172 | identifier: 'BlackBerry' 173 | }] 174 | 175 | export default OS_DATA 176 | -------------------------------------------------------------------------------- /src/components/container/public/style.scss: -------------------------------------------------------------------------------- 1 | .container[data-container] { 2 | position: absolute; 3 | background-color: black; 4 | height: 100%; 5 | width: 100%; 6 | max-width: 100%; 7 | 8 | .chromeless { 9 | cursor: default; 10 | } 11 | } 12 | 13 | [data-player]:not(.nocursor) .container[data-container]:not(.chromeless).pointer-enabled { 14 | cursor: pointer; 15 | } 16 | -------------------------------------------------------------------------------- /src/components/container_factory/container_factory.js: -------------------------------------------------------------------------------- 1 | // Copyright 2014 Globo.com Player authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | /** 6 | * The ContainerFactory is responsible for manage playback bootstrap and create containers. 7 | */ 8 | 9 | import $ from 'clappr-zepto' 10 | import BaseObject from '@/base/base_object' 11 | import Events from '@/base/events' 12 | import Container from '@/components/container' 13 | import Playback from '@/base/playback' 14 | 15 | export default class ContainerFactory extends BaseObject { 16 | get options() { return this._options } 17 | set options(options) { this._options = options } 18 | 19 | constructor(options, loader, i18n, playerError) { 20 | super(options) 21 | this._i18n = i18n 22 | this.loader = loader 23 | this.playerError = playerError 24 | } 25 | 26 | createContainers() { 27 | return $.Deferred((promise) => { 28 | promise.resolve(this.options.sources.map((source) => { 29 | return this.createContainer(source) 30 | })) 31 | }) 32 | } 33 | 34 | findPlaybackPlugin(source, mimeType) { 35 | return this.loader.playbackPlugins.filter(p => p.canPlay(source, mimeType))[0] 36 | } 37 | 38 | createContainer(source) { 39 | let resolvedSource = null 40 | let mimeType = this.options.mimeType 41 | 42 | if (typeof source === 'object') { 43 | resolvedSource = source.source.toString() 44 | if (source.mimeType) mimeType = source.mimeType 45 | } else { 46 | resolvedSource = source.toString() 47 | } 48 | 49 | if (resolvedSource.match(/^\/\//)) resolvedSource = window.location.protocol + resolvedSource 50 | 51 | let options = { ...this.options, src: resolvedSource, mimeType: mimeType } 52 | 53 | const playbackPlugin = this.findPlaybackPlugin(resolvedSource, mimeType) 54 | 55 | // Fallback to empty playback object until we sort out unsupported sources error without NoOp playback 56 | const playback = playbackPlugin ? new playbackPlugin(options, this._i18n, this.playerError) : new Playback() 57 | 58 | options = { ...options, playback: playback } 59 | 60 | const container = new Container(options, this._i18n, this.playerError) 61 | const defer = $.Deferred() 62 | defer.promise(container) 63 | this.addContainerPlugins(container) 64 | this.listenToOnce(container, Events.CONTAINER_READY, () => defer.resolve(container)) 65 | return container 66 | } 67 | 68 | addContainerPlugins(container) { 69 | this.loader.containerPlugins.forEach((Plugin) => { 70 | container.addPlugin(new Plugin(container)) 71 | }) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/components/container_factory/container_factory.test.js: -------------------------------------------------------------------------------- 1 | import ContainerFactory from './container_factory' 2 | import Loader from '@/components/loader' 3 | import ContainerPlugin from '@/base/container_plugin' 4 | import Playback from '@/base/playback' 5 | 6 | describe('ContainerFactory', function() { 7 | let options, playback, loader, container_factory 8 | beforeEach(() => { 9 | options = { 10 | source: 'http://some.url/for/video.mp4', 11 | autoPlay: false 12 | } 13 | playback = { canPlay: () => true } 14 | loader = { playbackPlugins: [playback] } 15 | container_factory = new ContainerFactory(options, loader, {}) 16 | }) 17 | 18 | test('finds playback based on source', () => { 19 | const activePlayback = container_factory.findPlaybackPlugin('video.mp4') 20 | expect(playback).toEqual(activePlayback) 21 | }) 22 | 23 | test('allows overriding options', () => { 24 | expect(container_factory.options.source).toEqual(options.source) 25 | expect(container_factory.options.autoPlay).toEqual(options.autoPlay) 26 | const newSource = 'http://some.url/for/video.m3u8' 27 | container_factory.options = { ...options, source: newSource } 28 | expect(container_factory.options.source).toEqual(newSource) 29 | }) 30 | 31 | test('addContainerPlugins method creates registered container plugins for a given container', () => { 32 | const plugin = ContainerPlugin.extend({ name: 'test_plugin' }) 33 | Loader.registerPlugin(plugin) 34 | 35 | const source = 'http://some.url/for/video.mp4' 36 | const containerFactory = new ContainerFactory({}, new Loader(), {}) 37 | const container = containerFactory.createContainer(source) 38 | expect(container.getPlugin('test_plugin')).not.toBeUndefined() 39 | 40 | const pluginInstance = container.getPlugin('test_plugin') 41 | 42 | expect(pluginInstance.container).toEqual(container) 43 | }) 44 | 45 | describe('createContainer method', () => { 46 | test('creates a container for a given source', () => { 47 | const source = 'http://some.url/for/video.mp4' 48 | const containerFactory = new ContainerFactory({}, new Loader(), {}) 49 | const container = containerFactory.createContainer(source) 50 | 51 | expect(container.options.src).toEqual(source) 52 | }) 53 | 54 | test('creates a playback instance based on existent playback plugins and a given source', () => { 55 | class CustomPlayback extends Playback { 56 | get name() { return 'custom-playback' } 57 | get supportedVersion() { return { min: VERSION } } 58 | } 59 | CustomPlayback.canPlay = () => true 60 | Loader.registerPlayback(CustomPlayback) 61 | 62 | const source = 'http://some.url/for/video.mp4' 63 | const containerFactory = new ContainerFactory({}, new Loader(), {}) 64 | const container = containerFactory.createContainer(source) 65 | 66 | expect(container.playback.name).toEqual('custom-playback') 67 | }) 68 | 69 | test('creates a container for a given set of options that includes a source', () => { 70 | const options = { source: 'http://some.url/for/video.mp4' } 71 | const containerFactory = new ContainerFactory({}, new Loader(), {}) 72 | const container = containerFactory.createContainer(options) 73 | 74 | expect(container.options.src).toEqual(options.source) 75 | }) 76 | 77 | test('creates a container for a given set of options that includes a source and a mimeType', () => { 78 | const options = { source: 'http://some.url/for/video', mimeType: 'mp4' } 79 | const containerFactory = new ContainerFactory({}, new Loader(), {}) 80 | const container = containerFactory.createContainer(options) 81 | 82 | expect(container.options.src).toEqual(options.source) 83 | }) 84 | 85 | test('uses current domain protocol to set source on the container instance', () => { 86 | const source = '//some.url/for/video.mp4' 87 | const containerFactory = new ContainerFactory({}, new Loader(), {}) 88 | const container = containerFactory.createContainer(source) 89 | 90 | expect(container.options.src).toEqual(`http:${source}`) 91 | }) 92 | }) 93 | 94 | describe('createContainers method', () => { 95 | test('creates a container for each source existent in sources array option', (done) => { 96 | const sources = ['http://some.url/for/video.mp4', 'http://another.url/for/video.mp4'] 97 | const containerFactory = new ContainerFactory({ sources }, new Loader(), {}) 98 | containerFactory.createContainers().then(containers => { 99 | expect(containers.length).toEqual(2) 100 | expect(containers[0].options.src).toEqual(sources[0]) 101 | expect(containers[1].options.src).toEqual(sources[1]) 102 | done() 103 | }) 104 | }) 105 | }) 106 | }) 107 | -------------------------------------------------------------------------------- /src/components/core/public/optional_reset.scss: -------------------------------------------------------------------------------- 1 | @import 'reset'; 2 | 3 | [data-player] { 4 | @include nested-reset; 5 | } 6 | -------------------------------------------------------------------------------- /src/components/core/public/style.scss: -------------------------------------------------------------------------------- 1 | @import 'noselect'; 2 | @import 'fontsmoothing'; 3 | 4 | [data-player] { 5 | @include no-select; 6 | @include font-smoothing; 7 | transform: translate3d(0,0,0); 8 | position: relative; 9 | margin: 0; 10 | padding: 0; 11 | border: 0; 12 | font-style: normal; 13 | font-weight: normal; 14 | text-align: center; 15 | overflow: hidden; 16 | font-size: 100%; 17 | font-family: "Roboto", "Open Sans", Arial, sans-serif; 18 | text-shadow: 0 0 0; 19 | box-sizing: border-box; 20 | 21 | &:focus { 22 | outline: 0; 23 | } 24 | 25 | * { 26 | box-sizing: inherit; 27 | } 28 | 29 | > * { 30 | float: none; 31 | max-width: none; 32 | } 33 | 34 | > div { 35 | display: block; 36 | } 37 | 38 | &.fullscreen { 39 | width: 100% !important; 40 | height: 100% !important; 41 | top: 0; 42 | left: 0; 43 | } 44 | 45 | &.nocursor { 46 | cursor: none; 47 | } 48 | } 49 | 50 | .clappr-style { 51 | display: none !important; 52 | } 53 | -------------------------------------------------------------------------------- /src/components/core_factory/core_factory.js: -------------------------------------------------------------------------------- 1 | // Copyright 2014 Globo.com Player authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | import BaseObject from '@/base/base_object' 6 | import Core from '@/components/core' 7 | 8 | /** 9 | * The Core Factory is responsible for instantiate the core and it's plugins. 10 | * @class CoreFactory 11 | * @constructor 12 | * @extends BaseObject 13 | * @module components 14 | */ 15 | export default class CoreFactory extends BaseObject { 16 | 17 | get loader() { return this.player.loader } 18 | 19 | /** 20 | * it builds the core factory 21 | * @method constructor 22 | * @param {Player} player the player object 23 | */ 24 | constructor(player) { 25 | super(player.options) 26 | this.player = player 27 | } 28 | 29 | /** 30 | * creates a core and its plugins 31 | * @method create 32 | * @return {Core} created core 33 | */ 34 | create() { 35 | this.options.loader = this.loader 36 | this.core = new Core(this.options) 37 | this.addCorePlugins() 38 | this.core.createContainers(this.options) 39 | return this.core 40 | } 41 | 42 | /** 43 | * given the core plugins (`loader.corePlugins`) it builds each one 44 | * @method addCorePlugins 45 | * @return {Core} the core with all plugins 46 | */ 47 | addCorePlugins() { 48 | this.loader.corePlugins.forEach((Plugin) => { 49 | const plugin = new Plugin(this.core) 50 | this.core.addPlugin(plugin) 51 | this.setupExternalInterface(plugin) 52 | }) 53 | return this.core 54 | } 55 | 56 | setupExternalInterface(plugin) { 57 | const externalFunctions = plugin.getExternalInterface() 58 | for (const key in externalFunctions) 59 | this.player[key] = externalFunctions[key].bind(plugin) 60 | 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/components/core_factory/core_factory.test.js: -------------------------------------------------------------------------------- 1 | import CoreFactory from './core_factory' 2 | import Core from '@/components/core' 3 | import CorePlugin from '@/base/core_plugin' 4 | import Player from '@/components/player' 5 | 6 | describe('CoreFactory', () => { 7 | const bareOptions = { source: 'http://some.url/for/video.mp4' } 8 | const barePlayer = new Player(bareOptions) 9 | const bareFactory = new CoreFactory(barePlayer) 10 | 11 | test('creates player reference on constructor', () => { 12 | expect(bareFactory.player).toEqual(barePlayer) 13 | }) 14 | 15 | test('have a getter called loader', () => { 16 | expect(Object.getOwnPropertyDescriptor(Object.getPrototypeOf(bareFactory), 'loader').get).toBeTruthy() 17 | }) 18 | 19 | test('loader getter returns current player loader reference', () => { 20 | expect(bareFactory.loader).toEqual(barePlayer.loader) 21 | }) 22 | 23 | describe('create method', () => { 24 | const factory = new CoreFactory(barePlayer) 25 | jest.spyOn(factory, 'addCorePlugins') 26 | const coreInstance = factory.create() 27 | 28 | test('sets a loader instance into options reference', () => { 29 | expect(factory.options.loader).toEqual(barePlayer.loader) 30 | }) 31 | 32 | test('sets a core instance into internal reference', () => { 33 | expect(factory.core instanceof Core).toBeTruthy() 34 | }) 35 | 36 | test('calls addCorePlugins method', () => { 37 | expect(factory.addCorePlugins).toHaveBeenCalledTimes(1) 38 | }) 39 | 40 | test('trigger container creation for the core instance', () => { 41 | expect(factory.core.activeContainer).not.toBeUndefined() 42 | }) 43 | 44 | test('returns the internal core instance', () => { 45 | expect(coreInstance).toEqual(factory.core) 46 | expect(coreInstance instanceof Core).toBeTruthy() 47 | }) 48 | }) 49 | 50 | describe('addCorePlugins method', () => { 51 | const factory = new CoreFactory(barePlayer) 52 | const plugin = CorePlugin.extend({ name: 'test_plugin' }) 53 | factory.loader.corePlugins = [plugin] 54 | factory.create() 55 | jest.spyOn(factory, 'setupExternalInterface') 56 | const coreInstance = factory.addCorePlugins() 57 | 58 | test('adds registered core plugins into the core instance', () => { 59 | expect(factory.core.getPlugin('test_plugin')).not.toBeUndefined() 60 | 61 | const pluginInstance = factory.core.getPlugin('test_plugin') 62 | 63 | expect(pluginInstance.core).toEqual(factory.core) 64 | }) 65 | 66 | test('calls setupExternalInterface method for each plugin added', () => { 67 | expect(factory.setupExternalInterface).toHaveBeenCalledTimes(1) 68 | }) 69 | 70 | test('returns the internal core instance', () => { 71 | expect(coreInstance).toEqual(factory.core) 72 | expect(coreInstance instanceof Core).toBeTruthy() 73 | }) 74 | }) 75 | 76 | describe('setupExternalInterface method', () => { 77 | class TestPlugin extends CorePlugin { 78 | get name() { return 'test_plugin' } 79 | constructor(core) { 80 | super(core) 81 | this.message = '' 82 | } 83 | addMessage(message) { this.message = message } 84 | getExternalInterface() { return { addMessage: message => this.addMessage(message) } } 85 | } 86 | 87 | const player = new Player(bareOptions) 88 | const factory = new CoreFactory(player) 89 | factory.loader.corePlugins = [TestPlugin] 90 | factory.create() 91 | factory.setupExternalInterface(factory.core.getPlugin('test_plugin')) 92 | 93 | test('binds registered methods in core plugins on Player component ', () => { 94 | expect(player.addMessage).not.toBeUndefined() 95 | 96 | player.addMessage('My awesome test!') 97 | 98 | expect(factory.core.getPlugin('test_plugin').message).toEqual('My awesome test!') 99 | }) 100 | }) 101 | }) 102 | -------------------------------------------------------------------------------- /src/components/error/error.js: -------------------------------------------------------------------------------- 1 | import Events from '@/base/events' 2 | import BaseObject from '@/base/base_object' 3 | import Log from '@/components/log' 4 | 5 | /** 6 | * The PlayerError is responsible to receive and propagate errors. 7 | * @class PlayerError 8 | * @constructor 9 | * @extends BaseObject 10 | * @module components 11 | */ 12 | class PlayerError extends BaseObject { 13 | get name() { return 'error' } 14 | 15 | /** 16 | * @property Levels 17 | * @type {Object} object with error levels 18 | */ 19 | static get Levels() { 20 | return { 21 | FATAL: 'FATAL', 22 | WARN: 'WARN', 23 | INFO: 'INFO', 24 | } 25 | } 26 | 27 | constructor(options = {}, core) { 28 | super(options) 29 | this.core = core 30 | } 31 | 32 | /** 33 | * creates and trigger an error. 34 | * @method createError 35 | * @param {Object} err should be an object with code, description, level, origin, scope and raw error. 36 | */ 37 | createError(err) { 38 | if (!this.core) { 39 | Log.warn(this.name, 'Core is not set. Error: ', err) 40 | return 41 | } 42 | this.core.trigger(Events.ERROR, err) 43 | } 44 | } 45 | 46 | export default PlayerError 47 | -------------------------------------------------------------------------------- /src/components/error/error.test.js: -------------------------------------------------------------------------------- 1 | import Core from '@/components/core' 2 | import PlayerError from './error' 3 | import Events from '@/base/events' 4 | 5 | describe('PlayerError', function() { 6 | let core, playerError, errorData 7 | beforeEach(() => { 8 | core = new Core({}) 9 | playerError = core.playerError 10 | errorData = { 11 | code: 'test_01', 12 | description: 'test error', 13 | level: PlayerError.Levels.FATAL, 14 | origin: 'test', 15 | scope: 'it', 16 | raw: {}, 17 | } 18 | }) 19 | 20 | test('has default value to options', () => { 21 | const playerError = new PlayerError(undefined, new Core({})) 22 | 23 | expect(playerError.options).toEqual({}) 24 | }) 25 | 26 | test('have reference to access received options on your construction', () => { 27 | const options = { testOption: 'some_option' } 28 | const playerError = new PlayerError(options, new Core(options)) 29 | 30 | expect(playerError.options).toEqual(options) 31 | }) 32 | 33 | describe('when error method is called', () => { 34 | test('triggers ERROR event', () => { 35 | jest.spyOn(core, 'trigger') 36 | playerError.createError(errorData) 37 | 38 | expect(core.trigger).toHaveBeenCalledWith(Events.ERROR, errorData) 39 | }) 40 | 41 | describe('when core is not set', () => { 42 | test('does not trigger ERROR event', () => { 43 | jest.spyOn(core, 'trigger') 44 | playerError.core = undefined 45 | playerError.createError(errorData) 46 | 47 | expect(core.trigger).not.toHaveBeenCalledWith(Events.ERROR, errorData) 48 | }) 49 | }) 50 | }) 51 | }) 52 | -------------------------------------------------------------------------------- /src/components/loader/loader.js: -------------------------------------------------------------------------------- 1 | // Copyright 2014 Globo.com Player authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | import Version from '@/utils/version' 5 | import Log from '@/components/log' 6 | 7 | const filterPluginsByType = (plugins, type) => { 8 | if (!plugins || !type) return {} 9 | 10 | return Object.entries(plugins) 11 | .filter(([, value]) => value.type === type) 12 | .reduce((obj, [key, value]) => (obj[key] = value, obj), {}) 13 | } 14 | 15 | /** 16 | * It keeps a list of the default plugins (playback, container, core) and it merges external plugins with its internals. 17 | * @class Loader 18 | * @constructor 19 | * @extends BaseObject 20 | * @module components 21 | */ 22 | export default (() => { 23 | 24 | const registry = { 25 | plugins: {}, 26 | playbacks: [] 27 | } 28 | 29 | const currentVersion = VERSION 30 | 31 | return class Loader { 32 | 33 | static get registeredPlaybacks() { 34 | return [...registry.playbacks] 35 | } 36 | 37 | static get registeredPlugins() { 38 | const { plugins } = registry 39 | const core = filterPluginsByType(plugins, 'core') 40 | const container = filterPluginsByType(plugins, 'container') 41 | return { 42 | core, 43 | container, 44 | } 45 | } 46 | 47 | static checkVersionSupport(entry) { 48 | const { supportedVersion, name } = entry.prototype 49 | 50 | if (!supportedVersion || !supportedVersion.min) { 51 | Log.warn('Loader', `missing version information for ${name}`) 52 | return false 53 | } 54 | 55 | const maxVersion = supportedVersion.max ? Version.parse(supportedVersion.max) : Version.parse(supportedVersion.min).inc('minor') 56 | const minVersion = Version.parse(supportedVersion.min) 57 | 58 | if (!Version.parse(currentVersion).satisfies(minVersion, maxVersion)) { 59 | Log.warn('Loader', `unsupported plugin ${name}: Clappr version ${currentVersion} does not match required range [${minVersion},${maxVersion})`) 60 | return false 61 | } 62 | 63 | return true 64 | } 65 | 66 | static registerPlugin(pluginEntry) { 67 | if (!pluginEntry || !pluginEntry.prototype.name) { 68 | Log.warn('Loader', `missing information to register plugin: ${pluginEntry}`) 69 | return false 70 | } 71 | 72 | Loader.checkVersionSupport(pluginEntry) 73 | 74 | const pluginRegistry = registry.plugins 75 | 76 | if (!pluginRegistry) return false 77 | 78 | const previousEntry = pluginRegistry[pluginEntry.prototype.name] 79 | 80 | if (previousEntry) Log.warn('Loader', `overriding plugin entry: ${pluginEntry.prototype.name} - ${previousEntry}`) 81 | 82 | pluginRegistry[pluginEntry.prototype.name] = pluginEntry 83 | 84 | return true 85 | } 86 | 87 | static registerPlayback(playbackEntry) { 88 | if (!playbackEntry || !playbackEntry.prototype.name) return false 89 | 90 | Loader.checkVersionSupport(playbackEntry) 91 | 92 | let { playbacks } = registry 93 | 94 | const previousEntryIdx = playbacks.findIndex((entry) => entry.prototype.name === playbackEntry.prototype.name) 95 | 96 | if (previousEntryIdx >= 0) { 97 | const previousEntry = playbacks[previousEntryIdx] 98 | playbacks.splice(previousEntryIdx, 1) 99 | Log.warn('Loader', `overriding playback entry: ${previousEntry.name} - ${previousEntry}`) 100 | } 101 | 102 | registry.playbacks = [playbackEntry, ...playbacks] 103 | 104 | return true 105 | } 106 | 107 | static unregisterPlugin(name) { 108 | if (!name) return false 109 | 110 | const { plugins } = registry 111 | const plugin = plugins[name] 112 | 113 | if (!plugin) return false 114 | 115 | delete plugins[name] 116 | return true 117 | } 118 | 119 | static unregisterPlayback(name) { 120 | if (!name) return false 121 | 122 | let { playbacks } = registry 123 | 124 | const index = playbacks.findIndex((entry) => entry.prototype.name === name) 125 | 126 | if (index < 0) return false 127 | 128 | playbacks.splice(index, 1) 129 | registry.playbacks = playbacks 130 | 131 | return true 132 | } 133 | 134 | static clearPlugins() { 135 | registry.plugins = {} 136 | } 137 | 138 | static clearPlaybacks() { 139 | registry.playbacks = [] 140 | } 141 | 142 | /** 143 | * builds the loader 144 | * @method constructor 145 | * @param {Object} externalPlugins the external plugins 146 | * @param {Number} playerId you can embed multiple instances of clappr, therefore this is the unique id of each one. 147 | */ 148 | constructor(externalPlugins = [], playerId = 0) { 149 | this.playerId = playerId 150 | 151 | this.playbackPlugins = [...registry.playbacks] 152 | 153 | const { core, container } = Loader.registeredPlugins 154 | this.containerPlugins = Object.values(container) 155 | this.corePlugins = Object.values(core) 156 | 157 | if (!Array.isArray(externalPlugins)) 158 | this.validateExternalPluginsType(externalPlugins) 159 | 160 | this.addExternalPlugins(externalPlugins) 161 | } 162 | 163 | /** 164 | * groups by type the external plugins that were passed through `options.plugins` it they're on a flat array 165 | * @method addExternalPlugins 166 | * @private 167 | * @param {Object} an config object or an array of plugins 168 | * @return {Object} plugins the config object with the plugins separated by type 169 | */ 170 | groupPluginsByType(plugins) { 171 | if (Array.isArray(plugins)) { 172 | plugins = plugins.reduce(function (memo, plugin) { 173 | memo[plugin.type] || (memo[plugin.type] = []) 174 | memo[plugin.type].push(plugin) 175 | return memo 176 | }, {}) 177 | } 178 | return plugins 179 | } 180 | 181 | removeDups(list, useReversePrecedence = false) { 182 | const groupUp = (plugins, plugin) => { 183 | if (plugins[plugin.prototype.name] && useReversePrecedence) return plugins 184 | 185 | plugins[plugin.prototype.name] && delete plugins[plugin.prototype.name] 186 | plugins[plugin.prototype.name] = plugin 187 | return plugins 188 | } 189 | const pluginsMap = list.reduceRight(groupUp, Object.create(null)) 190 | 191 | const plugins = [] 192 | for (let key in pluginsMap) 193 | plugins.unshift(pluginsMap[key]) 194 | 195 | return plugins 196 | } 197 | 198 | /** 199 | * adds all the external plugins that were passed through `options.plugins` 200 | * @method addExternalPlugins 201 | * @private 202 | * @param {Object} plugins the config object with all plugins 203 | */ 204 | addExternalPlugins(plugins) { 205 | const loadExternalPluginsFirst = typeof plugins.loadExternalPluginsFirst === 'boolean' 206 | ? plugins.loadExternalPluginsFirst 207 | : true 208 | const loadExternalPlaybacksFirst = typeof plugins.loadExternalPlaybacksFirst === 'boolean' 209 | ? plugins.loadExternalPlaybacksFirst 210 | : true 211 | 212 | plugins = this.groupPluginsByType(plugins) 213 | 214 | if (plugins.playback) { 215 | const playbacks = plugins.playback.filter((playback) => (Loader.checkVersionSupport(playback), true)) 216 | this.playbackPlugins = loadExternalPlaybacksFirst 217 | ? this.removeDups(playbacks.concat(this.playbackPlugins)) 218 | : this.removeDups(this.playbackPlugins.concat(playbacks), true) 219 | } 220 | 221 | if (plugins.container) { 222 | const containerPlugins = plugins.container.filter((plugin) => (Loader.checkVersionSupport(plugin), true)) 223 | this.containerPlugins = loadExternalPluginsFirst 224 | ? this.removeDups(containerPlugins.concat(this.containerPlugins)) 225 | : this.removeDups(this.containerPlugins.concat(containerPlugins), true) 226 | } 227 | 228 | if (plugins.core) { 229 | const corePlugins = plugins.core.filter((plugin) => (Loader.checkVersionSupport(plugin), true)) 230 | this.corePlugins = loadExternalPluginsFirst 231 | ? this.removeDups(corePlugins.concat(this.corePlugins)) 232 | : this.removeDups(this.corePlugins.concat(corePlugins), true) 233 | } 234 | } 235 | 236 | /** 237 | * validate if the external plugins that were passed through `options.plugins` are associated to the correct type 238 | * @method validateExternalPluginsType 239 | * @private 240 | * @param {Object} plugins the config object with all plugins 241 | */ 242 | validateExternalPluginsType(plugins) { 243 | const pluginTypes = ['playback', 'container', 'core'] 244 | pluginTypes.forEach((type) => { 245 | (plugins[type] || []).forEach((el) => { 246 | const errorMessage = 'external ' + el.type + ' plugin on ' + type + ' array' 247 | if (el.type !== type) throw new ReferenceError(errorMessage) 248 | }) 249 | }) 250 | } 251 | } 252 | })() 253 | -------------------------------------------------------------------------------- /src/components/log/log.js: -------------------------------------------------------------------------------- 1 | 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | const BOLD = 'font-weight: bold; font-size: 13px;' 6 | const INFO = 'color: #006600;' + BOLD 7 | const DEBUG = 'color: #0000ff;' + BOLD 8 | const WARN = 'color: #ff8000;' + BOLD 9 | const ERROR = 'color: #ff0000;' + BOLD 10 | 11 | const LEVEL_DEBUG = 0 12 | const LEVEL_INFO = 1 13 | const LEVEL_WARN = 2 14 | const LEVEL_ERROR = 3 15 | const LEVEL_DISABLED = LEVEL_ERROR 16 | 17 | const COLORS = [DEBUG, INFO, WARN, ERROR, ERROR] 18 | const DESCRIPTIONS = ['debug', 'info', 'warn', 'error', 'disabled'] 19 | 20 | export default class Log { 21 | 22 | get level() { return this._level } 23 | 24 | set level(newLevel) { this._level = newLevel } 25 | 26 | constructor(level = LEVEL_INFO, offLevel = LEVEL_DISABLED) { 27 | this.EXCLUDE_LIST = [ 28 | 'timeupdate', 29 | 'playback:timeupdate', 30 | 'playback:progress', 31 | 'container:hover', 32 | 'container:timeupdate', 33 | 'container:progress' 34 | ] 35 | this.level = level 36 | this.previousLevel = this.level 37 | this.offLevel = offLevel 38 | } 39 | 40 | debug(klass) { this.log(klass, LEVEL_DEBUG, Array.prototype.slice.call(arguments, 1)) } 41 | info(klass) { this.log(klass, LEVEL_INFO, Array.prototype.slice.call(arguments, 1)) } 42 | warn(klass) { this.log(klass, LEVEL_WARN, Array.prototype.slice.call(arguments, 1)) } 43 | error(klass) { this.log(klass, LEVEL_ERROR, Array.prototype.slice.call(arguments, 1)) } 44 | 45 | onOff() { 46 | if (this.level === this.offLevel) { 47 | this.level = this.previousLevel 48 | } else { 49 | this.previousLevel = this.level 50 | this.level = this.offLevel 51 | } 52 | // handle instances where console.log is unavailable 53 | window.console && window.console.log && window.console.log('%c[Clappr.Log] set log level to ' + DESCRIPTIONS[this.level], WARN) 54 | } 55 | 56 | log(klass, level, message) { 57 | if (this.EXCLUDE_LIST.indexOf(message[0]) >= 0) return 58 | if (level < this.level) return 59 | 60 | if (!message) { 61 | message = klass 62 | klass = null 63 | } 64 | const color = COLORS[level] 65 | let klassDescription = '' 66 | if (klass) 67 | klassDescription = '[' + klass + ']' 68 | 69 | window.console && window.console.log && window.console.log.apply(console, ['%c[' + DESCRIPTIONS[level] + ']' + klassDescription, color].concat(message)) 70 | } 71 | } 72 | 73 | Log.LEVEL_DEBUG = LEVEL_DEBUG 74 | Log.LEVEL_INFO = LEVEL_INFO 75 | Log.LEVEL_WARN = LEVEL_WARN 76 | Log.LEVEL_ERROR = LEVEL_ERROR 77 | 78 | Log.getInstance = function() { 79 | if (this._instance === undefined) 80 | this._instance = new this() 81 | return this._instance 82 | } 83 | 84 | Log.setLevel = function(level) { this.getInstance().level = level } 85 | 86 | Log.debug = function() { this.getInstance().debug.apply(this.getInstance(), arguments) } 87 | Log.info = function() { this.getInstance().info.apply(this.getInstance(), arguments) } 88 | Log.warn = function() { this.getInstance().warn.apply(this.getInstance(), arguments) } 89 | Log.error = function() { this.getInstance().error.apply(this.getInstance(), arguments) } 90 | -------------------------------------------------------------------------------- /src/components/log/log.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | import Log from './log' 4 | import mockConsole from 'jest-mock-console' 5 | 6 | describe('Log', () => { 7 | test('is created with default level', () => { 8 | const logger = new Log() 9 | 10 | expect(logger.level).toEqual(Log.LEVEL_INFO) 11 | }) 12 | 13 | test('is created with default offLevel', () => { 14 | const logger = new Log() 15 | 16 | expect(logger.offLevel).toEqual(Log.LEVEL_ERROR) 17 | }) 18 | 19 | test('have a getter and a setter called level', () => { 20 | const logger = new Log() 21 | 22 | expect(Object.getOwnPropertyDescriptor(Object.getPrototypeOf(logger), 'level').get).toBeTruthy() 23 | expect(Object.getOwnPropertyDescriptor(Object.getPrototypeOf(logger), 'level').set).toBeTruthy() 24 | }) 25 | 26 | test('level getter returns current level', () => { 27 | const logger = new Log(Log.LEVEL_DEBUG) 28 | 29 | expect(logger.level).toEqual(Log.LEVEL_DEBUG) 30 | }) 31 | 32 | test('can configure level after the creation', () => { 33 | const logger = new Log() 34 | logger.level = Log.LEVEL_WARN 35 | 36 | expect(logger.level).toEqual(Log.LEVEL_WARN) 37 | }) 38 | 39 | test('can change from current level to offLevel', () => { 40 | const logger = new Log() 41 | 42 | expect(logger.level).toEqual(Log.LEVEL_INFO) 43 | 44 | logger.onOff() 45 | 46 | expect(logger.level).toEqual(Log.LEVEL_ERROR) 47 | }) 48 | 49 | test('can change from offLevel to current level', () => { 50 | const logger = new Log() 51 | 52 | expect(logger.level).toEqual(Log.LEVEL_INFO) 53 | 54 | logger.onOff() 55 | 56 | expect(logger.level).toEqual(Log.LEVEL_ERROR) 57 | 58 | logger.onOff() 59 | 60 | expect(logger.level).toEqual(Log.LEVEL_INFO) 61 | }) 62 | 63 | describe('prints log', function() { 64 | 65 | let restoreConsole 66 | 67 | beforeEach(() => { restoreConsole = mockConsole() }) 68 | afterEach(() => { restoreConsole() }) 69 | 70 | test('indicating level and class with the message', () => { 71 | const logger = new Log() 72 | logger.log('class test', Log.LEVEL_ERROR, 'test message.') 73 | 74 | expect(console.log).toHaveBeenCalledWith('%c[error][class test]', 'color: #ff0000;font-weight: bold; font-size: 13px;', 'test message.') 75 | }) 76 | 77 | test('without the class attribute', () => { 78 | const logger = new Log() 79 | logger.log('test message.', Log.LEVEL_ERROR, '') 80 | 81 | expect(console.log).toHaveBeenCalledWith('%c[error]', 'color: #ff0000;font-weight: bold; font-size: 13px;', 'test message.') 82 | }) 83 | 84 | test('on debug level without passing the level attribute', () => { 85 | const logger = new Log(Log.LEVEL_DEBUG) 86 | logger.debug('class test', 'test message.') 87 | 88 | expect(console.log).toHaveBeenCalledWith('%c[debug][class test]', 'color: #0000ff;font-weight: bold; font-size: 13px;', 'test message.') 89 | }) 90 | 91 | test('on info level without passing the level attribute', () => { 92 | const logger = new Log(Log.LEVEL_INFO) 93 | logger.info('class test', 'test message.') 94 | 95 | expect(console.log).toHaveBeenCalledWith('%c[info][class test]', 'color: #006600;font-weight: bold; font-size: 13px;', 'test message.') 96 | }) 97 | 98 | test('on warn level without passing the level attribute', () => { 99 | const logger = new Log(Log.LEVEL_WARN) 100 | logger.warn('class test', 'test message.') 101 | 102 | expect(console.log).toHaveBeenCalledWith('%c[warn][class test]', 'color: #ff8000;font-weight: bold; font-size: 13px;', 'test message.') 103 | }) 104 | 105 | test('on error level without passing the level attribute', () => { 106 | const logger = new Log(Log.LEVEL_ERROR) 107 | logger.error('class test', 'test message.') 108 | 109 | expect(console.log).toHaveBeenCalledWith('%c[error][class test]', 'color: #ff0000;font-weight: bold; font-size: 13px;', 'test message.') 110 | }) 111 | }) 112 | 113 | describe('don\'t print log', function() { 114 | let restoreConsole 115 | 116 | beforeEach(() => { restoreConsole = mockConsole() }) 117 | afterEach(() => { restoreConsole() }) 118 | 119 | test('without the level attribute', () => { 120 | const logger = new Log() 121 | logger.log('test message.', '', '') 122 | 123 | expect(console.log).not.toHaveBeenCalled() 124 | }) 125 | 126 | test('if the message is registered on the block list', () => { 127 | const logger = new Log() 128 | logger.log('class test', Log.LEVEL_ERROR, ['timeupdate']) 129 | 130 | expect(console.log).not.toHaveBeenCalled() 131 | }) 132 | }) 133 | 134 | describe('have a static method', function() { 135 | let restoreConsole 136 | 137 | beforeEach(() => { restoreConsole = mockConsole() }) 138 | afterEach(() => { restoreConsole() }) 139 | 140 | test('to get one Log instance', () => { 141 | const logger = Log.getInstance() 142 | 143 | expect(logger instanceof Log).toBeTruthy() 144 | 145 | logger.testReference = true 146 | 147 | let anotherLogger = Log.getInstance() 148 | 149 | expect(anotherLogger).toEqual(logger) 150 | expect(anotherLogger.testReference).toBeTruthy() 151 | }) 152 | 153 | test('to set one Log level', () => { 154 | const logger = Log.getInstance() 155 | 156 | expect(logger.level).toEqual(Log.LEVEL_INFO) 157 | 158 | Log.setLevel(Log.LEVEL_WARN) 159 | 160 | expect(logger.level).toEqual(Log.LEVEL_WARN) 161 | }) 162 | 163 | test('to print messages on Log debug level', () => { 164 | Log.setLevel(Log.LEVEL_DEBUG) 165 | Log.debug('class test', 'test message.') 166 | 167 | expect(console.log).toHaveBeenCalledWith('%c[debug][class test]', 'color: #0000ff;font-weight: bold; font-size: 13px;', 'test message.') 168 | }) 169 | 170 | test('to print messages on Log info level', () => { 171 | Log.info('class test', 'test message.') 172 | 173 | expect(console.log).toHaveBeenCalledWith('%c[info][class test]', 'color: #006600;font-weight: bold; font-size: 13px;', 'test message.') 174 | }) 175 | 176 | test('to print messages on Log warn level', () => { 177 | Log.warn('class test', 'test message.') 178 | 179 | expect(console.log).toHaveBeenCalledWith('%c[warn][class test]', 'color: #ff8000;font-weight: bold; font-size: 13px;', 'test message.') 180 | }) 181 | 182 | test('to print messages on Log error level', () => { 183 | Log.error('class test', 'test message.') 184 | 185 | expect(console.log).toHaveBeenCalledWith('%c[error][class test]', 'color: #ff0000;font-weight: bold; font-size: 13px;', 'test message.') 186 | }) 187 | }) 188 | }) 189 | -------------------------------------------------------------------------------- /src/components/player/player.test.js: -------------------------------------------------------------------------------- 1 | import Player from '../player' 2 | import Events from '../../base/events' 3 | 4 | describe('Player', function() { 5 | describe('constructor', () => { 6 | 7 | test('has unique sequential id', () => { 8 | const player1 = new Player({ source: '/playlist.m3u8', baseUrl: 'http://cdn.clappr.io/latest' }) 9 | const player2 = new Player({ source: '/playlist.m3u8', baseUrl: 'http://cdn.clappr.io/latest' }) 10 | const player3 = new Player({ source: '/playlist.m3u8', baseUrl: 'http://cdn.clappr.io/latest' }) 11 | 12 | const p1Id = parseInt(player1.options.playerId) 13 | const p2Id = parseInt(player2.options.playerId) 14 | const p3Id = parseInt(player3.options.playerId) 15 | 16 | expect(p2Id).toBeGreaterThan(p1Id) 17 | expect(p3Id).toBeGreaterThan(p2Id) 18 | }) 19 | 20 | test('uses the baseUrl passed from initialization', () => { 21 | const player = new Player({ source: '/playlist.m3u8', baseUrl: 'http://cdn.clappr.io/latest' }) 22 | expect(player.options.baseUrl).toEqual('http://cdn.clappr.io/latest') 23 | }) 24 | 25 | test('persists config by default', () => { 26 | const player = new Player({ source: '/playlist.m3u8' }) 27 | expect(player.options.persistConfig).toEqual(true) 28 | }) 29 | 30 | test('can set persists config', () => { 31 | const player = new Player({ source: '/playlist.m3u8', persistConfig: false }) 32 | expect(player.options.persistConfig).toEqual(false) 33 | }) 34 | 35 | test('gets plugins by name', () => { 36 | const player = new Player({ source: '/playlist.m3u8', persistConfig: false }) 37 | const plugin = { name: 'fake' } 38 | player.core = { plugins: [plugin], activeContainer: { plugins: [] } } 39 | expect(plugin).toEqual(player.getPlugin('fake')) 40 | }) 41 | 42 | test('should normalize sources', () => { 43 | const player = new Player({ source: '/playlist.m3u8', persistConfig: false }) 44 | let normalizedSources = player._normalizeSources({ sources: ['http://test.mp4'] }) 45 | expect(normalizedSources.length).toEqual(1) 46 | expect(normalizedSources[0]).toEqual('http://test.mp4') 47 | 48 | normalizedSources = player._normalizeSources({ source: 'http://test.mp4' }) 49 | expect(normalizedSources.length).toEqual(1) 50 | expect(normalizedSources[0]).toEqual('http://test.mp4') 51 | 52 | normalizedSources = player._normalizeSources({ sources: [] }) 53 | expect(normalizedSources.length).toEqual(1) 54 | expect(JSON.stringify(normalizedSources[0])).toEqual(JSON.stringify({ source: '', mimeType: '' })) 55 | }) 56 | 57 | test('should trigger error events', () => { 58 | const player = new Player({ source: 'http://video.mp4', persistConfig: false }) 59 | const element = document.createElement('div') 60 | const onError = jest.fn() 61 | player.on(Events.PLAYER_ERROR, onError) 62 | player.attachTo(element) 63 | player.trigger(Events.PLAYER_ERROR) 64 | expect(onError).toHaveBeenCalledTimes(1) 65 | }) 66 | }) 67 | 68 | describe('register options event listeners', () => { 69 | let player 70 | beforeEach(() => { 71 | player = new Player({ source: '/video.mp4' }) 72 | const element = document.createElement('div') 73 | player.attachTo(element) 74 | jest.spyOn(player, '_registerOptionEventListeners') 75 | }) 76 | 77 | test('should register on configure', () => { 78 | player.configure({ 79 | events: { 80 | onPlay: () => {} 81 | } 82 | }) 83 | 84 | expect(player._registerOptionEventListeners).toHaveBeenCalledTimes(1) 85 | }) 86 | 87 | test('should call only last registered callback', () => { 88 | const callbacks = { 89 | callbackA: jest.fn(), 90 | callbackB: jest.fn(), 91 | } 92 | player.configure({ 93 | events: { 94 | onPlay: callbacks.callbackA 95 | } 96 | }) 97 | 98 | player.configure({ 99 | events: { 100 | onPlay: callbacks.callbackB 101 | } 102 | }) 103 | 104 | player._onPlay() 105 | 106 | expect(callbacks.callbackA).not.toHaveBeenCalled() 107 | expect(callbacks.callbackB).toHaveBeenCalledTimes(1) 108 | }) 109 | 110 | test('should add a new event callback', () => { 111 | const callbacks = { 112 | callbackC: jest.fn() 113 | } 114 | player.configure({ 115 | events: {} 116 | }) 117 | 118 | player.configure({ 119 | events: { 120 | onPause: callbacks.callbackC, 121 | } 122 | }) 123 | 124 | player._onPause() 125 | 126 | expect(callbacks.callbackC).toHaveBeenCalledTimes(1) 127 | }) 128 | 129 | test('should remove previous event callbacks', () => { 130 | const callbacks = { 131 | callbackA: jest.fn(), 132 | callbackB: jest.fn() 133 | } 134 | player.configure({ 135 | events: { 136 | onPlay: callbacks.callbackA, 137 | } 138 | }) 139 | 140 | player.configure({ 141 | events: { 142 | onPause: callbacks.callbackB, 143 | } 144 | }) 145 | 146 | player._onPlay() 147 | player._onPause() 148 | 149 | expect(callbacks.callbackA).not.toHaveBeenCalled() 150 | expect(callbacks.callbackB).toHaveBeenCalledTimes(1) 151 | }) 152 | 153 | test('does not override events on configure if there are no events', () => { 154 | const callbacks = { 155 | callbackA: jest.fn() 156 | } 157 | player.configure({ 158 | events: { 159 | onPause: callbacks.callbackA, 160 | } 161 | }) 162 | 163 | player.configure({ 164 | someOtherOption: true 165 | }) 166 | 167 | player._onPause() 168 | 169 | expect(callbacks.callbackA).toHaveBeenCalledTimes(1) 170 | }) 171 | 172 | test('does not interfere with event listeners added through Player.on', () => { 173 | const callbacks = { 174 | callbackA: jest.fn(), 175 | callbackB: jest.fn(), 176 | } 177 | 178 | player.on(Events.PLAYER_PAUSE, callbacks.callbackB) 179 | 180 | player.configure({ 181 | events: { 182 | onPause: callbacks.callbackA, 183 | } 184 | }) 185 | 186 | player._onPause() 187 | 188 | expect(callbacks.callbackA).toHaveBeenCalledTimes(1) 189 | expect(callbacks.callbackB).toHaveBeenCalledTimes(1) 190 | }) 191 | }) 192 | 193 | describe('when a core event is fired', () => { 194 | let onResizeSpy, player 195 | 196 | beforeEach(() => { 197 | onResizeSpy = jest.fn() 198 | 199 | player = new Player({ 200 | source: 'http://video.mp4', 201 | events: { 202 | onResize: onResizeSpy 203 | } 204 | }) 205 | 206 | const element = document.createElement('div') 207 | player.attachTo(element) 208 | }) 209 | 210 | describe('on Events.CORE_RESIZE', () => { 211 | test('calls onResize callback with width and height', () => { 212 | const newSize = { width: '50%', height: '50%' } 213 | player.core.trigger(Events.CORE_RESIZE, newSize) 214 | expect(onResizeSpy).toHaveBeenCalledWith(newSize) 215 | }) 216 | }) 217 | }) 218 | }) 219 | -------------------------------------------------------------------------------- /src/external_plugin.test.js: -------------------------------------------------------------------------------- 1 | import Clappr from './main' 2 | 3 | describe('External Plugin', function() { 4 | test('should expose extend method for the plugins exposed on Clappr scope', function() { 5 | let MyPluginClass 6 | let myPluginInstance 7 | let nativePluginInstance 8 | const testMethod = function() { 9 | return 'test' 10 | } 11 | 12 | const core = { options: {} } 13 | const container = { options: {} } 14 | 15 | MyPluginClass = Clappr.Playback.extend({ testMethod: testMethod }) 16 | myPluginInstance = new MyPluginClass() 17 | nativePluginInstance = new Clappr.Playback() 18 | expect(myPluginInstance.play).toEqual(nativePluginInstance.play) 19 | expect(myPluginInstance.stop).toEqual(nativePluginInstance.stop) 20 | expect(myPluginInstance.testMethod).toEqual(testMethod) 21 | expect(MyPluginClass.type).toEqual('playback') 22 | 23 | MyPluginClass = Clappr.ContainerPlugin.extend({ testMethod: testMethod }) 24 | myPluginInstance = new MyPluginClass(container) 25 | nativePluginInstance = new Clappr.ContainerPlugin(container) 26 | expect(myPluginInstance.enable).toEqual(nativePluginInstance.enable) 27 | expect(myPluginInstance.disable).toEqual(nativePluginInstance.disable) 28 | expect(myPluginInstance.testMethod).toEqual(testMethod) 29 | expect(MyPluginClass.type).toEqual('container') 30 | 31 | MyPluginClass = Clappr.UIContainerPlugin.extend({ testMethod: testMethod }) 32 | myPluginInstance = new MyPluginClass(container) 33 | nativePluginInstance = new Clappr.UIContainerPlugin(container) 34 | expect(myPluginInstance.enable).toEqual(nativePluginInstance.enable) 35 | expect(myPluginInstance.disable).toEqual(nativePluginInstance.disable) 36 | expect(myPluginInstance.testMethod).toEqual(testMethod) 37 | expect(MyPluginClass.type).toEqual('container') 38 | 39 | 40 | MyPluginClass = Clappr.UICorePlugin.extend({ testMethod: testMethod, render: function() {} }) 41 | myPluginInstance = new MyPluginClass(core) 42 | nativePluginInstance = new Clappr.UICorePlugin(core) 43 | expect(myPluginInstance.enable).toEqual(nativePluginInstance.enable) 44 | expect(myPluginInstance.disable).toEqual(nativePluginInstance.disable) 45 | expect(myPluginInstance.testMethod).toEqual(testMethod) 46 | expect(MyPluginClass.type).toEqual('core') 47 | 48 | MyPluginClass = Clappr.CorePlugin.extend({ testMethod: testMethod, render: function() {} }) 49 | myPluginInstance = new MyPluginClass(core) 50 | nativePluginInstance = new Clappr.CorePlugin(core) 51 | expect(myPluginInstance.enable).toEqual(nativePluginInstance.enable) 52 | expect(myPluginInstance.disable).toEqual(nativePluginInstance.disable) 53 | expect(myPluginInstance.testMethod).toEqual(testMethod) 54 | expect(MyPluginClass.type).toEqual('core') 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | // Copyright 2014 Globo.com Player authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | import Player from './components/player' 6 | import Utils from './utils' 7 | import Events from './base/events' 8 | import Playback from './base/playback' 9 | import ContainerPlugin from './base/container_plugin' 10 | import CorePlugin from './base/core_plugin' 11 | import UICorePlugin from './base/ui_core_plugin' 12 | import UIContainerPlugin from './base/ui_container_plugin' 13 | import BaseObject from './base/base_object' 14 | import UIObject from './base/ui_object' 15 | import Browser from './components/browser' 16 | import Container from './components/container' 17 | import Core from './components/core' 18 | import PlayerError from './components/error' 19 | import Loader from './components/loader' 20 | import Log from './components/log' 21 | import HTML5Audio from './playbacks/html5_audio' 22 | import HTML5Video from './playbacks/html5_video' 23 | import HTMLImg from './playbacks/html_img' 24 | import NoOp from './playbacks/no_op' 25 | import Styler from './base/styler' 26 | import template from './base/template' 27 | import Strings from './plugins/strings' 28 | import SourcesPlugin from './plugins/sources' 29 | 30 | import $ from 'clappr-zepto' 31 | 32 | const version = VERSION 33 | 34 | // Built-in Plugins/Playbacks 35 | 36 | Loader.registerPlugin(Strings) 37 | Loader.registerPlugin(SourcesPlugin) 38 | 39 | Loader.registerPlayback(NoOp) 40 | Loader.registerPlayback(HTMLImg) 41 | Loader.registerPlayback(HTML5Audio) 42 | Loader.registerPlayback(HTML5Video) 43 | 44 | export { 45 | Player, 46 | Events, 47 | Browser, 48 | ContainerPlugin, 49 | UIContainerPlugin, 50 | CorePlugin, 51 | UICorePlugin, 52 | Playback, 53 | Container, 54 | Core, 55 | PlayerError, 56 | Loader, 57 | BaseObject, 58 | UIObject, 59 | Utils, 60 | HTML5Audio, 61 | HTML5Video, 62 | HTMLImg, 63 | Log, 64 | Styler, 65 | version, 66 | template, 67 | $ 68 | } 69 | 70 | export default { 71 | Player, 72 | Events, 73 | Browser, 74 | ContainerPlugin, 75 | UIContainerPlugin, 76 | CorePlugin, 77 | UICorePlugin, 78 | Playback, 79 | Container, 80 | Core, 81 | PlayerError, 82 | Loader, 83 | BaseObject, 84 | UIObject, 85 | Utils, 86 | HTML5Audio, 87 | HTML5Video, 88 | HTMLImg, 89 | Log, 90 | Styler, 91 | version, 92 | template, 93 | $ 94 | } 95 | -------------------------------------------------------------------------------- /src/playbacks/html5_audio/html5_audio.js: -------------------------------------------------------------------------------- 1 | // Copyright 2014 Globo.com Player authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | import Events from '../../base/events' 6 | import Playback from '../../base/playback' 7 | import HTML5Video from '../html5_video' 8 | 9 | // TODO: remove this playback and change HTML5Video to HTML5Playback (breaking change, only after 0.3.0) 10 | export default class HTML5Audio extends HTML5Video { 11 | get name() { return 'html5_audio' } 12 | get supportedVersion() { return { min: VERSION } } 13 | get tagName() { return 'audio' } 14 | 15 | get isAudioOnly() { 16 | return true 17 | } 18 | 19 | updateSettings() { 20 | this.settings.left = ['playpause', 'position', 'duration'] 21 | this.settings.seekEnabled = this.isSeekEnabled() 22 | this.trigger(Events.PLAYBACK_SETTINGSUPDATE) 23 | } 24 | 25 | getPlaybackType() { 26 | return Playback.AOD 27 | } 28 | } 29 | 30 | HTML5Audio.canPlay = function(resourceUrl, mimeType) { 31 | const mimetypes = { 32 | 'wav': ['audio/wav'], 33 | 'mp3': ['audio/mp3', 'audio/mpeg;codecs="mp3"'], 34 | 'aac': ['audio/mp4;codecs="mp4a.40.5"'], 35 | 'oga': ['audio/ogg'] 36 | } 37 | return HTML5Video._canPlay('audio', mimetypes, resourceUrl, mimeType) 38 | } 39 | -------------------------------------------------------------------------------- /src/playbacks/html5_audio/html5_audio.test.js: -------------------------------------------------------------------------------- 1 | import HTML5Audio from './html5_audio' 2 | 3 | describe('HTML5Audio playback', function() { 4 | test('should check if canPlay resource', function() { 5 | expect(HTML5Audio.canPlay('')).toBeFalsy() 6 | expect(HTML5Audio.canPlay('resource_without_dots')).toBeFalsy() 7 | // expect(HTML5Audio.canPlay('http://domain.com/Audio.oga')).toBeTruthy() 8 | // expect(HTML5Audio.canPlay('http://domain.com/Audio.mp3?query_string=here')).toBeTruthy() 9 | // expect(HTML5Audio.canPlay('/relative/Audio.oga')).toBeTruthy() 10 | }) 11 | }) 12 | -------------------------------------------------------------------------------- /src/playbacks/html5_video/public/style.scss: -------------------------------------------------------------------------------- 1 | [data-html5-video] { 2 | position: absolute; 3 | height: 100%; 4 | width: 100%; 5 | display: block; 6 | } 7 | -------------------------------------------------------------------------------- /src/playbacks/html5_video/public/tracks.html: -------------------------------------------------------------------------------- 1 | <% for (var i = 0; i < tracks.length; i++) { %> 2 | 3 | <% }; %> 4 | -------------------------------------------------------------------------------- /src/playbacks/html_img/html_img.js: -------------------------------------------------------------------------------- 1 | // Copyright 2014 Globo.com Player authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | import Playback from '@/base/playback' 6 | import Events from '@/base/events' 7 | import Styler from '@/base/styler' 8 | import HTMLImgStyle from './public/style.scss' 9 | 10 | export default class HTMLImg extends Playback { 11 | get name() { return 'html_img' } 12 | get supportedVersion() { return { min: VERSION } } 13 | get tagName() { return 'img' } 14 | get attributes() { 15 | return { 16 | 'data-html-img': '' 17 | } 18 | } 19 | 20 | get events() { 21 | return { 22 | 'load': '_onLoad', 23 | 'abort': '_onError', 24 | 'error': '_onError' 25 | } 26 | } 27 | 28 | getPlaybackType() { 29 | return Playback.NO_OP 30 | } 31 | 32 | constructor(params) { 33 | super(params) 34 | this.el.src = params.src 35 | } 36 | 37 | render() { 38 | const style = Styler.getStyleFor(HTMLImgStyle.toString(), { baseUrl: this.options.baseUrl }) 39 | this.$el.append(style[0]) 40 | this.trigger(Events.PLAYBACK_READY, this.name) 41 | return this 42 | } 43 | 44 | _onLoad() { 45 | this.trigger(Events.PLAYBACK_ENDED, this.name) 46 | } 47 | 48 | _onError(evt) { 49 | const m = (evt.type === 'error') ? 'load error' : 'loading aborted' 50 | this.trigger(Events.PLAYBACK_ERROR, { message: m }, this.name) 51 | } 52 | } 53 | 54 | HTMLImg.canPlay = function(resource) { 55 | return /\.(png|jpg|jpeg|gif|bmp|tiff|pgm|pnm|webp)(|\?.*)$/i.test(resource) 56 | } 57 | -------------------------------------------------------------------------------- /src/playbacks/html_img/public/style.scss: -------------------------------------------------------------------------------- 1 | [data-html-img] { 2 | max-width:100%; 3 | max-height:100%; 4 | } -------------------------------------------------------------------------------- /src/playbacks/no_op/no_op.js: -------------------------------------------------------------------------------- 1 | import { requestAnimationFrame, cancelAnimationFrame } from '@/utils' 2 | import Styler from '@/base/styler' 3 | import Playback from '@/base/playback' 4 | import template from '@/base/template' 5 | import Events from '@/base/events' 6 | import noOpHTML from './public/error.html' 7 | import noOpStyle from './public/style.scss' 8 | 9 | export default class NoOp extends Playback { 10 | get name() { return 'no_op' } 11 | get supportedVersion() { return { min: VERSION } } 12 | get template() { return template(noOpHTML) } 13 | get attributes() { 14 | return { 'data-no-op': '' } 15 | } 16 | 17 | constructor(...args) { 18 | super(...args) 19 | this._noiseFrameNum = -1 20 | } 21 | 22 | render() { 23 | const playbackNotSupported = this.options.playbackNotSupportedMessage || this.i18n.t('playback_not_supported') 24 | const style = Styler.getStyleFor(noOpStyle.toString(), { baseUrl: this.options.baseUrl }) 25 | this.$el.append(style[0]) 26 | this.$el.html(this.template({ message: playbackNotSupported })) 27 | this.trigger(Events.PLAYBACK_READY, this.name) 28 | const showForNoOp = !!(this.options.poster && this.options.poster.showForNoOp) 29 | if (this.options.autoPlay || !showForNoOp) 30 | this._animate() 31 | 32 | return this 33 | } 34 | 35 | _noise() { 36 | this._noiseFrameNum = (this._noiseFrameNum+1)%5 37 | if (this._noiseFrameNum) { 38 | // only update noise every 5 frames to save cpu 39 | return 40 | } 41 | 42 | const idata = this.context.createImageData(this.context.canvas.width, this.context.canvas.height) 43 | let buffer32 44 | try { 45 | buffer32 = new Uint32Array(idata.data.buffer) 46 | } catch (err) { 47 | buffer32 = new Uint32Array(this.context.canvas.width * this.context.canvas.height * 4) 48 | const data=idata.data 49 | for (let i = 0; i < data.length; i++) 50 | buffer32[i]=data[i] 51 | 52 | } 53 | 54 | const len = buffer32.length, 55 | m = Math.random() * 6 + 4 56 | let run = 0, 57 | color = 0 58 | for (let i = 0; i < len;) { 59 | if (run < 0) { 60 | run = m * Math.random() 61 | const p = Math.pow(Math.random(), 0.4) 62 | color = (255 * p) << 24 63 | } 64 | run -= 1 65 | buffer32[i++] = color 66 | } 67 | this.context.putImageData(idata, 0, 0) 68 | } 69 | 70 | _loop() { 71 | if (this._stop) 72 | return 73 | 74 | this._noise() 75 | this._animationHandle = requestAnimationFrame(() => this._loop()) 76 | } 77 | 78 | destroy() { 79 | if (this._animationHandle) { 80 | cancelAnimationFrame(this._animationHandle) 81 | this._stop = true 82 | } 83 | } 84 | 85 | _animate() { 86 | this.canvas = this.$el.find('canvas[data-no-op-canvas]')[0] 87 | this.context = this.canvas.getContext('2d') 88 | this._loop() 89 | } 90 | } 91 | 92 | NoOp.canPlay = (source) => { // eslint-disable-line no-unused-vars 93 | return true 94 | } 95 | -------------------------------------------------------------------------------- /src/playbacks/no_op/public/error.html: -------------------------------------------------------------------------------- 1 | 2 |<%=message%>
3 | -------------------------------------------------------------------------------- /src/playbacks/no_op/public/style.scss: -------------------------------------------------------------------------------- 1 | [data-no-op] { 2 | position: absolute; 3 | height: 100%; 4 | width: 100%; 5 | text-align: center; 6 | } 7 | 8 | [data-no-op] p[data-no-op-msg] { 9 | position: absolute; 10 | text-align: center; 11 | font-size: 25px; 12 | left: 0; 13 | right: 0; 14 | color: white; 15 | padding: 10px; 16 | /* center vertically */ 17 | top: 50%; 18 | transform: translateY(-50%); 19 | max-height: 100%; 20 | overflow: auto; 21 | } 22 | 23 | [data-no-op] canvas[data-no-op-canvas] { 24 | background-color: #777; 25 | height: 100%; 26 | width: 100%; 27 | } 28 | -------------------------------------------------------------------------------- /src/plugins/sources/sources.js: -------------------------------------------------------------------------------- 1 | import CorePlugin from '@/base/core_plugin' 2 | import Events from '@/base/events' 3 | 4 | export default class SourcesPlugin extends CorePlugin { 5 | get name() { return 'sources' } 6 | get supportedVersion() { return { min: VERSION } } 7 | 8 | bindEvents() { 9 | this.listenTo(this.core, Events.CORE_CONTAINERS_CREATED, this.onContainersCreated) 10 | } 11 | 12 | onContainersCreated() { 13 | const firstValidSource = this.core.containers.filter(container => container.playback.name !== 'no_op')[0] || this.core.containers[0] 14 | firstValidSource && this.core.containers.forEach((container) => { 15 | if (container !== firstValidSource) container.destroy() 16 | }) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/plugins/sources/sources.test.js: -------------------------------------------------------------------------------- 1 | import SourcesPlugin from './sources' 2 | import Playback from '@/base/playback' 3 | import NoOp from '@/playbacks/no_op' 4 | import Container from '@/components/container' 5 | import Core from '@/components/core' 6 | import Events from '@/base/events' 7 | 8 | const createContainersArray = (options, quantity) => { 9 | const containers = [] 10 | 11 | for (let i = 0; i < quantity; i++) 12 | containers.push(new Container(options)) 13 | 14 | return containers 15 | } 16 | 17 | describe('SourcesPlugin', () => { 18 | window.HTMLMediaElement.prototype.load = () => { /* do nothing */ } 19 | 20 | test('is loaded on core plugins array', () => { 21 | const core = new Core({}) 22 | const plugin = new SourcesPlugin(core) 23 | core.addPlugin(plugin) 24 | 25 | expect(core.getPlugin(plugin.name).name).toEqual('sources') 26 | }) 27 | 28 | test('is compatible with the latest Clappr core version', () => { 29 | const core = new Core({}) 30 | const plugin = new SourcesPlugin(core) 31 | core.addPlugin(plugin) 32 | 33 | expect(core.getPlugin(plugin.name).supportedVersion).toEqual({ min: VERSION }) 34 | }) 35 | 36 | test('guarantees only one container rendered', () => { 37 | const callback = jest.fn() 38 | 39 | const containerOptions = { playback: new Playback() } 40 | 41 | const containersArray = createContainersArray(containerOptions, 3) 42 | 43 | const core = new Core({}) 44 | const plugin = new SourcesPlugin(core) 45 | 46 | core.containers = containersArray 47 | core.containers.forEach(container => plugin.listenTo(container, Events.CONTAINER_DESTROYED, callback)) 48 | core.trigger(Events.CORE_CONTAINERS_CREATED) 49 | 50 | expect(callback).toHaveBeenCalledTimes(2) 51 | }) 52 | 53 | test('destroys containers with NoOp playback', () => { 54 | const callback = jest.fn() 55 | 56 | const containerOptions = { playback: new NoOp() } 57 | 58 | const containers = createContainersArray(containerOptions, 5) 59 | const validContainer = new Container({ playback: new Playback() }) 60 | const core = new Core({}) 61 | const plugin = new SourcesPlugin(core) 62 | 63 | core.containers = containers 64 | core.containers.push(validContainer) 65 | core.containers.forEach(container => plugin.listenTo(container, Events.CONTAINER_DESTROYED, callback)) 66 | plugin.listenTo(validContainer, Events.CORE_CONTAINERS_CREATED, callback) 67 | core.trigger(Events.CORE_CONTAINERS_CREATED) 68 | 69 | expect(callback).toHaveBeenCalledTimes(5) 70 | }) 71 | }) 72 | -------------------------------------------------------------------------------- /src/plugins/strings/strings.js: -------------------------------------------------------------------------------- 1 | import { getBrowserLanguage } from '../../utils' 2 | import $ from 'clappr-zepto' 3 | import CorePlugin from '../../base/core_plugin' 4 | 5 | /** 6 | * The internationalization (i18n) plugin 7 | * @class Strings 8 | * @constructor 9 | * @extends CorePlugin 10 | * @module plugins 11 | */ 12 | export default class Strings extends CorePlugin { 13 | get name() { return 'strings' } 14 | get supportedVersion() { return { min: VERSION } } 15 | 16 | constructor(core) { 17 | super(core) 18 | this._initializeMessages() 19 | } 20 | /** 21 | * Gets a translated string for the given key. 22 | * @method t 23 | * @param {String} key the key to all messages 24 | * @return {String} translated label 25 | */ 26 | t(key) { 27 | const lang = this._language() 28 | const fallbackLang = this._messages['en'] 29 | const i18n = lang && this._messages[lang] || fallbackLang 30 | return i18n[key] || fallbackLang[key] || key 31 | } 32 | 33 | _language() { return this.core.options.language || getBrowserLanguage() } 34 | 35 | _initializeMessages() { 36 | const defaultMessages = { 37 | 'en': { 38 | 'live': 'live', 39 | 'back_to_live': 'back to live', 40 | 'disabled': 'Disabled', 41 | 'playback_not_supported': 'Your browser does not support the playback of this video. Please try using a different browser.', 42 | 'default_error_title': 'Could not play video.', 43 | 'default_error_message': 'There was a problem trying to load the video.', 44 | }, 45 | 'de': { 46 | 'live': 'Live', 47 | 'back_to_live': 'Zurück zum Live-Video', 48 | 'disabled': 'Deaktiviert', 49 | 'playback_not_supported': 'Ihr Browser unterstützt das Playback Verfahren nicht. Bitte vesuchen Sie es mit einem anderen Browser.', 50 | 'default_error_title': 'Video kann nicht abgespielt werden', 51 | 'default_error_message': 'Es gab ein Problem beim Laden des Videos', 52 | }, 53 | 'pt': { 54 | 'live': 'ao vivo', 55 | 'back_to_live': 'voltar para o ao vivo', 56 | 'disabled': 'Desativado', 57 | 'playback_not_supported': 'Seu navegador não suporta a reprodução deste video. Por favor, tente usar um navegador diferente.', 58 | 'default_error_title': 'Não foi possível reproduzir o vídeo.', 59 | 'default_error_message': 'Ocorreu um problema ao tentar carregar o vídeo.', 60 | }, 61 | 'es_am': { 62 | 'live': 'vivo', 63 | 'back_to_live': 'volver en vivo', 64 | 'disabled': 'No disponible', 65 | 'playback_not_supported': 'Su navegador no soporta la reproducción de este video. Por favor, utilice un navegador diferente.', 66 | 'default_error_title': 'No se puede reproducir el video.', 67 | 'default_error_message': 'Se ha producido un error al cargar el video.' 68 | }, 69 | 'es': { 70 | 'live': 'en directo', 71 | 'back_to_live': 'volver al directo', 72 | 'disabled': 'No disponible', 73 | 'playback_not_supported': 'Este navegador no es compatible para reproducir este vídeo. Utilice un navegador diferente.', 74 | 'default_error_title': 'No se puede reproducir el vídeo.', 75 | 'default_error_message': 'Se ha producido un problema al cargar el vídeo.' 76 | }, 77 | 'ru': { 78 | 'live': 'прямой эфир', 79 | 'back_to_live': 'к прямому эфиру', 80 | 'disabled': 'Отключено', 81 | 'playback_not_supported': 'Ваш браузер не поддерживает воспроизведение этого видео. Пожалуйста, попробуйте другой браузер.', 82 | }, 83 | 'bg': { 84 | 'live': 'на живо', 85 | 'back_to_live': 'Върни на живо', 86 | 'disabled': 'Изключено', 87 | 'playback_not_supported': 'Вашият браузър не поддържа възпроизвеждането на това видео. Моля, пробвайте с друг браузър.', 88 | 'default_error_title': 'Видеото не може да се възпроизведе.', 89 | 'default_error_message': 'Възникна проблем при зареждането на видеото.', 90 | }, 91 | 'fr': { 92 | 'live': 'en direct', 93 | 'back_to_live': 'retour au direct', 94 | 'disabled': 'Désactivé', 95 | 'playback_not_supported': 'Votre navigateur ne supporte pas la lecture de cette vidéo. Merci de tenter sur un autre navigateur.', 96 | 'default_error_title': 'Impossible de lire la vidéo.', 97 | 'default_error_message': 'Un problème est survenu lors du chargement de la vidéo.', 98 | }, 99 | 'tr': { 100 | 'live': 'canlı', 101 | 'back_to_live': 'canlı yayına dön', 102 | 'disabled': 'Engelli', 103 | 'playback_not_supported': 'Tarayıcınız bu videoyu oynatma desteğine sahip değil. Lütfen farklı bir tarayıcı ile deneyin.', 104 | }, 105 | 'et': { 106 | 'live': 'Otseülekanne', 107 | 'back_to_live': 'Tagasi otseülekande juurde', 108 | 'disabled': 'Keelatud', 109 | 'playback_not_supported': 'Teie brauser ei toeta selle video taasesitust. Proovige kasutada muud brauserit.', 110 | }, 111 | 'ar': { 112 | 'live': 'مباشر', 113 | 'back_to_live': 'الرجوع إلى المباشر', 114 | 'disabled': 'معطّل', 115 | 'playback_not_supported': 'المتصفح الذي تستخدمه لا يدعم تشغيل هذا الفيديو. الرجاء إستخدام متصفح آخر.', 116 | 'default_error_title': 'غير قادر الى التشغيل.', 117 | 'default_error_message': 'حدثت مشكلة أثناء تحميل الفيديو.', 118 | }, 119 | 'zh': { 120 | 'live': '直播', 121 | 'back_to_live': '返回直播', 122 | 'disabled': '已禁用', 123 | 'playback_not_supported': '您的浏览器不支持该视频的播放。请尝试使用另一个浏览器。', 124 | 'default_error_title': '无法播放视频。', 125 | 'default_error_message': '在尝试加载视频时出现了问题。', 126 | }, 127 | } 128 | 129 | this._messages = $.extend(true, defaultMessages, this.core.options.strings || {}) 130 | this._messages['de-DE'] = this._messages['de'] 131 | this._messages['pt-BR'] = this._messages['pt'] 132 | this._messages['en-US'] = this._messages['en'] 133 | this._messages['bg-BG'] = this._messages['bg'] 134 | this._messages['es-419'] = this._messages['es_am'] 135 | this._messages['es-ES'] = this._messages['es'] 136 | this._messages['fr-FR'] = this._messages['fr'] 137 | this._messages['tr-TR'] = this._messages['tr'] 138 | this._messages['et-EE'] = this._messages['et'] 139 | this._messages['ar-IQ'] = this._messages['ar'] 140 | this._messages['zh-CN'] = this._messages['zh'] 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/plugins/strings/strings.test.js: -------------------------------------------------------------------------------- 1 | import Strings from './strings' 2 | 3 | describe('Strings', function() { 4 | it('translates', function() { 5 | const fakeCore = { options: { } } 6 | const strings = new Strings(fakeCore) 7 | strings._language = function() { return 'en' } 8 | 9 | expect(strings.t('live')).toEqual('live') 10 | }) 11 | 12 | it('fallbacks to English language', function() { 13 | const fakeCore = { options: { language: '404' } } 14 | const strings = new Strings(fakeCore) 15 | 16 | expect(strings.t('live')).toEqual('live') 17 | }) 18 | 19 | it('shows key when it does not find the translation', function() { 20 | const fakeCore = { options: {} } 21 | const strings = new Strings(fakeCore) 22 | 23 | expect(strings.t('Example')).toEqual('Example') 24 | }) 25 | 26 | it('translates based on user language', function() { 27 | const fakeCore = { options: { language: 'es' } } 28 | const strings = new Strings(fakeCore) 29 | 30 | expect(strings.t('live')).toEqual('en directo') 31 | }) 32 | 33 | it('translates based on user options', function() { 34 | const fakeCore = { 35 | options: { 36 | language: 'en', 37 | strings: { 38 | 'en': { 39 | 'live': 'Company Live' 40 | } 41 | } 42 | } 43 | } 44 | const strings = new Strings(fakeCore) 45 | 46 | expect(strings.t('live')).toEqual('Company Live') 47 | }) 48 | 49 | it('merges user translations with default translations', function() { 50 | const fakeCore = { 51 | options: { 52 | language: 'en', 53 | strings: { 54 | 'en': { 55 | 'live': 'Company Live' 56 | } 57 | } 58 | } 59 | } 60 | const strings = new Strings(fakeCore) 61 | 62 | expect(strings.t('back_to_live')).toEqual('back to live') 63 | expect(strings.t('live')).toEqual('Company Live') 64 | }) 65 | 66 | it('merges user translations with a language not existing in default translations', function() { 67 | const fakeCore = { 68 | options: { 69 | language: 'hu', 70 | strings: { 71 | 'hu': { 72 | 'live': 'Élő', 73 | 'back_to_live': 'Ugrás élő képre' 74 | } 75 | } 76 | } 77 | } 78 | const strings = new Strings(fakeCore) 79 | 80 | expect(strings.t('back_to_live')).toEqual('Ugrás élő képre') 81 | expect(strings.t('live')).toEqual('Élő') 82 | }) 83 | }) 84 | -------------------------------------------------------------------------------- /src/utils/utils.js: -------------------------------------------------------------------------------- 1 | // Copyright 2014 Globo.com Player authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | /*jshint -W079 */ 5 | 6 | import '../base/polyfills' 7 | import Media from '../base/media' 8 | import Browser from '../components/browser' 9 | import $ from 'clappr-zepto' 10 | 11 | const idsCounter = {} 12 | const videoStack = [] 13 | 14 | export const requestAnimationFrame = (window.requestAnimationFrame || 15 | window.mozRequestAnimationFrame || 16 | window.webkitRequestAnimationFrame || 17 | function(fn) { window.setTimeout(fn, 1000/60) }).bind(window) 18 | 19 | export const cancelAnimationFrame = (window.cancelAnimationFrame || 20 | window.mozCancelAnimationFrame || 21 | window.webkitCancelAnimationFrame || 22 | window.clearTimeout).bind(window) 23 | 24 | export function assign(obj, source) { 25 | if (source) { 26 | for (const prop in source) { 27 | const propDescriptor = Object.getOwnPropertyDescriptor(source, prop) 28 | propDescriptor ? Object.defineProperty(obj, prop, propDescriptor) : obj[prop] = source[prop] 29 | } 30 | } 31 | return obj 32 | } 33 | 34 | export function extend(parent, properties) { 35 | class Surrogate extends parent { 36 | constructor(...args) { 37 | super(...args) 38 | if (properties.initialize) 39 | properties.initialize.apply(this, args) 40 | 41 | } 42 | } 43 | assign(Surrogate.prototype, properties) 44 | return Surrogate 45 | } 46 | 47 | export function formatTime(time, paddedHours) { 48 | if (!isFinite(time)) return '--:--' 49 | 50 | time = time * 1000 51 | time = parseInt(time/1000) 52 | const seconds = time % 60 53 | time = parseInt(time/60) 54 | const minutes = time % 60 55 | time = parseInt(time/60) 56 | const hours = time % 24 57 | const days = parseInt(time/24) 58 | let out = '' 59 | if (days && days > 0) { 60 | out += days + ':' 61 | if (hours < 1) out += '00:' 62 | } 63 | if (hours && hours > 0 || paddedHours) out += ('0' + hours).slice(-2) + ':' 64 | out += ('0' + minutes).slice(-2) + ':' 65 | out += ('0' + seconds).slice(-2) 66 | return out.trim() 67 | } 68 | 69 | export const Fullscreen = { 70 | fullscreenElement: function() { 71 | return document.fullscreenElement || 72 | document.webkitFullscreenElement || 73 | document.mozFullScreenElement || 74 | document.msFullscreenElement 75 | }, 76 | requestFullscreen: function(el) { 77 | if (el.requestFullscreen) { 78 | return el.requestFullscreen() 79 | } else if (el.webkitRequestFullscreen) { 80 | if (typeof el.then === 'function') return el.webkitRequestFullscreen() 81 | el.webkitRequestFullscreen() 82 | } else if (el.mozRequestFullScreen) { 83 | return el.mozRequestFullScreen() 84 | } else if (el.msRequestFullscreen) { 85 | return el.msRequestFullscreen() 86 | } else if (el.querySelector && el.querySelector('video') && el.querySelector('video').webkitEnterFullScreen) { 87 | el.querySelector('video').webkitEnterFullScreen() 88 | } else if (el.webkitEnterFullScreen) { 89 | el.webkitEnterFullScreen() 90 | } 91 | }, 92 | cancelFullscreen: function(el=document) { 93 | if (el.exitFullscreen) 94 | el.exitFullscreen() 95 | else if (el.webkitCancelFullScreen) 96 | el.webkitCancelFullScreen() 97 | else if (el.webkitExitFullscreen) 98 | el.webkitExitFullscreen() 99 | else if (el.mozCancelFullScreen) 100 | el.mozCancelFullScreen() 101 | else if (el.msExitFullscreen) 102 | el.msExitFullscreen() 103 | 104 | }, 105 | fullscreenEnabled: function() { 106 | return !!( 107 | document.fullscreenEnabled || 108 | document.webkitFullscreenEnabled || 109 | document.mozFullScreenEnabled || 110 | document.msFullscreenEnabled 111 | ) 112 | } 113 | } 114 | 115 | export class Config { 116 | 117 | static _defaultConfig() { 118 | return { 119 | volume: { 120 | value: 100, 121 | parse: parseInt 122 | } 123 | } 124 | } 125 | 126 | static _defaultValueFor(key) { 127 | try { 128 | return this._defaultConfig()[key].parse(this._defaultConfig()[key].value) 129 | } catch (e) { 130 | return undefined 131 | } 132 | } 133 | 134 | static _createKeyspace(key) { 135 | return `clappr.${document.domain}.${key}` 136 | } 137 | 138 | static restore(key) { 139 | if (Browser.hasLocalstorage && localStorage[this._createKeyspace(key)]) 140 | return this._defaultConfig()[key].parse(localStorage[this._createKeyspace(key)]) 141 | 142 | return this._defaultValueFor(key) 143 | } 144 | 145 | static persist(key, value) { 146 | if (Browser.hasLocalstorage) { 147 | try { 148 | localStorage[this._createKeyspace(key)] = value 149 | return true 150 | } catch (e) { 151 | return false 152 | } 153 | } 154 | } 155 | } 156 | 157 | export class QueryString { 158 | static get params() { 159 | const query = window.location.search.substring(1) 160 | if (query !== this.query) { 161 | this._urlParams = this.parse(query) 162 | this.query = query 163 | } 164 | return this._urlParams 165 | } 166 | 167 | static get hashParams() { 168 | const hash = window.location.hash.substring(1) 169 | if (hash !== this.hash) { 170 | this._hashParams = this.parse(hash) 171 | this.hash = hash 172 | } 173 | return this._hashParams 174 | } 175 | 176 | static parse(paramsString) { 177 | let match 178 | const pl = /\+/g, // Regex for replacing addition symbol with a space 179 | search = /([^&=]+)=?([^&]*)/g, 180 | decode = (s) => decodeURIComponent(s.replace(pl, ' ')), 181 | params = {} 182 | while (match = search.exec(paramsString)) { // eslint-disable-line no-cond-assign 183 | params[decode(match[1]).toLowerCase()] = decode(match[2]) 184 | } 185 | return params 186 | } 187 | } 188 | 189 | export function seekStringToSeconds(paramName = 't') { 190 | let seconds = 0 191 | const seekString = QueryString.params[paramName] || QueryString.hashParams[paramName] || '' 192 | const parts = seekString.match(/[0-9]+[hms]+/g) || [] 193 | if (parts.length > 0) { 194 | const factor = { 'h': 3600, 'm': 60, 's': 1 } 195 | parts.forEach(function(el) { 196 | if (el) { 197 | const suffix = el[el.length - 1] 198 | const time = parseInt(el.slice(0, el.length - 1), 10) 199 | seconds += time * (factor[suffix]) 200 | } 201 | }) 202 | } else if (seekString) { seconds = parseInt(seekString, 10) } 203 | 204 | return seconds 205 | } 206 | 207 | export function uniqueId(prefix) { 208 | idsCounter[prefix] || (idsCounter[prefix] = 0) 209 | const id = ++idsCounter[prefix] 210 | return prefix + id 211 | } 212 | 213 | export function isNumber(value) { 214 | return value - parseFloat(value) + 1 >= 0 215 | } 216 | 217 | export function currentScriptUrl() { 218 | const scripts = document.getElementsByTagName('script') 219 | return scripts.length ? scripts[scripts.length - 1].src : '' 220 | } 221 | 222 | export function getBrowserLanguage() { 223 | return window.navigator && window.navigator.language 224 | } 225 | 226 | export function now() { 227 | if (window.performance && window.performance.now) 228 | return performance.now() 229 | 230 | return Date.now() 231 | } 232 | 233 | // remove the item from the array if it exists in the array 234 | export function removeArrayItem(arr, item) { 235 | const i = arr.indexOf(item) 236 | if (i >= 0) 237 | arr.splice(i, 1) 238 | 239 | } 240 | 241 | // find an item regardless of its letter case 242 | export function listContainsIgnoreCase(item, items) { 243 | if (item === undefined || items === undefined) return false 244 | return items.find((itemEach) => item.toLowerCase() === itemEach.toLowerCase()) !== undefined 245 | } 246 | 247 | // https://github.com/video-dev/can-autoplay 248 | export function canAutoPlayMedia(cb, options) { 249 | options = Object.assign({ 250 | inline: false, 251 | muted: false, 252 | timeout: 250, 253 | type: 'video', 254 | source: Media.mp4, 255 | element: null 256 | }, options) 257 | 258 | let element = options.element ? options.element : document.createElement(options.type) 259 | 260 | element.muted = options.muted 261 | if (options.muted === true) 262 | element.setAttribute('muted', 'muted') 263 | 264 | if (options.inline === true) 265 | element.setAttribute('playsinline', 'playsinline') 266 | 267 | element.src = options.source 268 | 269 | let promise = element.play() 270 | 271 | let timeoutId = setTimeout(() => { 272 | setResult(false, new Error(`Timeout ${options.timeout} ms has been reached`)) 273 | }, options.timeout) 274 | 275 | let setResult = (result, error = null) => { 276 | clearTimeout(timeoutId) 277 | cb(result, error) 278 | } 279 | 280 | if (promise !== undefined) { 281 | promise 282 | .then(() => setResult(true)) 283 | .catch(err => setResult(false, err)) 284 | } else { 285 | setResult(true) 286 | } 287 | } 288 | 289 | // Simple element factory with video recycle feature. 290 | export class DomRecycler { 291 | static configure(options) { 292 | this.options = $.extend(true, this.options, options) 293 | } 294 | 295 | static create(name) { 296 | if (this.options.recycleVideo && name === 'video' && videoStack.length > 0) 297 | return videoStack.shift() 298 | 299 | return document.createElement(name) 300 | } 301 | 302 | static garbage(el) { 303 | if (!this.options.recycleVideo || el.tagName.toUpperCase() !== 'VIDEO') return 304 | $(el).children().remove() 305 | Object.values(el.attributes).forEach(attr => el.removeAttribute(attr.name)) 306 | videoStack.push(el) 307 | } 308 | } 309 | 310 | DomRecycler.options = { recycleVideo: false } 311 | 312 | export class DoubleEventHandler { 313 | constructor(delay = 500) { 314 | this.delay = delay 315 | this.lastTime = 0 316 | } 317 | 318 | handle(event, cb, prevented = true) { 319 | // Based on http://jsfiddle.net/brettwp/J4djY/ 320 | let currentTime = new Date().getTime() 321 | let diffTime = currentTime - this.lastTime 322 | 323 | if (diffTime < this.delay && diffTime > 0) { 324 | cb() 325 | prevented && event.preventDefault() 326 | } 327 | 328 | this.lastTime = currentTime 329 | } 330 | } 331 | 332 | export default { 333 | Config, 334 | Fullscreen, 335 | QueryString, 336 | DomRecycler, 337 | assign, 338 | extend, 339 | formatTime, 340 | seekStringToSeconds, 341 | uniqueId, 342 | currentScriptUrl, 343 | isNumber, 344 | requestAnimationFrame, 345 | cancelAnimationFrame, 346 | getBrowserLanguage, 347 | now, 348 | removeArrayItem, 349 | listContainsIgnoreCase, 350 | canAutoPlayMedia, 351 | Media, 352 | DoubleEventHandler, 353 | } 354 | -------------------------------------------------------------------------------- /src/utils/version.js: -------------------------------------------------------------------------------- 1 | const VERSION_REGEX = /(\d+)(?:\.(\d+))?(?:\.(\d+))?/ 2 | 3 | export default class Version { 4 | static parse(str = '') { 5 | const matches = str.match(VERSION_REGEX) || [] 6 | const [,major, minor, patch] = matches 7 | if (typeof(major) === 'undefined') return null 8 | 9 | return new Version(major, minor, patch) 10 | } 11 | 12 | constructor(major, minor, patch) { 13 | this.major = parseInt(major || 0, 10) 14 | this.minor = parseInt(minor || 0, 10) 15 | this.patch = parseInt(patch || 0, 10) 16 | } 17 | 18 | compare(other) { 19 | let diff = this.major - other.major 20 | diff = diff || (this.minor - other.minor) 21 | diff = diff || (this.patch - other.patch) 22 | return diff 23 | } 24 | 25 | inc(type = 'patch') { 26 | typeof(this[type]) !== 'undefined' && (this[type] += 1) 27 | return this 28 | } 29 | 30 | satisfies(min, max) { 31 | return this.compare(min) >= 0 && (!max || this.compare(max) < 0) 32 | } 33 | 34 | toString() { 35 | return `${this.major}.${this.minor}.${this.patch}` 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/utils/version.test.js: -------------------------------------------------------------------------------- 1 | import Version from './version' 2 | 3 | describe('Version', () => { 4 | describe('parse', () => { 5 | test('parses a version string in the format major.minor.patch', () => { 6 | const v = Version.parse('1.2.3') 7 | expect(v.major).toEqual(1) 8 | expect(v.minor).toEqual(2) 9 | expect(v.patch).toEqual(3) 10 | }) 11 | 12 | test('parses a version string in the format major.minor (patch omitted)', () => { 13 | const v = Version.parse('1.2') 14 | expect(v.major).toEqual(1) 15 | expect(v.minor).toEqual(2) 16 | expect(v.patch).toEqual(0) 17 | }) 18 | 19 | test('parses a version string in the format major (minor, patch omitted)', () => { 20 | const v = Version.parse('1') 21 | expect(v.major).toEqual(1) 22 | expect(v.minor).toEqual(0) 23 | expect(v.patch).toEqual(0) 24 | }) 25 | 26 | test('returns null when version is not in the right format', () => { 27 | const v = Version.parse('a.x') 28 | expect(v).toBeNull() 29 | }) 30 | }) 31 | 32 | describe('compare', () => { 33 | test('returns 0 if versions are equivalent', () => { 34 | const v1 = Version.parse('1.2') 35 | const v2 = Version.parse('1.2.0') 36 | expect(v1.compare(v2)).toEqual(0) 37 | }) 38 | 39 | test('returns a number greater than 0 if the version is greater than the specified', () => { 40 | const v = Version.parse('1.2.1') 41 | expect(v.compare(Version.parse('0.0.1'))).toBeGreaterThan(0) 42 | expect(v.compare(Version.parse('0.1.0'))).toBeGreaterThan(0) 43 | expect(v.compare(Version.parse('1.0.0'))).toBeGreaterThan(0) 44 | expect(v.compare(Version.parse('1.2.0'))).toBeGreaterThan(0) 45 | }) 46 | 47 | test('returns less than 0 if the version is less than the specified', () => { 48 | const v = Version.parse('1.2.1') 49 | expect(v.compare(Version.parse('2.0.0'))).toBeLessThan(0) 50 | expect(v.compare(Version.parse('1.4.0'))).toBeLessThan(0) 51 | expect(v.compare(Version.parse('1.2.3'))).toBeLessThan(0) 52 | }) 53 | }) 54 | 55 | describe('satisfies', () => { 56 | test('returns true if the version is within the determined range', () => { 57 | const v = Version.parse('1.3.0') 58 | const min = Version.parse('1.0.0') 59 | const max = Version.parse('2.0.0') 60 | expect(v.satisfies(min, max)).toBeTruthy() 61 | }) 62 | 63 | test('returns false if the version is out of the determined range', () => { 64 | const v = Version.parse('1.0.0') 65 | const min = Version.parse('1.3.0') 66 | const max = Version.parse('2.0.0') 67 | expect(v.satisfies(min, max)).toBeFalsy() 68 | }) 69 | }) 70 | }) 71 | --------------------------------------------------------------------------------