├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── index.html ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── comparator.ts ├── events.ts ├── index.ts ├── models.ts ├── pid-controller.ts └── styles │ └── index.scss ├── tests └── comparator.spec.js ├── tsconfig.json ├── tslint.json └── webpack.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /tmp 5 | /out-tsc 6 | /dist 7 | 8 | # dependencies 9 | /node_modules 10 | 11 | # IDEs and editors 12 | /.idea 13 | .project 14 | .classpath 15 | .c9/ 16 | *.launch 17 | .settings/ 18 | *.sublime-workspace 19 | 20 | # IDE - VSCode 21 | .vscode/* 22 | !.vscode/settings.json 23 | !.vscode/tasks.json 24 | !.vscode/launch.json 25 | !.vscode/extensions.json 26 | 27 | # misc 28 | /.sass-cache 29 | /connect.lock 30 | /coverage 31 | /libpeerconnection.log 32 | npm-debug.log 33 | yarn-error.log 34 | testem.log 35 | /typings 36 | 37 | # System Files 38 | .DS_Store 39 | Thumbs.db 40 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: required 2 | 3 | dist: trusty 4 | 5 | language: node_js 6 | 7 | node_js: 8 | - stable 9 | 10 | before_script: 11 | - npm install 12 | 13 | script: 14 | - npm run build && npm t 15 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | This document will track the changes of this project, based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) proposed schema. 4 | 5 | ## Wanted features / Roadmap 6 | - Add zoom feature. 7 | - Make mobile-aware the drag feature. 8 | 9 | ## [0.0.7] 10 | ### [Added] 11 | - Dispatching event on fullscreen mode toggled. 12 | 13 | ## [0.0.6] 14 | ### [Fixed] 15 | - Comparator destroy not working as expected. 16 | 17 | ## [0.0.5] 18 | ### [Added] 19 | - Auto rotating screen on mobile devices. 20 | - Dispatching event on comparator creation. 21 | 22 | ## [0.0.4] 23 | ### [Fixed] 24 | - Right stats position fixed on fullscreen. 25 | - Fullscreen mode now works fine for any video aspect ratio. 26 | 27 | ## [0.0.3] 28 | ### [Added] 29 | - Travis and npm information added to README. 30 | - Live demo added to README. 31 | 32 | ## [0.0.2] 33 | ### [Changed] 34 | - *setRenditionIndex* has been deprecated. Use *setRenditionByIndex* instead. 35 | - *setRenditionKbps* has been deprecated. Use *setRenditionByKbps* instead. 36 | ### [Fixed] 37 | - Autoplay config attribute not working as expected. 38 | - Disabling controls not working as expected. 39 | 40 | ## [0.0.1] 41 | ### [Added] 42 | - Initial version. 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 epic labs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Epic Video Comparator · [![npm version](https://img.shields.io/npm/v/@epiclabs/epic-video-comparator.svg?style=flat)](https://www.npmjs.com/package/@epiclabs/epic-video-comparator) [![Travis CI Status](https://api.travis-ci.org/epiclabs-io/epic-video-comparator.svg?branch=master)](https://travis-ci.org/epiclabs-io/epic-video-comparator) 2 | 3 | [LIVE DEMO](https://epiclabs-io.github.io/epic-video-comparator-demo/) 4 | 5 | JavaScript library which implements a video comparator component: two overlapped and synchronized video players each one playing an independent source. It is based on [epic-video-player](https://www.npmjs.com/package/@epiclabs/epic-video-player) library, which currently supports native HTML video (WebM, Ogg Theora Vorbis, Ogg Opus, Ogg FLAC and MP4 H.264), MPEG-DASH([dash.js](https://github.com/Dash-Industry-Forum/dash.js)) and HLS ([hls.js](https://github.com/video-dev/hls.js)) streams. 6 | 7 | ![video-comparator-overview](https://user-images.githubusercontent.com/467658/53631764-8f6f6c00-3c13-11e9-9f0f-638f6d0a39d8.png) 8 | 9 | For ABR sources, it is also possible to select the desired rendition to be played: 10 | 11 | ![video-comparator-quality-selector](https://user-images.githubusercontent.com/467658/53633279-52a57400-3c17-11e9-8942-dacb3b78d53e.png) 12 | 13 | # Installation 14 | 15 | Install epic-video-comparator into your project 16 | 17 | ``` 18 | $ npm install @epiclabs/epic-video-comparator --save 19 | ``` 20 | 21 | # Using it as CommonJS module 22 | ``` 23 | import { Comparator } from '@epiclabs/epic-video-comparator'; 24 | ... 25 | const comparatorConfig = { 26 | leftUrl: 'https://demo.unified-streaming.com/video/tears-of-steel/tears-of-steel.ism/.mpd', 27 | rightUrl: 'https://demo.unified-streaming.com/video/tears-of-steel/tears-of-steel.ism/.mpd', 28 | mediaControls: true, 29 | loop: true, 30 | }; 31 | const myComp = new Comparator(comparatorConfig, document.getElementById('comparator-container')); 32 | 33 | ``` 34 | 35 | # Using it as UMD module within ``` 40 | ... 41 | 42 | 43 | ... 44 |
45 | ... 46 | 57 | ... 58 | 59 | ``` 60 | 61 | # Development 62 | ``` 63 | $ git clone https://github.com/epiclabs-io/epic-video-comparator.git 64 | $ cd epic-video-comparator 65 | $ npm install 66 | $ npm run build 67 | ``` 68 | 69 | # API 70 | 71 | ## Methods 72 | 73 | - **new Comparator(config: IComparatorConfig, container: HTMLDivElement)** 74 | 75 | Creates a new instance of epic-video-comparator. 76 | 77 | - **pause()** 78 | 79 | Stops playback of both videos. 80 | 81 | - **play()** 82 | 83 | Starts playback of both videos. 84 | 85 | - **togglePlayPause()** 86 | 87 | Switches playing/pause status. 88 | 89 | - **seek(time: number)** 90 | 91 | Sets both players' playback to the same time position. 92 | 93 | - **reload()** 94 | 95 | Destroys and reload the epic-video-comparator. 96 | 97 | - **toggleFullScreen()** 98 | 99 | Enters / exits fullscreen mode. 100 | 101 | - **setRenditionByKbps(player: 'left' | 'right', kbps: number): IRendition** 102 | 103 | Sets a desired rendition given as Kbps on one of the players. 104 | 105 | - ~~setRenditionKbps(player: 'left' | 'right', kbps: number): IRendition~~ 106 | 107 | This method has been deprecated since version 0.0.2. Use *setRenditionByKbps* instead. 108 | 109 | - **setRenditionByIndex(player: 'left' | 'right', index: number): IRendition** 110 | 111 | Sets a desired rendition given as index number on one of the players. The order will be the order of the array returned by *getRenditions* method. 112 | 113 | - ~~setRenditionIndex(player: 'left' | 'right', index: number): IRendition~~ 114 | 115 | This method has been deprecated since version 0.0.2. Use *setRenditionByIndex* instead. 116 | 117 | - **getRenditions(player: 'left' | 'right'): IRendition[]** 118 | 119 | Retrieves the list of available renditions of one of the players. 120 | 121 | - **togggleStats(): void** 122 | 123 | Shows / Hides the stats boxes. 124 | 125 | - **updateStats(innerLeft: string, innerRight: string): void** 126 | 127 | Sets the given content to each one of the players' stats box. It will overwrite any stat given by this library as default. It is recommended to be used within a `setInterval`. 128 | 129 | - **destroy(): void** 130 | 131 | Removes all DOM elements and binding listeners. 132 | 133 | ## Events 134 | 135 | The events are binded to the comparator container. Usage example: 136 | 137 | ``` 138 | var container = document.getElementById('comparator-container'); 139 | container.addEventListener('created', () => console.log('created!')); 140 | ``` 141 | 142 | | Event | Description | 143 | | ----- | ----------- | 144 | | created | Fires when the comparator is created (it occurs during comparator creation or reload but also when a new rendition is selected on any side). | 145 | | fullscreen_toggle | Fires when the comparator toggles its fullscreen mode. | 146 | 147 | ## Object interfaces 148 | 149 | | Name | Properties | Default value | 150 | | ---- | ---------- |:-------------:| 151 | | IComparatorConfig | autoplay?: boolean;
leftUrl: string;
loop?: boolean;
rightUrl: string;
mediaControls?: boolean;
stats?: IStatsConfig / boolean | true
-
true
-
true
IStatsConfig defaults | 152 | | IStatsConfig | showDuration?: boolean;
showBitrate?: boolean;
showResolution?: boolean;
showVideoCodec?: boolean;
showAudioCodec?: boolean;
showDroppedFrames?: boolean;
showBuffered?: boolean;
showStartupTime?: boolean;
custom?: boolean; | true
true
true
true
true
true
true
true
false | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | epic-video-player demo 8 | 9 | 10 | 11 | 20 | 21 | 22 |
23 |
24 |
25 | 26 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // For a detailed explanation regarding each configuration property, visit: 2 | // https://jestjs.io/docs/en/configuration.html 3 | 4 | module.exports = { 5 | // All imported modules in your tests should be mocked automatically 6 | // automock: false, 7 | 8 | // Stop running tests after `n` failures 9 | // bail: 0, 10 | 11 | // Respect "browser" field in package.json when resolving modules 12 | // browser: false, 13 | 14 | // The directory where Jest should store its cached dependency information 15 | // cacheDirectory: "/tmp/jest_rs", 16 | 17 | // Automatically clear mock calls and instances between every test 18 | clearMocks: true, 19 | 20 | // Indicates whether the coverage information should be collected while executing the test 21 | // collectCoverage: false, 22 | 23 | // An array of glob patterns indicating a set of files for which coverage information should be collected 24 | // collectCoverageFrom: null, 25 | 26 | // The directory where Jest should output its coverage files 27 | // coverageDirectory: "coverage", 28 | 29 | // An array of regexp pattern strings used to skip coverage collection 30 | // coveragePathIgnorePatterns: [ 31 | // "/node_modules/" 32 | // ], 33 | 34 | // A list of reporter names that Jest uses when writing coverage reports 35 | // coverageReporters: [ 36 | // "json", 37 | // "text", 38 | // "lcov", 39 | // "clover" 40 | // ], 41 | 42 | // An object that configures minimum threshold enforcement for coverage results 43 | // coverageThreshold: null, 44 | 45 | // A path to a custom dependency extractor 46 | // dependencyExtractor: null, 47 | 48 | // Make calling deprecated APIs throw helpful error messages 49 | // errorOnDeprecated: false, 50 | 51 | // Force coverage collection from ignored files usin a array of glob patterns 52 | // forceCoverageMatch: [], 53 | 54 | // A path to a module which exports an async function that is triggered once before all test suites 55 | // globalSetup: null, 56 | 57 | // A path to a module which exports an async function that is triggered once after all test suites 58 | // globalTeardown: null, 59 | 60 | // A set of global variables that need to be available in all test environments 61 | // globals: {}, 62 | 63 | // An array of directory names to be searched recursively up from the requiring module's location 64 | // moduleDirectories: [ 65 | // "node_modules" 66 | // ], 67 | 68 | // An array of file extensions your modules use 69 | // moduleFileExtensions: [ 70 | // "js", 71 | // "json", 72 | // "jsx", 73 | // "ts", 74 | // "tsx", 75 | // "node" 76 | // ], 77 | 78 | // A map from regular expressions to module names that allow to stub out resources with a single module 79 | // moduleNameMapper: {}, 80 | 81 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 82 | // modulePathIgnorePatterns: [], 83 | 84 | // Activates notifications for test results 85 | // notify: false, 86 | 87 | // An enum that specifies notification mode. Requires { notify: true } 88 | // notifyMode: "failure-change", 89 | 90 | // A preset that is used as a base for Jest's configuration 91 | preset: 'ts-jest', 92 | 93 | // Run tests from one or more projects 94 | // projects: null, 95 | 96 | // Use this configuration option to add custom reporters to Jest 97 | // reporters: undefined, 98 | 99 | // Automatically reset mock state between every test 100 | // resetMocks: false, 101 | 102 | // Reset the module registry before running each individual test 103 | // resetModules: false, 104 | 105 | // A path to a custom resolver 106 | // resolver: null, 107 | 108 | // Automatically restore mock state between every test 109 | // restoreMocks: false, 110 | 111 | // The root directory that Jest should scan for tests and modules within 112 | // rootDir: null, 113 | 114 | // A list of paths to directories that Jest should use to search for files in 115 | // roots: [ 116 | // "" 117 | // ], 118 | 119 | // Allows you to use a custom runner instead of Jest's default test runner 120 | // runner: "jest-runner", 121 | 122 | // The paths to modules that run some code to configure or set up the testing environment before each test 123 | // setupFiles: [], 124 | 125 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 126 | // setupFilesAfterEnv: [], 127 | 128 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 129 | // snapshotSerializers: [], 130 | 131 | // The test environment that will be used for testing 132 | testEnvironment: "node", 133 | 134 | // Options that will be passed to the testEnvironment 135 | // testEnvironmentOptions: {}, 136 | 137 | // Adds a location field to test results 138 | // testLocationInResults: false, 139 | 140 | // The glob patterns Jest uses to detect test files 141 | // testMatch: [ 142 | // "**/__tests__/**/*.[jt]s?(x)", 143 | // "**/?(*.)+(spec|test).[tj]s?(x)" 144 | // ], 145 | 146 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 147 | testPathIgnorePatterns: [ 148 | "/node_modules/", 149 | "/dist" 150 | ], 151 | 152 | // The regexp pattern or array of patterns that Jest uses to detect test files 153 | // testRegex: [], 154 | 155 | // This option allows the use of a custom results processor 156 | // testResultsProcessor: null, 157 | 158 | // This option allows use of a custom test runner 159 | // testRunner: "jasmine2", 160 | 161 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href 162 | // testURL: "http://localhost", 163 | 164 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" 165 | // timers: "real", 166 | 167 | // A map from regular expressions to paths to transformers 168 | // transform: null, 169 | 170 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 171 | // transformIgnorePatterns: [ 172 | // "/node_modules/" 173 | // ], 174 | 175 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 176 | // unmockedModulePathPatterns: undefined, 177 | 178 | // Indicates whether each individual test should be reported during the run 179 | // verbose: null, 180 | 181 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 182 | // watchPathIgnorePatterns: [], 183 | 184 | // Whether to use watchman for file crawling 185 | // watchman: true, 186 | }; 187 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@epiclabs/epic-video-comparator", 3 | "version": "0.0.7", 4 | "description": "JS library to create a video comparator, i.e., two overlaped and syncrhonized video players each one with an independent source.", 5 | "main": "./index.js", 6 | "types": "./index.d.ts", 7 | "scripts": { 8 | "_clean": "rimraf dist", 9 | "_transpile": "tsc", 10 | "_bundle": "webpack", 11 | "_styles": "sass src/styles/index.scss dist/index.css", 12 | "_npm_ready": "cp-cli CHANGELOG.md ./dist/CHANGELOG.md && cp-cli LICENSE ./dist/LICENSE && cp-cli package.json ./dist/package.json && cp-cli README.md ./dist/README.md && cp-cli index.html ./dist/index.html", 13 | "build": "run-s _clean _bundle _transpile _styles _npm_ready", 14 | "_lint": "tslint -c tslint.json 'src/**/*.ts'", 15 | "_utest": "jest --coverage --env=jsdom", 16 | "test": "run-s _lint _utest" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/epiclabs-io/epic-video-comparator.git" 21 | }, 22 | "keywords": [ 23 | "epic", 24 | "labs", 25 | "epiclabs", 26 | "video", 27 | "player", 28 | "dash", 29 | "dashjs", 30 | "hls", 31 | "hls.js" 32 | ], 33 | "author": "Adrian Caballero ", 34 | "license": "MIT", 35 | "bugs": { 36 | "url": "https://github.com/epiclabs-io/epic-video-comparator/issues" 37 | }, 38 | "homepage": "https://github.com/epiclabs-io/epic-video-comparator#readme", 39 | "dependencies": { 40 | "@epiclabs/epic-video-player": "0.0.11", 41 | "screenfull": "4.0.0", 42 | "stream": "0.0.2" 43 | }, 44 | "devDependencies": { 45 | "@types/hls.js": "0.12.1", 46 | "@types/jest": "23.3.13", 47 | "@types/node": "10.12.18", 48 | "cp-cli": "1.1.2", 49 | "dashjs": "2.9.3", 50 | "hls.js": "0.12.2", 51 | "jest": "24.1.0", 52 | "npm-run-all": "4.1.5", 53 | "rimraf": "2.6.3", 54 | "sass": "1.17.0", 55 | "ts-jest": "23.10.5", 56 | "ts-loader": "5.3.3", 57 | "tslib": "1.9.3", 58 | "tslint": "5.12.1", 59 | "typescript": "3.2.4", 60 | "uglifyjs-webpack-plugin": "2.1.1", 61 | "webpack": "4.29.6", 62 | "webpack-cli": "3.2.3" 63 | }, 64 | "publishConfig": { 65 | "access": "public" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/comparator.ts: -------------------------------------------------------------------------------- 1 | import { IRendition, IStats, newPlayer, Player, PlayerClassType } from '@epiclabs/epic-video-player'; 2 | import * as screenfull from 'screenfull'; 3 | 4 | import { Events } from './events'; 5 | import { IComparatorConfig, IPlayerData, IStatsConfig, StatsConfig } from './models'; 6 | import { PidController } from './pid-controller'; 7 | 8 | export class Comparator { 9 | private static LIB_PREFIX = 'evc-'; 10 | private static PID_DIFF_OFFSET = 0.06917999999999935; 11 | private static DEFAULT_QUALITY_INDEX = 9999; 12 | private static DEFAULT_QUALITY_KBPS = 999999; 13 | 14 | public leftPlayer: Player; 15 | public rightPlayer: Player; 16 | 17 | private leftPlayerData: IPlayerData = {}; 18 | private rightPlayerData: IPlayerData = {}; 19 | private isSplitterSticked = true; 20 | private pidController: PidController; 21 | private fullScreenWrapper: HTMLDivElement; 22 | private isFullScreen = false; 23 | private statsInterval = undefined; 24 | 25 | private createdEvent = new Event(Events.CREATED_EVENT); 26 | private fullscreenToggle = new Event(Events.FULLSCREEN_TOGGLE_EVENT); 27 | 28 | constructor(public config: IComparatorConfig, public container: HTMLDivElement) { 29 | this.setInitialValues(); 30 | this.createVideoComparator(); 31 | this.initListeners(); 32 | return this; 33 | } 34 | 35 | public pause(): void { 36 | this.leftPlayer.pause(); 37 | this.rightPlayer.pause(); 38 | } 39 | 40 | public play(): void { 41 | this.leftPlayer.play(); 42 | this.rightPlayer.play(); 43 | this.hideSpinner(); 44 | } 45 | 46 | public togglePlayPause(): void { 47 | if (this.leftPlayer.htmlPlayer.paused) { 48 | this.play(); 49 | } else { 50 | this.pause(); 51 | } 52 | } 53 | 54 | public seek(time: number): void { 55 | this.showSpinner(); 56 | this.leftPlayer.currentTime(time); 57 | this.rightPlayer.currentTime(time); 58 | } 59 | 60 | public reload(): void { 61 | this.destroy(); 62 | this.setInitialValues(); 63 | this.createVideoComparator(); 64 | this.initListeners(); 65 | if (this.isFullScreen) { 66 | this.toggleFullScreenClasses(); 67 | } 68 | } 69 | 70 | public toggleFullScreen(): void { 71 | this.container.dispatchEvent(this.fullscreenToggle); 72 | if (this.isFullScreen) { 73 | screenfull.exit().catch(() => { 74 | this.isFullScreen = !this.isFullScreen; 75 | this.toggleFullScreen(); 76 | }); 77 | try { 78 | screen.orientation.unlock(); 79 | } catch (e) { 80 | // Screen API not available 81 | } 82 | } else { 83 | screenfull.request(this.container).catch(() => { 84 | this.isFullScreen = !this.isFullScreen; 85 | this.toggleFullScreen(); 86 | }); 87 | try { 88 | screen.orientation.lock('landscape-primary'); 89 | } catch (e) { 90 | // Screen API not available 91 | } 92 | } 93 | this.resizePlayers(); 94 | } 95 | 96 | /** 97 | * @deprecated since version 0.0.2 98 | */ 99 | public setRenditionKbps(player: 'left' | 'right' | Player, kbps: number): IRendition { 100 | return this.setRenditionByKbps(player, kbps); 101 | } 102 | 103 | public setRenditionByKbps(player: 'left' | 'right' | Player, kbps: number): IRendition { 104 | if (typeof kbps !== 'number') { 105 | return; 106 | } 107 | 108 | const playerObject = player === 'left' ? this.leftPlayer : player === 'right' ? this.rightPlayer : player; 109 | 110 | if (kbps < 0) { 111 | this.setAutoRendition(playerObject); 112 | return; 113 | } 114 | 115 | const renditions = this.getRenditions(playerObject); 116 | if (!renditions) { 117 | return; 118 | } 119 | 120 | let renditionBps = renditions[0].bitrate; 121 | let renditionIndex = 0; 122 | for (let i = 1; i < renditions.length; i++) { 123 | if (kbps >= Math.round(renditions[i].bitrate / 1000)) { 124 | renditionBps = renditions[i].bitrate; 125 | renditionIndex = i; 126 | } 127 | } 128 | 129 | this.setRendition(playerObject, renditionIndex, renditionBps); 130 | return renditions[renditionIndex]; 131 | } 132 | 133 | /** 134 | * @deprecated since version 0.0.2 135 | */ 136 | public setRenditionIndex(player: 'left' | 'right' | Player, index: number): IRendition { 137 | return this.setRenditionByIndex(player, index); 138 | } 139 | 140 | public setRenditionByIndex(player: 'left' | 'right' | Player, index: number): IRendition { 141 | if (typeof index !== 'number') { 142 | return; 143 | } 144 | 145 | const playerObject = player === 'left' ? this.leftPlayer : player === 'right' ? this.rightPlayer : player; 146 | 147 | if (index < 0) { 148 | this.setAutoRendition(playerObject); 149 | return; 150 | } 151 | 152 | const renditions = this.getRenditions(playerObject); 153 | if (!renditions) { 154 | return; 155 | } 156 | 157 | if (renditions[index]) { 158 | this.setRendition(playerObject, index, renditions[index].bitrate); 159 | return renditions[index]; 160 | } 161 | } 162 | 163 | public getRenditions(player: 'left' | 'right' | Player): IRendition[] { 164 | if (player === 'left') { 165 | return this.leftPlayer.getRenditions(); 166 | } else if (player === 'right') { 167 | return this.rightPlayer.getRenditions(); 168 | } else { 169 | return player.getRenditions(); 170 | } 171 | } 172 | 173 | public toggleStats(): void { 174 | const leftStatsContainer = this.container.getElementsByClassName(`${Comparator.LIB_PREFIX}left-stats`)[0] as HTMLDivElement; 175 | leftStatsContainer.classList.toggle('hidden'); 176 | const rightStatsContainer = this.container.getElementsByClassName(`${Comparator.LIB_PREFIX}right-stats`)[0] as HTMLDivElement; 177 | rightStatsContainer.classList.toggle('hidden'); 178 | } 179 | 180 | public updateStats(innerLeft: string, innerRight: string): void { 181 | clearInterval(this.statsInterval); 182 | this.config.stats = StatsConfig.customStats(); 183 | const leftStatsContainer = this.container.getElementsByClassName(`${Comparator.LIB_PREFIX}left-stats`)[0] as HTMLDivElement; 184 | leftStatsContainer.innerHTML = innerLeft; 185 | const rightStatsContainer = this.container.getElementsByClassName(`${Comparator.LIB_PREFIX}right-stats`)[0] as HTMLDivElement; 186 | rightStatsContainer.innerHTML = innerRight; 187 | 188 | } 189 | 190 | public destroy(): void { 191 | this.destroyListeners(); 192 | this.cleanVideoComparator(); 193 | } 194 | 195 | private updateStatsBox(player: 'left' | 'right', stats: IStats, rendition: IRendition): void { 196 | if (this.config.stats === false || (this.config.stats as IStatsConfig).custom === true) { 197 | return; 198 | } 199 | 200 | let inner = ''; 201 | const statsConfig = this.config.stats as IStatsConfig; 202 | 203 | if (statsConfig.showDuration !== false && stats && stats.duration > 0) { 204 | inner += `

