├── .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 |
20 | 21 | **Browser**: YOUR BROWSER (ex: Chrome Version 46.0.2490.80, Firefox, IE) 22 | 23 | **OS**: YOUR OS (ex: Mac OS 10.11.1, iOS9, android4.5) 24 | 25 | **Clappr Version**: x.x.x 26 | 27 | **Steps to reproduce**: 28 | 29 | * first step 30 | * then second step 31 | * I was expecting X but instead it shows Y 32 | 33 | ps: you can attach images, logs or whatever you think might be helpful. 34 |
35 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F680 Feature request" 3 | about: Suggest an idea for this project 4 | labels: feature 5 | --- 6 | 7 | 12 | 13 | **Is your feature request related to a problem? Please describe.** 14 | Please describe the problem you are trying to solve. 15 | 16 | **Describe the solution you'd like** 17 | Please describe the desired behavior. 18 | 19 | **Describe alternatives you've considered** 20 | Please describe alternative solutions or features you have considered. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "⁉️ Question" 3 | about: Need some help? 4 | labels: question 5 | --- 6 | 7 | **What do you want to do with Clappr?** 8 | 9 | **What have you tried so far?** 10 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Summary 2 | 3 | This PR implements / updates / fixes... 4 | 5 | 9 | 10 | ## Changes 11 | 12 | - `archive_name1.js`: 13 | - ... 14 | - ... 15 | - `archive_name2.js`: ... 16 | 17 | ## How to test 18 | 19 | 1. ... 20 | 1. ... 21 | 1. ... 22 | 23 | ## Images 24 | 25 | ### Before this PR 26 | 27 | 31 | 32 | ### After this PR 33 | 34 | 35 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 60 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - backlog 8 | - bug 9 | - feature 10 | - high-priority 11 | - in-progress 12 | - enhancement 13 | # Label to use when marking an issue as stale 14 | staleLabel: stale 15 | # Comment to post when marking an issue as stale. Set to `false` to disable 16 | markComment: > 17 | This issue has been automatically marked as stale because it has not had 18 | recent activity. It will be closed if no further activity occurs. Thank you 19 | for your contributions. 20 | # Comment to post when closing a stale issue. Set to `false` to disable 21 | closeComment: false 22 | -------------------------------------------------------------------------------- /.github/workflow/actions-ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | 16 | - name: Install Node 17 | uses: actions/setup-node@v2 18 | with: 19 | node-version: 14.x 20 | cache: 'yarn' 21 | 22 | - name: Install dependencies 23 | run: yarn 24 | 25 | - name: Run lint 26 | run: yarn lint 27 | 28 | - name: Run Unit tests 29 | run: yarn test 30 | 31 | - name: Coveralls 32 | uses: coverallsapp/github-action@1.1.3 33 | with: 34 | github-token: ${{ secrets.GITHUB_TOKEN }} 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules 3 | .DS_Store 4 | .env 5 | coverage/ 6 | build/ 7 | docs/ 8 | src/base/jst.js 9 | *.cache 10 | aws.json 11 | npm-debug.log 12 | yarn-error.log 13 | 14 | # bump 15 | *.bkp 16 | 17 | # Vim 18 | *~ 19 | *.swp 20 | *.swo 21 | 22 | # PhpStorm 23 | .idea 24 | -------------------------------------------------------------------------------- /.hound.yml: -------------------------------------------------------------------------------- 1 | eslint: 2 | enabled: true 3 | config_file: .eslintrc.json 4 | -------------------------------------------------------------------------------- /.yarnclean: -------------------------------------------------------------------------------- 1 | # test directories 2 | __tests__ 3 | test 4 | tests 5 | powered-test 6 | 7 | # asset directories 8 | docs 9 | doc 10 | website 11 | images 12 | 13 | # examples 14 | example 15 | examples 16 | 17 | # code coverage directories 18 | coverage 19 | .nyc_output 20 | 21 | # build scripts 22 | Makefile 23 | Gulpfile.js 24 | Gruntfile.js 25 | 26 | # configs 27 | appveyor.yml 28 | circle.yml 29 | codeship-services.yml 30 | codeship-steps.yml 31 | wercker.yml 32 | .tern-project 33 | .gitattributes 34 | .editorconfig 35 | .*ignore 36 | .flowconfig 37 | .documentup.json 38 | .yarn-metadata.json 39 | .travis.yml 40 | 41 | # misc 42 | *.md 43 | -------------------------------------------------------------------------------- /.yarnrc: -------------------------------------------------------------------------------- 1 | --install.ignore-engines true -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | # This is the official list of Globo.com Player authors for copyright purposes. 2 | # This file is distinct from the CONTRIBUTORS files. 3 | # See the latter for an explanation. 4 | 5 | Globo.com 6 | 7 | -------------------------------------------------------------------------------- /CONTRIBUTORS: -------------------------------------------------------------------------------- 1 | # This is the official list of people who have contributed code to 2 | # Clappr Player. 3 | # The AUTHORS file lists the copyright holders; this file lists people. 4 | # For example, Globo.com employees are listed here but not in AUTHORS, 5 | # because Globo.com holds the copyright. 6 | # 7 | # You can update this list using the following command: 8 | # 9 | # % git shortlog -se | awk '{$1=""; print $0}' | sed -e 's/^ //' 10 | # 11 | # Please keep this file sorted, and group users with multiple emails. 12 | 13 | Ahmad Mayahi 14 | Alvyn McQuitty 15 | Andrey Filimonow 16 | Andrey Nikitin 17 | Andrey Polischuk 18 | Andriy Lysnevych 19 | Ari Selseng 20 | Ben 21 | Bernardo Camilo 22 | Bruno Torres 23 | Daniel Afonso 24 | David Beitey 25 | Denis Sikuler 26 | Diego.Péres 27 | Eddie Lau 3dd13 28 | EmileP <36151637+emilepommier@users.noreply.github.com> 29 | Emre Karataşoğlu 30 | Flávio Ribeiro 31 | Guilherme Heynemann Bruzzi 32 | Gustavo Barbosa 33 | Henrique Breim 34 | Iulian Onofrei 35 | Jamie Stackhouse 36 | Jarom McDonald 37 | Jedidiah Hurt 38 | Jenna Smith 39 | Jhonatan Gomes 40 | Joao Paulo Vieira 41 | Jussi Keranen 42 | Jérôme DENIS 43 | Karim Slimani 44 | Ke Xu 45 | Leandro Moreira 46 | Leonardo 47 | Lewis Cowper 48 | Loris Mancel 49 | Lucas Costa 50 | Marcel 51 | Mark Allen Matney, Jr 52 | Martin Kolárik 53 | Maxwell Dayvson da Silva 54 | Mike Griffith 55 | Nicholas Asimov 56 | Niko78 57 | Ogün Karakuş 58 | Quentin V 59 | Raphael Amorim 60 | Robert Nagy 61 | Rodrigo Machado 62 | Roland Starke 63 | RussCoder 64 | Sean Hussey 65 | Sergey Chooh 66 | Stanley Gurnik 67 | Stephan Hesse 68 | Steven Lu 69 | Thiago Pontes 70 | Tom Jenkinson 71 | Tomasz Rybarczyk 72 | Vadim Anufriev 73 | Vagner Santana 74 | Vlad V. Teteria 75 | argoilves 76 | derrod 77 | hounvs 78 | hxl-dy 79 | otmjka 80 | rheber 81 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014, Globo.com Player authors. All rights reserved. 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are met: 5 | 6 | * Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above copyright 9 | notice, this list of conditions and the following disclaimer in the 10 | documentation and/or other materials provided with the distribution. 11 | * Neither the name of the Globo.com nor the names of its contributors 12 | may be used to endorse or promote products derived from this software without 13 | specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 16 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 17 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 18 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 19 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 20 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 21 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 22 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 23 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 24 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /bump: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | PROJECT_NAME='clappr-core' 4 | CDN_PATH="npm/@clappr/core@latest/dist/$PROJECT_NAME.min.js" 5 | 6 | update_dependencies() { 7 | echo 'updating dependencies' && 8 | yarn install 9 | } 10 | 11 | update_version() { 12 | current_tag=$(git describe --abbrev=0 --tags master) && 13 | echo 'bump from '$current_tag' to '$1 && 14 | sed -i ".bkp" "s/\(version\":[ ]*\"\)$current_tag/\1$1/" package.json 15 | } 16 | 17 | build() { 18 | echo "building RELEASE version $1" && 19 | yarn release 20 | } 21 | 22 | run_tests() { 23 | yarn lint 24 | yarn test 25 | } 26 | 27 | make_release_commit() { 28 | git add package.json yarn.lock && 29 | git commit -m 'chore(package): bump to '$1 && 30 | git tag -m "$1" $1 31 | } 32 | 33 | git_push() { 34 | echo 'pushing to github' 35 | git push origin master --tags 36 | } 37 | 38 | npm_publish() { 39 | npm publish 40 | } 41 | 42 | purge_cdn_cache() { 43 | echo 'purging cdn cache' 44 | curl -q "http://purge.jsdelivr.net/$CDN_PATH" 45 | } 46 | 47 | main() { 48 | npm whoami 49 | if (("$?" != "0")); then 50 | echo "you are not logged into npm" 51 | exit 1 52 | fi 53 | update_dependencies && 54 | update_version $1 && 55 | build $1 56 | if (("$?" != "0")); then 57 | echo "something failed during dependency update, version update, or build" 58 | exit 1 59 | fi 60 | run_tests 61 | if (("$?" == "0")); then 62 | make_release_commit $1 && 63 | git_push && 64 | npm_publish && 65 | purge_cdn_cache && 66 | exit 0 67 | 68 | echo "something failed" 69 | exit 1 70 | else 71 | echo "you broke the tests. fix it before bumping another version." 72 | exit 1 73 | fi 74 | } 75 | 76 | if [ "$1" != "" ]; then 77 | main $1 78 | else 79 | echo "Usage: bump [new_version]" 80 | fi 81 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const pkg = require('./package.json') 2 | 3 | module.exports = { 4 | 'testEnvironment': 'jsdom', 5 | 'globals': { 6 | 'VERSION': pkg.version 7 | }, 8 | 'verbose': true, 9 | 'resolver': 'jest-directory-named-resolver', 10 | 'transform': { 11 | '^.+\\.js$': 'babel-jest', 12 | '^.+\\.html$': '/src/__mocks__/htmlMock.js' 13 | }, 14 | 'moduleNameMapper': { 15 | '^@/(.*)$': '/src/$1', 16 | '^clappr-zepto$': 'clappr-zepto/zepto.js', 17 | '\\.(scss)$': '/src/__mocks__/styleMock.js' 18 | }, 19 | 'collectCoverageFrom': [ 20 | 'src/*.js', 21 | 'src/**/*.js', 22 | 'src/**/**/*.js' 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@clappr/core", 3 | "version": "0.7.2", 4 | "description": "Core components of the extensible media player for the web", 5 | "main": "./dist/clappr-core.js", 6 | "module": "./dist/clappr-core.esm.js", 7 | "scripts": { 8 | "bundle-check": "ANALYZE_BUNDLE=true rollup --config --bundleConfigAsCjs", 9 | "release": "MINIMIZE=true rollup --config --bundleConfigAsCjs", 10 | "build": "rollup --config --bundleConfigAsCjs", 11 | "watch": "rollup --config --watch --bundleConfigAsCjs", 12 | "test": "jest /src --coverage --silent", 13 | "test:coverage": "open coverage/lcov-report/index.html", 14 | "test:debug": "node --inspect node_modules/.bin/jest src/ --runInBand", 15 | "test:watch": "jest /src --watch", 16 | "lint": "eslint *.js src/", 17 | "lint:fix": "npm run lint -- --fix", 18 | "start": "DEV=true rollup --config --watch --bundleConfigAsCjs", 19 | "commitzen": "git-cz", 20 | "prepublishOnly": "npm run release" 21 | }, 22 | "files": [ 23 | "/dist", 24 | "/src" 25 | ], 26 | "publishConfig": { 27 | "access": "public" 28 | }, 29 | "repository": { 30 | "type": "git", 31 | "url": "git@github.com:clappr/clappr-core.git" 32 | }, 33 | "author": "Globo.com", 34 | "license": "BSD-3-Clause", 35 | "bugs": { 36 | "url": "https://github.com/clappr/clappr-core/issues" 37 | }, 38 | "homepage": "https://github.com/clappr/clappr-core", 39 | "devDependencies": { 40 | "@babel/core": "^7.22.17", 41 | "@babel/preset-env": "^7.22.15", 42 | "@rollup/plugin-alias": "^5.0.0", 43 | "@rollup/plugin-babel": "^6.0.3", 44 | "@rollup/plugin-commonjs": "^25.0.4", 45 | "@rollup/plugin-json": "^6.0.0", 46 | "@rollup/plugin-node-resolve": "^15.2.1", 47 | "@rollup/plugin-replace": "^5.0.2", 48 | "autoprefixer": "10.4.15", 49 | "babel-jest": "^29.7.0", 50 | "clappr-zepto": "0.0.7", 51 | "coveralls": "^3.1.1", 52 | "cz-conventional-changelog": "^3.3.0", 53 | "eslint": "^8.49.0", 54 | "jest": "^29.7.0", 55 | "jest-environment-jsdom": "^29.7.0", 56 | "jest-directory-named-resolver": "^0.3.0", 57 | "jest-mock-console": "^2.0.0", 58 | "node-sass": "^9.0.0", 59 | "postcss": "^8.4.29", 60 | "rollup": "^3.29.1", 61 | "rollup-plugin-filesize": "^10.0.0", 62 | "rollup-plugin-html": "^0.2.1", 63 | "rollup-plugin-livereload": "^2.0.5", 64 | "rollup-plugin-named-directory": "^1.0.0", 65 | "rollup-plugin-postcss": "^4.0.2", 66 | "rollup-plugin-serve": "^2.0.2", 67 | "rollup-plugin-sizes": "^1.0.5", 68 | "rollup-plugin-terser": "^7.0.2", 69 | "rollup-plugin-visualizer": "^5.9.2" 70 | }, 71 | "config": { 72 | "commitizen": { 73 | "path": "./node_modules/cz-conventional-changelog" 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | require('autoprefixer') 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /public/i/clappr_logo_black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clappr/clappr-core/3126c3a38a6eee9d5aba3918b194e6380fa1178c/public/i/clappr_logo_black.png -------------------------------------------------------------------------------- /public/i/favico.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clappr/clappr-core/3126c3a38a6eee9d5aba3918b194e6380fa1178c/public/i/favico.png -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | Clappr 18 |
    19 |
  • 20 | docs 21 |
  • 22 |
23 |
24 |
25 |
26 |

27 | Add external plugins: 28 | 29 | 30 |

31 |
32 |
33 |
34 |
35 | 54 |
55 |
56 | 57 | 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /public/j/add-external.js: -------------------------------------------------------------------------------- 1 | window.clappr = window.clappr || {} 2 | window.clappr.externals = [] 3 | 4 | function addExternal() { 5 | var url = document.getElementById('js-link') 6 | window.clappr.externals.push(url.value) 7 | addTag(url.value) 8 | url.value = '' 9 | } 10 | 11 | function addTag(url) { 12 | var colors = ["aliceblue", "antiquewhite", "azure", "black", "blue", "brown", "yellow", "teal"] 13 | var color = colors[Math.floor(Math.random() * colors.length)] 14 | var span = document.createElement('span') 15 | 16 | span.style.backgroundColor = color 17 | span.className = "external-js" 18 | span.innerText = url.split(/\//).pop().split(/\./)[0] 19 | 20 | document.getElementById('external-js-panel').appendChild(span) 21 | } 22 | -------------------------------------------------------------------------------- /public/j/clappr-config.js: -------------------------------------------------------------------------------- 1 | var playerElement = document.getElementById("player-wrapper"); 2 | 3 | player = new Clappr.Player({ 4 | source: urlParams.src || 'http://clappr.io/highline.mp4', 5 | poster: urlParams.poster || 'http://clappr.io/poster.png', 6 | mute: true, 7 | autoPlay: true, 8 | height: 360, 9 | width: 640, 10 | playback: { 11 | controls: true, 12 | }, 13 | }); 14 | 15 | player.attachTo(playerElement); 16 | -------------------------------------------------------------------------------- /public/j/editor/theme-katzenmilch.js: -------------------------------------------------------------------------------- 1 | define("ace/theme/katzenmilch",["require","exports","module","ace/lib/dom"],function(e,t,n){t.isDark=!1,t.cssClass="ace-katzenmilch",t.cssText=".ace-katzenmilch .ace_gutter,/* THIS THEME WAS AUTOGENERATED BY Theme.tmpl.css (UUID: ) */.ace-katzenmilch .ace_gutter {background: #e8e8e8;color: #333}.ace-katzenmilch .ace_print-margin {width: 1px;background: #e8e8e8}.ace-katzenmilch {background-color: #f3f2f3;color: rgba(15, 0, 9, 1.0)}.ace-katzenmilch .ace_cursor {border-left: 2px solid #100011}.ace-katzenmilch .ace_overwrite-cursors .ace_cursor {border-left: 0px;border-bottom: 1px solid #100011}.ace-katzenmilch .ace_marker-layer .ace_selection {background: rgba(100, 5, 208, 0.27)}.ace-katzenmilch.ace_multiselect .ace_selection.ace_start {box-shadow: 0 0 3px 0px #f3f2f3;border-radius: 2px}.ace-katzenmilch .ace_marker-layer .ace_step {background: rgb(198, 219, 174)}.ace-katzenmilch .ace_marker-layer .ace_bracket {margin: -1px 0 0 -1px;border: 1px solid #000000}.ace-katzenmilch .ace_marker-layer .ace_active-line {background: rgb(232, 242, 254)}.ace-katzenmilch .ace_gutter-active-line {background-color: rgb(232, 242, 254)}.ace-katzenmilch .ace_marker-layer .ace_selected-word {border: 1px solid rgba(100, 5, 208, 0.27)}.ace-katzenmilch .ace_fold {background-color: rgba(2, 95, 73, 0.97);border-color: rgba(15, 0, 9, 1.0)}.ace-katzenmilch .ace_keyword {color: #674Aa8;rbackground-color: rgba(163, 170, 216, 0.055)}.ace-katzenmilch .ace_constant.ace_language {color: #7D7e52;rbackground-color: rgba(189, 190, 130, 0.059)}.ace-katzenmilch .ace_constant.ace_numeric {color: rgba(79, 130, 123, 0.93);rbackground-color: rgba(119, 194, 187, 0.059)}.ace-katzenmilch .ace_constant.ace_character,.ace-katzenmilch .ace_constant.ace_other {color: rgba(2, 95, 105, 1.0);rbackground-color: rgba(127, 34, 153, 0.063)}.ace-katzenmilch .ace_support.ace_function {color: #9D7e62;rbackground-color: rgba(189, 190, 130, 0.039)}.ace-katzenmilch .ace_support.ace_class {color: rgba(239, 106, 167, 1.0);rbackground-color: rgba(239, 106, 167, 0.063)}.ace-katzenmilch .ace_storage {color: rgba(123, 92, 191, 1.0);rbackground-color: rgba(139, 93, 223, 0.051)}.ace-katzenmilch .ace_invalid {color: #DFDFD5;rbackground-color: #CC1B27}.ace-katzenmilch .ace_string {color: #5a5f9b;rbackground-color: rgba(170, 175, 219, 0.035)}.ace-katzenmilch .ace_comment {font-style: italic;color: rgba(64, 79, 80, 0.67);rbackground-color: rgba(95, 15, 255, 0.0078)}.ace-katzenmilch .ace_entity.ace_name.ace_function,.ace-katzenmilch .ace_variable {color: rgba(2, 95, 73, 0.97);rbackground-color: rgba(34, 255, 73, 0.12)}.ace-katzenmilch .ace_variable.ace_language {color: #316fcf;rbackground-color: rgba(58, 175, 255, 0.039)}.ace-katzenmilch .ace_variable.ace_parameter {font-style: italic;color: rgba(51, 150, 159, 0.87);rbackground-color: rgba(5, 214, 249, 0.043)}.ace-katzenmilch .ace_entity.ace_other.ace_attribute-name {color: rgba(73, 70, 194, 0.93);rbackground-color: rgba(73, 134, 194, 0.035)}.ace-katzenmilch .ace_entity.ace_name.ace_tag {color: #3976a2;rbackground-color: rgba(73, 166, 210, 0.039)}";var r=e("../lib/dom");r.importCssString(t.cssText,t.cssClass)}) -------------------------------------------------------------------------------- /public/j/main.js: -------------------------------------------------------------------------------- 1 | /* 2 | Query String 3 | */ 4 | var urlParams 5 | (function() { 6 | Clappr.Log.setLevel(Clappr.Log.LEVEL_WARN) 7 | window.onpopstate = function () { 8 | var match, 9 | pl = /\+/g, // Regex for replacing addition symbol with a space 10 | search = /([^&=]+)=?([^&]*)/g, 11 | decode = function (s) { return decodeURIComponent(s.replace(pl, ' ')) }, 12 | query = window.location.search.substring(1) 13 | 14 | urlParams = {} 15 | while (match = search.exec(query)) 16 | urlParams[decode(match[1])] = decode(match[2]) 17 | } 18 | window.onpopstate() 19 | })() 20 | 21 | /* 22 | Parser 23 | */ 24 | var Parser = function(output) { 25 | this.output = output; 26 | this.console = $("#console"); 27 | this.context = document; 28 | }; 29 | 30 | Parser.prototype = { 31 | parse: function(code) { 32 | try { 33 | var old = player; 34 | eval(code); 35 | old && old.destroy(); 36 | window.player = player; 37 | this.console.empty(); 38 | } catch(err) { 39 | this.console.html(err.message); 40 | } 41 | } 42 | }; 43 | 44 | $(document).ready(function() { 45 | var parser = new Parser($('#output')) 46 | var load = function (fn) { 47 | if (window.clappr.externals.length > 0) { 48 | var lastScript = window.clappr.externals.length 49 | window.clappr.externals.forEach(function (url, index) { 50 | var script = document.createElement('script') 51 | 52 | script.setAttribute("type", "text/javascript") 53 | script.setAttribute("src", url) 54 | if (index === (lastScript - 1)) { 55 | script.onload = fn 56 | } 57 | script.onerror = function (e) { alert('we cant load ' + url + ': e' + e) } 58 | 59 | document.body.appendChild(script) 60 | }) 61 | } else { 62 | fn() 63 | } 64 | } 65 | $('.run').click(function() { 66 | var code = ace.edit('editor').getSession().getValue() 67 | load(function () { parser.parse(code) }) 68 | }) 69 | }) 70 | 71 | /* 72 | Editor 73 | */ 74 | window.onload = function() { 75 | var editor = ace.edit('editor') 76 | var session = editor.getSession() 77 | 78 | editor.setTheme('ace/theme/katzenmilch') 79 | editor.$blockScrolling = Infinity 80 | session.setMode('ace/mode/javascript') 81 | session.setTabSize(2) 82 | session.setUseSoftTabs(true) 83 | editor.commands.addCommand({ 84 | name: 'run', 85 | bindKey: {mac: 'Command-Enter'}, 86 | exec: function(editor) { 87 | document.querySelector('.run').click() 88 | }, 89 | }) 90 | } 91 | -------------------------------------------------------------------------------- /public/stylesheets/bootstrap.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap v3.1.1 (http://getbootstrap.com) 3 | * Copyright 2011-2014 Twitter, Inc. 4 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) 5 | */ 6 | 7 | /*! normalize.css v3.0.0 | MIT License | git.io/normalize */html{font-family:sans-serif;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}article,aside,details,figcaption,figure,footer,header,hgroup,main,nav,section,summary{display:block}audio,canvas,progress,video{display:inline-block;vertical-align:baseline}audio:not([controls]){display:none;height:0}[hidden],template{display:none}a{background:transparent}a:active,a:hover{outline:0}abbr[title]{border-bottom:1px dotted}b,strong{font-weight:bold}dfn{font-style:italic}h1{font-size:2em;margin:0.67em 0}mark{background:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sup{top:-0.5em}sub{bottom:-0.25em}img{border:0}svg:not(:root){overflow:hidden}figure{margin:1em 40px}hr{-moz-box-sizing:content-box;box-sizing:content-box;height:0}pre{overflow:auto}code,kbd,pre,samp{font-family:monospace, monospace;font-size:1em}button,input,optgroup,select,textarea{color:inherit;font:inherit;margin:0}button{overflow:visible}button,select{text-transform:none}button,html input[type="button"],input[type="reset"],input[type="submit"]{-webkit-appearance:button;cursor:pointer}button[disabled],html input[disabled]{cursor:default}button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0}input{line-height:normal}input[type="checkbox"],input[type="radio"]{box-sizing:border-box;padding:0}input[type="number"]::-webkit-inner-spin-button,input[type="number"]::-webkit-outer-spin-button{height:auto}input[type="search"]{-webkit-appearance:textfield;-moz-box-sizing:content-box;-webkit-box-sizing:content-box;box-sizing:content-box}input[type="search"]::-webkit-search-cancel-button,input[type="search"]::-webkit-search-decoration{-webkit-appearance:none}fieldset{border:1px solid #c0c0c0;margin:0 2px;padding:0.35em 0.625em 0.75em}legend{border:0;padding:0}textarea{overflow:auto}optgroup{font-weight:bold}table{border-collapse:collapse;border-spacing:0}td,th{padding:0}*{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}*:before,*:after{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}html{font-size:62.5%;-webkit-tap-highlight-color:rgba(0,0,0,0)}body{font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:14px;line-height:1.42857143;color:#333;background-color:#fff}input,button,select,textarea{font-family:inherit;font-size:inherit;line-height:inherit}a{color:#428bca;text-decoration:none}a:hover,a:focus{color:#2a6496;text-decoration:underline}a:focus{outline:thin dotted;outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}figure{margin:0}img{vertical-align:middle}.img-responsive{display:block;max-width:100%;height:auto}.img-rounded{border-radius:6px}.img-thumbnail{padding:4px;line-height:1.42857143;background-color:#fff;border:1px solid #ddd;border-radius:4px;-webkit-transition:all .2s ease-in-out;transition:all .2s ease-in-out;display:inline-block;max-width:100%;height:auto}.img-circle{border-radius:50%}hr{margin-top:20px;margin-bottom:20px;border:0;border-top:1px solid #eee}.sr-only{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0, 0, 0, 0);border:0}.btn{display:inline-block;margin-bottom:0;font-weight:normal;text-align:center;vertical-align:middle;cursor:pointer;background-image:none;border:1px solid transparent;white-space:nowrap;padding:6px 12px;font-size:14px;line-height:1.42857143;border-radius:4px;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.btn:focus,.btn:active:focus,.btn.active:focus{outline:thin dotted;outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}.btn:hover,.btn:focus{color:#333;text-decoration:none}.btn:active,.btn.active{outline:0;background-image:none;-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,0.125);box-shadow:inset 0 3px 5px rgba(0,0,0,0.125)}.btn.disabled,.btn[disabled],fieldset[disabled] .btn{cursor:not-allowed;pointer-events:none;opacity:.65;filter:alpha(opacity=65);-webkit-box-shadow:none;box-shadow:none}.btn-default{color:#333;background-color:#fff;border-color:#ccc}.btn-default:hover,.btn-default:focus,.btn-default:active,.btn-default.active,.open .dropdown-toggle.btn-default{color:#333;background-color:#ebebeb;border-color:#adadad}.btn-default:active,.btn-default.active,.open .dropdown-toggle.btn-default{background-image:none}.btn-default.disabled,.btn-default[disabled],fieldset[disabled] .btn-default,.btn-default.disabled:hover,.btn-default[disabled]:hover,fieldset[disabled] .btn-default:hover,.btn-default.disabled:focus,.btn-default[disabled]:focus,fieldset[disabled] .btn-default:focus,.btn-default.disabled:active,.btn-default[disabled]:active,fieldset[disabled] .btn-default:active,.btn-default.disabled.active,.btn-default[disabled].active,fieldset[disabled] .btn-default.active{background-color:#fff;border-color:#ccc}.btn-default .badge{color:#fff;background-color:#333}.btn-primary{color:#fff;background-color:#428bca;border-color:#357ebd}.btn-primary:hover,.btn-primary:focus,.btn-primary:active,.btn-primary.active,.open .dropdown-toggle.btn-primary{color:#fff;background-color:#3276b1;border-color:#285e8e}.btn-primary:active,.btn-primary.active,.open .dropdown-toggle.btn-primary{background-image:none}.btn-primary.disabled,.btn-primary[disabled],fieldset[disabled] .btn-primary,.btn-primary.disabled:hover,.btn-primary[disabled]:hover,fieldset[disabled] .btn-primary:hover,.btn-primary.disabled:focus,.btn-primary[disabled]:focus,fieldset[disabled] .btn-primary:focus,.btn-primary.disabled:active,.btn-primary[disabled]:active,fieldset[disabled] .btn-primary:active,.btn-primary.disabled.active,.btn-primary[disabled].active,fieldset[disabled] .btn-primary.active{background-color:#428bca;border-color:#357ebd}.btn-primary .badge{color:#428bca;background-color:#fff}.btn-success{color:#fff;background-color:#5cb85c;border-color:#4cae4c}.btn-success:hover,.btn-success:focus,.btn-success:active,.btn-success.active,.open .dropdown-toggle.btn-success{color:#fff;background-color:#47a447;border-color:#398439}.btn-success:active,.btn-success.active,.open .dropdown-toggle.btn-success{background-image:none}.btn-success.disabled,.btn-success[disabled],fieldset[disabled] .btn-success,.btn-success.disabled:hover,.btn-success[disabled]:hover,fieldset[disabled] .btn-success:hover,.btn-success.disabled:focus,.btn-success[disabled]:focus,fieldset[disabled] .btn-success:focus,.btn-success.disabled:active,.btn-success[disabled]:active,fieldset[disabled] .btn-success:active,.btn-success.disabled.active,.btn-success[disabled].active,fieldset[disabled] .btn-success.active{background-color:#5cb85c;border-color:#4cae4c}.btn-success .badge{color:#5cb85c;background-color:#fff}.btn-info{color:#fff;background-color:#5bc0de;border-color:#46b8da}.btn-info:hover,.btn-info:focus,.btn-info:active,.btn-info.active,.open .dropdown-toggle.btn-info{color:#fff;background-color:#39b3d7;border-color:#269abc}.btn-info:active,.btn-info.active,.open .dropdown-toggle.btn-info{background-image:none}.btn-info.disabled,.btn-info[disabled],fieldset[disabled] .btn-info,.btn-info.disabled:hover,.btn-info[disabled]:hover,fieldset[disabled] .btn-info:hover,.btn-info.disabled:focus,.btn-info[disabled]:focus,fieldset[disabled] .btn-info:focus,.btn-info.disabled:active,.btn-info[disabled]:active,fieldset[disabled] .btn-info:active,.btn-info.disabled.active,.btn-info[disabled].active,fieldset[disabled] .btn-info.active{background-color:#5bc0de;border-color:#46b8da}.btn-info .badge{color:#5bc0de;background-color:#fff}.btn-warning{color:#fff;background-color:#f0ad4e;border-color:#eea236}.btn-warning:hover,.btn-warning:focus,.btn-warning:active,.btn-warning.active,.open .dropdown-toggle.btn-warning{color:#fff;background-color:#ed9c28;border-color:#d58512}.btn-warning:active,.btn-warning.active,.open .dropdown-toggle.btn-warning{background-image:none}.btn-warning.disabled,.btn-warning[disabled],fieldset[disabled] .btn-warning,.btn-warning.disabled:hover,.btn-warning[disabled]:hover,fieldset[disabled] .btn-warning:hover,.btn-warning.disabled:focus,.btn-warning[disabled]:focus,fieldset[disabled] .btn-warning:focus,.btn-warning.disabled:active,.btn-warning[disabled]:active,fieldset[disabled] .btn-warning:active,.btn-warning.disabled.active,.btn-warning[disabled].active,fieldset[disabled] .btn-warning.active{background-color:#f0ad4e;border-color:#eea236}.btn-warning .badge{color:#f0ad4e;background-color:#fff}.btn-danger{color:#fff;background-color:#d9534f;border-color:#d43f3a}.btn-danger:hover,.btn-danger:focus,.btn-danger:active,.btn-danger.active,.open .dropdown-toggle.btn-danger{color:#fff;background-color:#d2322d;border-color:#ac2925}.btn-danger:active,.btn-danger.active,.open .dropdown-toggle.btn-danger{background-image:none}.btn-danger.disabled,.btn-danger[disabled],fieldset[disabled] .btn-danger,.btn-danger.disabled:hover,.btn-danger[disabled]:hover,fieldset[disabled] .btn-danger:hover,.btn-danger.disabled:focus,.btn-danger[disabled]:focus,fieldset[disabled] .btn-danger:focus,.btn-danger.disabled:active,.btn-danger[disabled]:active,fieldset[disabled] .btn-danger:active,.btn-danger.disabled.active,.btn-danger[disabled].active,fieldset[disabled] .btn-danger.active{background-color:#d9534f;border-color:#d43f3a}.btn-danger .badge{color:#d9534f;background-color:#fff}.btn-link{color:#428bca;font-weight:normal;cursor:pointer;border-radius:0}.btn-link,.btn-link:active,.btn-link[disabled],fieldset[disabled] .btn-link{background-color:transparent;-webkit-box-shadow:none;box-shadow:none}.btn-link,.btn-link:hover,.btn-link:focus,.btn-link:active{border-color:transparent}.btn-link:hover,.btn-link:focus{color:#2a6496;text-decoration:underline;background-color:transparent}.btn-link[disabled]:hover,fieldset[disabled] .btn-link:hover,.btn-link[disabled]:focus,fieldset[disabled] .btn-link:focus{color:#999;text-decoration:none}.btn-lg{padding:10px 16px;font-size:18px;line-height:1.33;border-radius:6px}.btn-sm{padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}.btn-xs{padding:1px 5px;font-size:12px;line-height:1.5;border-radius:3px}.btn-block{display:block;width:100%;padding-left:0;padding-right:0}.btn-block+.btn-block{margin-top:5px}input[type="submit"].btn-block,input[type="reset"].btn-block,input[type="button"].btn-block{width:100%} -------------------------------------------------------------------------------- /public/stylesheets/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | } 5 | 6 | .header { 7 | position: relative; 8 | margin: 0; 9 | right: 0; 10 | left: 0; 11 | padding: 0 4%; 12 | top: 0; 13 | background-color: rgba(116, 116, 116, 1); 14 | color: #eee; 15 | height: 50px; 16 | line-height: 50px; 17 | width: 100%; 18 | } 19 | 20 | .header span { 21 | font-size: 30px; 22 | font-weight: bold; 23 | } 24 | 25 | .header img { 26 | height: 45px; 27 | } 28 | 29 | .header ul { 30 | right: 7%; 31 | } 32 | 33 | .header ul, .header li, .header a { 34 | position: relative; 35 | display: inline; 36 | float: right; 37 | color: #ddd; 38 | margin: 0; 39 | padding: 0; 40 | outline: none; 41 | } 42 | 43 | a:hover { 44 | color: #fff; 45 | text-decoration: none; 46 | } 47 | 48 | .header a:visited, a:active, a:link { 49 | color: #ddd; 50 | text-decoration: none; 51 | } 52 | 53 | .run { 54 | display: block; 55 | float: right; 56 | margin: 20px 0; 57 | } 58 | 59 | .container { 60 | text-align: center; 61 | } 62 | 63 | .main { 64 | display: inline-block; 65 | margin: 0; 66 | padding: 0px 20px 0 20px; 67 | border: 0; 68 | } 69 | 70 | .external-js { 71 | border-style: solid; 72 | border-width: 1px; 73 | color: #dcdcdc; 74 | font-size: 10px; 75 | padding: 2px; 76 | margin-right: 2px; 77 | font-family: monospace; 78 | } 79 | 80 | #player-wrapper { 81 | min-width: 320px; 82 | min-height: 180px; 83 | } 84 | 85 | .sidebar { 86 | display: inline-block; 87 | text-align: left; 88 | width: 680px; 89 | margin: 0; 90 | padding: 15px 20px 0 20px; 91 | border: 0; 92 | } 93 | 94 | #editor { 95 | border: 1px solid #c0c0EE; 96 | min-height: 230px; 97 | } 98 | 99 | #console { 100 | position: relative; 101 | color: red; 102 | left: 2%; 103 | top: 65px; 104 | } 105 | 106 | .btn:focus { outline: none; } 107 | 108 | .player { 109 | display: inline-block; 110 | margin: 0 auto; 111 | height: auto; 112 | width: auto; 113 | } 114 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import alias from '@rollup/plugin-alias' 2 | import babel from '@rollup/plugin-babel' 3 | import commonjs from '@rollup/plugin-commonjs' 4 | import jsonReader from '@rollup/plugin-json' 5 | import replace from '@rollup/plugin-replace' 6 | import resolve from '@rollup/plugin-node-resolve' 7 | import html from 'rollup-plugin-html' 8 | import namedDirectory from 'rollup-plugin-named-directory' 9 | import postcss from 'rollup-plugin-postcss' 10 | import livereload from 'rollup-plugin-livereload' 11 | import serve from 'rollup-plugin-serve' 12 | import filesize from 'rollup-plugin-filesize' 13 | import size from 'rollup-plugin-sizes' 14 | const { visualizer } = require('rollup-plugin-visualizer') 15 | import { terser } from 'rollup-plugin-terser' 16 | import pkg from './package.json' 17 | 18 | const dev = !!process.env.DEV 19 | const analyzeBundle = !!process.env.ANALYZE_BUNDLE 20 | const minimize = !!process.env.MINIMIZE 21 | 22 | const postcssOptions = { 23 | use: [ 24 | ['sass', { 25 | includePaths: ['src/base/scss'] 26 | }] 27 | ], 28 | inject: false, 29 | } 30 | const aliasPluginOptions = { entries: { 'clappr-zepto': 'node_modules/clappr-zepto/zepto.js', '@': __dirname + '/src' } } 31 | const replacePluginOptions = { VERSION: JSON.stringify(pkg.version), preventAssignment: true } 32 | const babelPluginOptions = { babelHelpers: 'bundled', exclude: 'node_modules/**' } 33 | const servePluginOptions = { contentBase: ['dist', 'public'], host: '0.0.0.0', port: '8080' } 34 | const livereloadPluginOptions = { watch: ['dist', 'public'] } 35 | const visualizePluginOptions = { open: true, filename: './public/stats.html' } 36 | 37 | const plugins = [ 38 | jsonReader(), 39 | alias(aliasPluginOptions), 40 | replace(replacePluginOptions), 41 | resolve(), 42 | commonjs(), 43 | babel(babelPluginOptions), 44 | namedDirectory(), 45 | html(), 46 | postcss(postcssOptions), 47 | size(), 48 | filesize(), 49 | dev && serve(servePluginOptions), 50 | dev && livereload(livereloadPluginOptions), 51 | analyzeBundle && visualizer(visualizePluginOptions) 52 | ] 53 | 54 | const mainBundle = { 55 | input: 'src/main.js', 56 | output: [ 57 | { 58 | exports: 'named', 59 | name: 'Clappr', 60 | file: pkg.main, 61 | format: 'umd', 62 | sourcemap: true, 63 | }, 64 | minimize && { 65 | exports: 'named', 66 | name: 'Clappr', 67 | file: 'dist/clappr-core.min.js', 68 | format: 'iife', 69 | sourcemap: true, 70 | plugins: terser(), 71 | } 72 | ], 73 | plugins, 74 | } 75 | 76 | const esmBundle = { 77 | input: 'src/main.js', 78 | output: { 79 | exports: 'named', 80 | name: 'Clappr', 81 | file: pkg.module, 82 | format: 'esm', 83 | }, 84 | plugins, 85 | } 86 | 87 | export default [mainBundle, esmBundle] 88 | -------------------------------------------------------------------------------- /src/__mocks__/htmlMock.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | module.exports = { 3 | process(sourceText) { 4 | return { 5 | code: `module.exports = ${JSON.stringify(sourceText)};`, 6 | } 7 | }, 8 | } -------------------------------------------------------------------------------- /src/__mocks__/styleMock.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | module.exports = {} -------------------------------------------------------------------------------- /src/base/adaptive_playback/adaptive_playback.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable getter-return */ 2 | import Playback from '@/base/playback' 3 | 4 | /** 5 | * @typedef {Function} AdaptiveMediaActivatorFunction 6 | * @function 7 | * @param {Boolean} scheduleActivity Enable/disable activity, switch to a different media representation 8 | * @param {Boolean} immediateFlush (default = false) Immediate switching, flushes playout buffer 9 | * @returns {Boolean} Whether the switch request could be processed 10 | * 11 | */ 12 | 13 | /** 14 | * Video quality level description. 15 | * In a set of quality levels, there should be exactly one of these objects representing each 16 | * available quality at a given moment. 17 | * @typedef {Object} VideoQualityLevel 18 | * @class 19 | * @property {String} id 20 | * @property {Boolean} active 21 | * @property {String} language 22 | * @property {Number} width pixel 23 | * @property {Number} height pixels 24 | * @property {Number} bitrate bits/s 25 | * @property {String} codec 26 | * @member {AdaptiveMediaActivatorFunction} setActive 27 | * 28 | */ 29 | 30 | /** 31 | * Audio option available. 32 | * @typedef {Object} AudioOption 33 | * @class 34 | * @property {String} id 35 | * @property {Boolean} active 36 | * @property {Number} volume 37 | * @property {String} language 38 | * @property {String} codec 39 | * @property {Number} channels 40 | * @property {String[]} roles 41 | * @member {AdaptiveMediaActivatorFunction} setActive 42 | */ 43 | 44 | /** 45 | * Closed caption option available. 46 | * @typedef ClosedCaptionOption 47 | * @class 48 | * @property {String} id 49 | * @property {Boolean} active 50 | * @property {String} language 51 | * @property {String[]} roles 52 | * @member {AdaptiveMediaActivatorFunction} setActive 53 | */ 54 | 55 | export default class AdaptivePlayback extends Playback { 56 | 57 | /** 58 | * @returns {Boolean} 59 | */ 60 | get isAdaptive() { 61 | return true 62 | } 63 | 64 | /** 65 | * @param {Boolean} enabled 66 | */ 67 | set isAutoAdaptive(enabled) {} 68 | 69 | /** 70 | * @returns {Boolean} 71 | */ 72 | get isAutoAdaptive() { return false } 73 | 74 | /** 75 | * @returns {VideoQualityLevel[]} 76 | */ 77 | get activeVideoQualityLevels() {} 78 | 79 | /** 80 | * @returns {VideoQualityLevel[]} 81 | */ 82 | get videoQualityLevels() {} 83 | 84 | /** 85 | * @returns {AudioOption[]} 86 | */ 87 | get availableAudioOptions() {} 88 | 89 | /** 90 | * @returns {AudioOption[]} 91 | */ 92 | get audioOptions() {} 93 | 94 | /** 95 | * @returns {ClosedCaptionOption[]} 96 | */ 97 | get availableClosedCaptions() {} 98 | 99 | /** 100 | * @returns {ClosedCaptionOption[]} 101 | */ 102 | get closedCaptions() {} 103 | } 104 | -------------------------------------------------------------------------------- /src/base/adaptive_playback/adaptive_playback.test.js: -------------------------------------------------------------------------------- 1 | import AdaptivePlayback from './adaptive_playback' 2 | 3 | const getProperty = (obj, prop) => { 4 | return Object.getOwnPropertyDescriptor(Object.getPrototypeOf(obj), prop) 5 | } 6 | 7 | const isSetterProperty = (obj, prop) => { 8 | return getProperty(obj, prop).set 9 | } 10 | 11 | const isGetterProperty = (obj, prop) => { 12 | return getProperty(obj, prop).get 13 | } 14 | 15 | describe('AdaptivePlayback', () => { 16 | describe('have a getter', function() { 17 | let playback 18 | beforeEach(() => { 19 | playback = new AdaptivePlayback() 20 | }) 21 | 22 | test('called isAdaptive', () => { 23 | expect(isGetterProperty(playback, 'isAdaptive')).toBeTruthy() 24 | expect(isSetterProperty(playback, 'isAdaptive')).toBeFalsy() 25 | }) 26 | 27 | test('called isAutoAdaptive and a setter too', () => { 28 | expect(isGetterProperty(playback, 'isAutoAdaptive')).toBeTruthy() 29 | expect(isSetterProperty(playback, 'isAutoAdaptive')).toBeTruthy() 30 | }) 31 | 32 | test('called activeVideoQualityLevels', () => { 33 | expect(isGetterProperty(playback, 'activeVideoQualityLevels')).toBeTruthy() 34 | expect(isSetterProperty(playback, 'activeVideoQualityLevels')).toBeFalsy() 35 | }) 36 | 37 | test('called videoQualityLevels', () => { 38 | expect(isGetterProperty(playback, 'videoQualityLevels')).toBeTruthy() 39 | expect(isSetterProperty(playback, 'videoQualityLevels')).toBeFalsy() 40 | }) 41 | 42 | test('called availableAudioOptions', () => { 43 | expect(isGetterProperty(playback, 'availableAudioOptions')).toBeTruthy() 44 | expect(isSetterProperty(playback, 'availableAudioOptions')).toBeFalsy() 45 | }) 46 | 47 | test('called audioOptions', () => { 48 | expect(isGetterProperty(playback, 'audioOptions')).toBeTruthy() 49 | expect(isSetterProperty(playback, 'audioOptions')).toBeFalsy() 50 | }) 51 | 52 | test('called availableClosedCaptions', () => { 53 | expect(isGetterProperty(playback, 'availableClosedCaptions')).toBeTruthy() 54 | expect(isSetterProperty(playback, 'availableClosedCaptions')).toBeFalsy() 55 | }) 56 | 57 | test('called closedCaptions', () => { 58 | expect(isGetterProperty(playback, 'closedCaptions')).toBeTruthy() 59 | expect(isSetterProperty(playback, 'closedCaptions')).toBeFalsy() 60 | }) 61 | }) 62 | 63 | test('isAdaptive getter returns default value', () => { 64 | const playback = new AdaptivePlayback() 65 | 66 | expect(playback.isAdaptive).toBeTruthy() 67 | }) 68 | 69 | test('isAutoAdaptive getter returns default value', () => { 70 | const playback = new AdaptivePlayback() 71 | 72 | expect(playback.isAutoAdaptive).toBeFalsy() 73 | }) 74 | }) 75 | -------------------------------------------------------------------------------- /src/base/base_object/base_object.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 { uniqueId } from '@/utils' 6 | import Events from '@/base/events' 7 | 8 | /** 9 | * @class BaseObject 10 | * @constructor 11 | * @extends Events 12 | * @module base 13 | */ 14 | export default class BaseObject extends Events { 15 | /** 16 | * returns the object options 17 | * @property options 18 | * @type Object 19 | */ 20 | get options() { return this._options } 21 | 22 | /** 23 | * @method constructor 24 | * @param {Object} options 25 | */ 26 | constructor(options={}) { 27 | super(options) 28 | this._options = options 29 | this.uniqueId = uniqueId('o') 30 | } 31 | /** 32 | * a unique id prefixed with `'o'`, `o1, o232` 33 | * 34 | * @property uniqueId 35 | * @type String 36 | */ 37 | } 38 | -------------------------------------------------------------------------------- /src/base/base_object/base_object.test.js: -------------------------------------------------------------------------------- 1 | import BaseObject from './base_object' 2 | 3 | describe('BaseObject', () => { 4 | test('has a getter that returns the set of options', () => { 5 | const options = { clappr: 'is awesome!' } 6 | const baseObject = new BaseObject(options) 7 | 8 | expect(baseObject.options).toEqual(options) 9 | }) 10 | 11 | test('has unique id', () => { 12 | const baseObject = new BaseObject() 13 | const baseObject2 = new BaseObject() 14 | 15 | expect(baseObject.uniqueId).toEqual('o2') 16 | expect(baseObject2.uniqueId).toEqual('o3') 17 | }) 18 | }) 19 | -------------------------------------------------------------------------------- /src/base/container_plugin/container_plugin.js: -------------------------------------------------------------------------------- 1 | import BaseObject from '@/base/base_object' 2 | import ErrorMixin from '@/base/error_mixin' 3 | import { extend } from '@/utils' 4 | 5 | /** 6 | * The base class for a container plugin 7 | * @class ContainerPlugin 8 | * @constructor 9 | * @extends BaseObject 10 | * @module base 11 | */ 12 | export default class ContainerPlugin extends BaseObject { 13 | get playerError() { return this.container.playerError } 14 | 15 | constructor(container) { 16 | super(container.options) 17 | this.container = container 18 | this.enabled = true 19 | this.bindEvents() 20 | } 21 | 22 | enable() { 23 | if (!this.enabled) { 24 | this.bindEvents() 25 | this.enabled = true 26 | } 27 | } 28 | 29 | disable() { 30 | if (this.enabled) { 31 | this.stopListening() 32 | this.enabled = false 33 | } 34 | } 35 | 36 | bindEvents() {} 37 | 38 | destroy() { 39 | this.stopListening() 40 | } 41 | } 42 | 43 | Object.assign(ContainerPlugin.prototype, ErrorMixin) 44 | 45 | ContainerPlugin.extend = function(properties) { 46 | return extend(ContainerPlugin, properties) 47 | } 48 | 49 | ContainerPlugin.type = 'container' 50 | -------------------------------------------------------------------------------- /src/base/container_plugin/container_plugin.test.js: -------------------------------------------------------------------------------- 1 | import ContainerPlugin from './container_plugin' 2 | import ErrorMixin from '@/base/error_mixin' 3 | 4 | describe('Container Plugin', () => { 5 | describe('#constructor', () => { 6 | test('enables the plugin', () => { 7 | const plugin = new ContainerPlugin({}) 8 | 9 | expect(plugin.enabled).toBeTruthy() 10 | }) 11 | 12 | test('binds all events', () => { 13 | let bind = false 14 | const Plugin = class MyPlugin extends ContainerPlugin { 15 | bindEvents() { 16 | bind = true 17 | } 18 | } 19 | 20 | new Plugin({}) 21 | 22 | expect(bind).toBeTruthy() 23 | }) 24 | }) 25 | 26 | test('can be disabled after your creation', () => { 27 | const plugin = new ContainerPlugin({}) 28 | 29 | plugin.disable() 30 | 31 | expect(plugin.enabled).toBeFalsy() 32 | }) 33 | 34 | test('can be enabled after your creation', () => { 35 | const plugin = new ContainerPlugin({}) 36 | 37 | plugin.disable() 38 | 39 | expect(plugin.enabled).toBeFalsy() 40 | 41 | plugin.enable() 42 | 43 | expect(plugin.enabled).toBeTruthy() 44 | }) 45 | 46 | test('receives createdError method from ErrorMixin', () => { 47 | const plugin = new ContainerPlugin({}) 48 | 49 | expect(plugin.createError).not.toBeUndefined() 50 | expect(plugin.createError).toEqual(ErrorMixin.createError) 51 | }) 52 | 53 | test('stops listening when disable an enabled plugin', () => { 54 | const plugin = new ContainerPlugin({}) 55 | const spy = jest.spyOn(plugin, 'stopListening') 56 | 57 | plugin.disable() 58 | 59 | expect(spy).toHaveBeenCalledTimes(1) 60 | }) 61 | 62 | test('doesn\'t stops listening when disable a disabled plugin', () => { 63 | const plugin = new ContainerPlugin({}) 64 | const spy = jest.spyOn(plugin, 'stopListening') 65 | 66 | plugin.enabled = false 67 | plugin.disable() 68 | 69 | expect(spy).not.toHaveBeenCalled() 70 | }) 71 | 72 | test('stops listening when destroyed', () => { 73 | const plugin = new ContainerPlugin({}) 74 | const spy = jest.spyOn(plugin, 'stopListening') 75 | 76 | plugin.destroy() 77 | 78 | expect(spy).toHaveBeenCalledTimes(1) 79 | }) 80 | 81 | test('binds events once', () => { 82 | const plugin = new ContainerPlugin({}) 83 | const spy = jest.spyOn(plugin, 'bindEvents') 84 | 85 | plugin.enable() 86 | plugin.enable() 87 | plugin.enable() 88 | 89 | expect(spy).not.toHaveBeenCalled() 90 | }) 91 | 92 | test('can be created via extends method', () => { 93 | const plugin = ContainerPlugin.extend({ name: 'test_plugin' }) 94 | 95 | expect(plugin.prototype instanceof ContainerPlugin).toBeTruthy() 96 | }) 97 | }) 98 | -------------------------------------------------------------------------------- /src/base/core_plugin/core_plugin.js: -------------------------------------------------------------------------------- 1 | import { extend } from '@//utils' 2 | import BaseObject from '@/base/base_object' 3 | import ErrorMixin from '@/base/error_mixin' 4 | 5 | export default class CorePlugin extends BaseObject { 6 | get playerError() { return this.core.playerError } 7 | 8 | constructor(core) { 9 | super(core.options) 10 | this.core = core 11 | this.enabled = true 12 | this.bindEvents() 13 | } 14 | 15 | bindEvents() {} 16 | 17 | enable() { 18 | if (!this.enabled) { 19 | this.bindEvents() 20 | this.enabled = true 21 | } 22 | } 23 | 24 | disable() { 25 | if (this.enabled) { 26 | this.stopListening() 27 | this.enabled = false 28 | } 29 | } 30 | 31 | getExternalInterface() { return {} } 32 | 33 | destroy() { 34 | this.stopListening() 35 | } 36 | } 37 | 38 | Object.assign(CorePlugin.prototype, ErrorMixin) 39 | 40 | CorePlugin.extend = function(properties) { 41 | return extend(CorePlugin, properties) 42 | } 43 | 44 | CorePlugin.type = 'core' 45 | -------------------------------------------------------------------------------- /src/base/core_plugin/core_plugin.test.js: -------------------------------------------------------------------------------- 1 | import CorePlugin from './core_plugin' 2 | import ErrorMixin from '@/base/error_mixin' 3 | import Player from '@/components/player' 4 | 5 | describe('Core Plugin', () => { 6 | describe('#constructor', () => { 7 | test('enables the plugin', () => { 8 | const plugin = new CorePlugin({}) 9 | 10 | expect(plugin.enabled).toBeTruthy() 11 | }) 12 | 13 | test('binds all events', () => { 14 | let bind = false 15 | const Plugin = class MyPlugin extends CorePlugin { 16 | bindEvents() { 17 | bind = true 18 | } 19 | } 20 | 21 | new Plugin({}) 22 | 23 | expect(bind).toBeTruthy() 24 | }) 25 | }) 26 | 27 | test('can be disabled after your creation', () => { 28 | const plugin = new CorePlugin({}) 29 | 30 | plugin.disable() 31 | 32 | expect(plugin.enabled).toBeFalsy() 33 | }) 34 | 35 | test('can be enabled after your creation', () => { 36 | const plugin = new CorePlugin({}) 37 | 38 | plugin.disable() 39 | 40 | expect(plugin.enabled).toBeFalsy() 41 | 42 | plugin.enable() 43 | 44 | expect(plugin.enabled).toBeTruthy() 45 | }) 46 | 47 | test('receives createdError method from ErrorMixin', () => { 48 | const plugin = new CorePlugin({}) 49 | 50 | expect(plugin.createError).not.toBeUndefined() 51 | expect(plugin.createError).toEqual(ErrorMixin.createError) 52 | }) 53 | 54 | test('stops listening when disable an enabled plugin', () => { 55 | const plugin = new CorePlugin({}) 56 | const spy = jest.spyOn(plugin, 'stopListening') 57 | 58 | plugin.disable() 59 | 60 | expect(spy).toHaveBeenCalledTimes(1) 61 | }) 62 | 63 | test('doesn\'t stops listening when disable a disabled plugin', () => { 64 | const plugin = new CorePlugin({}) 65 | const spy = jest.spyOn(plugin, 'stopListening') 66 | 67 | plugin.enabled = false 68 | plugin.disable() 69 | 70 | expect(spy).not.toHaveBeenCalled() 71 | }) 72 | 73 | test('stops listening when destroyed', () => { 74 | const plugin = new CorePlugin({}) 75 | const spy = jest.spyOn(plugin, 'stopListening') 76 | 77 | plugin.destroy() 78 | 79 | expect(spy).toHaveBeenCalled() 80 | }) 81 | 82 | test('binds events once', () => { 83 | const plugin = new CorePlugin({}) 84 | const spy = jest.spyOn(plugin, 'bindEvents') 85 | 86 | plugin.enable() 87 | plugin.enable() 88 | plugin.enable() 89 | 90 | expect(spy).not.toHaveBeenCalled() 91 | }) 92 | 93 | test('can be created via extends method', () => { 94 | const plugin = CorePlugin.extend({ name: 'test_plugin' }) 95 | 96 | expect(plugin.prototype instanceof CorePlugin).toBeTruthy() 97 | }) 98 | 99 | test('exposes interfaces for player scope', () => { 100 | const pluginInterface = () => 'This is awesome!' 101 | 102 | const Plugin = class MyPlugin extends CorePlugin { 103 | getExternalInterface() { 104 | return { pluginInterface } 105 | } 106 | } 107 | 108 | const plugin = new Plugin({}) 109 | const player = new Player({}) 110 | 111 | player._coreFactory.setupExternalInterface(plugin) 112 | 113 | expect(player.pluginInterface()).toEqual('This is awesome!') 114 | }) 115 | 116 | test('has a default value for getExternalInterface method', () => { 117 | const plugin = new CorePlugin({}) 118 | 119 | expect(plugin.getExternalInterface()).toEqual({}) 120 | }) 121 | }) 122 | -------------------------------------------------------------------------------- /src/base/error_mixin/error_mixin.js: -------------------------------------------------------------------------------- 1 | import Log from '@/components/log' 2 | import PlayerError from '@/components/error' 3 | 4 | const ErrorMixin = { 5 | /** 6 | * creates an error. 7 | * @method createError 8 | * @param {Object} error should be an object with code, description, level and raw error. 9 | * @return {Object} Object with formatted error data including origin and scope 10 | */ 11 | createError(error, options = { useCodePrefix: true }) { 12 | const scope = this.constructor && this.constructor.type || '' 13 | const origin = this.name || scope 14 | const i18n = this.i18n || this.core && this.core.i18n || this.container && this.container.i18n 15 | 16 | const prefixedCode = `${origin}:${error && error.code || 'unknown'}` 17 | const defaultError = { 18 | description: '', 19 | level: PlayerError.Levels.FATAL, 20 | origin, 21 | scope, 22 | raw: {}, 23 | } 24 | 25 | const errorData = Object.assign({}, defaultError, error, { 26 | code: options.useCodePrefix ? prefixedCode : error.code 27 | }) 28 | 29 | if (i18n && errorData.level == PlayerError.Levels.FATAL && !errorData.UI) { 30 | const defaultUI = { 31 | title: i18n.t('default_error_title'), 32 | message: i18n.t('default_error_message') 33 | } 34 | errorData.UI = defaultUI 35 | } 36 | 37 | this.playerError 38 | ? this.playerError.createError(errorData) 39 | : Log.warn(origin, 'PlayerError is not defined. Error: ', errorData) 40 | 41 | return errorData 42 | } 43 | } 44 | 45 | export default ErrorMixin 46 | -------------------------------------------------------------------------------- /src/base/error_mixin/error_mixin.test.js: -------------------------------------------------------------------------------- 1 | import ErrorMixin from './error_mixin' 2 | import PlayerError from '@/components/error' 3 | import CorePlugin from '@/base/core_plugin' 4 | import UICorePlugin from '@/base/ui_core_plugin' 5 | import ContainerPlugin from '@/base/container_plugin' 6 | import UIContainerPlugin from '@/base/ui_container_plugin' 7 | import Core from '@/components/core' 8 | import Playback from '@/base/playback' 9 | import Events from '@/base/events' 10 | 11 | describe('ErrorMixin', function() { 12 | let errorExample 13 | beforeEach(() => { 14 | errorExample = { code: 'TEST_ERROR', description: 'A error example.', level: PlayerError.Levels.FATAL } 15 | }) 16 | 17 | test('is used on all plugins base classes', () => { 18 | const plugin1 = new CorePlugin({}) 19 | const plugin2 = new UICorePlugin({}) 20 | const plugin3 = new ContainerPlugin({}) 21 | const plugin4 = new UIContainerPlugin({}) 22 | const plugin5 = new Playback({}) 23 | 24 | expect(plugin1.createError).toEqual(ErrorMixin.createError) 25 | expect(plugin2.createError).toEqual(ErrorMixin.createError) 26 | expect(plugin3.createError).toEqual(ErrorMixin.createError) 27 | expect(plugin4.createError).toEqual(ErrorMixin.createError) 28 | expect(plugin5.createError).toEqual(ErrorMixin.createError) 29 | }) 30 | 31 | describe('creates a error', () => { 32 | 33 | test('with default values', () => { 34 | expect(ErrorMixin.createError(errorExample)).toEqual({ 35 | code: ':TEST_ERROR', 36 | description: 'A error example.', 37 | level: 'FATAL', 38 | origin: '', 39 | raw: {}, 40 | scope: '', 41 | }) 42 | 43 | const err = { description: 'A error example.', level: PlayerError.Levels.FATAL } 44 | 45 | expect(ErrorMixin.createError(err)).toEqual({ 46 | description: 'A error example.', 47 | level: 'FATAL', 48 | origin: '', 49 | scope: '', 50 | raw: {}, 51 | code: ':unknown', 52 | }) 53 | }) 54 | 55 | test('owns option to not manipulate error code', () => { 56 | expect(ErrorMixin.createError(errorExample, { useCodePrefix: false })).toEqual({ 57 | description: 'A error example.', 58 | level: 'FATAL', 59 | origin: '', 60 | scope: '', 61 | raw: {}, 62 | code: 'TEST_ERROR' 63 | }) 64 | }) 65 | 66 | test('needs a scope to generate a more descriptive error', () => { 67 | const playback = new Playback({}) 68 | const plugin1 = new CorePlugin({}) 69 | const plugin2 = new ContainerPlugin({}) 70 | 71 | expect(playback.createError(errorExample)).toEqual({ 72 | description: 'A error example.', 73 | level: 'FATAL', 74 | origin: 'playback', 75 | scope: 'playback', 76 | raw: {}, 77 | code: 'playback:TEST_ERROR' 78 | }) 79 | 80 | expect(plugin1.createError(errorExample)).toEqual({ 81 | description: 'A error example.', 82 | level: 'FATAL', 83 | origin: 'core', 84 | scope: 'core', 85 | raw: {}, 86 | code: 'core:TEST_ERROR' 87 | }) 88 | 89 | expect(plugin2.createError(errorExample)).toEqual({ 90 | description: 'A error example.', 91 | level: 'FATAL', 92 | origin: 'container', 93 | scope: 'container', 94 | raw: {}, 95 | code: 'container:TEST_ERROR' 96 | }) 97 | }) 98 | 99 | test('with default UI for FATAL errors on scopes with i18n configured', () => { 100 | const plugin = new UIContainerPlugin({ 101 | get i18n() { 102 | return { t: (key) => key } 103 | } 104 | }) 105 | 106 | expect(plugin.createError(errorExample)).toEqual({ 107 | description: 'A error example.', 108 | level: 'FATAL', 109 | origin: 'container', 110 | scope: 'container', 111 | raw: {}, 112 | code: 'container:TEST_ERROR', 113 | UI: { title: 'default_error_title', message: 'default_error_message' }, 114 | }) 115 | }) 116 | }) 117 | 118 | test('sends the error for one existing PlayerError instance', () => { 119 | const callback = jest.fn() 120 | const plugin = new UICorePlugin(new Core({})) 121 | 122 | plugin.listenTo(plugin.core, Events.ERROR, callback) 123 | plugin.createError(errorExample) 124 | 125 | expect(callback).toHaveBeenCalledWith({ 126 | description: 'A error example.', 127 | level: 'FATAL', 128 | origin: 'core', 129 | scope: 'core', 130 | raw: {}, 131 | code: 'core:TEST_ERROR', 132 | UI: { title: 'default_error_title', message: 'default_error_message' }, 133 | }) 134 | }) 135 | }) 136 | -------------------------------------------------------------------------------- /src/base/media.js: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | // https://github.com/mathiasbynens/small 3 | export const mp4 = 'data:video/mp4;base64,AAAAHGZ0eXBpc29tAAACAGlzb21pc28ybXA0MQAAAAhmcmVlAAAC721kYXQhEAUgpBv/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA3pwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcCEQBSCkG//AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADengAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcAAAAsJtb292AAAAbG12aGQAAAAAAAAAAAAAAAAAAAPoAAAALwABAAABAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAAAB7HRyYWsAAABcdGtoZAAAAAMAAAAAAAAAAAAAAAIAAAAAAAAALwAAAAAAAAAAAAAAAQEAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAACRlZHRzAAAAHGVsc3QAAAAAAAAAAQAAAC8AAAAAAAEAAAAAAWRtZGlhAAAAIG1kaGQAAAAAAAAAAAAAAAAAAKxEAAAIAFXEAAAAAAAtaGRscgAAAAAAAAAAc291bgAAAAAAAAAAAAAAAFNvdW5kSGFuZGxlcgAAAAEPbWluZgAAABBzbWhkAAAAAAAAAAAAAAAkZGluZgAAABxkcmVmAAAAAAAAAAEAAAAMdXJsIAAAAAEAAADTc3RibAAAAGdzdHNkAAAAAAAAAAEAAABXbXA0YQAAAAAAAAABAAAAAAAAAAAAAgAQAAAAAKxEAAAAAAAzZXNkcwAAAAADgICAIgACAASAgIAUQBUAAAAAAfQAAAHz+QWAgIACEhAGgICAAQIAAAAYc3R0cwAAAAAAAAABAAAAAgAABAAAAAAcc3RzYwAAAAAAAAABAAAAAQAAAAIAAAABAAAAHHN0c3oAAAAAAAAAAAAAAAIAAAFzAAABdAAAABRzdGNvAAAAAAAAAAEAAAAsAAAAYnVkdGEAAABabWV0YQAAAAAAAAAhaGRscgAAAAAAAAAAbWRpcmFwcGwAAAAAAAAAAAAAAAAtaWxzdAAAACWpdG9vAAAAHWRhdGEAAAABAAAAAExhdmY1Ni40MC4xMDE=' 4 | 5 | export default { 6 | mp4: mp4, 7 | } 8 | -------------------------------------------------------------------------------- /src/base/playback/playback.test.js: -------------------------------------------------------------------------------- 1 | import Playback from './playback' 2 | import Core from '@/components/core' 3 | import PlayerError from '@/components/error' 4 | 5 | const getProperty = (obj, prop) => { 6 | return Object.getOwnPropertyDescriptor(Object.getPrototypeOf(obj), prop) 7 | } 8 | 9 | const isGetterProperty = (obj, prop) => { 10 | return getProperty(obj, prop).get 11 | } 12 | 13 | const isSetterProperty = (obj, prop) => { 14 | return getProperty(obj, prop).set 15 | } 16 | 17 | describe('Playback', function() { 18 | let basePlayback 19 | beforeEach(() => { 20 | basePlayback = new Playback({}) 21 | }) 22 | 23 | describe('have a getter', () => { 24 | test('called isAudioOnly', () => { 25 | expect(isGetterProperty(basePlayback, 'isAudioOnly')).toBeTruthy() 26 | expect(isSetterProperty(basePlayback, 'isAudioOnly')).toBeFalsy() 27 | }) 28 | test('called isAdaptive', () => { 29 | expect(isGetterProperty(basePlayback, 'isAdaptive')).toBeTruthy() 30 | expect(isSetterProperty(basePlayback, 'isAdaptive')).toBeFalsy() 31 | }) 32 | test('called ended', () => { 33 | expect(isGetterProperty(basePlayback, 'ended')).toBeTruthy() 34 | expect(isSetterProperty(basePlayback, 'ended')).toBeFalsy() 35 | }) 36 | test('called i18n', () => { 37 | expect(isGetterProperty(basePlayback, 'i18n')).toBeTruthy() 38 | expect(isSetterProperty(basePlayback, 'i18n')).toBeFalsy() 39 | }) 40 | test('called buffering', () => { 41 | expect(isGetterProperty(basePlayback, 'buffering')).toBeTruthy() 42 | expect(isSetterProperty(basePlayback, 'buffering')).toBeFalsy() 43 | }) 44 | test('called isReady', () => { 45 | expect(isGetterProperty(basePlayback, 'isReady')).toBeTruthy() 46 | expect(isSetterProperty(basePlayback, 'isReady')).toBeFalsy() 47 | }) 48 | test('called audioTracks', () => { 49 | expect(isGetterProperty(basePlayback, 'audioTracks')).toBeTruthy() 50 | expect(isSetterProperty(basePlayback, 'audioTracks')).toBeFalsy() 51 | }) 52 | test('called currentAudioTracks', () => { 53 | expect(isGetterProperty(basePlayback, 'currentAudioTrack')).toBeTruthy() 54 | expect(isSetterProperty(basePlayback, 'currentAudioTrack')).toBeFalsy() 55 | }) 56 | test('called hasClosedCaptionsTracks', () => { 57 | expect(isGetterProperty(basePlayback, 'hasClosedCaptionsTracks')).toBeTruthy() 58 | expect(isSetterProperty(basePlayback, 'hasClosedCaptionsTracks')).toBeFalsy() 59 | }) 60 | test('called closedCaptionsTracks', () => { 61 | expect(isGetterProperty(basePlayback, 'closedCaptionsTracks')).toBeTruthy() 62 | expect(isSetterProperty(basePlayback, 'closedCaptionsTracks')).toBeFalsy() 63 | }) 64 | test('called closedCaptionsTrackId and a setter too', () => { 65 | expect(isGetterProperty(basePlayback, 'closedCaptionsTrackId')).toBeTruthy() 66 | expect(isSetterProperty(basePlayback, 'closedCaptionsTrackId')).toBeTruthy() 67 | }) 68 | }) 69 | 70 | test('isAudioOnly getter returns default value', () => { 71 | expect(basePlayback.isAudioOnly).toBeFalsy() 72 | }) 73 | 74 | test('isAdaptive getter returns default value', () => { 75 | expect(basePlayback.isAdaptive).toBeFalsy() 76 | }) 77 | 78 | test('ended getter returns default value', () => { 79 | expect(basePlayback.ended).toBeFalsy() 80 | }) 81 | 82 | test('buffering getter returns default value', () => { 83 | expect(basePlayback.buffering).toBeFalsy() 84 | }) 85 | 86 | test('isReady getter returns default value', () => { 87 | expect(basePlayback.isReady).toBeFalsy() 88 | }) 89 | 90 | test('closedCaptionsTracks getter returns default value', () => { 91 | expect(basePlayback.closedCaptionsTracks).toEqual([]) 92 | }) 93 | 94 | test('hasClosedCaptionsTracks getter returns default value', () => { 95 | expect(basePlayback.hasClosedCaptionsTracks).toBeFalsy() 96 | }) 97 | 98 | test('closedCaptionsTrackId getter returns default value', () => { 99 | expect(basePlayback.closedCaptionsTrackId).toEqual(-1) 100 | }) 101 | 102 | test('audioTracks getter returns default value', () => { 103 | expect(basePlayback.audioTracks).toEqual([]) 104 | }) 105 | 106 | test('currentAudioTrack getter returns default value', () => { 107 | expect(basePlayback.currentAudioTrack).toBeNull() 108 | }) 109 | 110 | test('i18n getter returns default value', () => { 111 | const i18n = { t: (key) => key } 112 | const playback = new Playback({}, i18n) 113 | 114 | expect(playback.i18n).toEqual(i18n) 115 | }) 116 | 117 | test('has default duration', () => { 118 | expect(basePlayback.getDuration()).toEqual(0) 119 | }) 120 | 121 | test('has default getStartTimeOffset', () => { 122 | expect(basePlayback.getStartTimeOffset()).toEqual(0) 123 | }) 124 | 125 | test('has static method to check if playback implementation can play one source', () => { 126 | expect(Playback.canPlay()).toBeFalsy() 127 | }) 128 | 129 | test('is not playing by default', () => { 130 | expect(basePlayback.isPlaying()).toBeFalsy() 131 | }) 132 | 133 | test('is not ready by default', () => { 134 | expect(basePlayback.isReady).toBeFalsy() 135 | }) 136 | 137 | test('has NO_OP as playback type', () => { 138 | expect(basePlayback.getPlaybackType()).toEqual(Playback.NO_OP) 139 | }) 140 | 141 | test('is not on high definition by default', () => { 142 | expect(basePlayback.isHighDefinitionInUse()).toBeFalsy() 143 | }) 144 | 145 | test('can be consented', (done) => { 146 | const callback = jest.fn(() => { 147 | expect(callback).toHaveBeenCalledTimes(1) 148 | done() 149 | }) 150 | basePlayback.consent(callback) 151 | }) 152 | 153 | test('consider auto play is available as default', () => { 154 | const spy = jest.fn() 155 | basePlayback.canAutoPlay(spy) 156 | 157 | expect(spy).toHaveBeenCalledWith(true, null) 158 | }) 159 | 160 | test('can checks if auto play is available', () => { 161 | jest.spyOn(basePlayback, 'play') 162 | basePlayback.attemptAutoPlay() 163 | 164 | expect(basePlayback.play).toHaveBeenCalledTimes(1) 165 | }) 166 | 167 | test('destroys by removing element from DOM', () => { 168 | const spy = jest.fn() 169 | basePlayback.$el = { remove: spy, off: () => {} } 170 | 171 | basePlayback.destroy() 172 | 173 | expect(spy).toHaveBeenCalledTimes(1) 174 | }) 175 | 176 | test('can be configured after your creation', () => { 177 | const newOptions = { test: 'test' } 178 | 179 | basePlayback.configure(newOptions) 180 | 181 | expect(basePlayback.options).toEqual({ ...basePlayback.options, ...newOptions }) 182 | }) 183 | 184 | test('can be created via extends method', () => { 185 | const plugin = Playback.extend({ name: 'test_plugin' }) 186 | 187 | expect(plugin.prototype instanceof Playback).toBeTruthy() 188 | }) 189 | 190 | describe('error', () => { 191 | let defaultError 192 | let core 193 | beforeEach(() => { 194 | core = new Core({}) 195 | basePlayback = new Playback({}, null, core.playerError) 196 | defaultError = { 197 | description: '', 198 | level: PlayerError.Levels.FATAL, 199 | origin: 'playback', 200 | scope: 'playback', 201 | raw: {}, 202 | code: 'playback:unknown', 203 | } 204 | }) 205 | 206 | describe('when no data is given', () => { 207 | 208 | test('creates a default error', () => { 209 | const errorData = basePlayback.createError() 210 | 211 | expect(errorData).toEqual(defaultError) 212 | }) 213 | 214 | test('has default error level equals to FATAL', () => { 215 | const errorData = basePlayback.createError() 216 | 217 | expect(errorData.level).toEqual(PlayerError.Levels.FATAL) 218 | }) 219 | 220 | describe('when i18n is defined', () => { 221 | test('creates a default error with UI data', () => { 222 | const basePlayback = new Playback({}, core.i18n, core.playerError) 223 | const errorData = basePlayback.createError() 224 | const defaultError = { ...defaultError, UI: { title: 'default_error_title', message: 'default_error_message' } } 225 | expect(errorData.UI).toEqual(defaultError.UI) 226 | }) 227 | }) 228 | }) 229 | 230 | describe('when some data is given', () => { 231 | test('creates a code error on the following format: name:code', () => { 232 | const basePlayback = new Playback({}, null, core.playerError) 233 | basePlayback.name = 'test' 234 | const error = { code: '42' } 235 | const errorData = basePlayback.createError(error) 236 | 237 | expect(errorData.code).toEqual(`${basePlayback.name}:${error.code}`) 238 | }) 239 | 240 | test('does not overwrite level when it is not equal to default', () => { 241 | const error = { level: PlayerError.Levels.WARN } 242 | const errorData = basePlayback.createError(error) 243 | 244 | expect(errorData.level).toEqual(PlayerError.Levels.WARN) 245 | }) 246 | 247 | test('does not overwrite code when useCodePrefix is false', () => { 248 | const error = { code: 'MY_CODE' } 249 | const options = { useCodePrefix: false } 250 | const errorData = basePlayback.createError(error, options) 251 | 252 | expect(errorData.code).toEqual(error.code) 253 | }) 254 | 255 | describe('when i18n is defined', () => { 256 | 257 | beforeEach(() => { new Playback({}, core.i18n, core.playerError) }) 258 | 259 | test('does not overwrite UI when it is defined', () => { 260 | const UIData = { title: 'my_title', message: 'my_message' } 261 | const errorData = basePlayback.createError({ UI: UIData }) 262 | expect(errorData.UI).toEqual(UIData) 263 | }) 264 | 265 | test('does not add UI data if level is not FATAL', () => { 266 | const error = { level: PlayerError.Levels.WARN } 267 | const errorData = basePlayback.createError(error) 268 | 269 | expect(errorData.UI).toBeUndefined() 270 | }) 271 | }) 272 | }) 273 | 274 | test('always calls error method to trigger ERROR event', () => { 275 | const spy = jest.spyOn(basePlayback.playerError, 'createError') 276 | basePlayback.createError() 277 | 278 | expect(spy).toHaveBeenCalledWith(defaultError) 279 | }) 280 | }) 281 | }) 282 | -------------------------------------------------------------------------------- /src/base/polyfills.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 | /* istanbul ignore file */ 6 | 7 | /** 8 | * Array.prototype.find 9 | * 10 | * Original source : https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Array/find 11 | * See also : https://tc39.github.io/ecma262/#sec-array.prototype.find 12 | */ 13 | if (!Array.prototype.find) { 14 | // eslint-disable-next-line 15 | Object.defineProperty(Array.prototype, 'find', { 16 | // Note: ES6 arrow function syntax is not used on purpose to avoid this to be undefined 17 | value: function(predicate) { 18 | // 1. Let O be ? ToObject(this value). 19 | if (this == null) 20 | throw new TypeError('"this" is null or not defined') 21 | 22 | 23 | let o = Object(this) 24 | 25 | // 2. Let len be ? ToLength(? Get(O, "length")). 26 | let len = o.length >>> 0 27 | 28 | // 3. If IsCallable(predicate) is false, throw a TypeError exception. 29 | if (typeof predicate !== 'function') 30 | throw new TypeError('predicate must be a function') 31 | 32 | 33 | // 4. If thisArg was supplied, let T be thisArg; else let T be undefined. 34 | let thisArg = arguments[1] 35 | 36 | // 5. Let k be 0. 37 | let k = 0 38 | 39 | // 6. Repeat, while k < len 40 | while (k < len) { 41 | // a. Let Pk be ! ToString(k). 42 | // b. Let kValue be ? Get(O, Pk). 43 | // c. Let testResult be ToBoolean(? Call(predicate, T, « kValue, k, O »)). 44 | // d. If testResult is true, return kValue. 45 | let kValue = o[k] 46 | if (predicate.call(thisArg, kValue, k, o)) 47 | return kValue 48 | 49 | // e. Increase k by 1. 50 | k++ 51 | } 52 | 53 | // 7. Return undefined. 54 | return undefined 55 | } 56 | }) 57 | } 58 | 59 | // polyfills for smart TVs 60 | 61 | if (!Object.entries) { 62 | Object.entries = function( obj ) { 63 | let ownProps = Object.keys( obj ), 64 | i = ownProps.length, 65 | resArray = new Array(i) // preallocate the Array 66 | while (i--) 67 | resArray[i] = [ownProps[i], obj[ownProps[i]]] 68 | 69 | return resArray 70 | } 71 | } 72 | 73 | if (!Object.values) { 74 | Object.values = function (obj) { 75 | let ownProps = Object.keys(obj), 76 | i = ownProps.length, 77 | resArray = new Array(i) // preallocate the Array 78 | while (i--) 79 | resArray[i] = obj[ownProps[i]] 80 | 81 | return resArray 82 | } 83 | } 84 | 85 | /** 86 | * Object.assign 87 | * This polyfill doesn't support symbol properties, since ES5 doesn't have symbols anyway 88 | * 89 | * Original source : https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign 90 | */ 91 | if (typeof Object.assign != 'function') { 92 | // Must be writable: true, enumerable: false, configurable: true 93 | Object.defineProperty(Object, 'assign', { 94 | // length of function is 2. 95 | value: function assign(target, varArgs) { //eslint-disable-line no-unused-vars 96 | 'use strict' 97 | if (target == null) { // TypeError if undefined or null 98 | throw new TypeError('Cannot convert undefined or null to object') 99 | } 100 | 101 | let to = Object(target) 102 | 103 | for (let index = 1; index < arguments.length; index++) { 104 | let nextSource = arguments[index] 105 | 106 | if (nextSource != null) { // Skip over if undefined or null 107 | for (let nextKey in nextSource) { 108 | // Avoid bugs when hasOwnProperty is shadowed 109 | if (Object.prototype.hasOwnProperty.call(nextSource, nextKey)) { 110 | to[nextKey] = nextSource[nextKey] 111 | } 112 | } 113 | } 114 | } 115 | return to 116 | }, 117 | writable: true, 118 | configurable: true 119 | }) 120 | } 121 | 122 | // https://tc39.github.io/ecma262/#sec-array.prototype.findindex 123 | if (!Array.prototype.findIndex) { 124 | Object.defineProperty(Array.prototype, 'findIndex', { 125 | value: function(predicate) { 126 | // 1. Let O be ? ToObject(this value). 127 | if (this == null) { 128 | throw new TypeError('"this" is null or not defined'); 129 | } 130 | 131 | var o = Object(this); 132 | 133 | // 2. Let len be ? ToLength(? Get(O, "length")). 134 | var len = o.length >>> 0; 135 | 136 | // 3. If IsCallable(predicate) is false, throw a TypeError exception. 137 | if (typeof predicate !== 'function') { 138 | throw new TypeError('predicate must be a function'); 139 | } 140 | 141 | // 4. If thisArg was supplied, let T be thisArg; else let T be undefined. 142 | var thisArg = arguments[1]; 143 | 144 | // 5. Let k be 0. 145 | var k = 0; 146 | 147 | // 6. Repeat, while k < len 148 | while (k < len) { 149 | // a. Let Pk be ! ToString(k). 150 | // b. Let kValue be ? Get(O, Pk). 151 | // c. Let testResult be ToBoolean(? Call(predicate, T, « kValue, k, O »)). 152 | // d. If testResult is true, return k. 153 | var kValue = o[k]; 154 | if (predicate.call(thisArg, kValue, k, o)) { 155 | return k; 156 | } 157 | // e. Increase k by 1. 158 | k++; 159 | } 160 | 161 | // 7. Return -1. 162 | return -1; 163 | }, 164 | configurable: true, 165 | writable: true 166 | }); 167 | } 168 | -------------------------------------------------------------------------------- /src/base/scss/_fontsmoothing.scss: -------------------------------------------------------------------------------- 1 | @mixin font-smoothing($value: antialiased) { 2 | @if $value == antialiased { 3 | -webkit-font-smoothing: antialiased; 4 | -moz-osx-font-smoothing: grayscale; 5 | } 6 | @else { 7 | -webkit-font-smoothing: subpixel-antialiased; 8 | -moz-osx-font-smoothing: auto; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/base/scss/_noselect.scss: -------------------------------------------------------------------------------- 1 | @mixin no-select { 2 | -webkit-touch-callout: none; 3 | -webkit-user-select: none; 4 | -khtml-user-select: none; 5 | -moz-user-select: none; 6 | -ms-user-select: none; 7 | -o-user-select: none; 8 | user-select: none; 9 | } 10 | -------------------------------------------------------------------------------- /src/base/scss/_reset.scss: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2009-2014 Christopher M. Eppstein 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | // 5 | // The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. No attribution is required by products that make use of this software. 6 | // 7 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | // 9 | // Except as contained in this notice, the name(s) of the above copyright holders shall not be used in advertising or otherwise to promote the sale, use or other dealings in this Software without prior written authorization. 10 | // 11 | // Contributors to this project agree to grant all rights to the copyright holder of the primary product. Attribution is maintained in the source control history of the product. 12 | 13 | // Reset all elements within some selector scope. To reset the selector itself, 14 | // mixin the appropriate reset mixin for that element type as well. This could be 15 | // useful if you want to style a part of your page in a dramatically different way. 16 | @mixin nested-reset { 17 | div, span, applet, object, iframe, 18 | h1, h2, h3, h4, h5, h6, p, blockquote, pre, 19 | a, abbr, acronym, address, big, cite, code, 20 | del, dfn, em, img, ins, kbd, q, s, samp, 21 | small, strike, strong, sub, sup, tt, var, 22 | b, u, i, center, 23 | dl, dt, dd, ol, ul, li, 24 | fieldset, form, label, legend, 25 | table, caption, tbody, tfoot, thead, tr, th, td, 26 | article, aside, canvas, details, embed, 27 | figure, figcaption, footer, header, hgroup, 28 | menu, nav, output, ruby, section, summary, 29 | time, mark, audio, video { 30 | @include reset-box-model; 31 | @include reset-font; } 32 | table { 33 | @include reset-table; } 34 | caption, th, td { 35 | @include reset-table-cell; } 36 | q, blockquote { 37 | @include reset-quotation; } 38 | a img { 39 | @include reset-image-anchor-border; } } 40 | 41 | // Reset the box model measurements. 42 | @mixin reset-box-model { 43 | margin: 0; 44 | padding: 0; 45 | border: 0; } 46 | 47 | // Reset the font and vertical alignment. 48 | @mixin reset-font { 49 | font: inherit; 50 | font-size: 100%; 51 | vertical-align: baseline; } 52 | 53 | // Reset a table 54 | @mixin reset-table { 55 | border-collapse: collapse; 56 | border-spacing: 0; } 57 | 58 | // Reset a table cell (`th`, `td`) 59 | @mixin reset-table-cell { 60 | text-align: left; 61 | font-weight: normal; 62 | vertical-align: middle; } 63 | 64 | // Reset a quotation (`q`, `blockquote`) 65 | @mixin reset-quotation { 66 | quotes: none; 67 | &:before, &:after { 68 | content: ""; 69 | content: none; } } 70 | 71 | // Resets the border. 72 | @mixin reset-image-anchor-border { 73 | border: none; } 74 | -------------------------------------------------------------------------------- /src/base/styler/styler.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 $ from 'clappr-zepto' 6 | import template from '../template' 7 | 8 | const Styler = { 9 | getStyleFor: function(style, options={ baseUrl: '' }) { 10 | return $('').html(template(style.toString())(options)) 11 | } 12 | } 13 | 14 | export default Styler 15 | -------------------------------------------------------------------------------- /src/base/styler/styler.test.js: -------------------------------------------------------------------------------- 1 | import Styler from './styler' 2 | 3 | const TEST_STYLE = '#wp3-player-0 { .test { video { object-fit: cover; } } }' 4 | 5 | describe('Styler', () => { 6 | test('generates a style HTML tag with received css', () => { 7 | const style = Styler.getStyleFor(TEST_STYLE) 8 | 9 | expect(style[0].innerHTML).toEqual(TEST_STYLE) 10 | }) 11 | }) 12 | -------------------------------------------------------------------------------- /src/base/template.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-var */ 2 | // Simple JavaScript Templating 3 | // Paul Miller (http://paulmillr.com) 4 | // http://underscorejs.org 5 | // (c) 2009-2013 Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors 6 | 7 | // By default, Underscore uses ERB-style template delimiters, change the 8 | // following template settings to use alternative delimiters. 9 | var settings = { 10 | evaluate : /<%([\s\S]+?)%>/g, 11 | interpolate : /<%=([\s\S]+?)%>/g, 12 | escape : /<%-([\s\S]+?)%>/g 13 | } 14 | 15 | // When customizing `templateSettings`, if you don't want to define an 16 | // interpolation, evaluation or escaping regex, we need one that is 17 | // guaranteed not to match. 18 | var noMatch = /(.)^/ 19 | 20 | // Certain characters need to be escaped so that they can be put into a 21 | // string literal. 22 | var escapes = { 23 | '\'': '\'', 24 | '\\': '\\', 25 | '\r': 'r', 26 | '\n': 'n', 27 | '\t': 't', 28 | '\u2028': 'u2028', 29 | '\u2029': 'u2029' 30 | } 31 | 32 | var escaper = /\\|'|\r|\n|\t|\u2028|\u2029/g 33 | 34 | // List of HTML entities for escaping. 35 | var htmlEntities = { 36 | '&': '&', 37 | '<': '<', 38 | '>': '>', 39 | '"': '"', 40 | '\'': ''' 41 | } 42 | 43 | var entityRe = new RegExp('[&<>"\']', 'g') 44 | 45 | var escapeExpr = function(string) { 46 | if (string === null) return '' 47 | return ('' + string).replace(entityRe, function(match) { 48 | return htmlEntities[match] 49 | }) 50 | } 51 | 52 | var counter = 0 53 | 54 | // JavaScript micro-templating, similar to John Resig's implementation. 55 | // Underscore templating handles arbitrary delimiters, preserves whitespace, 56 | // and correctly escapes quotes within interpolated code. 57 | var tmpl = function(text, data) { 58 | var render 59 | 60 | // Combine delimiters into one regular expression via alternation. 61 | var matcher = new RegExp([ 62 | (settings.escape || noMatch).source, 63 | (settings.interpolate || noMatch).source, 64 | (settings.evaluate || noMatch).source 65 | ].join('|') + '|$', 'g') 66 | 67 | // Compile the template source, escaping string literals appropriately. 68 | var index = 0 69 | var source = '__p+=\'' 70 | text.replace(matcher, function(match, escape, interpolate, evaluate, offset) { 71 | source += text.slice(index, offset) 72 | .replace(escaper, function(match) { return '\\' + escapes[match] }) 73 | 74 | if (escape) 75 | source += '\'+\n((__t=(' + escape + '))==null?\'\':escapeExpr(__t))+\n\'' 76 | 77 | if (interpolate) 78 | source += '\'+\n((__t=(' + interpolate + '))==null?\'\':__t)+\n\'' 79 | 80 | if (evaluate) 81 | source += '\';\n' + evaluate + '\n__p+=\'' 82 | 83 | index = offset + match.length 84 | return match 85 | }) 86 | source += '\';\n' 87 | 88 | // If a variable is not specified, place data values in local scope. 89 | if (!settings.variable) source = 'with(obj||{}){\n' + source + '}\n' 90 | 91 | source = 'var __t,__p=\'\',__j=Array.prototype.join,' + 92 | 'print=function(){__p+=__j.call(arguments,\'\');};\n' + 93 | source + 'return __p;\n//# sourceURL=/microtemplates/source[' + counter++ + ']' 94 | 95 | try { 96 | /*jshint -W054 */ 97 | // TODO: find a way to avoid eval 98 | render = new Function(settings.variable || 'obj', 'escapeExpr', source) 99 | } catch (e) { 100 | e.source = source 101 | throw e 102 | } 103 | 104 | if (data) return render(data, escapeExpr) 105 | var template = function(data) { 106 | return render.call(this, data, escapeExpr) 107 | } 108 | 109 | // Provide the compiled function source as a convenience for precompilation. 110 | template.source = 'function(' + (settings.variable || 'obj') + '){\n' + source + '}' 111 | 112 | return template 113 | } 114 | tmpl.settings = settings 115 | 116 | export default tmpl 117 | -------------------------------------------------------------------------------- /src/base/ui_container_plugin/ui_container_plugin.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 { extend } from '@/utils' 6 | import UIObject from '@/base/ui_object' 7 | import ErrorMixin from '@/base/error_mixin' 8 | 9 | /** 10 | * The base class for an ui container plugin 11 | * @class UIContainerPlugin 12 | * @constructor 13 | * @extends UIObject 14 | * @module base 15 | */ 16 | export default class UIContainerPlugin extends UIObject { 17 | get playerError() { return this.container.playerError } 18 | 19 | constructor(container) { 20 | super(container.options) 21 | this.container = container 22 | this.enabled = true 23 | this.bindEvents() 24 | } 25 | 26 | enable() { 27 | if (!this.enabled) { 28 | this.bindEvents() 29 | this.$el.show() 30 | this.enabled = true 31 | } 32 | } 33 | 34 | disable() { 35 | this.stopListening() 36 | this.$el.hide() 37 | this.enabled = false 38 | } 39 | 40 | bindEvents() {} 41 | 42 | } 43 | 44 | Object.assign(UIContainerPlugin.prototype, ErrorMixin) 45 | 46 | UIContainerPlugin.extend = function(properties) { 47 | return extend(UIContainerPlugin, properties) 48 | } 49 | 50 | UIContainerPlugin.type = 'container' 51 | -------------------------------------------------------------------------------- /src/base/ui_container_plugin/ui_container_plugin.test.js: -------------------------------------------------------------------------------- 1 | import UIContainerPlugin from './ui_container_plugin' 2 | import ErrorMixin from '@/base/error_mixin' 3 | 4 | describe('UI Container Plugin', () => { 5 | describe('constructor', () => { 6 | test('enables the plugin', () => { 7 | const plugin = new UIContainerPlugin({}) 8 | 9 | expect(plugin.enabled).toBeTruthy() 10 | }) 11 | 12 | test('binds all events', () => { 13 | let bind = false 14 | const Plugin = class MyPlugin extends UIContainerPlugin { 15 | bindEvents() { 16 | bind = true 17 | } 18 | } 19 | 20 | new Plugin({}) 21 | 22 | expect(bind).toBeTruthy() 23 | }) 24 | }) 25 | 26 | test('enables the plugin', () => { 27 | const plugin = new UIContainerPlugin({}) 28 | const spy = jest.spyOn(plugin, 'bindEvents') 29 | const show = jest.fn() 30 | plugin.$el = { show: show } 31 | plugin.enabled = false 32 | 33 | plugin.enable() 34 | 35 | expect(spy).toHaveBeenCalledTimes(1) 36 | expect(show).toHaveBeenCalledTimes(1) 37 | expect(plugin.enabled).toBeTruthy() 38 | }) 39 | 40 | test('can be enabled after your creation', () => { 41 | const plugin = new UIContainerPlugin({}) 42 | 43 | plugin.disable() 44 | 45 | expect(plugin.enabled).toBeFalsy() 46 | 47 | plugin.enable() 48 | 49 | expect(plugin.enabled).toBeTruthy() 50 | }) 51 | 52 | test('ignores enable call if the plugin is already enabled', () => { 53 | const plugin = new UIContainerPlugin({}) 54 | const spy = jest.spyOn(plugin, 'bindEvents') 55 | 56 | expect(plugin.enabled).toBeTruthy() 57 | 58 | plugin.enable() 59 | plugin.enable() 60 | 61 | expect(spy).not.toHaveBeenCalled() 62 | expect(plugin.enabled).toBeTruthy() 63 | }) 64 | 65 | test('disables the plugin', () => { 66 | const plugin = new UIContainerPlugin({}) 67 | const spy = jest.spyOn(plugin, 'stopListening') 68 | const hide = jest.fn() 69 | plugin.$el = { hide: hide } 70 | 71 | plugin.disable() 72 | 73 | expect(spy).toHaveBeenCalledTimes(1) 74 | expect(hide).toHaveBeenCalledTimes(1) 75 | expect(plugin.enabled).toBeFalsy() 76 | }) 77 | 78 | test('can be disabled after your creation', () => { 79 | const plugin = new UIContainerPlugin({}) 80 | 81 | plugin.disable() 82 | 83 | expect(plugin.enabled).toBeFalsy() 84 | }) 85 | 86 | test('destroys the plugin', () => { 87 | const plugin = new UIContainerPlugin({}) 88 | const spy = jest.spyOn(plugin, 'destroy') 89 | 90 | plugin.destroy() 91 | 92 | expect(spy).toHaveBeenCalledTimes(1) 93 | }) 94 | 95 | test('receives createdError method from ErrorMixin', () => { 96 | const plugin = new UIContainerPlugin({}) 97 | 98 | expect(plugin.createError).not.toBeUndefined() 99 | expect(plugin.createError).toEqual(ErrorMixin.createError) 100 | }) 101 | 102 | test('can be created via extends method', () => { 103 | const plugin = UIContainerPlugin.extend({ name: 'test_plugin' }) 104 | 105 | expect(plugin.prototype instanceof UIContainerPlugin).toBeTruthy() 106 | }) 107 | }) 108 | -------------------------------------------------------------------------------- /src/base/ui_core_plugin/ui_core_plugin.js: -------------------------------------------------------------------------------- 1 | import { extend } from '@/utils' 2 | import UIObject from '@/base/ui_object' 3 | import ErrorMixin from '@/base/error_mixin' 4 | 5 | export default class UICorePlugin extends UIObject { 6 | get playerError() { return this.core.playerError } 7 | 8 | constructor(core) { 9 | super(core.options) 10 | this.core = core 11 | this.enabled = true 12 | this.bindEvents() 13 | this.render() 14 | } 15 | 16 | bindEvents() {} 17 | 18 | getExternalInterface() { return {} } 19 | 20 | enable() { 21 | if (!this.enabled) { 22 | this.bindEvents() 23 | this.$el.show() 24 | this.enabled = true 25 | } 26 | } 27 | 28 | disable() { 29 | this.stopListening() 30 | this.$el.hide() 31 | this.enabled = false 32 | } 33 | 34 | render() { 35 | return this 36 | } 37 | } 38 | 39 | Object.assign(UICorePlugin.prototype, ErrorMixin) 40 | 41 | UICorePlugin.extend = function(properties) { 42 | return extend(UICorePlugin, properties) 43 | } 44 | 45 | UICorePlugin.type = 'core' 46 | -------------------------------------------------------------------------------- /src/base/ui_core_plugin/ui_core_plugin.test.js: -------------------------------------------------------------------------------- 1 | import UICorePlugin from './ui_core_plugin' 2 | import ErrorMixin from '@/base/error_mixin' 3 | 4 | describe('UI Core Plugin', () => { 5 | describe('constructor', () => { 6 | test('enables the plugin', () => { 7 | const plugin = new UICorePlugin({}) 8 | 9 | expect(plugin.enabled).toBeTruthy() 10 | }) 11 | 12 | test('binds all events', () => { 13 | let bind = false 14 | const Plugin = class MyPlugin extends UICorePlugin { 15 | bindEvents() { 16 | bind = true 17 | } 18 | } 19 | 20 | new Plugin({}) 21 | 22 | expect(bind).toBeTruthy() 23 | }) 24 | }) 25 | 26 | test('has a default value for getExternalInterface method', () => { 27 | const plugin = new UICorePlugin({}) 28 | 29 | expect(plugin.getExternalInterface()).toEqual({}) 30 | }) 31 | 32 | test('enables the plugin', () => { 33 | const plugin = new UICorePlugin({}) 34 | const spy = jest.spyOn(plugin, 'bindEvents') 35 | const show = jest.fn() 36 | plugin.$el = { show: show } 37 | plugin.enabled = false 38 | 39 | plugin.enable() 40 | 41 | expect(spy).toHaveBeenCalledTimes(1) 42 | expect(show).toHaveBeenCalledTimes(1) 43 | expect(plugin.enabled).toBeTruthy() 44 | }) 45 | 46 | test('can be enabled after your creation', () => { 47 | const plugin = new UICorePlugin({}) 48 | 49 | plugin.disable() 50 | 51 | expect(plugin.enabled).toBeFalsy() 52 | 53 | plugin.enable() 54 | 55 | expect(plugin.enabled).toBeTruthy() 56 | }) 57 | 58 | test('ignores enable call if the plugin is already enabled', () => { 59 | const plugin = new UICorePlugin({}) 60 | const spy = jest.spyOn(plugin, 'bindEvents') 61 | 62 | expect(plugin.enabled).toBeTruthy() 63 | 64 | plugin.enable() 65 | plugin.enable() 66 | 67 | expect(spy).not.toHaveBeenCalled() 68 | expect(plugin.enabled).toBeTruthy() 69 | }) 70 | 71 | test('disables the plugin', () => { 72 | const plugin = new UICorePlugin({}) 73 | const spy = jest.spyOn(plugin, 'stopListening') 74 | const hide = jest.fn() 75 | plugin.$el = { hide: hide } 76 | 77 | plugin.disable() 78 | 79 | expect(spy).toHaveBeenCalledTimes(1) 80 | expect(hide).toHaveBeenCalledTimes(1) 81 | expect(plugin.enabled).toBeFalsy() 82 | }) 83 | 84 | test('can be disabled after your creation', () => { 85 | const plugin = new UICorePlugin({}) 86 | 87 | plugin.disable() 88 | 89 | expect(plugin.enabled).toBeFalsy() 90 | }) 91 | 92 | test('destroys the plugin', () => { 93 | const plugin = new UICorePlugin({}) 94 | const spy = jest.spyOn(plugin, 'destroy') 95 | 96 | plugin.destroy() 97 | 98 | expect(spy).toHaveBeenCalledTimes(1) 99 | }) 100 | 101 | test('receives createdError method from ErrorMixin', () => { 102 | const plugin = new UICorePlugin({}) 103 | 104 | expect(plugin.createError).not.toBeUndefined() 105 | expect(plugin.createError).toEqual(ErrorMixin.createError) 106 | }) 107 | 108 | test('can be created via extends method', () => { 109 | const plugin = UICorePlugin.extend({ name: 'test_plugin' }) 110 | 111 | expect(plugin.prototype instanceof UICorePlugin).toBeTruthy() 112 | }) 113 | }) 114 | -------------------------------------------------------------------------------- /src/base/ui_object/ui_object.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 $ from 'clappr-zepto' 6 | import { uniqueId, DomRecycler } from '@/utils' 7 | import BaseObject from '@/base/base_object' 8 | 9 | const delegateEventSplitter = /^(\S+)\s*(.*)$/ 10 | 11 | /** 12 | * A base class to create ui object. 13 | * @class UIObject 14 | * @constructor 15 | * @extends BaseObject 16 | * @module base 17 | */ 18 | export default class UIObject extends BaseObject { 19 | /** 20 | * a unique id prefixed with `'c'`, `c1, c232` 21 | * 22 | * @property cid 23 | * @type String 24 | */ 25 | /** 26 | * the dom element itself 27 | * 28 | * @property el 29 | * @type HTMLElement 30 | */ 31 | /** 32 | * the dom element wrapped by `$` 33 | * 34 | * @property $el 35 | * @type HTMLElement 36 | */ 37 | 38 | /** 39 | * gets the tag name for the ui component 40 | * @method tagName 41 | * @default div 42 | * @return {String} tag's name 43 | */ 44 | get tagName() { return 'div' } 45 | /** 46 | * a literal object mapping element's events to methods 47 | * @property events 48 | * @type Object 49 | * @example 50 | * 51 | *```javascript 52 | * 53 | * class MyButton extends UIObject { 54 | * constructor(options) { 55 | * super(options) 56 | * this.myId = 0 57 | * } 58 | * get events() { return { 'click': 'myClick' } } 59 | * myClick(){ this.myId = 42 } 60 | * } 61 | * 62 | * // when you click on MyButton the method `myClick` will be called 63 | *``` 64 | */ 65 | get events() { return {} } 66 | /** 67 | * a literal object mapping attributes and values to the element 68 | * element's attribute name and the value the attribute value 69 | * @property attributes 70 | * @type Object 71 | * @example 72 | * 73 | *```javascript 74 | * 75 | * class MyButton extends UIObject { 76 | * constructor(options) { super(options) } 77 | * get attributes() { return { class: 'my-button'} } 78 | * } 79 | * 80 | * // MyButton.el.className will be 'my-button' 81 | * ``` 82 | */ 83 | get attributes() { return {} } 84 | 85 | /** 86 | * it builds an ui component by: 87 | * * creating an id for the component `cid` 88 | * * making sure the element is created `$el` 89 | * * delegating all `events` to the element 90 | * @method constructor 91 | * @param {Object} options the options object 92 | */ 93 | constructor(options) { 94 | super(options) 95 | this.cid = uniqueId('c') 96 | this._ensureElement() 97 | this.delegateEvents() 98 | } 99 | 100 | /** 101 | * selects within the component. 102 | * @method $ 103 | * @param {String} selector a selector to find within the component. 104 | * @return {HTMLElement} an element, if it exists. 105 | * @example 106 | * ```javascript 107 | * fullScreenBarUIComponent.$('.button-full') //will return only `.button-full` within the component 108 | * ``` 109 | */ 110 | $(selector) { 111 | return this.$el.find(selector) 112 | } 113 | 114 | /** 115 | * render the component, usually attach it to a real existent `element` 116 | * @method render 117 | * @return {UIObject} itself 118 | */ 119 | render() { 120 | return this 121 | } 122 | 123 | /** 124 | * removes the ui component from DOM 125 | * @method destroy 126 | * @return {UIObject} itself 127 | */ 128 | destroy() { 129 | this.$el.remove() 130 | this.stopListening() 131 | this.undelegateEvents() 132 | return this 133 | } 134 | 135 | /** 136 | * set element to `el` and `$el` 137 | * @method setElement 138 | * @param {HTMLElement} element 139 | * @param {Boolean} delegate whether is delegate or not 140 | * @return {UIObject} itself 141 | */ 142 | setElement(element, delegate) { 143 | if (this.$el) this.undelegateEvents() 144 | this.$el = $.zepto.isZ(element) ? element : $(element) 145 | this.el = this.$el[0] 146 | if (delegate !== false) this.delegateEvents() 147 | return this 148 | } 149 | 150 | /** 151 | * delegates all the original `events` on `element` to its callbacks 152 | * @method delegateEvents 153 | * @param {Object} events 154 | * @return {UIObject} itself 155 | */ 156 | delegateEvents(events) { 157 | if (!events) events = this.events 158 | this.undelegateEvents() 159 | for (const key in events) { 160 | let method = events[key] 161 | if ((method && method.constructor !== Function)) method = this[events[key]] 162 | if (!method) continue 163 | 164 | const match = key.match(delegateEventSplitter) 165 | let eventName = match[1], selector = match[2] 166 | eventName += '.delegateEvents' + this.cid 167 | if (selector === '') 168 | this.$el.on(eventName, method.bind(this)) 169 | else 170 | this.$el.on(eventName, selector, method.bind(this)) 171 | 172 | } 173 | return this 174 | } 175 | 176 | /** 177 | * undelegats all the `events` 178 | * @method undelegateEvents 179 | * @return {UIObject} itself 180 | */ 181 | undelegateEvents() { 182 | this.$el.off('.delegateEvents' + this.cid) 183 | return this 184 | } 185 | 186 | /** 187 | * ensures the creation of this ui component 188 | * @method _ensureElement 189 | * @private 190 | */ 191 | _ensureElement() { 192 | if (!this.el) { 193 | const attrs = $.extend(true, {}, this.attributes) 194 | if (this.id) attrs.id = this.id 195 | if (this.className) attrs['class'] = this.className 196 | const $el = $(DomRecycler.create(this.tagName)).attr(attrs) 197 | this.setElement($el, false) 198 | } else { this.setElement(this.el, false) } 199 | 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /src/base/ui_object/ui_object.test.js: -------------------------------------------------------------------------------- 1 | import UIObject from './ui_object' 2 | import $ from 'clappr-zepto' 3 | 4 | describe('UIObject', () => { 5 | test('is a div tag by default', () => { 6 | const uiObject = new UIObject() 7 | expect(uiObject.tagName).toEqual('div') 8 | }) 9 | 10 | test('render method returns the component itself by default', () => { 11 | const uiObject = new UIObject() 12 | 13 | expect(uiObject.render()).toEqual(uiObject) 14 | }) 15 | 16 | test('can be any tag', () => { 17 | class MyButton extends UIObject { get tagName() { return 'button' } } 18 | const myButton = new MyButton() 19 | expect(myButton.tagName).toEqual('button') 20 | }) 21 | 22 | test('has an unique id', () => { 23 | const uiObjectA = new UIObject() 24 | const uiObjectB = new UIObject() 25 | expect(uiObjectA.cid).not.toEqual(uiObjectB.cid) 26 | }) 27 | 28 | test('creates element for a given ui component', () => { 29 | const uiObject = new UIObject() 30 | const component = $('
') 31 | expect(uiObject.el).toEqual(component[0]) 32 | expect(uiObject.$el).toEqual(component) 33 | }) 34 | 35 | test('can set element', () => { 36 | const uiObject = new UIObject() 37 | const element = $('
') 38 | uiObject.setElement(element) 39 | 40 | expect(uiObject.el).toEqual(element[0]) 41 | expect(uiObject.$el).toEqual(element) 42 | }) 43 | 44 | test('creates an element with attributes', () => { 45 | class MyButton extends UIObject { 46 | constructor(options) { super(options) } 47 | get attributes() { return { class: 'my-button' } } 48 | } 49 | 50 | const myButton = new MyButton() 51 | 52 | expect(myButton.el.className).toEqual('my-button') 53 | }) 54 | 55 | test('creates an element with only id/className attributes', () => { 56 | class MyButton extends UIObject { 57 | constructor(options) { super(options) } 58 | get id() { return 'id-example' } 59 | get className() { return 'class__example' } 60 | } 61 | 62 | const myButton = new MyButton() 63 | 64 | expect(myButton.el.id).toEqual('id-example') 65 | expect(myButton.el.className).toEqual('class__example') 66 | }) 67 | 68 | test('binds events of an element to methods', () => { 69 | class MyButton extends UIObject { 70 | constructor(options) { 71 | super(options) 72 | this.myId = 0 73 | } 74 | get events() { return { 'click': 'myClick' } } 75 | myClick() { this.myId = 42 } 76 | } 77 | 78 | const myButton = new MyButton() 79 | 80 | expect(myButton.myId).toEqual(0) 81 | 82 | myButton.$el.trigger('click') 83 | 84 | expect(myButton.myId).toEqual(42) 85 | }) 86 | 87 | test('can bind events dynamically', () => { 88 | class MyButton extends UIObject { 89 | constructor(options) { 90 | super(options) 91 | this.myId = 0 92 | } 93 | myClick() { this.myId = 42 } 94 | } 95 | 96 | const myButton = new MyButton() 97 | 98 | myButton.delegateEvents({ 'click': 'myClick' }) 99 | 100 | expect(myButton.myId).toEqual(0) 101 | 102 | myButton.$el.trigger('click') 103 | 104 | expect(myButton.myId).toEqual(42) 105 | }) 106 | 107 | test('binds events of an element with specific selectors to methods', () => { 108 | class MyButton extends UIObject { myClick() {} } 109 | const myButton = new MyButton() 110 | jest.spyOn(myButton, 'myClick') 111 | 112 | myButton.$el.append($('
')) 113 | const $specificSelector = myButton.$('.class__example[data-example]') 114 | 115 | myButton.delegateEvents({ 'click .class__example[data-example]': 'myClick' }) 116 | 117 | myButton.$el.trigger('click') 118 | $specificSelector.trigger('click') 119 | 120 | expect(myButton.myClick).toHaveBeenCalledTimes(1) 121 | }) 122 | 123 | test('only bind events whit correct dictionary { event: callback } input', () => { 124 | class MyButton extends UIObject { myClick() {} } 125 | const myButton = new MyButton() 126 | jest.spyOn(myButton, 'myClick') 127 | 128 | myButton.delegateEvents({ 'click': null }) 129 | myButton.delegateEvents({ 'click': 'test' }) 130 | myButton.delegateEvents({ 'click': 'myClick' }) 131 | 132 | myButton.$el.trigger('click') 133 | 134 | expect(myButton.myClick).toHaveBeenCalledTimes(1) 135 | }) 136 | 137 | test('selects elements within the component', () => { 138 | const insideComponent = $('

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