├── .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 · [](https://www.npmjs.com/package/@epiclabs/epic-video-comparator) [](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 | 
8 |
9 | For ABR sources, it is also possible to select the desired rendition to be played:
10 |
11 | 
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 |
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 |
--------------------------------------------------------------------------------