Duration: ${Math.round(stats.duration)} s

`; 205 | } 206 | 207 | if (statsConfig.showDroppedFrames !== false && stats && stats.droppedFrames >= 0) { 208 | inner += `

Dropped frames: ${stats.droppedFrames}

`; 209 | } 210 | 211 | if (statsConfig.showBuffered !== false && stats && stats.buffered !== undefined) { 212 | inner += `

Buffered: ${this.getTotalBuffer(stats.buffered)} s

`; 213 | } 214 | 215 | if (statsConfig.showStartupTime !== false && stats && stats.loadTime > 0) { 216 | inner += `

Startup time: ${Math.round(stats.loadTime * 100) / 100} s

`; 217 | } 218 | 219 | if (statsConfig.showBitrate !== false && rendition && rendition.bitrate > 0) { 220 | inner += `

Bitrate: ${Math.round(rendition.bitrate / 1000)} Kbps

`; 221 | } 222 | 223 | if (statsConfig.showResolution !== false && rendition && rendition.width > 0 && rendition.height > 0) { 224 | inner += `

Resolution: ${rendition.width}x${rendition.height}

`; 225 | } 226 | 227 | if (statsConfig.showVideoCodec !== false && rendition && !!rendition.videoCodec) { 228 | inner += `

Video codec: ${rendition.videoCodec}

`; 229 | } 230 | 231 | if (statsConfig.showAudioCodec !== false && rendition && !!rendition.audioCodec) { 232 | inner += `

Audio codec: ${rendition.audioCodec}

`; 233 | } 234 | 235 | const statsContainer = this.container.getElementsByClassName(`${Comparator.LIB_PREFIX}${player}-stats`)[0] as HTMLDivElement; 236 | statsContainer.innerHTML = inner; 237 | } 238 | 239 | private getTotalBuffer(buffered: Array<{ start: number; end: number; }>): number { 240 | let res = 0; 241 | if (buffered !== undefined && buffered.length > 0) { 242 | for (const buffer of buffered) { 243 | res += (buffer.end - buffer.start); 244 | } 245 | } 246 | return Math.round(res); 247 | } 248 | 249 | private cleanVideoComparator(): void { 250 | if (this.leftPlayer) { 251 | this.leftPlayer.destroy(); 252 | } 253 | if (this.rightPlayer) { 254 | this.rightPlayer.destroy(); 255 | } 256 | while (this.container.firstChild) { 257 | this.container.removeChild(this.container.firstChild); 258 | } 259 | } 260 | 261 | private seekInner($event): void { 262 | const seekBar = (this.container.getElementsByClassName(`${Comparator.LIB_PREFIX}seek-bar`)[0] as HTMLDivElement); 263 | const seekBarInner = (this.container.getElementsByClassName(`${Comparator.LIB_PREFIX}seek-bar-inner`)[0] as HTMLDivElement); 264 | const time = $event.offsetX * this.leftPlayerData.duration / seekBar.offsetWidth; 265 | seekBarInner.style.width = (time / this.leftPlayerData.duration * 100) + '%'; 266 | this.seek(time); 267 | } 268 | 269 | private createVideoComparator(): void { 270 | this.container.classList.add(`${Comparator.LIB_PREFIX}container`); 271 | 272 | this.fullScreenWrapper = document.createElement('div'); 273 | this.fullScreenWrapper.className = `${Comparator.LIB_PREFIX}full-screen-wrapper`; 274 | this.container.appendChild(this.fullScreenWrapper); 275 | 276 | const wrapper = document.createElement('div'); 277 | wrapper.className = `${Comparator.LIB_PREFIX}wrapper`; 278 | const leftVideoWrapper = this.createVideoPlayer('left'); 279 | const rightVideoWrapper = this.createVideoPlayer('right'); 280 | wrapper.appendChild(leftVideoWrapper); 281 | wrapper.appendChild(rightVideoWrapper); 282 | 283 | this.fullScreenWrapper.appendChild(this.createLoadingSpinner()); 284 | this.fullScreenWrapper.appendChild(wrapper); 285 | if (this.config.mediaControls !== false) { 286 | this.fullScreenWrapper.appendChild(this.createMediaControls()); 287 | } 288 | 289 | this.leftPlayer = newPlayer(this.config.leftUrl, leftVideoWrapper.getElementsByTagName('video')[0], this.leftPlayerData.config); 290 | this.rightPlayer = newPlayer(this.config.rightUrl, rightVideoWrapper.getElementsByTagName('video')[0], this.rightPlayerData.config); 291 | 292 | this.container.dispatchEvent(this.createdEvent); 293 | } 294 | 295 | private createVideoPlayer(player: 'left' | 'right'): HTMLDivElement { 296 | const videoWrapper = document.createElement('div'); 297 | videoWrapper.className = `${Comparator.LIB_PREFIX}${player}-video-wrapper`; 298 | const videoElement = document.createElement('video'); 299 | videoElement.className = `${Comparator.LIB_PREFIX}${player}-video`; 300 | videoElement.muted = true; 301 | videoElement.autoplay = false; 302 | videoWrapper.appendChild(videoElement); 303 | videoWrapper.appendChild(this.createStatsBox(player)); 304 | return videoWrapper; 305 | } 306 | 307 | private createStatsBox(player: 'left' | 'right'): HTMLDivElement { 308 | const stats = document.createElement('div'); 309 | stats.className = `${Comparator.LIB_PREFIX}${player}-stats`; 310 | stats.classList.add('hidden'); 311 | return stats; 312 | } 313 | 314 | private createLoadingSpinner(): HTMLDivElement { 315 | const loadingSpiner = document.createElement('div'); 316 | loadingSpiner.className = `${Comparator.LIB_PREFIX}loading-spinner`; 317 | loadingSpiner.innerHTML = '
'; 318 | return loadingSpiner; 319 | } 320 | 321 | private createMediaControls(): HTMLDivElement { 322 | const controls = document.createElement('div'); 323 | controls.className = `${Comparator.LIB_PREFIX}media-controls`; 324 | 325 | // play pause button 326 | const playPause = document.createElement('div'); 327 | playPause.className = `${Comparator.LIB_PREFIX}play-pause`; 328 | playPause.onclick = () => this.togglePlayPause(); 329 | controls.appendChild(playPause); 330 | 331 | // reload button 332 | const reload = document.createElement('div'); 333 | reload.className = `${Comparator.LIB_PREFIX}reload`; 334 | reload.title = 'Reload'; 335 | reload.onclick = () => this.reload(); 336 | reload.appendChild(document.createElement('div')); 337 | controls.appendChild(reload); 338 | 339 | // seekbar 340 | const seekBar = document.createElement('div'); 341 | seekBar.className = `${Comparator.LIB_PREFIX}seek-bar`; 342 | seekBar.onclick = ($event) => this.seekInner($event); 343 | const seekBarInner = document.createElement('div'); 344 | seekBarInner.className = `${Comparator.LIB_PREFIX}seek-bar-inner`; 345 | seekBar.appendChild(seekBarInner); 346 | controls.appendChild(seekBar); 347 | 348 | // quality selector popup 349 | const qualitySelectorPopupWrapper = document.createElement('div'); 350 | qualitySelectorPopupWrapper.className = `${Comparator.LIB_PREFIX}quality-selector-popup-wrapper`; 351 | const qualitySelectorPopup = document.createElement('div'); 352 | qualitySelectorPopup.className = `${Comparator.LIB_PREFIX}quality-selector-popup`; 353 | qualitySelectorPopupWrapper.appendChild(qualitySelectorPopup); 354 | this.container.getElementsByClassName(`${Comparator.LIB_PREFIX}wrapper`)[0].appendChild(qualitySelectorPopupWrapper); 355 | 356 | // quality selector button 357 | const qualitySelectorIcon = document.createElement('div'); 358 | qualitySelectorIcon.className = `${Comparator.LIB_PREFIX}quality-icon`; 359 | qualitySelectorIcon.title = 'Quality selector'; 360 | qualitySelectorIcon.onclick = ($event) => this.onQualityIconClick($event, qualitySelectorIcon, qualitySelectorPopup); 361 | controls.appendChild(qualitySelectorIcon); 362 | 363 | // fullscreen button 364 | const fullScreen = document.createElement('div'); 365 | fullScreen.className = `${Comparator.LIB_PREFIX}full-screen`; 366 | fullScreen.title = 'Full screen'; 367 | fullScreen.onclick = () => this.toggleFullScreen(); 368 | controls.appendChild(fullScreen); 369 | 370 | return controls; 371 | } 372 | 373 | private onQualityIconClick($event: MouseEvent, icon: HTMLDivElement, popup: HTMLDivElement): void { 374 | popup.classList.toggle('visible'); 375 | icon.classList.toggle('active'); 376 | } 377 | 378 | private setInitialValues() { 379 | this.leftPlayerData = { 380 | config: this.leftPlayerData.config || { 381 | initialRenditionIndex: Comparator.DEFAULT_QUALITY_INDEX, 382 | initialRenditionKbps: Comparator.DEFAULT_QUALITY_KBPS, 383 | }, 384 | duration: undefined, 385 | isInitialized: false, 386 | }; 387 | 388 | this.rightPlayerData = { 389 | config: this.rightPlayerData.config || { 390 | initialRenditionIndex: Comparator.DEFAULT_QUALITY_INDEX, 391 | initialRenditionKbps: Comparator.DEFAULT_QUALITY_KBPS, 392 | }, 393 | duration: undefined, 394 | isInitialized: false, 395 | }; 396 | 397 | this.pidController = undefined; 398 | 399 | if (this.config.stats === undefined) { 400 | this.config.stats = StatsConfig.defaultStats(); 401 | } else if ((this.config.stats as IStatsConfig).custom === true) { 402 | this.config.stats = StatsConfig.customStats(); 403 | } 404 | } 405 | 406 | private setPidController() { 407 | const target = this.leftPlayer.playerType === this.rightPlayer.playerType ? 0 : 408 | Comparator.PID_DIFF_OFFSET; 409 | 410 | this.pidController = new PidController(0.5, 0.1, 0.1, target); 411 | } 412 | 413 | private showSpinner(): void { 414 | this.container.getElementsByClassName(`${Comparator.LIB_PREFIX}loading-spinner`)[0].classList.remove('hidden'); 415 | } 416 | 417 | private hideSpinner(): void { 418 | this.container.getElementsByClassName(`${Comparator.LIB_PREFIX}loading-spinner`)[0].classList.add('hidden'); 419 | } 420 | 421 | private populateQualitySelector(): void { 422 | if (this.config.mediaControls === false) { 423 | return; 424 | } 425 | 426 | const popup = this.container.getElementsByClassName(`${Comparator.LIB_PREFIX}quality-selector-popup`)[0]; 427 | 428 | while (popup.firstChild) { 429 | popup.removeChild(popup.firstChild); 430 | } 431 | 432 | this.populateQualitySelectorSide(this.leftPlayer, this.leftPlayerData, popup as HTMLDivElement); 433 | this.populateQualitySelectorSide(this.rightPlayer, this.rightPlayerData, popup as HTMLDivElement); 434 | } 435 | 436 | private populateQualitySelectorSide(player: Player, data: IPlayerData, popup: HTMLDivElement) { 437 | const [renditions, currentRendition] = [player.getRenditions(), player.getCurrentRendition()]; 438 | 439 | const sideElementList = document.createElement('ul'); 440 | 441 | if (!renditions) { 442 | return; 443 | } 444 | 445 | if (data.config.initialRenditionIndex === Comparator.DEFAULT_QUALITY_INDEX && 446 | data.config.initialRenditionKbps === Comparator.DEFAULT_QUALITY_KBPS) { 447 | data.config.initialRenditionIndex = renditions.length - 1; 448 | data.config.initialRenditionKbps = renditions[renditions.length - 1].bitrate / 1000; 449 | } 450 | 451 | const listItemAuto = document.createElement('li'); 452 | listItemAuto.innerHTML = `${data.config.initialRenditionIndex === -1 ? '> ' : ''}Auto`; 453 | listItemAuto.onclick = () => this.setAutoRendition(player); 454 | sideElementList.appendChild(listItemAuto); 455 | 456 | for (let i = 0; i < renditions.length; i++) { 457 | const listItem = document.createElement('li'); 458 | const selected = data.config.initialRenditionIndex === i ? '> ' : ''; 459 | const [width, height, kbps] = [renditions[i].width, renditions[i].height, Math.round(renditions[i].bitrate / 1000)]; 460 | listItem.innerHTML = `${selected}${width}x${height} (${kbps} kbps)`; 461 | listItem.className = currentRendition && renditions[i].bitrate === currentRendition.bitrate ? 'current' : ''; 462 | listItem.onclick = () => this.setRendition(player, i, renditions[i].bitrate); 463 | sideElementList.appendChild(listItem); 464 | } 465 | 466 | const sideElement = document.createElement('div'); 467 | const side = player === this.leftPlayer ? 'LEFT' : 'RIGHT'; 468 | sideElement.innerHTML = `

${side}

`; 469 | sideElement.appendChild(sideElementList); 470 | popup.appendChild(sideElement); 471 | } 472 | 473 | private setRendition(player: Player, index: number, bitrate: number): void { 474 | player.config.initialRenditionIndex = index; 475 | player.config.initialRenditionKbps = bitrate >= 0 ? Math.round(bitrate / 1000) + 1 : -1; 476 | 477 | if (player === this.leftPlayer) { 478 | this.leftPlayerData.config.initialRenditionIndex = index; 479 | } else { 480 | this.rightPlayerData.config.initialRenditionIndex = index; 481 | } 482 | this.reload(); 483 | } 484 | 485 | private setAutoRendition(player: Player): void { 486 | this.setRendition(player, -1, -1); 487 | } 488 | 489 | /** 490 | * Event listeners 491 | */ 492 | 493 | private initListeners(): void { 494 | if (screenfull && screenfull.on) { 495 | screenfull.on('change', this.onFullscreenChange); 496 | } 497 | 498 | this.leftPlayer.htmlPlayer.addEventListener('canplaythrough', this.onCanPlayThrough); 499 | this.leftPlayer.htmlPlayer.addEventListener('ended', this.onEnded); 500 | this.leftPlayer.htmlPlayer.addEventListener('loadstart', this.onLoadStart); 501 | this.leftPlayer.htmlPlayer.addEventListener('pause', this.onPause); 502 | this.leftPlayer.htmlPlayer.addEventListener('play', this.onPlay); 503 | this.leftPlayer.htmlPlayer.addEventListener('seeked', this.onSeeked); 504 | this.leftPlayer.htmlPlayer.addEventListener('seeking', this.onSeeking); 505 | this.leftPlayer.htmlPlayer.addEventListener('timeupdate', this.onTimeUpdate); 506 | 507 | this.rightPlayer.htmlPlayer.addEventListener('canplaythrough', this.onCanPlayThrough); 508 | this.rightPlayer.htmlPlayer.addEventListener('ended', this.onEnded); 509 | this.leftPlayer.htmlPlayer.addEventListener('pause', this.onPause); 510 | this.leftPlayer.htmlPlayer.addEventListener('play', this.onPlay); 511 | this.rightPlayer.htmlPlayer.addEventListener('seeked', this.onSeeked); 512 | this.rightPlayer.htmlPlayer.addEventListener('seeking', this.onSeeking); 513 | 514 | const wrapper = this.container.getElementsByClassName(`${Comparator.LIB_PREFIX}wrapper`)[0] as HTMLDivElement; 515 | const popupWrapper = this.container.getElementsByClassName(`${Comparator.LIB_PREFIX}quality-selector-popup-wrapper`)[0]; 516 | const leftStatsWrappers = this.container.getElementsByClassName(`${Comparator.LIB_PREFIX}left-stats`)[0]; 517 | const rightStatsWrappers = this.container.getElementsByClassName(`${Comparator.LIB_PREFIX}right-stats`)[0]; 518 | 519 | const moveSplit = (event) => { 520 | if (!this.isSplitterSticked) { 521 | const leftWrapper = (wrapper.getElementsByClassName(`${Comparator.LIB_PREFIX}left-video-wrapper`)[0] as HTMLDivElement); 522 | leftWrapper.style.width = event.offsetX + 'px'; 523 | leftWrapper.getElementsByTagName('video')[0].style.width = wrapper.offsetWidth + 'px'; 524 | } 525 | }; 526 | 527 | const stickSplit = (event) => { 528 | this.isSplitterSticked = !this.isSplitterSticked; 529 | if (!this.isSplitterSticked) { 530 | moveSplit(event); 531 | } 532 | if (this.config.mediaControls !== false) { 533 | popupWrapper.classList.toggle('moving-split'); 534 | } 535 | leftStatsWrappers.classList.toggle('moving-split'); 536 | rightStatsWrappers.classList.toggle('moving-split'); 537 | }; 538 | 539 | wrapper.onmousemove = moveSplit; 540 | wrapper.ontouchstart = moveSplit; 541 | wrapper.ontouchmove = moveSplit; 542 | wrapper.onclick = stickSplit; 543 | window.addEventListener('resize', this.resizePlayers); 544 | 545 | if (this.config.stats !== false) { 546 | leftStatsWrappers.classList.remove('hidden'); 547 | rightStatsWrappers.classList.remove('hidden'); 548 | this.updateStatsBox('left', this.leftPlayer.getStats(), this.leftPlayer.getCurrentRendition()); 549 | this.updateStatsBox('right', this.rightPlayer.getStats(), this.rightPlayer.getCurrentRendition()); 550 | this.statsInterval = setInterval(() => { 551 | this.updateStatsBox('left', this.leftPlayer.getStats(), this.leftPlayer.getCurrentRendition()); 552 | this.updateStatsBox('right', this.rightPlayer.getStats(), this.rightPlayer.getCurrentRendition()); 553 | }, 1500); 554 | } 555 | } 556 | 557 | private destroyListeners(): void { 558 | if (screenfull && screenfull.off) { 559 | screenfull.off('change', this.onFullscreenChange); 560 | } 561 | 562 | clearInterval(this.statsInterval); 563 | 564 | this.leftPlayer.htmlPlayer.removeEventListener('canplaythrough', this.onCanPlayThrough); 565 | this.leftPlayer.htmlPlayer.removeEventListener('ended', this.onEnded); 566 | this.leftPlayer.htmlPlayer.removeEventListener('loadstart', this.onLoadStart); 567 | this.leftPlayer.htmlPlayer.removeEventListener('pause', this.onPause); 568 | this.leftPlayer.htmlPlayer.removeEventListener('play', this.onPlay); 569 | this.leftPlayer.htmlPlayer.removeEventListener('seeked', this.onSeeked); 570 | this.leftPlayer.htmlPlayer.removeEventListener('seeking', this.onSeeking); 571 | this.leftPlayer.htmlPlayer.removeEventListener('timeupdate', this.onTimeUpdate); 572 | 573 | this.rightPlayer.htmlPlayer.removeEventListener('canplaythrough', this.onCanPlayThrough); 574 | this.rightPlayer.htmlPlayer.removeEventListener('ended', this.onEnded); 575 | this.leftPlayer.htmlPlayer.removeEventListener('pause', this.onPause); 576 | this.leftPlayer.htmlPlayer.removeEventListener('play', this.onPlay); 577 | this.rightPlayer.htmlPlayer.removeEventListener('seeked', this.onSeeked); 578 | this.rightPlayer.htmlPlayer.removeEventListener('seeking', this.onSeeking); 579 | 580 | const wrapper = this.container.getElementsByClassName(`${Comparator.LIB_PREFIX}wrapper`)[0] as HTMLDivElement; 581 | wrapper.onmousemove = undefined; 582 | wrapper.ontouchstart = undefined; 583 | wrapper.ontouchmove = undefined; 584 | wrapper.onclick = undefined; 585 | window.removeEventListener('resize', this.resizePlayers); 586 | } 587 | 588 | private onCanPlayThrough = (evt: Event): void => { 589 | if (!this.leftPlayerData.isInitialized || !this.rightPlayerData.isInitialized) { 590 | if ((evt.target as HTMLVideoElement).classList.contains(`${Comparator.LIB_PREFIX}left-video`)) { 591 | this.leftPlayerData.isInitialized = true; 592 | this.leftPlayerData.duration = this.leftPlayer.htmlPlayer.duration; 593 | this.leftPlayer.htmlPlayer.oncanplaythrough = undefined; 594 | if (this.rightPlayerData.isInitialized) { 595 | this.onCanPlayThroughBoth(); 596 | } 597 | } else { 598 | this.rightPlayerData.isInitialized = true; 599 | this.rightPlayerData.duration = this.leftPlayer.htmlPlayer.duration; 600 | this.rightPlayer.htmlPlayer.oncanplaythrough = undefined; 601 | if (this.leftPlayerData.isInitialized) { 602 | this.onCanPlayThroughBoth(); 603 | } 604 | } 605 | } 606 | } 607 | 608 | private onCanPlayThroughBoth(): void { 609 | this.hideSpinner(); 610 | this.resizePlayers(); 611 | this.populateQualitySelector(); 612 | this.updatePlayersData(); 613 | if (this.config.autoplay !== false) { 614 | this.play(); 615 | } else { 616 | setTimeout(() => { 617 | this.pause(); 618 | }, 1000); 619 | } 620 | } 621 | 622 | private onEnded = (evt: Event): void => { 623 | if (this.config.loop !== false) { 624 | this.reload(); 625 | } 626 | } 627 | 628 | private onLoadStart = (evt: Event): void => { 629 | this.container.classList.add('loaded-metadata'); 630 | } 631 | 632 | private onSeeked = (evt: Event): void => { 633 | const player = (evt.target as HTMLVideoElement).classList.contains(`${Comparator.LIB_PREFIX}left-video`) ? 'left' : 'right'; 634 | if (player === 'left' && !this.rightPlayer.htmlPlayer.seeking || player === 'right' && !this.leftPlayer.htmlPlayer.seeking) { 635 | this.play(); 636 | } else { 637 | this.pause(); 638 | this.showSpinner(); 639 | } 640 | } 641 | 642 | private onSeeking = (evt: Event): void => { 643 | this.pause(); 644 | this.showSpinner(); 645 | } 646 | 647 | private onPlay = (evt: Event): void => { 648 | const playPause = this.container.getElementsByClassName(`${Comparator.LIB_PREFIX}play-pause`)[0] as HTMLDivElement; 649 | if (playPause !== undefined) { 650 | playPause.classList.add('playing'); 651 | playPause.title = 'Pause'; 652 | } 653 | } 654 | 655 | private onPause = (evt: Event): void => { 656 | const playPause = this.container.getElementsByClassName(`${Comparator.LIB_PREFIX}play-pause`)[0] as HTMLDivElement; 657 | if (playPause !== undefined) { 658 | playPause.classList.remove('playing'); 659 | playPause.title = 'Play'; 660 | } 661 | } 662 | 663 | private onTimeUpdate = (evt: Event): void => { 664 | if (!this.pidController) { 665 | this.setPidController(); 666 | } 667 | 668 | if (this.updatePlayersData()) { 669 | this.populateQualitySelector(); 670 | } 671 | 672 | const leftCurrentTime = this.leftPlayer.currentTime() as number; 673 | const rightCurrentTime = this.rightPlayer.currentTime() as number; 674 | 675 | const seekBarInner = (this.container.getElementsByClassName(`${Comparator.LIB_PREFIX}seek-bar-inner`)[0] as HTMLDivElement); 676 | if (seekBarInner !== undefined) { 677 | seekBarInner.style.width = (leftCurrentTime / this.leftPlayerData.duration * 100) + '%'; 678 | } 679 | 680 | const diff = leftCurrentTime - rightCurrentTime; 681 | const update = this.pidController.update(diff); 682 | let rate = 1 + update; 683 | rate = rate < 0.0625 ? 0.0625 : rate > 2 ? 2 : rate; 684 | this.leftPlayer.playbackRate(rate); 685 | } 686 | 687 | private updatePlayersData(): boolean { 688 | const lChanged = this.updatePlayerData(this.leftPlayer, this.leftPlayerData); 689 | const rChanged = this.updatePlayerData(this.rightPlayer, this.rightPlayerData); 690 | return lChanged || rChanged; 691 | } 692 | 693 | // returns true if any value has changed 694 | private updatePlayerData(player: Player, data: IPlayerData): boolean { 695 | let changed = false; 696 | 697 | const currentRendition = player.getCurrentRendition(); 698 | 699 | if (currentRendition) { 700 | changed = data.currentBitrate !== currentRendition.bitrate ? true : changed; 701 | data.currentBitrate = currentRendition.bitrate; 702 | 703 | changed = data.currentWidth !== currentRendition.width ? true : changed; 704 | data.currentWidth = currentRendition.width; 705 | 706 | changed = data.currentHeight !== currentRendition.height ? true : changed; 707 | data.currentHeight = currentRendition.height; 708 | } 709 | 710 | const renditions = player.getRenditions(); 711 | changed = this.areEqualRenditions(renditions, data.renditions) === false ? true : changed; 712 | data.renditions = renditions; 713 | 714 | return changed; 715 | } 716 | 717 | private areEqualRenditions(rend1: IRendition[], rend2: IRendition[]): boolean { 718 | if (rend1 === undefined || rend2 === undefined || rend1.length !== rend2.length) { 719 | return false; 720 | } 721 | 722 | if (rend1.length > 0 && rend2.length > 0) { 723 | for (let i = 0; i < rend1.length; i++) { 724 | if (rend1[i].bitrate !== rend2[i].bitrate && rend1[i].level !== rend2[i].level && rend1[i].height !== rend2[i].height) { 725 | return false; 726 | } 727 | } 728 | } 729 | 730 | return true; 731 | } 732 | 733 | private onFullscreenChange = () => { 734 | this.isFullScreen = !this.isFullScreen; 735 | this.toggleFullScreenClasses(); 736 | this.resizePlayers(); 737 | } 738 | 739 | private toggleFullScreenClasses(): void { 740 | this.container.getElementsByClassName(`${Comparator.LIB_PREFIX}wrapper`)[0].classList.toggle('full-screen-mode'); 741 | this.container.getElementsByClassName(`${Comparator.LIB_PREFIX}media-controls`)[0].classList.toggle('full-screen-mode'); 742 | 743 | if (this.isFullScreen === true) { 744 | const width = this.leftPlayer.htmlPlayer.videoWidth; 745 | const height = this.leftPlayer.htmlPlayer.videoHeight; 746 | const maxWidth = `calc(100vh * ${width} / ${height} - 100px)`; 747 | 748 | (this.container.getElementsByClassName(`${Comparator.LIB_PREFIX}wrapper`)[0] as HTMLDivElement).style.maxWidth = maxWidth; 749 | (this.container.getElementsByClassName(`${Comparator.LIB_PREFIX}media-controls`)[0] as HTMLDivElement).style.maxWidth = maxWidth; 750 | } else { 751 | (this.container.getElementsByClassName(`${Comparator.LIB_PREFIX}wrapper`)[0] as HTMLDivElement).style.maxWidth = 'unset'; 752 | (this.container.getElementsByClassName(`${Comparator.LIB_PREFIX}media-controls`)[0] as HTMLDivElement).style.maxWidth = 'unset'; 753 | } 754 | } 755 | 756 | private resizePlayers = () => { 757 | const wrapper = this.container.getElementsByClassName(`${Comparator.LIB_PREFIX}wrapper`)[0] as HTMLDivElement; 758 | const wrapperWidth = wrapper.offsetWidth; 759 | const leftWrapper = (wrapper.getElementsByClassName(`${Comparator.LIB_PREFIX}left-video-wrapper`)[0] as HTMLDivElement); 760 | leftWrapper.style.width = (wrapperWidth / 2) + 'px'; 761 | leftWrapper.getElementsByTagName('video')[0].style.width = wrapperWidth + 'px'; 762 | } 763 | } 764 | -------------------------------------------------------------------------------- /src/events.ts: -------------------------------------------------------------------------------- 1 | export class Events { 2 | public static CREATED_EVENT = 'created'; 3 | public static FULLSCREEN_TOGGLE_EVENT = 'fullscreen_toggle'; 4 | } 5 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { Comparator } from './comparator'; 2 | export * from './models'; 3 | -------------------------------------------------------------------------------- /src/models.ts: -------------------------------------------------------------------------------- 1 | import { IPlayerConfig, IRendition } from '@epiclabs/epic-video-player'; 2 | 3 | export interface IComparatorConfig { 4 | autoplay?: boolean; 5 | leftUrl: string; 6 | loop?: boolean; 7 | rightUrl: string; 8 | mediaControls?: boolean; 9 | stats?: IStatsConfig | boolean; 10 | } 11 | 12 | export interface IStatsConfig { 13 | showDuration?: boolean; 14 | showBitrate?: boolean; 15 | showResolution?: boolean; 16 | showVideoCodec?: boolean; 17 | showAudioCodec?: boolean; 18 | showDroppedFrames?: boolean; 19 | showBuffered?: boolean; 20 | showStartupTime?: boolean; 21 | custom?: boolean; 22 | } 23 | 24 | export class StatsConfig implements IStatsConfig { 25 | public static customStats(): IStatsConfig { 26 | const statsConfig = new StatsConfig(); 27 | statsConfig.showDuration = false; 28 | statsConfig.showBitrate = false; 29 | statsConfig.showResolution = false; 30 | statsConfig.showVideoCodec = false; 31 | statsConfig.showAudioCodec = false; 32 | statsConfig.showDroppedFrames = false; 33 | statsConfig.showBuffered = false; 34 | statsConfig.showStartupTime = false; 35 | statsConfig.custom = true; 36 | return statsConfig; 37 | } 38 | 39 | public static defaultStats(): IStatsConfig { 40 | return new StatsConfig(); 41 | } 42 | 43 | public showDuration = true; 44 | public showBitrate = true; 45 | public showResolution = true; 46 | public showVideoCodec = true; 47 | public showAudioCodec = true; 48 | public showDroppedFrames = true; 49 | public showBuffered = true; 50 | public showStartupTime = true; 51 | public custom = false; 52 | } 53 | 54 | export interface IPlayerData { 55 | config?: IPlayerConfig; 56 | currentBitrate?: number; 57 | currentHeight?: number; 58 | currentWidth?: number; 59 | duration?: number; 60 | isInitialized?: boolean; 61 | renditions?: IRendition[]; 62 | } 63 | -------------------------------------------------------------------------------- /src/pid-controller.ts: -------------------------------------------------------------------------------- 1 | export class PidController { 2 | private sumError: number; 3 | private lastError: number; 4 | private lastTime: number; 5 | 6 | private dT: number; 7 | private iMax: number; 8 | private currentValue: number; 9 | 10 | constructor(private kP: number, private kI: number, private kD: number, private target: number) { 11 | // PID constants 12 | this.kP = kP || 1; 13 | this.kI = kI || 0; 14 | this.kD = kD || 0; 15 | 16 | this.target = target; 17 | 18 | // Interval of time between two updates 19 | this.dT = 0; 20 | 21 | // Maximum absolute value of sumError 22 | this.iMax = 0; 23 | 24 | this.sumError = 0; 25 | this.lastError = 0; 26 | this.lastTime = 0; 27 | 28 | this.target = 0; // default value, can be modified with .setTarget 29 | } 30 | 31 | public update(currentValue): number { 32 | this.currentValue = currentValue; 33 | 34 | // Calculate dt 35 | let dt = this.dT; 36 | if (!dt) { 37 | const currentTime = Date.now(); 38 | if (this.lastTime === 0) { // First time update() is called 39 | dt = 0; 40 | } else { 41 | dt = (currentTime - this.lastTime) / 1000; // in seconds 42 | } 43 | this.lastTime = currentTime; 44 | } 45 | if (typeof dt !== 'number' || dt === 0) { 46 | dt = 1; 47 | } 48 | 49 | const error = (this.target - this.currentValue); 50 | this.sumError = this.sumError + error * dt; 51 | if (this.iMax > 0 && Math.abs(this.sumError) > this.iMax) { 52 | const sumSign = (this.sumError > 0) ? 1 : -1; 53 | this.sumError = sumSign * this.iMax; 54 | } 55 | 56 | const dError = (error - this.lastError) / dt; 57 | this.lastError = error; 58 | 59 | return (this.kP * error) + (this.kI * this.sumError) + (this.kD * dError); 60 | } 61 | 62 | public reset(): void { 63 | this.sumError = 0; 64 | this.lastError = 0; 65 | this.lastTime = 0; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/styles/index.scss: -------------------------------------------------------------------------------- 1 | $library-prefix: 'evc-'; 2 | 3 | $hightlight-color-1: '#7abb00'; 4 | $hightlight-color-2: '#fed100'; 5 | $dark-color: '#202020'; 6 | $splitter-color: '#ffffff'; 7 | 8 | .#{$library-prefix}container { 9 | position: relative; 10 | margin: 0 auto; 11 | background-color: black; 12 | 13 | *, *:before, *:after { 14 | box-sizing: content-box; 15 | -webkit-user-select: none; 16 | -moz-user-select: none; 17 | -ms-user-select: none; 18 | user-select: none; 19 | } 20 | 21 | *:focus { 22 | outline: none; 23 | } 24 | 25 | *.full-screen-mode { 26 | margin: 0 auto; 27 | max-width: calc(100vh * 16 / 9 - 100px); 28 | } 29 | 30 | &.loaded-metadata { 31 | background-color: transparent; 32 | 33 | .#{$library-prefix}wrapper { 34 | .#{$library-prefix}left-video-wrapper { 35 | border-right: 2px dashed #{$splitter-color}; 36 | } 37 | } 38 | } 39 | 40 | &:not(.loaded-metadata) { 41 | .#{$library-prefix}media-controls { 42 | display: none; 43 | } 44 | } 45 | 46 | .#{$library-prefix}wrapper { 47 | 48 | .#{$library-prefix}left-stats, .#{$library-prefix}right-stats { 49 | position: absolute; 50 | white-space: nowrap; 51 | min-width: 133px; 52 | background-color: rgba(0, 0, 0, 0.4); 53 | padding: 5px; 54 | -webkit-border-radius: 2px; 55 | -moz-border-radius: 2px; 56 | border-radius: 2px; 57 | top: 10px; 58 | color: white; 59 | font-size: 0.85em; 60 | 61 | p { 62 | margin: 0; 63 | } 64 | } 65 | .#{$library-prefix}left-stats { 66 | left: 10px; 67 | } 68 | .#{$library-prefix}right-stats { 69 | right: 10px; 70 | } 71 | .#{$library-prefix}left-stats.hidden, .#{$library-prefix}right-stats.hidden { 72 | display: none; 73 | } 74 | .#{$library-prefix}left-stats.moving-split, .#{$library-prefix}right-stats.moving-split { 75 | pointer-events: none; 76 | opacity: .6; 77 | } 78 | 79 | .#{$library-prefix}left-video-wrapper { 80 | overflow: hidden; 81 | position: absolute; 82 | top: 0; 83 | z-index: 1; 84 | width: 50%; 85 | 86 | .#{$library-prefix}left-video { 87 | display: block; 88 | width: 200%; 89 | position: relative; 90 | } 91 | } 92 | 93 | .#{$library-prefix}right-video-wrapper { 94 | width: 100%; 95 | position: relative; 96 | 97 | .#{$library-prefix}right-video { 98 | display: block; 99 | width: 100%; 100 | } 101 | } 102 | 103 | .#{$library-prefix}quality-selector-popup-wrapper { 104 | position: relative; 105 | z-index: 2; 106 | 107 | &.moving-split { 108 | pointer-events: none; 109 | opacity: .6; 110 | } 111 | 112 | .#{$library-prefix}quality-selector-popup { 113 | padding: 10px; 114 | position: absolute; 115 | grid-gap: 10px; 116 | bottom: 10px; 117 | right: 6px; 118 | color: white; 119 | display: grid; 120 | background-color: rgba(1, 1, 1, .7); 121 | grid-template-columns: 1fr 1fr; 122 | font-family: monospace; 123 | transform: scale(.0000001); 124 | transition: transform .2s ease-out; 125 | 126 | p { 127 | margin: 0; 128 | border-bottom: 1px dashed white; 129 | } 130 | 131 | &.visible { 132 | transform: scale(1); 133 | } 134 | 135 | ul { 136 | padding: 0; 137 | margin: 0; 138 | list-style: none; 139 | 140 | li { 141 | cursor: pointer; 142 | 143 | &.current { 144 | color: #{$hightlight-color-1}; 145 | } 146 | 147 | &:not(.current) { 148 | &:hover { 149 | color: #{$hightlight-color-2}; 150 | } 151 | } 152 | } 153 | } 154 | } 155 | } 156 | } 157 | 158 | .#{$library-prefix}loading-spinner { 159 | position: absolute; 160 | top: 0; 161 | right: 0; 162 | bottom: 0; 163 | left: 0; 164 | z-index: 2; 165 | display: flex; 166 | align-items: center; 167 | justify-content: center; 168 | transition: opacity .5s ease-out; 169 | 170 | &.hidden { 171 | opacity: 0; 172 | display: none; 173 | } 174 | 175 | div { 176 | width: 60px; 177 | height: 60px; 178 | background-color: #{$hightlight-color-1}; 179 | position: relative; 180 | -webkit-border-radius: 50%; 181 | -moz-border-radius: 50%; 182 | border-radius: 50%; 183 | background-size: cover; 184 | animation: spin 1s ease-in-out infinite; 185 | 186 | @keyframes spin { 187 | 0% { 188 | transform: rotate(0); 189 | } 190 | 75% { 191 | transform: rotate(360deg); 192 | } 193 | 100% { 194 | transform: rotate(360deg); 195 | } 196 | } 197 | 198 | div { 199 | background-image: url("data:image/svg+xml;utf8,"); 200 | width: 40px; 201 | height: 40px; 202 | position: relative; 203 | left: 10px; 204 | top: 10px; 205 | animation: pulse 1s ease-out infinite; 206 | 207 | @keyframes pulse { 208 | 0% { 209 | opacity: 1; 210 | transform: scale(.8); 211 | } 212 | 50% { 213 | opacity: .5; 214 | transform: scale(1); 215 | } 216 | 100% { 217 | opacity: 1; 218 | transform: scale(.8); 219 | } 220 | } 221 | } 222 | } 223 | } 224 | 225 | .#{$library-prefix}media-controls { 226 | padding: 10px; 227 | display: grid; 228 | grid-column-gap: 10px; 229 | grid-template-columns: 24px 24px 1fr 24px 24px; 230 | align-items: center; 231 | 232 | .#{$library-prefix}play-pause { 233 | border: 0; 234 | background: transparent; 235 | box-sizing: border-box; 236 | width: 0; 237 | height: 24px; 238 | border-color: transparent transparent transparent #{$dark-color}; 239 | transition: .2s all ease; 240 | cursor: pointer; 241 | 242 | // paused state 243 | border-style: solid; 244 | border-width: 12px 0 12px 18px; 245 | 246 | // playing state 247 | &.playing { 248 | border-style: double; 249 | border-width: 0 0 0 18px; 250 | } 251 | 252 | &:hover { 253 | border-color: transparent transparent transparent #{$hightlight-color-1}; 254 | } 255 | } 256 | 257 | .#{$library-prefix}reload { 258 | cursor: pointer; 259 | width: 18px; 260 | height: 18px; 261 | -webkit-border-radius: 50%; 262 | -moz-border-radius: 50%; 263 | border-radius: 50%; 264 | border-width: 3px; 265 | border-style: solid; 266 | border-color: #{$dark-color} #{$dark-color} #{$dark-color} transparent; 267 | position: relative; 268 | transition: .2s all ease; 269 | 270 | &:hover { 271 | border-top-color: #{$hightlight-color-1}; 272 | border-right-color: #{$hightlight-color-1}; 273 | border-bottom-color: #{$hightlight-color-1}; 274 | div { 275 | border-color: transparent transparent transparent #{$hightlight-color-1}; 276 | } 277 | } 278 | 279 | & > div { 280 | transition: .2s all ease; 281 | position: absolute; 282 | border-style: solid; 283 | border-color: transparent transparent transparent #{$dark-color}; 284 | border-width: 5px 0 5px 10px; 285 | transform: rotate(138deg); 286 | top: -1px; 287 | left: -5px; 288 | } 289 | } 290 | 291 | .#{$library-prefix}seek-bar { 292 | height: 4px; 293 | background-color: #{$dark-color}; 294 | position: relative; 295 | cursor: pointer; 296 | 297 | .#{$library-prefix}seek-bar-inner { 298 | width: 0%; 299 | height: 100%; 300 | background-color: #{$hightlight-color-1}; 301 | position: absolute; 302 | } 303 | } 304 | 305 | .#{$library-prefix}quality-icon { 306 | position: relative; 307 | width: 12px; 308 | height: 12px; 309 | margin-bottom: 4px; 310 | -webkit-border-radius: 5px; 311 | -moz-border-radius: 5px; 312 | border-radius: 5px; 313 | cursor: pointer; 314 | border: 4px solid #{$dark-color}; 315 | 316 | &:hover, &.active { 317 | border-color: #{$hightlight-color-1}; 318 | &:before { 319 | background: #{$hightlight-color-1}; 320 | } 321 | } 322 | 323 | &:before { 324 | content: ""; 325 | position: absolute; 326 | width: 4px; 327 | height: 15px; 328 | background: #{$dark-color}; 329 | bottom: -9px; 330 | right: -3px; 331 | transform: rotate(-45deg); 332 | } 333 | } 334 | 335 | .#{$library-prefix}full-screen { 336 | vertical-align: middle; 337 | box-sizing: border-box; 338 | display: inline-block; 339 | border: 4px solid #{$dark-color}; 340 | width: 24px; 341 | height: 24px; 342 | position: relative; 343 | cursor: pointer; 344 | 345 | &:hover { 346 | border-color: #{$hightlight-color-1}; 347 | } 348 | 349 | &:before, &:after { 350 | content: ''; 351 | background: white; 352 | position: absolute; 353 | } 354 | 355 | &:before { 356 | width: 8px; 357 | height: 24px; 358 | left: 4px; 359 | top: -4px; 360 | } 361 | 362 | &:after { 363 | width: 24px; 364 | height: 8px; 365 | left: -4px; 366 | top: 4px; 367 | } 368 | } 369 | 370 | &.full-screen-mode { 371 | .#{$library-prefix}play-pause { 372 | border-color: transparent transparent transparent white; 373 | &:hover { 374 | border-color: transparent transparent transparent #{$hightlight-color-1}; 375 | } 376 | } 377 | 378 | .#{$library-prefix}reload { 379 | border-color: white white white transparent; 380 | & > div { 381 | border-color: transparent transparent transparent white; 382 | } 383 | 384 | &:hover { 385 | border-top-color: #{$hightlight-color-1}; 386 | border-right-color: #{$hightlight-color-1}; 387 | border-bottom-color: #{$hightlight-color-1}; 388 | div { 389 | border-color: transparent transparent transparent #{$hightlight-color-1}; 390 | } 391 | } 392 | } 393 | 394 | .#{$library-prefix}seek-bar { 395 | background-color: white; 396 | } 397 | 398 | .#{$library-prefix}quality-icon { 399 | border: 4px solid white; 400 | &:before { 401 | background: white; 402 | } 403 | &:hover, &.active { 404 | border-color: #{$hightlight-color-1}; 405 | &:before { 406 | background: #{$hightlight-color-1}; 407 | } 408 | } 409 | } 410 | 411 | .#{$library-prefix}full-screen { 412 | border: 4px solid white; 413 | &:before, &:after { 414 | background: black; 415 | } 416 | &:hover { 417 | border-color: #{$hightlight-color-1}; 418 | } 419 | } 420 | } 421 | } 422 | } 423 | -------------------------------------------------------------------------------- /tests/comparator.spec.js: -------------------------------------------------------------------------------- 1 | const evc = require('../src/index'); 2 | const screenfull = require('screenfull'); 3 | 4 | const config = { 5 | leftUrl: 'https://demo.unified-streaming.com/video/tears-of-steel/tears-of-steel.ism/.m3u8', 6 | rightUrl: 'https://demo.unified-streaming.com/video/tears-of-steel/tears-of-steel.ism/.m3u8', 7 | }; 8 | const video = document.createElement('video'); 9 | 10 | 11 | test('Comparator creation', () => { 12 | const evcInstance = new evc.Comparator(config, video); 13 | expect(evcInstance).toBeDefined(); 14 | expect(evcInstance).toBeInstanceOf(evc.Comparator); 15 | }); 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "commonjs", 5 | "lib": [ 6 | "es2017", 7 | "dom" 8 | ], 9 | "declaration": true, 10 | "sourceMap": false, 11 | "outDir": "dist", 12 | "importHelpers": true, 13 | "strict": true, 14 | "noImplicitAny": false, 15 | "strictNullChecks": false, 16 | "baseUrl": "./", 17 | "typeRoots": [ 18 | "node_modules/@types" 19 | ], 20 | "esModuleInterop": true, 21 | "experimentalDecorators": false, 22 | "allowSyntheticDefaultImports": true 23 | }, 24 | "exclude": [ 25 | "node_modules", 26 | "dist", 27 | "**/*.spec.ts" 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": [ 4 | "tslint:recommended" 5 | ], 6 | "jsRules": {}, 7 | "rules": { 8 | "quotemark": [ 9 | true, 10 | "single" 11 | ], 12 | "no-console": [ 13 | false 14 | ], 15 | "max-line-length": [ 16 | true, 17 | 140 18 | ] 19 | }, 20 | "rulesDirectory": [] 21 | } 22 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const UglifyJsPlugin = require('uglifyjs-webpack-plugin'); 3 | 4 | const PATHS = { 5 | entryPoint: path.resolve(__dirname, './src/index.ts'), 6 | bundle: path.resolve(__dirname, './dist/bundle') 7 | }; 8 | 9 | const dev = { 10 | mode: 'development', 11 | target: 'web', 12 | entry: { 13 | 'evc': [PATHS.entryPoint] 14 | }, 15 | output: { 16 | path: PATHS.bundle, 17 | filename: 'index.min.js', 18 | libraryTarget: 'umd', 19 | library: 'evc', 20 | umdNamedDefine: true, 21 | }, 22 | optimization: { 23 | minimizer: [ 24 | new UglifyJsPlugin({ 25 | cache: true, 26 | parallel: true, 27 | uglifyOptions: { 28 | compress: false, 29 | ecma: 5, 30 | mangle: false 31 | }, 32 | sourceMap: false 33 | }) 34 | ] 35 | }, 36 | resolve: { 37 | extensions: ['.ts', '.js'] 38 | }, 39 | module: { 40 | rules: [ 41 | { 42 | test: /\.ts$/, 43 | use: 'ts-loader', 44 | exclude: /node_modules/ 45 | } 46 | ] 47 | } 48 | }; 49 | 50 | const prod = { 51 | mode: 'production', 52 | target: 'web', 53 | entry: { 54 | 'evc': [PATHS.entryPoint] 55 | }, 56 | output: { 57 | path: PATHS.bundle, 58 | filename: 'index.min.js', 59 | libraryTarget: 'umd', 60 | library: 'evc', 61 | umdNamedDefine: true, 62 | }, 63 | optimization: { 64 | minimizer: [ 65 | new UglifyJsPlugin({ 66 | cache: true, 67 | parallel: true, 68 | uglifyOptions: { 69 | compress: true, 70 | ecma: 5, 71 | mangle: true 72 | }, 73 | sourceMap: false 74 | }) 75 | ] 76 | }, 77 | resolve: { 78 | extensions: ['.ts', '.js'] 79 | }, 80 | module: { 81 | rules: [ 82 | { 83 | test: /\.ts$/, 84 | use: 'ts-loader', 85 | exclude: /node_modules/ 86 | } 87 | ] 88 | } 89 | }; 90 | 91 | module.exports = dev; 92 | --------------------------------------------------------------------------------