jnotnull 6 years ago
parent
commit
0f5ee5c470
82 changed files with 19161 additions and 2 deletions
  1. 4
    0
      .babelrc
  2. 20
    0
      .editorconfig
  3. 2
    0
      .eslintignore
  4. 29
    0
      .eslintrc.json
  5. 4
    0
      .gitignore
  6. 1
    0
      .nvmrc
  7. 20
    0
      .travis.yml
  8. 21
    0
      LICENSE.md
  9. 2
    2
      README.md
  10. 1
    0
      __mocks__/fileMock.js
  11. 7
    0
      __mocks__/fileTransformer.js
  12. 3
    0
      browserslist
  13. 4
    0
      demo/.babelrc
  14. BIN
      demo/assets/poster-big-buck-bunny.png
  15. BIN
      demo/assets/poster-sintel-trailer.png
  16. 19
    0
      demo/assets/sintel-en.vtt
  17. 18
    0
      demo/assets/sintel-es.vtt
  18. BIN
      demo/assets/static/example.png
  19. BIN
      demo/assets/static/example.v2.png
  20. 95
    0
      demo/generateConfig.js
  21. 7123
    0
      demo/package-lock.json
  22. 9
    0
      demo/package.json
  23. 26
    0
      demo/src/components/App.css
  24. 81
    0
      demo/src/components/App.js
  25. 25
    0
      demo/src/components/App.test.js
  26. 20
    0
      demo/src/entry.js
  27. 19
    0
      demo/src/index.html
  28. 9
    0
      demo/webpack.config.js
  29. 54
    0
      generateConfig.js
  30. 8836
    0
      package-lock.json
  31. 103
    0
      package.json
  32. 5
    0
      postcss.config.js
  33. 51
    0
      scripts/deploy:demo.js
  34. 52
    0
      scripts/start.js
  35. 59
    0
      src/DefaultPlayer/Captions/Captions.css
  36. 35
    0
      src/DefaultPlayer/Captions/Captions.js
  37. 32
    0
      src/DefaultPlayer/Captions/Captions.test.js
  38. 31
    0
      src/DefaultPlayer/DefaultPlayer.css
  39. 186
    0
      src/DefaultPlayer/DefaultPlayer.js
  40. 108
    0
      src/DefaultPlayer/DefaultPlayer.test.js
  41. 26
    0
      src/DefaultPlayer/Fullscreen/Fullscreen.css
  42. 22
    0
      src/DefaultPlayer/Fullscreen/Fullscreen.js
  43. 44
    0
      src/DefaultPlayer/Fullscreen/Fullscreen.test.js
  44. 6
    0
      src/DefaultPlayer/Icon/caption_new.svg
  45. 6
    0
      src/DefaultPlayer/Icon/closed_caption.svg
  46. 6
    0
      src/DefaultPlayer/Icon/fullscreen.svg
  47. 6
    0
      src/DefaultPlayer/Icon/fullscreen_exit.svg
  48. 6
    0
      src/DefaultPlayer/Icon/pause.svg
  49. 6
    0
      src/DefaultPlayer/Icon/play_arrow.svg
  50. 6
    0
      src/DefaultPlayer/Icon/report.svg
  51. 6
    0
      src/DefaultPlayer/Icon/speed.svg
  52. 1
    0
      src/DefaultPlayer/Icon/spin.svg
  53. 6
    0
      src/DefaultPlayer/Icon/volume_down.svg
  54. 6
    0
      src/DefaultPlayer/Icon/volume_mute.svg
  55. 6
    0
      src/DefaultPlayer/Icon/volume_off.svg
  56. 6
    0
      src/DefaultPlayer/Icon/volume_up.svg
  57. 36
    0
      src/DefaultPlayer/Overlay/Overlay.css
  58. 53
    0
      src/DefaultPlayer/Overlay/Overlay.js
  59. 56
    0
      src/DefaultPlayer/Overlay/Overlay.test.js
  60. 26
    0
      src/DefaultPlayer/PlayPause/PlayPause.css
  61. 29
    0
      src/DefaultPlayer/PlayPause/PlayPause.js
  62. 83
    0
      src/DefaultPlayer/PlayPause/PlayPause.test.js
  63. 36
    0
      src/DefaultPlayer/Seek/Seek.css
  64. 34
    0
      src/DefaultPlayer/Seek/Seek.js
  65. 91
    0
      src/DefaultPlayer/Seek/Seek.test.js
  66. 59
    0
      src/DefaultPlayer/Speed/Speed.css
  67. 35
    0
      src/DefaultPlayer/Speed/Speed.js
  68. 32
    0
      src/DefaultPlayer/Speed/Speed.test.js
  69. 14
    0
      src/DefaultPlayer/Time/Time.css
  70. 29
    0
      src/DefaultPlayer/Time/Time.js
  71. 91
    0
      src/DefaultPlayer/Time/Time.test.js
  72. 74
    0
      src/DefaultPlayer/Volume/Volume.css
  73. 56
    0
      src/DefaultPlayer/Volume/Volume.js
  74. 165
    0
      src/DefaultPlayer/Volume/Volume.test.js
  75. 12
    0
      src/DefaultPlayer/copy.js
  76. 24
    0
      src/entry.js
  77. 131
    0
      src/video/api.js
  78. 276
    0
      src/video/api.test.js
  79. 71
    0
      src/video/constants.js
  80. 132
    0
      src/video/video.js
  81. 234
    0
      src/video/video.test.js
  82. 3
    0
      webpack.config.js

+ 4
- 0
.babelrc View File

@@ -0,0 +1,4 @@
1
+{
2
+	"presets": ["es2015", "react"],
3
+    "plugins": ["transform-runtime", "transform-object-rest-spread"]
4
+}

+ 20
- 0
.editorconfig View File

@@ -0,0 +1,20 @@
1
+# EditorConfig: http://EditorConfig.org
2
+
3
+root = true
4
+
5
+[*]
6
+charset = utf-8
7
+end_of_line = lf
8
+indent_size = 4
9
+indent_style = space
10
+insert_final_newline = true
11
+trim_trailing_whitespace = true
12
+
13
+[*.md]
14
+trim_trailing_whitespace = false
15
+
16
+[{package.json,.travis.yml}]
17
+# The indent size used in the `package.json` file cannot be changed
18
+# https://github.com/npm/npm/pull/3180#issuecomment-16336516
19
+indent_size = 2
20
+indent_style = space

+ 2
- 0
.eslintignore View File

@@ -0,0 +1,2 @@
1
+node_modules
2
+dist

+ 29
- 0
.eslintrc.json View File

@@ -0,0 +1,29 @@
1
+{
2
+    "env": {
3
+        "browser": true,
4
+        "node": true,
5
+        "commonjs": true,
6
+        "es6": true,
7
+        "jest": true,
8
+        "jasmine": true
9
+    },
10
+    "extends": "eslint:recommended",
11
+    "parserOptions": {
12
+        "ecmaFeatures": {
13
+            "experimentalObjectRestSpread": true,
14
+            "jsx": true
15
+        },
16
+        "sourceType": "module"
17
+    },
18
+    "plugins": [
19
+        "react"
20
+    ],
21
+    "rules": {
22
+        "quotes": [
23
+            "error",
24
+            "single"
25
+        ],
26
+        "react/jsx-uses-react": "error",
27
+        "react/jsx-uses-vars": "error"
28
+    }
29
+}

+ 4
- 0
.gitignore View File

@@ -0,0 +1,4 @@
1
+node_modules
2
+npm-debug.log
3
+.DS_Store
4
+dist

+ 1
- 0
.nvmrc View File

@@ -0,0 +1 @@
1
+6

+ 20
- 0
.travis.yml View File

@@ -0,0 +1,20 @@
1
+language: node_js
2
+script:
3
+  - npm run install:demo
4
+  - npm run build:demo
5
+  - npm run lint
6
+  - npm test
7
+deploy:
8
+  provider: npm
9
+  email: me@mattderrick.co.uk
10
+  api_key:
11
+    secure: ge4pjksEwP8kKRdS3nq1Bq7gPH9t9/G44vMqUd5BO5puR760jucvXCdqJ9isBzAHyXE7+0jbXmpE1KbUsiGqkasGEYsxtJYvx1Lb2F37e6W7QDRWxmN1AG0+MkTl49+RQFaL9uMLSWl9yStRWnzljcqbK8Jcnt8cE+JU1zvwCkKnokysyCbL7pz8w22fwuXZP8wg8wYZQL5c9mYRtNyUKFloNfADMEO1Zl0ZbpgBJdiT/MXtY6F1Y7mdCc+fCPucv0kbxoXm/kNsryYB0iLdtqwn3TW56Bwt+jaTEy2Wr62il1wST9/da4Iwzt5THLPO4+8sR2te45HkmaZmeRI92W6t7GUgseImTzYeFdxoyxXyziFH1hNTlOQuGDSie5DX87OXlEouILUmiLTcNZL0GrF6BALOyFB8ac/wtAO0OlVJqnMwlTI4s8K4yVBlP5cpRdPKVwbuwS/KX8pOhAmtRhyKgIBonsLuTp6GRkS1704Ui0nIJPjo65gOJIfnorYkKeJqrT6oDG1IUipCXX9vN5R0h00JqR3fOXsA1h6j1QFpg3hlG6UBXFWJ3SQdUUGjczy+z80sipRA2Kdf2TnFSKu1tVpRghWJ9bfmQmyN8NyzYCH9rxJj4K/Nh0TAi3J5Ns/6ypHwOg/9/s/8MhJUwE+FLaKy7NYl+RQXLnEeanA=
12
+  skip_cleanup: true
13
+  on:
14
+    tags: true
15
+    repo: mderrick/react-html5video
16
+after_deploy:
17
+  - npm run deploy:demo
18
+env:
19
+  global:
20
+    secure: Jay4xSxm/nSDbbv7AghcCRCxbkzrX92SToFjW1ZvbnH/4gjgEHqNgaoZH/3mJ/s+1GyC9SCiAcOwlnCAGFabYdQNhwHBhQJFMK8v0WP2IKC2Vwh294sGPrCTvexRceumlh6y1la1KYYTuu6DDDsAaZ8eQcrK6hOcfuwl5xXB4xGNfshLavITs8a71F1RdX0cCa/ifSzbDjkfY1+1/o7vCCK595WP+WvTD/gA/OlpDygmtzJwg0400o9vmAlz7KzCLkk7pVakNODv3QvGHpd5n8jpzW30sHyKbnZfiqZPuw/6yV6NIYBmAXrviLUYQt07KU0IFWDO6cjc2H6puk3tL0yyZgZ0fMnin9mEGM0Rrf71zrtBH3XBhsLJGFK/vCuC1Kl+/mDSBrxm7koBawh7tzxOw7H2L3PMDin0A4MvUQS54ffGXd2AhNQKcf3w7eEb/kwdIOWPMcvDO7cca9OQxi50HB7bPOTsOIVflmCfgomBV2pwWAaAqcAGJni8dvp1BI8wN95bDLU1lSeTT/9RQlDRjykG5FzH/yBKhqVax+mtl6AxEFDHsZKE2Tc/mVqzpRChGJ9GF4N+E003qTaS6kTmqzYja/p9UL8aWThkQf4MnD91SR8duvD2wZOXoyNn+rSrrtZJ0p055mhms58ZbOSDgNPK8xSS0RZdPlwd+zo=

+ 21
- 0
LICENSE.md View File

@@ -0,0 +1,21 @@
1
+The MIT License (MIT)
2
+
3
+Copyright (c) 2015 Matt Derrick
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
13
+all 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
21
+THE SOFTWARE.

+ 2
- 2
README.md View File

@@ -1,3 +1,3 @@
1
-# video_player
1
+Fork from [mderrick/react-html5video](https://github.com/mderrick/react-html5video)
2 2
 
3
-视频播放器仓库
3
+Add speed/playbackRate controls

+ 1
- 0
__mocks__/fileMock.js View File

@@ -0,0 +1 @@
1
+module.exports = 'test-file-stub';

+ 7
- 0
__mocks__/fileTransformer.js View File

@@ -0,0 +1,7 @@
1
+const path = require('path');
2
+
3
+module.exports = {
4
+  process(src, filename) {
5
+    return 'module.exports = ' + JSON.stringify(path.basename(filename)) + ';';
6
+  }
7
+};

+ 3
- 0
browserslist View File

@@ -0,0 +1,3 @@
1
+> 1%
2
+Last 5 versions
3
+not ie < 9

+ 4
- 0
demo/.babelrc View File

@@ -0,0 +1,4 @@
1
+{
2
+	"extends": "./../.babelrc",
3
+	"plugins": ["react-hot-loader/babel"]
4
+}

BIN
demo/assets/poster-big-buck-bunny.png View File


BIN
demo/assets/poster-sintel-trailer.png View File


+ 19
- 0
demo/assets/sintel-en.vtt View File

@@ -0,0 +1,19 @@
1
+WEBVTT - Sintel Caption File
2
+
3
+Sage
4
+00:00:12.000 --> 00:00:15.000
5
+What brings you to the land
6
+of the gatekeepers?
7
+
8
+Searching
9
+00:00:18.500 --> 00:00:20.500
10
+I'm searching for someone.
11
+
12
+Quest
13
+00:00:36.500 --> 00:00:39.000
14
+A dangerous quest for a lone hunter.
15
+
16
+Alone
17
+00:00:41.500 --> 00:00:44.000
18
+I've been alone for as long
19
+as I can remember.

+ 18
- 0
demo/assets/sintel-es.vtt View File

@@ -0,0 +1,18 @@
1
+WEBVTT - Spanish Sintel Caption File
2
+
3
+Sage
4
+00:00:12.000 --> 00:00:15.000
5
+Que te trae a la tierra 
6
+de los porteros?
7
+
8
+Searching
9
+00:00:18.500 --> 00:00:20.500
10
+Estoy buscando a alguien.
11
+
12
+Quest
13
+00:00:36.500 --> 00:00:39.000
14
+Una busqueda peligrosa para un cazador solitario.
15
+
16
+Alone
17
+00:00:41.500 --> 00:00:44.000
18
+He estado sola desde que recuerdo.  

BIN
demo/assets/static/example.png View File


BIN
demo/assets/static/example.v2.png View File


+ 95
- 0
demo/generateConfig.js View File

@@ -0,0 +1,95 @@
1
+const path = require('path');
2
+const webpack = require('webpack');
3
+const ExtractTextPlugin = require('extract-text-webpack-plugin');
4
+const HtmlWebpackPlugin = require('html-webpack-plugin');
5
+const CopyWebpackPlugin = require('copy-webpack-plugin');
6
+const CaseSensitivePathsPlugin = require('case-sensitive-paths-webpack-plugin');
7
+
8
+const pkg = require('./../package.json');
9
+const srcPath = path.resolve(__dirname, 'src');
10
+const distPath = path.resolve(__dirname, 'dist');
11
+
12
+module.exports = ({ optimize, extractCss, hot, publicPath = '/' }) => {
13
+    const cssString = 'css?modules&importLoaders=1&localIdentName=[hash:base64:5]&-autoprefixer!postcss';
14
+    let config = {
15
+        entry: [
16
+            path.resolve(srcPath, 'entry.js')
17
+        ],
18
+        output: {
19
+            path: distPath,
20
+            filename: '[name].js',
21
+            publicPath: publicPath
22
+        },
23
+        resolve: {
24
+            extensions: ['.js', '.json', '.jsx', '']
25
+        },
26
+        module: {
27
+            loaders: [{
28
+                test: /\.(js|jsx)$/,
29
+                include: srcPath,
30
+                loader: 'babel',
31
+                query: {
32
+                    cacheDirectory: true
33
+                }
34
+            }, {
35
+                test: /\.json$/,
36
+                loader: 'json'
37
+            }, {
38
+                test: /\.css$/,
39
+                include: srcPath,
40
+                loader: extractCss
41
+                    ? ExtractTextPlugin.extract('style', cssString)
42
+                    : 'style!' + cssString
43
+            }, {
44
+                test: /\.css$/,
45
+                include: [new RegExp(pkg.name + '/dist/'), new RegExp('reset-css')],
46
+                loader: extractCss
47
+                    ? ExtractTextPlugin.extract('style', 'css')
48
+                    : 'style!css'
49
+            }, {
50
+                test: /\.(ico|jpg|jpeg|png|gif|eot|otf|webp|ttf|woff|woff2|svg|vtt)(\?.*)?$/,
51
+                loader: 'file'
52
+            }]
53
+        },
54
+        plugins: [
55
+            new HtmlWebpackPlugin({
56
+                template: path.resolve(srcPath, 'index.html')
57
+            }),
58
+            new CopyWebpackPlugin([{
59
+                context: 'assets/static',
60
+                from: '**/*'
61
+            }]),
62
+            new CaseSensitivePathsPlugin()
63
+        ]
64
+    };
65
+
66
+    if (hot) {
67
+        config.entry.unshift(
68
+            'webpack-hot-middleware/client',
69
+            'react-hot-loader/patch'
70
+        );
71
+        config.plugins.unshift(
72
+            new webpack.optimize.OccurenceOrderPlugin(),
73
+            new webpack.HotModuleReplacementPlugin(),
74
+            new webpack.NoErrorsPlugin()
75
+        );
76
+    }
77
+
78
+    if (extractCss) {
79
+        config.plugins.push(new ExtractTextPlugin('[name].css'));
80
+    }
81
+
82
+    if (optimize) {
83
+        config.plugins.push(new webpack.optimize.UglifyJsPlugin({
84
+            minimize: true,
85
+            compress: {
86
+                warnings: false
87
+            },
88
+            output: {
89
+                comments: false
90
+            }
91
+        }));
92
+    }
93
+
94
+    return config;
95
+};

+ 7123
- 0
demo/package-lock.json
File diff suppressed because it is too large
View File


+ 9
- 0
demo/package.json View File

@@ -0,0 +1,9 @@
1
+{
2
+  "name": "react-html5video-demo",
3
+  "dependencies": {
4
+    "react": "^16.0.0-rc.3",
5
+    "react-dom": "^16.0.0-rc.3",
6
+    "react-html5video": "file:../",
7
+    "reset-css": "^2.2.0"
8
+  }
9
+}

+ 26
- 0
demo/src/components/App.css View File

@@ -0,0 +1,26 @@
1
+.component {
2
+    padding: 20px;
3
+    font-family: Arial, 'Helvetica Neue', Helvetica, sans-serif;
4
+}
5
+
6
+.header {
7
+    margin-bottom: 20px;
8
+}
9
+
10
+.title {
11
+    font-size: 30px;
12
+    margin-bottom: 5px;
13
+}
14
+
15
+.link {
16
+    color: #2492a8;
17
+}
18
+
19
+.videoListItem {
20
+    background-color: #efefef;
21
+    padding: 20px;
22
+    margin-bottom: 20px;
23
+    max-width: 634px;
24
+    max-height: 356px;
25
+    margin: 0 auto;
26
+}

+ 81
- 0
demo/src/components/App.js View File

@@ -0,0 +1,81 @@
1
+import React, { Component } from 'react';
2
+// import { DefaultPlayer as Video } from 'react-html5video';
3
+import { DefaultPlayer as Video } from '../../../dist/index.js';
4
+import 'react-html5video/dist/styles.css';
5
+import styles from './App.css';
6
+import 'reset-css/reset.css';
7
+import vttEn from './../../assets/sintel-en.vtt';
8
+import vttEs from './../../assets/sintel-es.vtt';
9
+import bigBuckBunnyPoster from './../../assets/poster-big-buck-bunny.png';
10
+import sintelTrailerPoster from './../../assets/poster-sintel-trailer.png';
11
+
12
+const sintelTrailer = 'https://download.blender.org/durian/trailer/sintel_trailer-720p.mp4';
13
+const bigBuckBunny = 'http://download.blender.org/peach/bigbuckbunny_movies/big_buck_bunny_480p_h264.mov';
14
+
15
+class App extends Component {
16
+    render () {
17
+        return (
18
+            <div className={styles.component}>
19
+                <header className={styles.header}>
20
+                    <h1 className={styles.title}>React HTML5 Video</h1>
21
+                    <a className={styles.link}
22
+                        href="https://github.com/mderrick/react-html5video">
23
+                        View on GitHub &raquo;
24
+                    </a>
25
+                </header>
26
+                <ul className={styles.videoList}>
27
+                    <li className={styles.videoListItem}>
28
+                        <Video
29
+                            autoPlay
30
+                            ref="video1"
31
+                            onPlay={() => {
32
+                                this.refs.video2.videoEl.pause();
33
+                            }}
34
+                            onScreenClickCallback={()=>{
35
+                                debugger;
36
+                            }}
37
+                            data-playbackrates={JSON.stringify([{
38
+                                id: 0.5, name: '0.5x', mode: 'disabled'
39
+                            },{
40
+                                id: 0.75, name: '0.75x', mode: 'disabled'
41
+                            },{
42
+                                id: 1, name: 'Normal', mode: 'showing'
43
+                            },{
44
+                                id: 1.25, name: '1.25x', mode: 'disabled'
45
+                            },{
46
+                                id: 1.5, name: '1.5x', mode: 'disabled'
47
+                            },{
48
+                                id: 2, name: '2x', mode: 'disabled'
49
+                            }])}
50
+                            poster={sintelTrailerPoster}>
51
+                            <source src={sintelTrailer} type="video/mp4" />
52
+                            <track
53
+                                label="English"
54
+                                kind="subtitles"
55
+                                srcLang="en"
56
+                                src={vttEn}
57
+                                default />
58
+                            <track
59
+                                label="Español"
60
+                                kind="subtitles"
61
+                                srcLang="es"
62
+                                src={vttEs} />
63
+                        </Video>
64
+                    </li>
65
+                    <li className={styles.videoListItem}>
66
+                        <Video
67
+                            ref="video2"
68
+                            onPlay={() => {
69
+                                this.refs.video1.videoEl.pause();
70
+                            }}
71
+                            src={bigBuckBunny}
72
+                            poster={bigBuckBunnyPoster}>
73
+                        </Video>
74
+                    </li>
75
+                </ul>
76
+            </div>
77
+        );
78
+    }
79
+}
80
+
81
+export default App;

+ 25
- 0
demo/src/components/App.test.js View File

@@ -0,0 +1,25 @@
1
+import React from 'react';
2
+import { shallow } from 'enzyme';
3
+
4
+import App from './App';
5
+import styles from './App.css';
6
+
7
+describe('App', () => {
8
+    let component;
9
+
10
+    beforeEach(() => {
11
+        component = shallow(
12
+      <App />
13
+    );
14
+    });
15
+
16
+    it('contains heading text', () => {
17
+        expect(component.find('h1').text())
18
+            .toEqual('React HTML5 Video');
19
+    });
20
+
21
+    it('has a className', () => {
22
+        expect(component.prop('className'))
23
+            .toEqual(styles.component);
24
+    });
25
+});

+ 20
- 0
demo/src/entry.js View File

@@ -0,0 +1,20 @@
1
+/* eslint-disable global-require */
2
+
3
+import React from 'react';
4
+import { render } from 'react-dom';
5
+import App from './components/App';
6
+
7
+render(
8
+    <App />,
9
+    document.getElementById('content')
10
+);
11
+
12
+if (module.hot) {
13
+    module.hot.accept('./components/App', () => {
14
+        const NextRoot = require('./components/App').default;
15
+        render(
16
+            <NextRoot />,
17
+            document.getElementById('content')
18
+        );
19
+    });
20
+}

+ 19
- 0
demo/src/index.html View File

@@ -0,0 +1,19 @@
1
+<!DOCTYPE html>
2
+<html>
3
+  <head>
4
+    <meta charset="UTF-8">
5
+    <title>React HTML5 Video Component</title>
6
+  </head>
7
+  <body>
8
+    <div id="content"></div>
9
+    <script>
10
+        (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
11
+        (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
12
+        m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
13
+        })(window,document,'script','https://www.google-analytics.com/analytics.js','ga');
14
+
15
+        ga('create', 'UA-47861305-2', 'auto');
16
+        ga('send', 'pageview');
17
+    </script>
18
+  </body>
19
+</html>

+ 9
- 0
demo/webpack.config.js View File

@@ -0,0 +1,9 @@
1
+var generateConfig = require('./generateConfig');
2
+var pkg = require('./../package.json');
3
+
4
+module.exports = generateConfig({
5
+    hot: false,
6
+    optimize: true,
7
+    extractCss: true,
8
+    publicPath: '/' + pkg.name + '/'
9
+});

+ 54
- 0
generateConfig.js View File

@@ -0,0 +1,54 @@
1
+const path = require('path');
2
+const ExtractTextPlugin = require('extract-text-webpack-plugin');
3
+const CaseSensitivePathsPlugin = require('case-sensitive-paths-webpack-plugin');
4
+
5
+const pkg = require('./package.json');
6
+const srcPath = path.resolve(__dirname, 'src');
7
+const distPath = path.resolve(__dirname, 'dist');
8
+
9
+module.exports = (options = {}) => {
10
+    return {
11
+        entry: [
12
+            path.resolve(srcPath, 'entry.js')
13
+        ],
14
+        target: 'web',
15
+        output: {
16
+            path: options.outputPath || distPath,
17
+            filename: 'index.js',
18
+            libraryTarget: 'commonjs2',
19
+            library: pkg.name
20
+        },
21
+        resolve: {
22
+            extensions: ['.js', '.json', '.jsx', '']
23
+        },
24
+        externals: [{
25
+            react: 'react',
26
+            'react-dom': 'react-dom',
27
+            'prop-types': 'prop-types'
28
+        }],
29
+        module: {
30
+            loaders: [{
31
+                test: /\.(js|jsx)$/,
32
+                include: srcPath,
33
+                loader: 'babel',
34
+                query: {
35
+                    cacheDirectory: true
36
+                }
37
+            }, {
38
+                test: /\.svg$/,
39
+                loader: 'babel!react-svg'
40
+            }, {
41
+                test: /\.css$/,
42
+                include: srcPath,
43
+                loader: ExtractTextPlugin.extract('style', 'css?modules&importLoaders=1&localIdentName=rh5v-[name]_[local]&-autoprefixer!postcss')
44
+            }, {
45
+                test: /\.(eot|ttf|woff|woff2)(\?.*)?$/,
46
+                loader: 'url'
47
+            }]
48
+        },
49
+        plugins: [
50
+            new ExtractTextPlugin('styles.css'),
51
+            new CaseSensitivePathsPlugin()
52
+        ]
53
+    };
54
+};

+ 8836
- 0
package-lock.json
File diff suppressed because it is too large
View File


+ 103
- 0
package.json View File

@@ -0,0 +1,103 @@
1
+{
2
+  "name": "react-html5video",
3
+  "version": "2.5.1",
4
+  "description": "A customizeable HTML5 Video",
5
+  "main": "dist",
6
+  "files": [
7
+    "dist"
8
+  ],
9
+  "scripts": {
10
+    "start": "node scripts/start.js",
11
+    "preinstall:demo": "npm run build",
12
+    "install:demo": "cd demo && npm i",
13
+    "i:demo": "npm run install:demo",
14
+    "test": "jest --env=jsdom",
15
+    "test:watch": "npm run test -- --watch",
16
+    "build": "webpack --display-error-details",
17
+    "build:demo": "cd demo && webpack",
18
+    "deploy:demo": "node scripts/deploy:demo.js",
19
+    "clean": "rm -rf node_modules && rm -rf dist && rm -rf demo/node_modules && rm -rf demo/dist",
20
+    "lint": "eslint ./",
21
+    "lint:fix": "npm run lint -- --fix",
22
+    "preversion": "npm run clean && npm i && npm run i:demo && npm run lint && npm run test",
23
+    "postversion": "git push && git push --tags"
24
+  },
25
+  "repository": {
26
+    "type": "git",
27
+    "url": "https://github.com/mderrick/react-html5video.git"
28
+  },
29
+  "keywords": [
30
+    "react-component",
31
+    "html5-video"
32
+  ],
33
+  "author": {
34
+    "name": "Matt Derrick",
35
+    "email": "me@mattderrick.co.uk",
36
+    "url": "http://mattderrick.co.uk"
37
+  },
38
+  "license": "MIT",
39
+  "jest": {
40
+    "moduleFileExtensions": [
41
+      "jsx",
42
+      "js",
43
+      "json"
44
+    ],
45
+    "moduleNameMapper": {
46
+      "^.+\\.(jpg|jpeg|png|gif|eot|otf|webp|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga|ico|vtt)$": "<rootDir>/__mocks__/fileMock.js",
47
+      "^.+\\.css$": "identity-obj-proxy"
48
+    },
49
+    "transform": {
50
+      "^.+\\.js$": "babel-jest",
51
+      "^.+\\.(svg)$": "<rootDir>/__mocks__/fileTransformer.js"
52
+    },
53
+    "testPathIgnorePatterns": [
54
+      "<rootDir>/(build|docs|node_modules)/"
55
+    ],
56
+    "testEnvironment": "node",
57
+    "testRegex": "(/__tests__/.*|\\.(test|spec))\\.(js|jsx)$"
58
+  },
59
+  "peerDependencies": {
60
+    "react": ">=15.0.0",
61
+    "react-dom": ">=15.0.0"
62
+  },
63
+  "devDependencies": {
64
+    "babel": "^6.5.2",
65
+    "babel-core": "^6.18.2",
66
+    "babel-jest": "^18.0.0",
67
+    "babel-loader": "^6.2.7",
68
+    "babel-plugin-transform-object-rest-spread": "^6.19.0",
69
+    "babel-plugin-transform-runtime": "^6.23.0",
70
+    "babel-preset-es2015": "^6.18.0",
71
+    "babel-preset-react": "^6.16.0",
72
+    "babel-runtime": "^6.23.0",
73
+    "case-sensitive-paths-webpack-plugin": "^1.1.4",
74
+    "chalk": "^1.1.3",
75
+    "copy-webpack-plugin": "^4.0.1",
76
+    "css-loader": "^0.25.0",
77
+    "detect-port": "^1.0.6",
78
+    "enzyme": "^2.6.0",
79
+    "eslint": "^3.11.1",
80
+    "eslint-plugin-react": "^6.7.1",
81
+    "express": "^4.14.0",
82
+    "extract-text-webpack-plugin": "^1.0.1",
83
+    "file-loader": "^0.9.0",
84
+    "gh-pages": "^0.12.0",
85
+    "html-webpack-plugin": "^2.24.1",
86
+    "identity-obj-proxy": "^3.0.0",
87
+    "jest": "^18.0.0",
88
+    "json-loader": "^0.5.4",
89
+    "open": "0.0.5",
90
+    "postcss-loader": "^1.1.1",
91
+    "prop-types": "^15.5.10",
92
+    "react": "^15.4.0",
93
+    "react-dom": "^15.4.0",
94
+    "react-hot-loader": "^3.0.0-beta.6",
95
+    "react-svg-loader": "^1.1.1",
96
+    "react-test-renderer": "^15.5.4",
97
+    "style-loader": "^0.13.1",
98
+    "url-loader": "^0.5.7",
99
+    "webpack": "^1.13.3",
100
+    "webpack-dev-middleware": "^1.8.4",
101
+    "webpack-hot-middleware": "^2.13.2"
102
+  }
103
+}

+ 5
- 0
postcss.config.js View File

@@ -0,0 +1,5 @@
1
+module.exports = {
2
+  plugins: [
3
+    require('autoprefixer')
4
+  ]
5
+};

+ 51
- 0
scripts/deploy:demo.js View File

@@ -0,0 +1,51 @@
1
+/* eslint-disable no-console */
2
+
3
+const ghpages = require('gh-pages');
4
+const pkg = require('./../package.json');
5
+const path = require('path');
6
+const chalk = require('chalk');
7
+const fs = require('fs');
8
+const distPath = path.join(__dirname, '../demo/dist');
9
+
10
+const deploy = (options = {}) => {
11
+    ghpages.publish(distPath, Object.assign({
12
+        message: pkg.version
13
+    }, options), (err) => {
14
+        if (err) {
15
+            error([err]);
16
+            return;
17
+        }
18
+        console.log(chalk.green('Demo has succesfully deployed.'));
19
+    });
20
+};
21
+
22
+const error = (errs = []) => {
23
+    errs.forEach((err) => {
24
+        console.log(chalk.red(err));
25
+    });
26
+    process.exit(1);
27
+};
28
+
29
+try {
30
+    fs.accessSync(distPath, fs.F_OK);
31
+    if (process.env.TRAVIS) {
32
+        if (process.env.GITHUB_TOKEN) {
33
+            deploy({
34
+                repo: `https://${process.env.GITHUB_TOKEN}@github.com/${process.env.TRAVIS_REPO_SLUG}.git`,
35
+                user: {
36
+                    name: 'Travis CI'
37
+                }
38
+            });
39
+        } else {
40
+            error(['process.env.GITHUB_TOKEN with "repo" access is required to deploy gh-pages.']);
41
+        }
42
+    } else {
43
+        // Deploys using git origin, username and email.
44
+        deploy();
45
+    }
46
+} catch (e) {
47
+    error([
48
+        `${distPath} does not exist.`,
49
+        'Please run "npm i && npm run i:demo && npm run build:demo" and try again.'
50
+    ]);
51
+}

+ 52
- 0
scripts/start.js View File

@@ -0,0 +1,52 @@
1
+/* eslint-disable no-console */
2
+
3
+const path = require('path');
4
+const pkg = require('./../package.json');
5
+const express = require('express');
6
+const webpack = require('webpack');
7
+const detect = require('detect-port');
8
+const chalk = require('chalk');
9
+const open = require('open');
10
+const webpackDevMiddleware = require('webpack-dev-middleware');
11
+const webpackHotMiddleware = require('webpack-hot-middleware');
12
+const generateDemoConfig = require('./../demo/generateConfig');
13
+const generateComponentConfig = require('./../generateConfig');
14
+
15
+const app = express();
16
+const compiler = webpack([
17
+    generateComponentConfig({
18
+        // Build the component library into node_modules
19
+        // so we need not do a symlink for development.
20
+        outputPath: path.resolve(__dirname, '../demo/node_modules/' + pkg.name + '/dist')
21
+    }),
22
+    generateDemoConfig({
23
+        hot: true,
24
+        optimize: false,
25
+        extractCss: false
26
+    })
27
+]);
28
+const [componentCompiler, demoCompiler] = compiler.compilers;
29
+
30
+componentCompiler.watch({}, function(err) {
31
+    if (err) {
32
+        throw err;
33
+    }
34
+});
35
+app.use(webpackDevMiddleware(demoCompiler));
36
+app.use(webpackHotMiddleware(demoCompiler));
37
+
38
+const run = (port) => {
39
+    detect(port, (err, _port) => {
40
+        if (port === _port) {
41
+            app.listen(_port, () => {
42
+                const url = `http://localhost:${port}`;
43
+                console.log(chalk.cyan(`Server running at ${url}.`));
44
+                open(url);
45
+            });
46
+        } else {
47
+            run(port + 1);
48
+        }
49
+    });
50
+};
51
+
52
+run(6060);

+ 59
- 0
src/DefaultPlayer/Captions/Captions.css View File

@@ -0,0 +1,59 @@
1
+.component {
2
+    position: relative;
3
+}
4
+
5
+.component:hover {
6
+    background-color: #000;
7
+}
8
+
9
+.button {
10
+    width: 34px;
11
+    height: 34px;
12
+    background: none;
13
+    border: 0;
14
+    color: inherit;
15
+    font: inherit;
16
+    line-height: normal;
17
+    overflow: visible;
18
+    padding: 0;
19
+    cursor: pointer;
20
+}
21
+
22
+.button:focus {
23
+    outline: 0;
24
+}
25
+
26
+.icon {
27
+    padding: 5px;
28
+}
29
+
30
+.trackList {
31
+    position: absolute;
32
+    right: 0;
33
+    bottom: 100%;
34
+    display: none;
35
+    background-color: rgba(0,0,0,0.7);
36
+    list-style: none;
37
+    padding: 0;
38
+    margin: 0;
39
+    color: #fff;
40
+}
41
+
42
+.component:hover .trackList {
43
+    display: block;
44
+}
45
+
46
+.trackItem {
47
+    padding: 7px;
48
+    cursor: pointer;
49
+}
50
+
51
+.activeTrackItem,
52
+.trackItem:hover {
53
+    background: #000;
54
+}
55
+
56
+.activeTrackItem {
57
+    composes: trackItem;
58
+    text-decoration: underline;
59
+}

+ 35
- 0
src/DefaultPlayer/Captions/Captions.js View File

@@ -0,0 +1,35 @@
1
+import React from 'react';
2
+import styles from './Captions.css';
3
+import ClosedCaptionIcon from './../Icon/caption_new.svg';
4
+
5
+export default ({ textTracks, onClick, onItemClick, className, ariaLabel }) => {
6
+    console.log('caption')
7
+    return (
8
+        <div className={[
9
+            styles.component,
10
+            className
11
+        ].join(' ')}>
12
+            <button
13
+                type="button"
14
+                onClick={onClick}
15
+                aria-label={ariaLabel}
16
+                className={styles.button}>
17
+                <ClosedCaptionIcon
18
+                    className={styles.icon}
19
+                    fill="#fff" />
20
+            </button>
21
+            <ul className={styles.trackList}>
22
+                { textTracks && [...textTracks].map((track) => (
23
+                    <li
24
+                        key={track.language}
25
+                        className={track.mode === track.SHOWING || track.mode == 'showing'
26
+                            ? styles.activeTrackItem
27
+                            : styles.trackItem}
28
+                        onClick={onItemClick.bind(this, track)}>
29
+                        { track.label }
30
+                    </li>
31
+                ))}
32
+            </ul>
33
+        </div>
34
+    );
35
+};

+ 32
- 0
src/DefaultPlayer/Captions/Captions.test.js View File

@@ -0,0 +1,32 @@
1
+import React from 'react';
2
+import { shallow } from 'enzyme';
3
+import Captions from './Captions';
4
+import styles from './Captions.css';
5
+
6
+describe('Captions', () => {
7
+    let component;
8
+
9
+    beforeAll(() => {
10
+        component = shallow(
11
+            <Captions ariaLabel="Captions" />
12
+        );
13
+    });
14
+
15
+    it('should accept a className prop and append it to the components class', () => {
16
+        const newClassNameString = 'a new className';
17
+        expect(component.prop('className'))
18
+            .toContain(styles.component);
19
+        component.setProps({
20
+            className: newClassNameString
21
+        });
22
+        expect(component.prop('className'))
23
+            .toContain(styles.component);
24
+        expect(component.prop('className'))
25
+            .toContain(newClassNameString);
26
+    });
27
+
28
+    it('has correct aria-label', () => {
29
+        expect(component.find('button').prop('aria-label'))
30
+            .toEqual('Captions');
31
+    });
32
+});

+ 31
- 0
src/DefaultPlayer/DefaultPlayer.css View File

@@ -0,0 +1,31 @@
1
+.component {
2
+    position: relative;
3
+    font-family: Helvetica;
4
+    font-size: 11px;
5
+    background-color: #000;
6
+}
7
+
8
+.video {
9
+    width: 100%;
10
+    height: 100%;
11
+}
12
+
13
+.controls {
14
+    position: absolute;
15
+    bottom: 0;
16
+    right: 0;
17
+    left: 0;
18
+    height: 34px;
19
+    display: flex;
20
+    background-color: rgba(0,0,0,0.7);
21
+    opacity: 0;
22
+    transition: opacity 0.2s;
23
+}
24
+
25
+.seek {
26
+    flex-grow: 1;
27
+}
28
+
29
+.component:hover .controls {
30
+    opacity: 1;
31
+}

+ 186
- 0
src/DefaultPlayer/DefaultPlayer.js View File

@@ -0,0 +1,186 @@
1
+import React from 'react';
2
+import PropTypes from 'prop-types';
3
+import videoConnect from './../video/video';
4
+import copy from './copy';
5
+import {
6
+    setVolume,
7
+    showTrack,
8
+    showSpeed,
9
+    toggleTracks,
10
+    toggleSpeeds,
11
+    toggleMute,
12
+    togglePause,
13
+    setCurrentTime,
14
+    toggleFullscreen,
15
+    getPercentagePlayed,
16
+    getPercentageBuffered
17
+} from './../video/api';
18
+import styles from './DefaultPlayer.css';
19
+import Time from './Time/Time';
20
+import Seek from './Seek/Seek';
21
+import Volume from './Volume/Volume';
22
+import Captions from './Captions/Captions';
23
+import Speed from './Speed/Speed';
24
+import PlayPause from './PlayPause/PlayPause';
25
+import Fullscreen from './Fullscreen/Fullscreen';
26
+import Overlay from './Overlay/Overlay';
27
+
28
+const DefaultPlayer = ({
29
+    copy,
30
+    video,
31
+    style,
32
+    controls,
33
+    children,
34
+    className,
35
+    onSeekChange,
36
+    onVolumeChange,
37
+    onVolumeClick,
38
+    onCaptionsClick,
39
+    onSpeedClick,
40
+    onPlayPauseClick,
41
+    onFullscreenClick,
42
+    onCaptionsItemClick,
43
+    onSpeedsItemClick,
44
+    ...restProps
45
+}) => {
46
+    let playbackrates = restProps['data-playbackrates'];
47
+    if (playbackrates) {
48
+        playbackrates = JSON.parse(playbackrates);
49
+    }
50
+    let onScreenClickCallback = restProps['onScreenClickCallback'];
51
+
52
+    return (
53
+        <div className={[
54
+            styles.component,
55
+            className
56
+        ].join(' ')}
57
+        style={style}>
58
+            <video
59
+                className={styles.video}
60
+                {...restProps}>
61
+                { children }
62
+            </video>
63
+            <Overlay
64
+                onClick={onPlayPauseClick}
65
+                {...video} />
66
+            { controls && controls.length && !video.error
67
+                ? <div className={styles.controls}>
68
+                        { controls.map((control, i) => {
69
+                            switch (control) {
70
+                                case 'Seek':
71
+                                    return <Seek
72
+                                        key={i}
73
+                                        ariaLabel={copy.seek}
74
+                                        className={styles.seek}
75
+                                        onChange={onSeekChange}
76
+                                        {...video} />;
77
+                                case 'PlayPause':
78
+                                    return <PlayPause
79
+                                        key={i}
80
+                                        ariaLabelPlay={copy.play}
81
+                                        ariaLabelPause={copy.pause}
82
+                                        onClick={onPlayPauseClick}
83
+                                        {...video} />;
84
+                                case 'Fullscreen':
85
+                                    return <Fullscreen
86
+                                        key={i}
87
+                                        ariaLabel={copy.fullscreen}
88
+                                        onClick={onFullscreenClick}
89
+                                        onScreenClickCallback={onScreenClickCallback}
90
+                                        {...video} />;
91
+                                case 'Time':
92
+                                    return <Time
93
+                                        key={i}
94
+                                        {...video} />;
95
+                                case 'Volume':
96
+                                    return <Volume
97
+                                        key={i}
98
+                                        onClick={onVolumeClick}
99
+                                        onChange={onVolumeChange}
100
+                                        ariaLabelMute={copy.mute}
101
+                                        ariaLabelUnmute={copy.unmute}
102
+                                        ariaLabelVolume={copy.volume}
103
+                                        {...video} />;
104
+                                case 'Captions':
105
+                                    return video.textTracks && video.textTracks.length
106
+                                        ? <Captions
107
+                                            key={i}
108
+                                            onClick={onCaptionsClick}
109
+                                            ariaLabel={copy.captions}
110
+                                            onItemClick={onCaptionsItemClick}
111
+                                            {...video}/>
112
+                                        : null;
113
+                                case 'Speed':
114
+                                    return playbackrates && playbackrates.length > 0
115
+                                        ? <Speed
116
+                                            key={i}
117
+                                            onClick={onSpeedClick}
118
+                                            ariaLabel={copy.captions}
119
+                                            onItemClick={onSpeedsItemClick}
120
+                                            playbackrates={playbackrates}
121
+                                            {...video}/>
122
+                                        : null;
123
+                                default:
124
+                                    return null;
125
+                            }
126
+                        }) }
127
+                    </div>
128
+                : null }
129
+        </div>
130
+    );
131
+};
132
+
133
+const controls = ['PlayPause', 'Seek', 'Time', 'Volume', 'Captions', 'Speed', 'Fullscreen'];
134
+
135
+DefaultPlayer.defaultProps = {
136
+    copy,
137
+    controls,
138
+    video: {}
139
+};
140
+
141
+DefaultPlayer.propTypes = {
142
+    copy: PropTypes.object.isRequired,
143
+    controls: PropTypes.arrayOf(PropTypes.oneOf(controls)),
144
+    video: PropTypes.object.isRequired
145
+};
146
+
147
+const connectedPlayer = videoConnect(
148
+    DefaultPlayer,
149
+    ({ networkState, readyState, error, ...restState }) => ({
150
+        video: {
151
+            readyState,
152
+            networkState,
153
+            error: error || networkState === 3,
154
+            // TODO: This is not pretty. Doing device detection to remove
155
+            // spinner on iOS devices for a quick and dirty win. We should see if
156
+            // we can use the same readyState check safely across all browsers.
157
+            loading: readyState < (/iPad|iPhone|iPod/.test(navigator.userAgent) ? 1 : 4),
158
+            percentagePlayed: getPercentagePlayed(restState),
159
+            percentageBuffered: getPercentageBuffered(restState),
160
+            ...restState
161
+        }
162
+    }),
163
+    (videoEl, state) => ({
164
+        onFullscreenClick: () => toggleFullscreen(videoEl.parentElement),
165
+        onVolumeClick: () => toggleMute(videoEl, state),
166
+        onCaptionsClick: () => toggleTracks(state),
167
+        onSpeedClick: () => toggleSpeeds(videoEl, state),
168
+        onPlayPauseClick: () => togglePause(videoEl, state),
169
+        onCaptionsItemClick: (track) => showTrack(state, track),
170
+        onSpeedsItemClick: (speed) => showSpeed(videoEl, state, speed),
171
+        onVolumeChange: (e) => setVolume(videoEl, state, e.target.value),
172
+        onSeekChange: (e) => setCurrentTime(videoEl, state, e.target.value * state.duration / 100)
173
+    })
174
+);
175
+
176
+export {
177
+    connectedPlayer as default,
178
+    DefaultPlayer,
179
+    Time,
180
+    Seek,
181
+    Volume,
182
+    Captions,
183
+    PlayPause,
184
+    Fullscreen,
185
+    Overlay
186
+};

+ 108
- 0
src/DefaultPlayer/DefaultPlayer.test.js View File

@@ -0,0 +1,108 @@
1
+import React from 'react';
2
+import { shallow } from 'enzyme';
3
+import { DefaultPlayer } from './DefaultPlayer';
4
+import styles from './DefaultPlayer.css';
5
+import Time from './Time/Time';
6
+import Seek from './Seek/Seek';
7
+import Volume from './Volume/Volume';
8
+import PlayPause from './PlayPause/PlayPause';
9
+import Fullscreen from './Fullscreen/Fullscreen';
10
+import Overlay from './Overlay/Overlay';
11
+
12
+describe('DefaultPlayer', () => {
13
+    let component;
14
+
15
+    beforeAll(() => {
16
+        component = shallow(<DefaultPlayer />);
17
+    });
18
+
19
+    it('should accept a className prop and append it to the components class', () => {
20
+        const newClassNameString = 'a new className';
21
+        expect(component.prop('className'))
22
+            .toContain(styles.component);
23
+        component.setProps({
24
+            className: newClassNameString
25
+        });
26
+        expect(component.prop('className'))
27
+            .toContain(styles.component);
28
+        expect(component.prop('className'))
29
+            .toContain(newClassNameString);
30
+    });
31
+
32
+    it('applies `style` prop if provided', () => {
33
+        component.setProps({
34
+            style: { color: 'red' }
35
+        });
36
+        expect(component.prop('style'))
37
+            .toEqual({ color: 'red' });
38
+    });
39
+
40
+    it('spreads all parent props on the video element', () => {
41
+        component.setProps({
42
+            autoPlay: true
43
+        });
44
+        expect(component.find('video').prop('autoPlay'))
45
+            .toEqual(true);
46
+    });
47
+
48
+    it('has an overlay component', () => {
49
+        expect(component.find(Overlay).exists())
50
+            .toBeTruthy();
51
+    });
52
+
53
+    it('renders some default controls in a default order', () => {
54
+        const controlsComponent = component.find(`.${styles.controls}`);
55
+        expect(controlsComponent.childAt(0).is(PlayPause))
56
+            .toBeTruthy();
57
+        expect(controlsComponent.childAt(1).is(Seek))
58
+            .toBeTruthy();
59
+        expect(controlsComponent.childAt(2).is(Time))
60
+            .toBeTruthy();
61
+        expect(controlsComponent.childAt(3).is(Volume))
62
+            .toBeTruthy();
63
+        expect(controlsComponent.childAt(4).is(Fullscreen))
64
+            .toBeTruthy();
65
+    });
66
+
67
+    it('renders controls in a given custom order', () => {
68
+        component.setProps({
69
+            controls: ['Fullscreen', 'Seek', 'Time']
70
+        });
71
+        const controlsComponent = component.find(`.${styles.controls}`);
72
+        expect(controlsComponent.childAt(0).is(Fullscreen))
73
+            .toBeTruthy();
74
+        expect(controlsComponent.childAt(1).is(Seek))
75
+            .toBeTruthy();
76
+        expect(controlsComponent.childAt(2).is(Time))
77
+            .toBeTruthy();
78
+    });
79
+
80
+    it('renders no controls when given an empty array', () => {
81
+        expect(component.find(`.${styles.controls}`).exists())
82
+            .toBeTruthy();
83
+        component.setProps({
84
+            controls: []
85
+        });
86
+        expect(component.find(`.${styles.controls}`).exists())
87
+            .toBeFalsy();
88
+    });
89
+
90
+    it('renders no controls when there is an error', () => {
91
+        component.setProps({
92
+            controls: ['PlayPause'],
93
+            video: {
94
+                error: false
95
+            }
96
+        });
97
+        expect(component.find(`.${styles.controls}`).exists())
98
+            .toBeTruthy();
99
+        component.setProps({
100
+            controls: ['PlayPause'],
101
+            video: {
102
+                error: true
103
+            }
104
+        });
105
+        expect(component.find(`.${styles.controls}`).exists())
106
+            .toBeFalsy();
107
+    });
108
+});

+ 26
- 0
src/DefaultPlayer/Fullscreen/Fullscreen.css View File

@@ -0,0 +1,26 @@
1
+.component {}
2
+
3
+.component:hover {
4
+    background-color: #000;
5
+}
6
+
7
+.button {
8
+    width: 34px;
9
+    height: 34px;
10
+    background: none;
11
+    border: 0;
12
+    color: inherit;
13
+    font: inherit;
14
+    line-height: normal;
15
+    overflow: visible;
16
+    padding: 0;
17
+    cursor: pointer;
18
+}
19
+
20
+.button:focus {
21
+    outline: 0;
22
+}
23
+
24
+.icon {
25
+    padding: 5px;
26
+}

+ 22
- 0
src/DefaultPlayer/Fullscreen/Fullscreen.js View File

@@ -0,0 +1,22 @@
1
+import React from 'react';
2
+import styles from './Fullscreen.css';
3
+import FullscreenIcon from './../Icon/fullscreen.svg';
4
+
5
+export default ({ onClick, className, ariaLabel, onScreenClickCallback }) => {
6
+    return (
7
+        <div className={[
8
+            styles.component,
9
+            className
10
+        ].join(' ')}>
11
+            <button
12
+                type="button"
13
+                onClick={onClick.bind(this, onScreenClickCallback)}
14
+                aria-label={ariaLabel}
15
+                className={styles.button}>
16
+                    <FullscreenIcon
17
+                        fill="#fff"
18
+                        className={styles.icon} />
19
+            </button>
20
+        </div>
21
+    );
22
+};

+ 44
- 0
src/DefaultPlayer/Fullscreen/Fullscreen.test.js View File

@@ -0,0 +1,44 @@
1
+import React from 'react';
2
+import { shallow } from 'enzyme';
3
+import Fullscreen from './Fullscreen';
4
+import styles from './Fullscreen.css';
5
+
6
+describe('Fullscreen', () => {
7
+    let component;
8
+
9
+    beforeAll(() => {
10
+        component = shallow(
11
+            <Fullscreen ariaLabel="Fullscreen" />
12
+        );
13
+    });
14
+
15
+    it('should accept a className prop and append it to the components class', () => {
16
+        const newClassNameString = 'a new className';
17
+        expect(component.prop('className'))
18
+            .toContain(styles.component);
19
+        component.setProps({
20
+            className: newClassNameString
21
+        });
22
+        expect(component.prop('className'))
23
+            .toContain(styles.component);
24
+        expect(component.prop('className'))
25
+            .toContain(newClassNameString);
26
+    });
27
+
28
+    it('triggers \'onClick\' prop when the button is clicked', () => {
29
+        const spy = jest.fn();
30
+        component.setProps({
31
+            onClick: spy
32
+        });
33
+        expect(spy)
34
+            .not.toHaveBeenCalled();
35
+        component.find('button').simulate('click');
36
+        expect(spy)
37
+            .toHaveBeenCalled();
38
+    });
39
+
40
+    it('has correct aria-label', () => {
41
+        expect(component.find('button').prop('aria-label'))
42
+            .toEqual('Fullscreen');
43
+    });
44
+});

+ 6
- 0
src/DefaultPlayer/Icon/caption_new.svg View File

@@ -0,0 +1,6 @@
1
+<?xml version="1.0" encoding="utf-8"?>
2
+<!-- Generated by IcoMoon.io -->
3
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
4
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="34" height="34" viewBox="4 0 20 16">
5
+<path d="M0 0h26v22.677L21.544 17.8H0V0zm6.276 11.602a2.373 2.373 0 1 0 0-4.746 2.373 2.373 0 0 0 0 4.746zm6.592 0a2.373 2.373 0 1 0 0-4.746 2.373 2.373 0 0 0 0 4.746zm6.592 0a2.373 2.373 0 1 0 0-4.746 2.373 2.373 0 0 0 0 4.746z" fill="#FFF" fill-rule="evenodd"></path>
6
+</svg>

+ 6
- 0
src/DefaultPlayer/Icon/closed_caption.svg View File

@@ -0,0 +1,6 @@
1
+<?xml version="1.0" encoding="utf-8"?>
2
+<!-- Generated by IcoMoon.io -->
3
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
4
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
5
+<path d="M18 11.016v-1.031c0-0.563-0.422-0.984-0.984-0.984h-3c-0.563 0-1.031 0.422-1.031 0.984v4.031c0 0.563 0.469 0.984 1.031 0.984h3c0.563 0 0.984-0.422 0.984-0.984v-1.031h-1.5v0.516h-2.016v-3h2.016v0.516h1.5zM11.016 11.016v-1.031c0-0.563-0.469-0.984-1.031-0.984h-3c-0.563 0-0.984 0.422-0.984 0.984v4.031c0 0.563 0.422 0.984 0.984 0.984h3c0.563 0 1.031-0.422 1.031-0.984v-1.031h-1.5v0.516h-2.016v-3h2.016v0.516h1.5zM18.984 3.984c1.078 0 2.016 0.938 2.016 2.016v12c0 1.078-0.938 2.016-2.016 2.016h-13.969c-1.125 0-2.016-0.938-2.016-2.016v-12c0-1.078 0.891-2.016 2.016-2.016h13.969z"></path>
6
+</svg>

+ 6
- 0
src/DefaultPlayer/Icon/fullscreen.svg View File

@@ -0,0 +1,6 @@
1
+<?xml version="1.0" encoding="utf-8"?>
2
+<!-- Generated by IcoMoon.io -->
3
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
4
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="34" height="34" viewBox="0 0 24 24">
5
+<path d="M14.016 5.016h4.969v4.969h-1.969v-3h-3v-1.969zM17.016 17.016v-3h1.969v4.969h-4.969v-1.969h3zM5.016 9.984v-4.969h4.969v1.969h-3v3h-1.969zM6.984 14.016v3h3v1.969h-4.969v-4.969h1.969z"></path>
6
+</svg>

+ 6
- 0
src/DefaultPlayer/Icon/fullscreen_exit.svg View File

@@ -0,0 +1,6 @@
1
+<?xml version="1.0" encoding="utf-8"?>
2
+<!-- Generated by IcoMoon.io -->
3
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
4
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="34" height="34" viewBox="0 0 24 24">
5
+<path d="M15.984 8.016h3v1.969h-4.969v-4.969h1.969v3zM14.016 18.984v-4.969h4.969v1.969h-3v3h-1.969zM8.016 8.016v-3h1.969v4.969h-4.969v-1.969h3zM5.016 15.984v-1.969h4.969v4.969h-1.969v-3h-3z"></path>
6
+</svg>

+ 6
- 0
src/DefaultPlayer/Icon/pause.svg View File

@@ -0,0 +1,6 @@
1
+<?xml version="1.0" encoding="utf-8"?>
2
+<!-- Generated by IcoMoon.io -->
3
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
4
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="34" height="34" viewBox="0 0 24 24">
5
+<path d="M14.016 5.016h3.984v13.969h-3.984v-13.969zM6 18.984v-13.969h3.984v13.969h-3.984z"></path>
6
+</svg>

+ 6
- 0
src/DefaultPlayer/Icon/play_arrow.svg View File

@@ -0,0 +1,6 @@
1
+<?xml version="1.0" encoding="utf-8"?>
2
+<!-- Generated by IcoMoon.io -->
3
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
4
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="34" height="34" viewBox="0 0 24 24">
5
+<path d="M8.016 5.016l10.969 6.984-10.969 6.984v-13.969z"></path>
6
+</svg>

+ 6
- 0
src/DefaultPlayer/Icon/report.svg View File

@@ -0,0 +1,6 @@
1
+<?xml version="1.0" encoding="utf-8"?>
2
+<!-- Generated by IcoMoon.io -->
3
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
4
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="34" height="34" viewBox="0 0 24 24">
5
+<path d="M12.984 12.984v-6h-1.969v6h1.969zM12 17.297c0.703 0 1.313-0.609 1.313-1.313s-0.609-1.266-1.313-1.266-1.313 0.563-1.313 1.266 0.609 1.313 1.313 1.313zM15.75 3l5.25 5.25v7.5l-5.25 5.25h-7.5l-5.25-5.25v-7.5l5.25-5.25h7.5z"></path>
6
+</svg>

+ 6
- 0
src/DefaultPlayer/Icon/speed.svg View File

@@ -0,0 +1,6 @@
1
+<?xml version="1.0" encoding="utf-8"?>
2
+<!-- Generated by IcoMoon.io -->
3
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
4
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="34" height="34" viewBox="14 8 12 24">
5
+<path d="M12.0791137,21.6949304 C12.406739,21.7334745 12.7729086,21.7527466 13.1583502,21.7720187 C13.4281593,21.7912908 13.6594243,21.9647395 13.7365126,22.2345486 C13.8714171,22.6585344 14.0255938,23.063248 14.2375867,23.4294176 C14.3724912,23.6606825 14.3339471,23.9497637 14.1604983,24.1617566 C13.9099613,24.4508378 13.6594243,24.739919 13.4667035,24.9904561 C13.2547106,25.2409931 13.2739827,25.6264347 13.5052476,25.8576997 L14.6423003,26.9947524 C14.8735653,27.2260173 15.2590069,27.2452894 15.5095439,27.0332965 C15.760081,26.8213037 16.0298901,26.5900387 16.3382434,26.3395017 C16.5502363,26.1660529 16.8393175,26.1275088 17.0705824,26.2624133 C17.456024,26.4551341 17.8607377,26.6285829 18.2654514,26.7634874 C18.5159884,26.8405757 18.7087092,27.0718407 18.7279813,27.3416498 C18.7472534,27.7270914 18.7857976,28.093261 18.8050696,28.4208863 C18.8436138,28.7485117 19.1134229,28.9990487 19.4410483,28.9990487 L21.059903,28.9990487 C21.3875284,28.9990487 21.6766096,28.7485117 21.6958816,28.4208863 C21.7344258,28.093261 21.7536979,27.7270914 21.77297,27.3416498 C21.792242,27.0718407 21.9656908,26.8405757 22.2354999,26.7634874 C22.6594857,26.6285829 23.0641993,26.4744062 23.4303689,26.2624133 C23.6616338,26.1275088 23.950715,26.1660529 24.1627079,26.3395017 C24.4517891,26.5900387 24.7408703,26.8405757 24.9914073,27.0332965 C25.2419444,27.2452894 25.627386,27.2260173 25.8586509,26.9947524 L26.9957037,25.8576997 C27.2269686,25.6264347 27.2462407,25.2409931 27.0342478,24.9904561 C26.822255,24.739919 26.59099,24.4701099 26.3404529,24.1617566 C26.1670042,23.9497637 26.1284601,23.6606825 26.2633646,23.4294176 C26.4560854,23.043976 26.6295341,22.6392623 26.7644387,22.2345486 C26.841527,21.9840116 27.072792,21.7912908 27.3426011,21.7720187 C27.7280427,21.7527466 28.0942122,21.7142024 28.4218376,21.6949304 C28.749463,21.6563862 29,21.3865771 29,21.0589517 L29,19.440097 C29,19.1124716 28.749463,18.8233904 28.4218376,18.8041184 C28.0942122,18.7655742 27.7280427,18.7463021 27.3426011,18.72703 C27.072792,18.707758 26.841527,18.5343092 26.7644387,18.2645001 C26.6295341,17.8405143 26.4753575,17.4358007 26.2633646,17.0696311 C26.1284601,16.8383662 26.1670042,16.549285 26.3404529,16.3372921 C26.59099,16.0482109 26.841527,15.7591297 27.0342478,15.5085927 C27.2462407,15.2580556 27.2269686,14.872614 26.9957037,14.6413491 L25.8586509,13.5042963 C25.627386,13.2730314 25.2419444,13.2537593 24.9914073,13.4657522 C24.7408703,13.677745 24.4710612,13.90901 24.1627079,14.1595471 C23.950715,14.3329958 23.6616338,14.3715399 23.4303689,14.2366354 C23.0449273,14.0439146 22.6402136,13.8704659 22.2354999,13.7355613 C21.9849628,13.658473 21.792242,13.427208 21.77297,13.1573989 C21.7536979,12.7719573 21.7151537,12.4057878 21.6958816,12.0781624 C21.6573375,11.750537 21.3875284,11.5 21.059903,11.5 L19.4410483,11.5 C19.1134229,11.5 18.8243417,11.750537 18.8050696,12.0781624 C18.7665255,12.4057878 18.7472534,12.7719573 18.7279813,13.1573989 C18.7087092,13.427208 18.5352605,13.658473 18.2654514,13.7355613 C17.8414656,13.8704659 17.436752,14.0246425 17.0705824,14.2366354 C16.8393175,14.3715399 16.5502363,14.3329958 16.3382434,14.1595471 C16.0491622,13.90901 15.760081,13.658473 15.5095439,13.4657522 C15.2590069,13.2537593 14.8735653,13.2730314 14.6423003,13.5042963 L13.5052476,14.6413491 C13.2739827,14.872614 13.2547106,15.2580556 13.4667035,15.5085927 C13.6786963,15.7591297 13.9099613,16.0289388 14.1604983,16.3372921 C14.3339471,16.549285 14.3724912,16.8383662 14.2375867,17.0696311 C14.0448659,17.4550727 13.8714171,17.8597864 13.7365126,18.2645001 C13.6594243,18.5150372 13.4281593,18.707758 13.1583502,18.72703 C12.7729086,18.7463021 12.406739,18.7848463 12.0791137,18.8041184 C11.7514883,18.8426625 11.5009513,19.1124716 11.5009513,19.440097 L11.5009513,21.0589517 C11.4816792,21.3865771 11.7322162,21.6756583 12.0791137,21.6949304 L12.0791137,21.6949304 Z M20.2504756,15.8747622 C22.6787577,15.8747622 24.6252378,17.8405143 24.6252378,20.2495244 C24.6252378,22.6778064 22.6594857,24.6242865 20.2504756,24.6242865 C17.8414656,24.6242865 15.8757135,22.6778064 15.8757135,20.2495244 C15.8757135,17.8212423 17.8221936,15.8747622 20.2504756,15.8747622 L20.2504756,15.8747622 Z"></path>
6
+</svg>

+ 1
- 0
src/DefaultPlayer/Icon/spin.svg View File

@@ -0,0 +1 @@
1
+<?xml version="1.0" encoding="utf-8"?><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid" class="uil-ring-alt"><rect x="0" y="0" width="100" height="100" fill="none" class="bk"></rect><circle cx="50" cy="50" r="40" stroke="#757575" fill="none" stroke-width="10" stroke-linecap="round"></circle><circle cx="50" cy="50" r="40" stroke="#ffffff" fill="none" stroke-width="6" stroke-linecap="round"><animate attributeName="stroke-dashoffset" dur="2s" repeatCount="indefinite" from="0" to="502"></animate><animate attributeName="stroke-dasharray" dur="2s" repeatCount="indefinite" values="150.6 100.4;1 250;150.6 100.4"></animate></circle></svg>

+ 6
- 0
src/DefaultPlayer/Icon/volume_down.svg View File

@@ -0,0 +1,6 @@
1
+<?xml version="1.0" encoding="utf-8"?>
2
+<!-- Generated by IcoMoon.io -->
3
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
4
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="34" height="34" viewBox="0 0 24 24">
5
+<path d="M5.016 9h3.984l5.016-5.016v16.031l-5.016-5.016h-3.984v-6zM18.516 12c0 1.781-1.031 3.281-2.531 4.031v-8.063c1.5 0.75 2.531 2.25 2.531 4.031z"></path>
6
+</svg>

+ 6
- 0
src/DefaultPlayer/Icon/volume_mute.svg View File

@@ -0,0 +1,6 @@
1
+<?xml version="1.0" encoding="utf-8"?>
2
+<!-- Generated by IcoMoon.io -->
3
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
4
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="34" height="34" viewBox="0 0 24 24">
5
+<path d="M6.984 9h4.031l4.969-5.016v16.031l-4.969-5.016h-4.031v-6z"></path>
6
+</svg>

+ 6
- 0
src/DefaultPlayer/Icon/volume_off.svg View File

@@ -0,0 +1,6 @@
1
+<?xml version="1.0" encoding="utf-8"?>
2
+<!-- Generated by IcoMoon.io -->
3
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
4
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="34" height="34" viewBox="0 0 24 24">
5
+<path d="M12 3.984v4.219l-2.109-2.109zM4.266 3l16.734 16.734-1.266 1.266-2.063-2.063c-1.078 0.844-2.297 1.5-3.656 1.828v-2.063c0.844-0.234 1.594-0.656 2.25-1.172l-4.266-4.266v6.75l-5.016-5.016h-3.984v-6h4.734l-4.734-4.734zM18.984 12c0-3.188-2.063-5.859-4.969-6.703v-2.063c4.031 0.891 6.984 4.5 6.984 8.766 0 1.5-0.375 2.953-1.031 4.172l-1.5-1.547c0.328-0.797 0.516-1.688 0.516-2.625zM16.5 12c0 0.234 0 0.422-0.047 0.609l-2.438-2.438v-2.203c1.5 0.75 2.484 2.25 2.484 4.031z"></path>
6
+</svg>

+ 6
- 0
src/DefaultPlayer/Icon/volume_up.svg View File

@@ -0,0 +1,6 @@
1
+<?xml version="1.0" encoding="utf-8"?>
2
+<!-- Generated by IcoMoon.io -->
3
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
4
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="34" height="34" viewBox="0 0 24 24">
5
+<path d="M14.016 3.234c4.031 0.891 6.984 4.5 6.984 8.766s-2.953 7.875-6.984 8.766v-2.063c2.906-0.844 4.969-3.516 4.969-6.703s-2.063-5.859-4.969-6.703v-2.063zM16.5 12c0 1.781-0.984 3.281-2.484 4.031v-8.063c1.5 0.75 2.484 2.25 2.484 4.031zM3 9h3.984l5.016-5.016v16.031l-5.016-5.016h-3.984v-6z"></path>
6
+</svg>

+ 36
- 0
src/DefaultPlayer/Overlay/Overlay.css View File

@@ -0,0 +1,36 @@
1
+.component {
2
+    position: absolute;
3
+    top: 0;
4
+    right: 0;
5
+    bottom: 0;
6
+    left: 0;
7
+    height: 100%;
8
+    width: 100%;
9
+    color: #fff;
10
+    text-align: center;
11
+    cursor: pointer;
12
+    background-color: rgba(0,0,0,0);
13
+}
14
+
15
+.inner {
16
+    display: inline-block;
17
+    position: absolute;
18
+    top: 50%;
19
+    right: 0;
20
+    left: 50%;
21
+    width: 60px;
22
+    height: 60px;
23
+    transform: translateY(-50%);
24
+    margin-left: -30px;
25
+    background-color: rgba(0,0,0,0.7);
26
+    border-radius: 10px;
27
+}
28
+
29
+.icon {
30
+    position: absolute;
31
+    top: 50%;
32
+    right: 0;
33
+    left: 50%;
34
+    margin-left: -20px;
35
+    transform: translateY(-50%);
36
+}

+ 53
- 0
src/DefaultPlayer/Overlay/Overlay.js View File

@@ -0,0 +1,53 @@
1
+import React, { Component } from 'react';
2
+import styles from './Overlay.css';
3
+import PlayArrow from './../Icon/play_arrow.svg';
4
+import Spin from './../Icon/spin.svg';
5
+import Report from './../Icon/report.svg';
6
+
7
+export default class Overlay extends Component {
8
+    renderContent () {
9
+        const {
10
+            error,
11
+            paused,
12
+            loading
13
+        } = this.props;
14
+        const iconProps = {
15
+            className: styles.icon,
16
+            height: 40,
17
+            width: 40,
18
+            fill: '#fff'
19
+        };
20
+        if (error) {
21
+            return (
22
+                <span className={styles.inner}>
23
+                    <Report {...iconProps} />
24
+                </span>
25
+            );
26
+        } else if (loading) {
27
+            return (
28
+                <span className={styles.inner}>
29
+                    <Spin {...iconProps} />
30
+                </span>
31
+            );
32
+        } else if (paused) {
33
+            return (
34
+                <span className={styles.inner}>
35
+                    <PlayArrow {...iconProps} />
36
+                </span>
37
+            );
38
+        }
39
+    }
40
+
41
+    render () {
42
+        const { className, onClick } = this.props;
43
+        return (
44
+            <div className={[
45
+                styles.component,
46
+                className
47
+            ].join(' ')}
48
+            onClick={onClick}>
49
+                { this.renderContent() }
50
+            </div>
51
+        );
52
+    }
53
+}

+ 56
- 0
src/DefaultPlayer/Overlay/Overlay.test.js View File

@@ -0,0 +1,56 @@
1
+import React from 'react';
2
+import { shallow } from 'enzyme';
3
+import Overlay from './Overlay';
4
+import styles from './Overlay.css';
5
+import PlayArrow from './../Icon/play_arrow.svg';
6
+import Spin from './../Icon/spin.svg';
7
+import Report from './../Icon/report.svg';
8
+
9
+describe('Overlay', () => {
10
+    let component;
11
+
12
+    beforeAll(() => {
13
+        component = shallow(<Overlay />);
14
+    });
15
+
16
+    it('should accept a className prop and append it to the components class', () => {
17
+        const newClassNameString = 'a new className';
18
+        expect(component.prop('className'))
19
+            .toContain(styles.component);
20
+        component.setProps({
21
+            className: newClassNameString
22
+        });
23
+        expect(component.prop('className'))
24
+            .toContain(styles.component);
25
+        expect(component.prop('className'))
26
+            .toContain(newClassNameString);
27
+    });
28
+
29
+    it('shows a PlayArrow icon if paused', () => {
30
+        component.setProps({
31
+            paused: true
32
+        });
33
+        expect(component.html())
34
+            .toContain(PlayArrow);
35
+    });
36
+
37
+    it('shows Report icon if error regardless of loading or paused state', () => {
38
+        component.setProps({
39
+            error: true,
40
+            loading: true,
41
+            paused: true
42
+        });
43
+        expect(component.html())
44
+            .toContain(Report);
45
+    });
46
+
47
+    it('shows Spin icon if loading regardless of paused state', () => {
48
+        component.setProps({
49
+            loading: true,
50
+            paused: true,
51
+            error: false
52
+        });
53
+        expect(component.html())
54
+            .toContain(Spin);
55
+    });
56
+});

+ 26
- 0
src/DefaultPlayer/PlayPause/PlayPause.css View File

@@ -0,0 +1,26 @@
1
+.component {}
2
+
3
+.component:hover {
4
+    background-color: #000;
5
+}
6
+
7
+.button {
8
+    width: 34px;
9
+    height: 34px;
10
+    background: none;
11
+    border: 0;
12
+    color: inherit;
13
+    font: inherit;
14
+    line-height: normal;
15
+    overflow: visible;
16
+    padding: 0;
17
+    cursor: pointer;
18
+}
19
+
20
+.button:focus {
21
+    outline: 0;
22
+}
23
+
24
+.icon {
25
+    padding: 5px;
26
+}

+ 29
- 0
src/DefaultPlayer/PlayPause/PlayPause.js View File

@@ -0,0 +1,29 @@
1
+import React from 'react';
2
+import styles from './PlayPause.css';
3
+import PlayArrow from './../Icon/play_arrow.svg';
4
+import Pause from './../Icon/pause.svg';
5
+
6
+export default ({ onClick, paused, className, ariaLabelPlay, ariaLabelPause }) => {
7
+    return (
8
+        <div className={[
9
+            styles.component,
10
+            className
11
+        ].join(' ')}>
12
+            <button
13
+                className={styles.button}
14
+                onClick={onClick}
15
+                aria-label={ paused
16
+                    ? ariaLabelPlay
17
+                    : ariaLabelPause }
18
+                type="button">
19
+                { paused
20
+                    ? <PlayArrow
21
+                        className={styles.icon}
22
+                        fill="#fff" />
23
+                    : <Pause
24
+                        className={styles.icon}
25
+                        fill="#fff" /> }
26
+            </button>
27
+        </div>
28
+    );
29
+};

+ 83
- 0
src/DefaultPlayer/PlayPause/PlayPause.test.js View File

@@ -0,0 +1,83 @@
1
+import React from 'react';
2
+import { shallow } from 'enzyme';
3
+import PlayPause from './PlayPause';
4
+import styles from './PlayPause.css';
5
+import PlayArrow from './../Icon/play_arrow.svg';
6
+import Pause from './../Icon/pause.svg';
7
+
8
+describe('PlayPause', () => {
9
+    let component;
10
+
11
+    beforeAll(() => {
12
+        component = shallow(
13
+            <PlayPause
14
+                ariaLabelPlay="Play"
15
+                ariaLabelPause="Pause" />
16
+        );
17
+    });
18
+
19
+    it('should accept a className prop and append it to the components class', () => {
20
+        const newClassNameString = 'a new className';
21
+        expect(component.prop('className'))
22
+            .toContain(styles.component);
23
+        component.setProps({
24
+            className: newClassNameString
25
+        });
26
+        expect(component.prop('className'))
27
+            .toContain(styles.component);
28
+        expect(component.prop('className'))
29
+            .toContain(newClassNameString);
30
+    });
31
+
32
+    it('triggers \'onClick\' prop when the button is clicked', () => {
33
+        const spy = jest.fn();
34
+        component.setProps({
35
+            onClick: spy
36
+        });
37
+        expect(spy)
38
+            .not.toHaveBeenCalled();
39
+        component.find('button').simulate('click');
40
+        expect(spy)
41
+            .toHaveBeenCalled();
42
+    });
43
+
44
+    describe('when paused', () => {
45
+        beforeAll(() => {
46
+            component.setProps({
47
+                paused: true
48
+            });
49
+        });
50
+
51
+        it('shows a play icon', () => {
52
+            expect(component.html())
53
+                .toContain(PlayArrow);
54
+            expect(component.html())
55
+                .not.toContain(Pause);
56
+        });
57
+
58
+        it('has correct aria-label', () => {
59
+            expect(component.find('button').prop('aria-label'))
60
+                .toEqual('Play');
61
+        });
62
+    });
63
+
64
+    describe('when playing', () => {
65
+        beforeAll(() => {
66
+            component.setProps({
67
+                paused: false
68
+            });
69
+        });
70
+
71
+        it('shows a pause icon', () => {
72
+            expect(component.html())
73
+                .toContain(Pause);
74
+            expect(component.html())
75
+                .not.toContain(PlayArrow);
76
+        });
77
+
78
+        it('has correct aria-label', () => {
79
+            expect(component.find('button').prop('aria-label'))
80
+                .toEqual('Pause');
81
+        });
82
+    });
83
+});

+ 36
- 0
src/DefaultPlayer/Seek/Seek.css View File

@@ -0,0 +1,36 @@
1
+.component {
2
+    position: relative;
3
+}
4
+
5
+.track {
6
+    position: absolute;
7
+    top: 50%;
8
+    left: 5px;
9
+    right: 5px;
10
+    height: 4px;
11
+    transform: translateY(-50%);
12
+    background-color: #3e3e3e;
13
+}
14
+
15
+.buffer,
16
+.fill,
17
+.input {
18
+    position: absolute;
19
+    top: 0;
20
+    left: 0;
21
+    height: 100%;
22
+}
23
+
24
+.buffer {
25
+    background-color: #5a5a5a;
26
+}
27
+
28
+.fill {
29
+    background: #fff;
30
+}
31
+
32
+.input {
33
+    width: 100%;
34
+    opacity: 0;
35
+    cursor: pointer;
36
+}

+ 34
- 0
src/DefaultPlayer/Seek/Seek.js View File

@@ -0,0 +1,34 @@
1
+import React from 'react';
2
+import styles from './Seek.css';
3
+
4
+export default ({ onChange, percentagePlayed, percentageBuffered, className, ariaLabel }) => {
5
+    return (
6
+        <div className={[
7
+            styles.component,
8
+            className
9
+        ].join(' ')}>
10
+            <div className={styles.track}>
11
+                <div
12
+                    className={styles.buffer}
13
+                    style={{
14
+                        width: `${percentageBuffered || 0}%`
15
+                    }} />
16
+                <div
17
+                    className={styles.fill}
18
+                    style={{
19
+                        width: `${percentagePlayed || 0}%`
20
+                    }} />
21
+                <input
22
+                    min="0"
23
+                    step={1}
24
+                    max="100"
25
+                    type="range"
26
+                    orient="horizontal"
27
+                    onChange={onChange}
28
+                    aria-label={ariaLabel}
29
+                    className={styles.input}
30
+                    value={percentagePlayed || 0} />
31
+            </div>
32
+        </div>
33
+    );
34
+};

+ 91
- 0
src/DefaultPlayer/Seek/Seek.test.js View File

@@ -0,0 +1,91 @@
1
+import React from 'react';
2
+import { shallow } from 'enzyme';
3
+import Seek from './Seek';
4
+import styles from './Seek.css';
5
+
6
+describe('Seek', () => {
7
+    let component;
8
+
9
+    beforeAll(() => {
10
+        component = shallow(<Seek />);
11
+    });
12
+
13
+    it('should accept a className prop and append it to the components class', () => {
14
+        const newClassNameString = 'a new className';
15
+        expect(component.prop('className'))
16
+            .toContain(styles.component);
17
+        component.setProps({
18
+            className: newClassNameString
19
+        });
20
+        expect(component.prop('className'))
21
+            .toContain(styles.component);
22
+        expect(component.prop('className'))
23
+            .toContain(newClassNameString);
24
+    });
25
+
26
+    it('has a range input with correct ranges and percentagePlayed value', () => {
27
+        component.setProps({
28
+            percentagePlayed: 10
29
+        });
30
+        const rangeInput = component.find(`.${styles.input}`);
31
+        expect(rangeInput.prop('type'))
32
+            .toEqual('range');
33
+        expect(rangeInput.prop('orient'))
34
+            .toEqual('horizontal');
35
+        expect(rangeInput.prop('step'))
36
+            .toEqual(1);
37
+        expect(rangeInput.prop('min'))
38
+            .toEqual('0');
39
+        expect(rangeInput.prop('max'))
40
+            .toEqual('100');
41
+        expect(rangeInput.prop('value'))
42
+            .toEqual(10);
43
+    });
44
+
45
+    it('handles an undefined percentagePlayed value', () => {
46
+        component.setProps({
47
+            percentagePlayed: undefined
48
+        });
49
+        const rangeInput = component.find(`.${styles.input}`);
50
+        expect(rangeInput.prop('value'))
51
+            .toEqual(0);
52
+    });
53
+
54
+    it('triggers \'onChange\' prop when the input is changed', () => {
55
+        const spy = jest.fn();
56
+        component.setProps({
57
+            onChange: spy
58
+        });
59
+        expect(spy)
60
+            .not.toHaveBeenCalled();
61
+        component.find('input').simulate('change');
62
+        expect(spy)
63
+            .toHaveBeenCalled();
64
+    });
65
+
66
+    it('changes the played fill bar width', () => {
67
+        component.setProps({
68
+            percentagePlayed: 0
69
+        });
70
+        expect(component.find(`.${styles.fill}`).prop('style').width)
71
+            .toEqual('0%');
72
+        component.setProps({
73
+            percentagePlayed: 11
74
+        });
75
+        expect(component.find(`.${styles.fill}`).prop('style').width)
76
+            .toEqual('11%');
77
+    });
78
+
79
+    it('changes the buffer bar width', () => {
80
+        component.setProps({
81
+            percentageBuffered: 0
82
+        });
83
+        expect(component.find(`.${styles.buffer}`).prop('style').width)
84
+            .toEqual('0%');
85
+        component.setProps({
86
+            percentageBuffered: 11
87
+        });
88
+        expect(component.find(`.${styles.buffer}`).prop('style').width)
89
+            .toEqual('11%');
90
+    });
91
+});

+ 59
- 0
src/DefaultPlayer/Speed/Speed.css View File

@@ -0,0 +1,59 @@
1
+.component {
2
+    position: relative;
3
+}
4
+
5
+.component:hover {
6
+    background-color: #000;
7
+}
8
+
9
+.button {
10
+    width: 34px;
11
+    height: 34px;
12
+    background: none;
13
+    border: 0;
14
+    color: inherit;
15
+    font: inherit;
16
+    line-height: normal;
17
+    overflow: visible;
18
+    padding: 0;
19
+    cursor: pointer;
20
+}
21
+
22
+.button:focus {
23
+    outline: 0;
24
+}
25
+
26
+.icon {
27
+    padding: 5px;
28
+}
29
+
30
+.speedList {
31
+    position: absolute;
32
+    right: 0;
33
+    bottom: 100%;
34
+    display: none;
35
+    background-color: rgba(0,0,0,0.7);
36
+    list-style: none;
37
+    padding: 0;
38
+    margin: 0;
39
+    color: #fff;
40
+}
41
+
42
+.component:hover .speedList {
43
+    display: block;
44
+}
45
+
46
+.speedItem {
47
+    padding: 7px;
48
+    cursor: pointer;
49
+}
50
+
51
+.activeSpeedItem,
52
+.speedItem:hover {
53
+    background: #000;
54
+}
55
+
56
+.activeSpeedItem {
57
+    composes: speedItem;
58
+    text-decoration: underline;
59
+}

+ 35
- 0
src/DefaultPlayer/Speed/Speed.js View File

@@ -0,0 +1,35 @@
1
+import React from 'react';
2
+import styles from './Speed.css';
3
+import SpeedIcon from './../Icon/speed.svg';
4
+
5
+export default ({ playbackrates, onClick, onItemClick, className, ariaLabel }) => {
6
+    console.log('speed')
7
+    return (
8
+        <div className={[
9
+            styles.component,
10
+            className
11
+        ].join(' ')}>
12
+            <button
13
+                type="button"
14
+                onClick={onClick}
15
+                aria-label={ariaLabel}
16
+                className={styles.button}>
17
+                <SpeedIcon
18
+                    className={styles.icon}
19
+                    fill="#fff" />
20
+            </button>
21
+            <ul className={styles.speedList}>
22
+                { playbackrates && playbackrates.map((rate) => (
23
+                    <li
24
+                        key={rate.id}
25
+                        className={rate.mode == 'showing'
26
+                            ? styles.activeSpeedItem
27
+                            : styles.speedItem}
28
+                        onClick={onItemClick.bind(this, rate)}>
29
+                        { rate.name }
30
+                    </li>
31
+                ))}
32
+            </ul>
33
+        </div>
34
+    );
35
+};

+ 32
- 0
src/DefaultPlayer/Speed/Speed.test.js View File

@@ -0,0 +1,32 @@
1
+import React from 'react';
2
+import { shallow } from 'enzyme';
3
+import Speed from './Speed';
4
+import styles from './Captions.css';
5
+
6
+describe('Speed', () => {
7
+    let component;
8
+
9
+    beforeAll(() => {
10
+        component = shallow(
11
+            <Speed ariaLabel="Speed" />
12
+        );
13
+    });
14
+
15
+    it('should accept a className prop and append it to the components class', () => {
16
+        const newClassNameString = 'a new className';
17
+        expect(component.prop('className'))
18
+            .toContain(styles.component);
19
+        component.setProps({
20
+            className: newClassNameString
21
+        });
22
+        expect(component.prop('className'))
23
+            .toContain(styles.component);
24
+        expect(component.prop('className'))
25
+            .toContain(newClassNameString);
26
+    });
27
+
28
+    it('has correct aria-label', () => {
29
+        expect(component.find('button').prop('aria-label'))
30
+            .toEqual('Speed');
31
+    });
32
+});

+ 14
- 0
src/DefaultPlayer/Time/Time.css View File

@@ -0,0 +1,14 @@
1
+.component {
2
+    padding: 0 10px 0 10px;
3
+    line-height: 35px;
4
+    color: #fff;
5
+}
6
+
7
+.current {
8
+    margin-right: 5px;
9
+}
10
+
11
+.duration {
12
+    margin-left: 5px;
13
+    color: #919191;
14
+}

+ 29
- 0
src/DefaultPlayer/Time/Time.js View File

@@ -0,0 +1,29 @@
1
+import React from 'react';
2
+import styles from './Time.css';
3
+
4
+const formatTime = (seconds) => {
5
+    const date = new Date(Date.UTC(1970,1,1,0,0,0,0));
6
+    seconds = isNaN(seconds) || seconds > 86400
7
+        ? 0
8
+        : Math.floor(seconds);
9
+    date.setSeconds(seconds);
10
+    const duration = date.toISOString().substr(11, 8).replace(/^0{1,2}:?0{0,1}/,'');
11
+    return duration;
12
+};
13
+
14
+export default ({ currentTime, duration, className }) => {
15
+    return (
16
+        <div className={[
17
+            styles.component,
18
+            className
19
+        ].join(' ')}>
20
+            <span className={styles.current}>
21
+                { formatTime(currentTime) }
22
+            </span>
23
+            /
24
+            <span className={styles.duration}>
25
+                { formatTime(duration) }
26
+            </span>
27
+        </div>
28
+    );
29
+};

+ 91
- 0
src/DefaultPlayer/Time/Time.test.js View File

@@ -0,0 +1,91 @@
1
+import React from 'react';
2
+import { shallow } from 'enzyme';
3
+import Time from './Time';
4
+import styles from './Time.css';
5
+
6
+describe('Time', () => {
7
+    let component;
8
+
9
+    beforeAll(() => {
10
+        component = shallow(<Time />);
11
+    });
12
+
13
+    it('should accept a className prop and append it to the components class', () => {
14
+        const newClassNameString = 'a new className';
15
+        expect(component.prop('className'))
16
+            .toContain(styles.component);
17
+        component.setProps({
18
+            className: newClassNameString
19
+        });
20
+        expect(component.prop('className'))
21
+            .toContain(styles.component);
22
+        expect(component.prop('className'))
23
+            .toContain(newClassNameString);
24
+    });
25
+
26
+    it('shows video duration', () => {
27
+        component.setProps({
28
+            duration: 10
29
+        });
30
+        expect(component.find(`.${styles.duration}`).text())
31
+            .toEqual('0:10');
32
+    });
33
+
34
+    it('shows current video elapsed time', () => {
35
+        component.setProps({
36
+            currentTime: 10
37
+        });
38
+        expect(component.find(`.${styles.current}`).text())
39
+            .toEqual('0:10');
40
+    });
41
+
42
+    it('can handle minutes, hours and seconds', () => {
43
+        component.setProps({
44
+            currentTime: 60 * 2
45
+        });
46
+        expect(component.find(`.${styles.current}`).text())
47
+            .toEqual('2:00');
48
+
49
+        component.setProps({
50
+            currentTime: 60 * 60 * 3
51
+        });
52
+        expect(component.find(`.${styles.current}`).text())
53
+            .toEqual('3:00:00');
54
+
55
+        component.setProps({
56
+            currentTime: 60 * 60 * 3 + 72
57
+        });
58
+        expect(component.find(`.${styles.current}`).text())
59
+            .toEqual('3:01:12');
60
+    });
61
+
62
+    it('fails gracefully and shows 00:00:00 when video is greater than 24 hours', () => {
63
+        // Who has a video longer than 24 hours? If this ever occurs then we
64
+        // should consider adding it.
65
+        component.setProps({
66
+            currentTime: 86401
67
+        });
68
+        expect(component.find(`.${styles.current}`).text())
69
+            .toEqual('0:00');
70
+
71
+        component.setProps({
72
+            currentTime: 86400
73
+        });
74
+        expect(component.find(`.${styles.current}`).text())
75
+            .toEqual('0:00');
76
+    });
77
+
78
+    it('fails gracefully and shows 00:00 when not given a number', () => {
79
+        component.setProps({
80
+            currentTime: null
81
+        });
82
+        expect(component.find(`.${styles.current}`).text())
83
+            .toEqual('0:00');
84
+
85
+        component.setProps({
86
+            currentTime: undefined
87
+        });
88
+        expect(component.find(`.${styles.current}`).text())
89
+            .toEqual('0:00');
90
+    });
91
+});

+ 74
- 0
src/DefaultPlayer/Volume/Volume.css View File

@@ -0,0 +1,74 @@
1
+.component {
2
+    position: relative;
3
+}
4
+
5
+.component:hover {
6
+    background-color: #000;
7
+}
8
+
9
+.button {
10
+    width: 34px;
11
+    height: 34px;
12
+    background: none;
13
+    border: 0;
14
+    color: inherit;
15
+    font: inherit;
16
+    line-height: normal;
17
+    overflow: visible;
18
+    padding: 0;
19
+    cursor: pointer;
20
+}
21
+
22
+.button:focus {
23
+    outline: 0;
24
+}
25
+
26
+.icon {
27
+    padding: 7px;
28
+}
29
+
30
+.slider {
31
+    display: none;
32
+    position: absolute;
33
+    right: 5px;
34
+    bottom: 100%;
35
+    left: 5px;
36
+    height: 56px;
37
+    background-color: #000;
38
+}
39
+
40
+.component:hover .slider {
41
+    display: block;
42
+}
43
+
44
+.track {
45
+    position: absolute;
46
+    top: 8px;
47
+    bottom: 8px;
48
+    left: 50%;
49
+    width: 4px;
50
+    transform: translateX(-50%);
51
+    background-color: #3e3e3e;
52
+}
53
+
54
+.fill,
55
+.input {
56
+    position: absolute;
57
+    right: 0;
58
+    bottom: 0;
59
+    left: 0;
60
+    height: 100%;
61
+    width: 100%;
62
+}
63
+
64
+.fill {
65
+    background-color: #fff;
66
+}
67
+
68
+.input {
69
+    padding: 0;
70
+    margin: 0;
71
+    opacity: 0;
72
+    -webkit-appearance: slider-vertical;
73
+    cursor: pointer;
74
+}

+ 56
- 0
src/DefaultPlayer/Volume/Volume.js View File

@@ -0,0 +1,56 @@
1
+import React from 'react';
2
+import styles from './Volume.css';
3
+import VolumeOff from './../Icon/volume_off.svg';
4
+import VolumeUp from './../Icon/volume_up.svg';
5
+
6
+export default ({ onChange, onClick, volume, muted, className, ariaLabelMute, ariaLabelUnmute, ariaLabelVolume }) => {
7
+    const volumeValue = muted || !volume
8
+        ? 0
9
+        : +volume;
10
+    const isSilent = muted || volume <= 0;
11
+    return (
12
+        <div className={[
13
+            styles.component,
14
+            className
15
+        ].join(' ')}>
16
+            <button
17
+                aria-label={isSilent
18
+                    ? ariaLabelUnmute
19
+                    : ariaLabelMute}
20
+                className={styles.button}
21
+                onClick={onClick}
22
+                type="button">
23
+                { isSilent
24
+                    ? <VolumeOff
25
+                        height={34}
26
+                        width={34}
27
+                        className={styles.icon}
28
+                        fill="#fff" />
29
+                    : <VolumeUp
30
+                        height={34}
31
+                        width={34}
32
+                        className={styles.icon}
33
+                        fill="#fff"/> }
34
+            </button>
35
+            <div className={styles.slider}>
36
+                <div className={styles.track}>
37
+                    <div
38
+                        className={styles.fill}
39
+                        style={{
40
+                            height: `${volumeValue * 100}%`
41
+                        }} />
42
+                    <input
43
+                        min="0"
44
+                        step={0.1}
45
+                        max="1"
46
+                        type="range"
47
+                        orient="vertical"
48
+                        onChange={onChange}
49
+                        aria-label={ariaLabelVolume}
50
+                        className={styles.input}
51
+                        value={volumeValue} />
52
+                </div>
53
+            </div>
54
+        </div>
55
+    );
56
+};

+ 165
- 0
src/DefaultPlayer/Volume/Volume.test.js View File

@@ -0,0 +1,165 @@
1
+import React from 'react';
2
+import { shallow } from 'enzyme';
3
+import Volume from './Volume';
4
+import styles from './Volume.css';
5
+import VolumeOff from './../Icon/volume_off.svg';
6
+import VolumeUp from './../Icon/volume_up.svg';
7
+
8
+describe('Volume', () => {
9
+    let component;
10
+
11
+    beforeAll(() => {
12
+        component = shallow(
13
+            <Volume
14
+                ariaLabelMute="Mute"
15
+                ariaLabelUnmute="Unmute"
16
+                ariaLabelVolume="Change volume" />
17
+        );
18
+    });
19
+
20
+    it('should accept a className prop and append it to the components class', () => {
21
+        const newClassNameString = 'a new className';
22
+        expect(component.prop('className'))
23
+            .toContain(styles.component);
24
+        component.setProps({
25
+            className: newClassNameString
26
+        });
27
+        expect(component.prop('className'))
28
+            .toContain(styles.component);
29
+        expect(component.prop('className'))
30
+            .toContain(newClassNameString);
31
+    });
32
+
33
+    it('has a vertical range input with correct ranges and step', () => {
34
+        const rangeInput = component.find(`.${styles.input}`);
35
+        expect(rangeInput.prop('type'))
36
+            .toEqual('range');
37
+        expect(rangeInput.prop('orient'))
38
+            .toEqual('vertical');
39
+        expect(rangeInput.prop('step'))
40
+            .toEqual(0.1);
41
+        expect(rangeInput.prop('min'))
42
+            .toEqual('0');
43
+        expect(rangeInput.prop('max'))
44
+            .toEqual('1');
45
+        expect(rangeInput.prop('aria-label'))
46
+            .toEqual('Change volume');
47
+    });
48
+
49
+    it('handles an undefined volume value', () => {
50
+        component.setProps({
51
+            volume: undefined
52
+        });
53
+        const rangeInput = component.find(`.${styles.input}`);
54
+        expect(rangeInput.prop('value'))
55
+            .toEqual(0);
56
+    });
57
+
58
+    it('triggers \'onClick\' prop when the button is clicked', () => {
59
+        const spy = jest.fn();
60
+        component.setProps({
61
+            onClick: spy
62
+        });
63
+        expect(spy)
64
+            .not.toHaveBeenCalled();
65
+        component.find('button').simulate('click');
66
+        expect(spy)
67
+            .toHaveBeenCalled();
68
+    });
69
+
70
+    it('triggers \'onChange\' prop when the input is changed', () => {
71
+        const spy = jest.fn();
72
+        component.setProps({
73
+            onChange: spy
74
+        });
75
+        expect(spy)
76
+            .not.toHaveBeenCalled();
77
+        component.find('input').simulate('change');
78
+        expect(spy)
79
+            .toHaveBeenCalled();
80
+    });
81
+
82
+    describe('when muted', () => {
83
+        beforeAll(() => {
84
+            component.setProps({
85
+                muted: true,
86
+                volume: 0.5
87
+            });
88
+        });
89
+
90
+        it('shows a muted icon', () => {
91
+            expect(component.html())
92
+                .toContain(VolumeOff);
93
+            expect(component.html())
94
+                .not.toContain(VolumeUp);
95
+        });
96
+
97
+        it('has an empty track fill and range input', () => {
98
+            expect(component.find(`.${styles.fill}`).prop('style').height)
99
+                .toEqual('0%');
100
+            expect(component.find(`.${styles.input}`).prop('value'))
101
+                .toEqual(0);
102
+        });
103
+
104
+        it('has correct aria-label', () => {
105
+            expect(component.find('button').prop('aria-label'))
106
+                .toEqual('Unmute');
107
+        });
108
+    });
109
+
110
+    describe('when unmuted but has no volume', () => {
111
+        beforeAll(() => {
112
+            component.setProps({
113
+                muted: false,
114
+                volume: 0
115
+            });
116
+        });
117
+
118
+        it('shows a muted icon', () => {
119
+            expect(component.html())
120
+                .toContain(VolumeOff);
121
+            expect(component.html())
122
+                .not.toContain(VolumeUp);
123
+        });
124
+
125
+        it('has an empty track fill and range input', () => {
126
+            expect(component.find(`.${styles.fill}`).prop('style').height)
127
+                .toEqual('0%');
128
+            expect(component.find(`.${styles.input}`).prop('value'))
129
+                .toEqual(0);
130
+        });
131
+
132
+        it('has correct aria-label', () => {
133
+            expect(component.find('button').prop('aria-label'))
134
+                .toEqual('Unmute');
135
+        });
136
+    });
137
+
138
+    describe('when has volume and is not muted', () => {
139
+        beforeAll(() => {
140
+            component.setProps({
141
+                muted: false,
142
+                volume: 0.5
143
+            });
144
+        });
145
+
146
+        it('shows an unmute icon', () => {
147
+            expect(component.html())
148
+                .toContain(VolumeUp);
149
+            expect(component.html())
150
+                .not.toContain(VolumeOff);
151
+        });
152
+
153
+        it('has some track filled and a range input value', () => {
154
+            expect(component.find(`.${styles.fill}`).prop('style').height)
155
+                .toEqual('50%');
156
+            expect(component.find(`.${styles.input}`).prop('value'))
157
+                .toEqual(0.5);
158
+        });
159
+
160
+        it('has correct aria-label', () => {
161
+            expect(component.find('button').prop('aria-label'))
162
+                .toEqual('Mute');
163
+        });
164
+    });
165
+});

+ 12
- 0
src/DefaultPlayer/copy.js View File

@@ -0,0 +1,12 @@
1
+const copy = {
2
+    play: 'Play video',
3
+    pause: 'Pause video',
4
+    mute: 'Mute video',
5
+    unmute: 'Unmute video',
6
+    volume: 'Change volume',
7
+    fullscreen: 'View video fullscreen',
8
+    seek: 'Seek video',
9
+    captions: 'Toggle captions'
10
+};
11
+
12
+export default copy;

+ 24
- 0
src/entry.js View File

@@ -0,0 +1,24 @@
1
+import videoConnect from './video/video';
2
+import * as apiHelpers from './video/api';
3
+import DefaultPlayer, {
4
+    Time,
5
+    Seek,
6
+    Volume,
7
+    Captions,
8
+    PlayPause,
9
+    Fullscreen,
10
+    Overlay
11
+} from './DefaultPlayer/DefaultPlayer';
12
+
13
+export {
14
+    videoConnect as default,
15
+    apiHelpers,
16
+    DefaultPlayer,
17
+    Time,
18
+    Seek,
19
+    Volume,
20
+    Captions,
21
+    PlayPause,
22
+    Fullscreen,
23
+    Overlay
24
+};

+ 131
- 0
src/video/api.js View File

@@ -0,0 +1,131 @@
1
+/**
2
+ * These are custom helper methods that are not native
3
+ * to the HTMLMediaElement API. Pass in the native
4
+ * Video element, state and optional desired value to
5
+ * set. To be primarily used in `mapVideoElToProps`.
6
+ */
7
+export const togglePause = (videoEl, { paused }) => {
8
+    if (paused) {
9
+        videoEl.play();
10
+    } else {
11
+        videoEl.pause();
12
+    }
13
+};
14
+
15
+export const setCurrentTime = (videoEl, state, value) => {
16
+    videoEl.currentTime = value;
17
+};
18
+
19
+export const setVolume = (videoEl, state, value) => {
20
+    videoEl.muted = false;
21
+    videoEl.volume = value;
22
+};
23
+
24
+export const mute = (videoEl) => {
25
+    videoEl.muted = true;
26
+};
27
+
28
+export const unmute = (videoEl) => {
29
+    videoEl.muted = false;
30
+};
31
+
32
+export const toggleMute = (videoEl, { volume, muted }) => {
33
+    if (muted || volume <= 0) {
34
+        if (volume <= 0) {
35
+            videoEl.volume = 1;
36
+        }
37
+        videoEl.muted = false;
38
+    } else {
39
+        videoEl.muted = true;
40
+    }
41
+};
42
+
43
+export const toggleFullscreen = (videoEl, callback) => {debugger;
44
+    videoEl.requestFullScreen =
45
+        videoEl.requestFullscreen
46
+        || videoEl.msRequestFullscreen
47
+        || videoEl.mozRequestFullScreen
48
+        || videoEl.webkitRequestFullscreen;
49
+    document.exitFullscreen =
50
+        document.exitFullscreen
51
+        || document.msExitFullscreen
52
+        || document.mozCancelFullScreen
53
+        || document.webkitExitFullscreen;
54
+    const fullscreenElement =
55
+        document.fullscreenElement
56
+        || document.msFullscreenElement
57
+        || document.mozFullScreenElement
58
+        || document.webkitFullscreenElement;
59
+    if (fullscreenElement === videoEl) {
60
+        document.exitFullscreen();
61
+    } else {
62
+        videoEl.requestFullScreen();
63
+    }
64
+};
65
+
66
+export const showTrack = ({ textTracks }, track) => {
67
+    hideTracks({ textTracks });
68
+    track.mode = track.SHOWING || 'showing';
69
+};
70
+
71
+export const hideTracks = ({ textTracks }) => {
72
+    for (var i = 0; i < textTracks.length; i++) {
73
+        textTracks[i].mode = textTracks[i].DISABLED || 'disabled';
74
+    }
75
+};
76
+
77
+export const toggleTracks = (() => {
78
+    let previousTrack;
79
+    return ({ textTracks }) => {
80
+        let currentTrack = [...textTracks]
81
+            .filter((track) => track.mode === track.SHOWING || track.mode === 'showing')[0];
82
+        if (currentTrack) {
83
+            hideTracks({ textTracks });
84
+            previousTrack = currentTrack;
85
+        } else {
86
+            showTrack({ textTracks }, previousTrack || textTracks[0]);
87
+        }
88
+}})();
89
+
90
+export const showSpeed = (videoEl, state, speed) => {
91
+    let playbackrates = state.playbackrates;
92
+    hideSpeeds(videoEl, {playbackrates});
93
+    speed.mode = speed.SHOWING || 'showing';
94
+
95
+    videoEl.dataset['playbackrates'] = JSON.stringify(playbackrates);
96
+    videoEl.playbackRate = speed.id;
97
+};
98
+
99
+export const hideSpeeds = (videoEl, state) => {
100
+    let playbackrates = state.playbackrates;
101
+    for (var i = 0; i < playbackrates.length; i++) {
102
+        playbackrates[i].mode = playbackrates[i].DISABLED || 'disabled';
103
+    }
104
+};
105
+
106
+export const toggleSpeeds = (() => {
107
+    let previousSpeed;
108
+    return (videoEl, state) => {
109
+        let playbackrates = state.playbackrates;
110
+
111
+        let currentSpeed = playbackrates
112
+            .filter((item) => item.mode === 'showing')[0];
113
+
114
+        if (currentSpeed) {
115
+            hideSpeeds(videoEl, {playbackrates});
116
+            previousSpeed = currentSpeed;
117
+        } else {
118
+            showSpeed(videoEl, {playbackrates}, previousSpeed || playbackrates[0]);
119
+        }
120
+}})();
121
+
122
+/**
123
+ * Custom getter methods that are commonly used
124
+ * across video layouts. To be primarily used in
125
+ * `mapStateToProps`
126
+ */
127
+export const getPercentageBuffered = ({ buffered, duration }) =>
128
+    buffered && buffered.length && buffered.end(buffered.length - 1) / duration * 100 || 0;
129
+
130
+export const getPercentagePlayed = ({ currentTime, duration }) =>
131
+    currentTime / duration * 100;

+ 276
- 0
src/video/api.test.js View File

@@ -0,0 +1,276 @@
1
+import {
2
+    mute,
3
+    unmute,
4
+    setVolume,
5
+    showTrack,
6
+    toggleMute,
7
+    hideTracks,
8
+    togglePause,
9
+    toggleTracks,
10
+    setCurrentTime,
11
+    toggleFullscreen,
12
+    getPercentagePlayed
13
+} from './api';
14
+
15
+describe('api', () => {
16
+    let videoElMock;
17
+    let textTracksMock;
18
+
19
+    beforeEach(() => {
20
+        videoElMock = {
21
+            play: jest.fn(),
22
+            pause: jest.fn()
23
+        };
24
+        textTracksMock = [{
25
+            id: 1,
26
+            mode: 'showing'
27
+        }, {
28
+            id: 2,
29
+            mode: 'disabled'
30
+        }, {
31
+            id: 3,
32
+            mode: 'disabled'
33
+        }];
34
+    });
35
+
36
+    describe('togglePause', () => {
37
+        it('plays if the video is paused', () => {
38
+            expect(videoElMock.play).not.toHaveBeenCalled();
39
+            togglePause(videoElMock, { paused: true });
40
+            expect(videoElMock.play).toHaveBeenCalled();
41
+            expect(videoElMock.pause).not.toHaveBeenCalled();
42
+        });
43
+
44
+        it('pauses if the video is playing', () => {
45
+            expect(videoElMock.pause).not.toHaveBeenCalled();
46
+            togglePause(videoElMock, { paused: false });
47
+            expect(videoElMock.pause).toHaveBeenCalled();
48
+            expect(videoElMock.play).not.toHaveBeenCalled();
49
+        });
50
+    });
51
+
52
+    describe('setCurrentTime', () => {
53
+        it('sets the current time', () => {
54
+            expect(videoElMock.currentTime).toBe(undefined);
55
+            setCurrentTime(videoElMock, undefined, 10);
56
+            expect(videoElMock.currentTime).toBe(10);
57
+        });
58
+    });
59
+
60
+    describe('setVolume', () => {
61
+        it('unmutes', () => {
62
+            expect(videoElMock.muted).toBe(undefined);
63
+            setVolume(videoElMock, undefined, 10);
64
+            expect(videoElMock.muted).toBe(false);
65
+        });
66
+
67
+        it('sets the volume', () => {
68
+            expect(videoElMock.volume).toBe(undefined);
69
+            setVolume(videoElMock, undefined, 10);
70
+            expect(videoElMock.volume).toBe(10);
71
+        });
72
+    });
73
+
74
+    describe('mute', () => {
75
+        it('mutes', () => {
76
+            expect(videoElMock.muted).toBe(undefined);
77
+            mute(videoElMock);
78
+            expect(videoElMock.muted).toBe(true);
79
+        });
80
+    });
81
+
82
+    describe('unmute', () => {
83
+        it('unmutes', () => {
84
+            expect(videoElMock.muted).toBe(undefined);
85
+            unmute(videoElMock);
86
+            expect(videoElMock.muted).toBe(false);
87
+        });
88
+    });
89
+
90
+    describe('toggleMute', () => {
91
+        it('unmutes if muted and does not change volume', () => {
92
+            expect(videoElMock.muted).toBe(undefined);
93
+            toggleMute(videoElMock, { volume: 0.5, muted: true });
94
+            expect(videoElMock.volume).toBe(undefined);
95
+            expect(videoElMock.muted).toBe(false);
96
+        });
97
+
98
+        it('mutes if unmuted and does not change volume', () => {
99
+            expect(videoElMock.muted).toBe(undefined);
100
+            toggleMute(videoElMock, { volume: 0.5, muted: false });
101
+            expect(videoElMock.volume).toBe(undefined);
102
+            expect(videoElMock.muted).toBe(true);
103
+        });
104
+
105
+        it('sets volume to max if volume is 0', () => {
106
+            expect(videoElMock.muted).toBe(undefined);
107
+            expect(videoElMock.volume).toBe(undefined);
108
+            toggleMute(videoElMock, { volume: 0, muted: false });
109
+            expect(videoElMock.volume).toBe(1);
110
+            expect(videoElMock.muted).toBe(false);
111
+        });
112
+
113
+        it('unmutes and sets volume to max if volume is 0', () => {
114
+            expect(videoElMock.muted).toBe(undefined);
115
+            expect(videoElMock.volume).toBe(undefined);
116
+            toggleMute(videoElMock, { volume: 0, muted: true });
117
+            expect(videoElMock.volume).toBe(1);
118
+            expect(videoElMock.muted).toBe(false);
119
+        });
120
+    });
121
+
122
+    describe('toggleFullscreen', () => {
123
+        describe('going fullscreen', () => {
124
+            it('requestsFullscreen', () => {
125
+                videoElMock.requestFullscreen = jest.fn();
126
+                toggleFullscreen(videoElMock);
127
+                expect(videoElMock.requestFullscreen).toHaveBeenCalled();
128
+            });
129
+
130
+            it('requestsFullscreen for ms', () => {
131
+                videoElMock.msRequestFullscreen = jest.fn();
132
+                toggleFullscreen(videoElMock);
133
+                expect(videoElMock.msRequestFullscreen).toHaveBeenCalled();
134
+            });
135
+
136
+            it('requestsFullscreen for moz', () => {
137
+                videoElMock.mozRequestFullScreen = jest.fn();
138
+                toggleFullscreen(videoElMock);
139
+                expect(videoElMock.mozRequestFullScreen).toHaveBeenCalled();
140
+            });
141
+
142
+            it('requestsFullscreen for webkit', () => {
143
+                videoElMock.webkitRequestFullscreen = jest.fn();
144
+                toggleFullscreen(videoElMock);
145
+                expect(videoElMock.webkitRequestFullscreen).toHaveBeenCalled();
146
+            });
147
+        });
148
+
149
+        describe('exiting fullscreen', () => {
150
+            beforeEach(() => {
151
+                document.exitFullscreen = undefined;
152
+                document.msExitFullscreen = undefined;
153
+                document.mozCancelFullScreen = undefined;
154
+                document.webkitExitFullscreen = undefined;
155
+                document.fullscreenElement = undefined;
156
+                document.msFullscreenElement = undefined;
157
+                document.mozFullScreenElement = undefined;
158
+                document.webkitFullscreenElement = undefined;
159
+            });
160
+
161
+            it('exitFullscreen', () => {
162
+                document.fullscreenElement = videoElMock;
163
+                document.exitFullscreen = jest.fn();
164
+                toggleFullscreen(videoElMock);
165
+                expect(document.exitFullscreen).toHaveBeenCalled();
166
+            });
167
+
168
+            it('exitFullscreen for ms', () => {
169
+                document.msFullscreenElement = videoElMock;
170
+                document.msExitFullscreen = jest.fn();
171
+                toggleFullscreen(videoElMock);
172
+                expect(document.msExitFullscreen).toHaveBeenCalled();
173
+            });
174
+
175
+            it('exitFullscreen for moz', () => {
176
+                document.mozFullScreenElement = videoElMock;
177
+                document.mozCancelFullScreen = jest.fn();
178
+                toggleFullscreen(videoElMock);
179
+                expect(document.mozCancelFullScreen).toHaveBeenCalled();
180
+            });
181
+
182
+            it('exitFullscreen for webkit', () => {
183
+                document.webkitFullscreenElement = videoElMock;
184
+                document.webkitExitFullscreen = jest.fn();
185
+                toggleFullscreen(videoElMock);
186
+                expect(document.webkitExitFullscreen).toHaveBeenCalled();
187
+            });
188
+        });
189
+    });
190
+
191
+    describe('hideTracks', () => {
192
+        it('hides all of the tracks', () => {
193
+            expect(textTracksMock[0].mode).toBe('showing');
194
+            hideTracks({ textTracks: textTracksMock });
195
+            expect(textTracksMock[0].mode).toBe('disabled');
196
+        });
197
+
198
+        it('uses constants on text tracks if they exist for IE', () => {
199
+            textTracksMock[0].DISABLED = 3;
200
+            expect(textTracksMock[0].mode).toBe('showing');
201
+            hideTracks({ textTracks: textTracksMock });
202
+            expect(textTracksMock[0].mode).toBe(3);
203
+        });
204
+    });
205
+
206
+    describe('showTrack', () => {
207
+        it('hides all of the tracks', () => {
208
+            expect(textTracksMock[0].mode).toBe('showing');
209
+            showTrack({ textTracks: textTracksMock }, textTracksMock[2]);
210
+            expect(textTracksMock[0].mode).toBe('disabled');
211
+        });
212
+
213
+        it('sets the given track to show', () => {
214
+            expect(textTracksMock[2].mode).toBe('disabled');
215
+            showTrack({ textTracks: textTracksMock }, textTracksMock[2]);
216
+            expect(textTracksMock[2].mode).toBe('showing');
217
+        });
218
+
219
+        it('uses constants on text tracks if they exist for IE', () => {
220
+            textTracksMock[2].SHOWING = 2;
221
+            expect(textTracksMock[2].mode).toBe('disabled');
222
+            showTrack({ textTracks: textTracksMock }, textTracksMock[2]);
223
+            expect(textTracksMock[2].mode).toBe(2);
224
+        });
225
+    });
226
+
227
+    describe('toggleTracks', () => {
228
+        it('shows the first track if no tracks are showing and there is no previously active track', () => {
229
+            textTracksMock[0].mode = 'disabled';
230
+            expect(textTracksMock[0].mode).toBe('disabled');
231
+            toggleTracks({ textTracks: textTracksMock });
232
+            expect(textTracksMock[0].mode).toBe('showing');
233
+        });
234
+
235
+        it('hides all tracks if a current track is showing', () => {
236
+            expect(textTracksMock[0].mode).toBe('showing');
237
+            toggleTracks({ textTracks: textTracksMock });
238
+            expect(textTracksMock[0].mode).toBe('disabled');
239
+            expect(textTracksMock[1].mode).toBe('disabled');
240
+            expect(textTracksMock[2].mode).toBe('disabled');
241
+        });
242
+
243
+        it('shows the previously active track if no tracks are showing', () => {
244
+            expect(textTracksMock[0].mode).toBe('showing');
245
+            toggleTracks({ textTracks: textTracksMock });
246
+            expect(textTracksMock[0].mode).toBe('disabled');
247
+            toggleTracks({ textTracks: textTracksMock });
248
+            expect(textTracksMock[0].mode).toBe('showing');
249
+            showTrack({ textTracks: textTracksMock }, textTracksMock[2]);
250
+            expect(textTracksMock[2].mode).toBe('showing');
251
+            toggleTracks({ textTracks: textTracksMock });
252
+            expect(textTracksMock[2].mode).toBe('disabled');
253
+            toggleTracks({ textTracks: textTracksMock });
254
+            expect(textTracksMock[2].mode).toBe('showing');
255
+        });
256
+    });
257
+
258
+    describe('getPercentagePlayed', () => {
259
+        it('returns correct percentage played', () => {
260
+            expect(getPercentagePlayed({
261
+                currentTime: 10,
262
+                duration: 100
263
+            })).toBe(10);
264
+
265
+            expect(getPercentagePlayed({
266
+                currentTime: 1,
267
+                duration: 10
268
+            })).toBe(10);
269
+
270
+            expect(getPercentagePlayed({
271
+                currentTime: 5,
272
+                duration: 20
273
+            })).toBe(25);
274
+        });
275
+    });
276
+});

+ 71
- 0
src/video/constants.js View File

@@ -0,0 +1,71 @@
1
+export const EVENTS = [
2
+    'abort',
3
+    'canPlay',
4
+    'canPlayThrough',
5
+    'durationChange',
6
+    'emptied',
7
+    'encrypted',
8
+    'ended',
9
+    'error',
10
+    'loadedData',
11
+    'loadedMetadata',
12
+    'loadStart',
13
+    'pause',
14
+    'play',
15
+    'playing',
16
+    'progress',
17
+    'rateChange',
18
+    'seeked',
19
+    'seeking',
20
+    'stalled',
21
+    'suspend',
22
+    'timeUpdate',
23
+    'volumeChange',
24
+    'waiting'
25
+];
26
+
27
+export const TRACKEVENTS = [
28
+    'change',
29
+    'addTrack',
30
+    'removeTrack'
31
+];
32
+
33
+export const METHODS = [
34
+    'addTextTrack',
35
+    'canPlayType',
36
+    'load',
37
+    'play',
38
+    'pause'
39
+];
40
+
41
+export const PROPERTIES = [
42
+    'audioTracks',
43
+    'autoPlay',
44
+    'buffered',
45
+    'controller',
46
+    'controls',
47
+    'currentSrc',
48
+    'currentTime',
49
+    'defaultMuted',
50
+    'defaultPlaybackRate',
51
+    'playbackrates',
52
+    'duration',
53
+    'ended',
54
+    'error',
55
+    'loop',
56
+    'mediaGroup',
57
+    'muted',
58
+    'networkState',
59
+    'paused',
60
+    'playbackRate',
61
+    'played',
62
+    'preload',
63
+    'readyState',
64
+    'seekable',
65
+    'seeking',
66
+    'src',
67
+    'startDate',
68
+    'textTracks',
69
+    'videoTracks',
70
+    'volume'
71
+];

+ 132
- 0
src/video/video.js View File

@@ -0,0 +1,132 @@
1
+/**
2
+ * This is a HoC that finds a single
3
+ * <video> in a component and makes
4
+ * all its PROPERTIES available as props.
5
+ */
6
+import React, { Component } from 'react';
7
+import { findDOMNode } from 'react-dom';
8
+import {
9
+    EVENTS,
10
+    PROPERTIES,
11
+    TRACKEVENTS
12
+} from './constants';
13
+
14
+const defaultMapStateToProps = (state = {}) => Object.assign({
15
+    video: {
16
+        ...state
17
+    }
18
+});
19
+
20
+const defaultMapVideoElToProps = (videoEl) => ({
21
+    videoEl
22
+});
23
+
24
+const defaultMergeProps = (
25
+    stateProps = {},
26
+    videoElProps = {},
27
+    ownProps = {}
28
+) => Object.assign({}, stateProps, videoElProps, ownProps);
29
+
30
+export default (
31
+    BaseComponent,
32
+    mapStateToProps = defaultMapStateToProps,
33
+    mapVideoElToProps = defaultMapVideoElToProps,
34
+    mergeProps = defaultMergeProps
35
+) => class Video extends Component {
36
+        constructor(props) {
37
+            super(props);
38
+            this.updateState = this.updateState.bind(this);
39
+            this.state = {};
40
+        }
41
+
42
+        updateState() {
43
+            this.setState(
44
+                PROPERTIES.reduce((p, c) => {
45
+                    p[c] = this.videoEl && this.videoEl[c];
46
+                    if (c == 'playbackrates' && this.videoEl.dataset[c]) {
47
+                        p[c] = this.videoEl && JSON.parse(this.videoEl.dataset[c]);
48
+                    }
49
+                    return p;
50
+                }, {})
51
+            );
52
+        }
53
+
54
+        bindEventsToUpdateState() {
55
+            EVENTS.forEach(event => {
56
+                this.videoEl.addEventListener(event.toLowerCase(), this.updateState);
57
+            });
58
+
59
+            TRACKEVENTS.forEach(event => {
60
+                // TODO: JSDom does not have this method on
61
+                // `textTracks`. Investigate so we can test this without this check.
62
+                this.videoEl.textTracks.addEventListener
63
+                && this.videoEl.textTracks.addEventListener(event.toLowerCase(), this.updateState);
64
+            });
65
+
66
+            // If <source> elements are used instead of a src attribute then
67
+            // errors for unsupported format do not bubble up to the <video>.
68
+            // Do this manually by listening to the last <source> error event
69
+            // to force an update.
70
+            // https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Using_HTML5_audio_and_video
71
+            const sources = this.videoEl.getElementsByTagName('source');
72
+            if (sources.length) {
73
+                const lastSource = sources[sources.length - 1];
74
+                lastSource.addEventListener('error', this.updateState);
75
+            }
76
+        }
77
+
78
+        unbindEvents() {
79
+            EVENTS.forEach(event => {
80
+                this.videoEl.removeEventListener(event.toLowerCase(), this.updateState);
81
+            });
82
+
83
+            TRACKEVENTS.forEach(event => {
84
+                // TODO: JSDom does not have this method on
85
+                // `textTracks`. Investigate so we can test this without this check.
86
+                this.videoEl.textTracks.removeEventListener
87
+                && this.videoEl.textTracks.removeEventListener(event.toLowerCase(), this.updateState);
88
+            });
89
+
90
+            const sources = this.videoEl.getElementsByTagName('source');
91
+            if (sources.length) {
92
+                const lastSource = sources[sources.length - 1];
93
+                lastSource.removeEventListener('error', this.updateState);
94
+            }
95
+        }
96
+
97
+        componentWillUnmount() {
98
+            this.unbindEvents();
99
+        }
100
+
101
+        // Stop `this.el` from being null briefly on every render,
102
+        // see: https://github.com/mderrick/react-html5video/pull/65
103
+        setRef(el) {
104
+            this.el = findDOMNode(el);
105
+        }
106
+
107
+        componentDidMount() {
108
+            this.videoEl = this.el.getElementsByTagName('video')[0];
109
+            this.bindEventsToUpdateState();
110
+        }
111
+
112
+        render() {
113
+            const stateProps = mapStateToProps(
114
+                this.state,
115
+                this.props
116
+            );
117
+            const videoElProps = mapVideoElToProps(
118
+                this.videoEl,
119
+                this.state,
120
+                this.props
121
+            );
122
+            return (
123
+                <div ref={this.setRef.bind(this)}>
124
+                    <BaseComponent
125
+                        {...mergeProps(
126
+                            stateProps,
127
+                            videoElProps,
128
+                            this.props)} />
129
+                </div>
130
+            );
131
+        }
132
+    }

+ 234
- 0
src/video/video.test.js View File

@@ -0,0 +1,234 @@
1
+import React from 'react';
2
+import { mount, shallow } from 'enzyme';
3
+import video from './video';
4
+import { EVENTS } from './constants';
5
+
6
+const TestControl = ({ duration }) => {
7
+    return (
8
+        <div>
9
+            { duration }
10
+        </div>
11
+    );
12
+};
13
+
14
+const TestVideo = ({ video, ...restProps }) => {
15
+    // Remove `videoEl` so we do not spread an unsupported
16
+    // prop onto a DOM element.
17
+    delete restProps.videoEl;
18
+    return (
19
+        <div>
20
+            <video {...restProps}>
21
+                <source src="1" />
22
+            </video>
23
+            <TestControl {...video} />
24
+        </div>
25
+    );
26
+};
27
+
28
+describe('video', () => {
29
+    let Component;
30
+    let component;
31
+
32
+    beforeAll(() => {
33
+        Component = video(TestVideo);
34
+    });
35
+
36
+    describe('the wrapped component', () => {
37
+        beforeEach(() => {
38
+            component = mount(
39
+                <Component autoPlay />
40
+            );
41
+        });
42
+
43
+        describe('HTMLMediaElement API as props', () => {
44
+            let testControl;
45
+            beforeEach(() => {
46
+                component = mount(
47
+                    <Component autoPlay />
48
+                );
49
+                testControl = component.find(TestControl);
50
+                expect(testControl.props()).toEqual({});
51
+            });
52
+
53
+            it('should be provided when a video event is triggered', () => {
54
+                component.find('video').node.dispatchEvent(new Event('play'));
55
+            });
56
+
57
+            it('should be provided when an error occurs on last source element', () => {
58
+                component.find('source').node.dispatchEvent(new Event('error'));
59
+            });
60
+
61
+            afterEach(() => {
62
+                // Only matching a subset is sufficient.
63
+                expect(testControl.props()).toMatchObject({
64
+                    controller: undefined,
65
+                    autoPlay: undefined,
66
+                    controls: false,
67
+                    currentSrc: '',
68
+                    currentTime: 0,
69
+                    defaultMuted: false,
70
+                    defaultPlaybackRate: 1,
71
+                    duration: 0,
72
+                    ended: false,
73
+                    error: undefined,
74
+                    loop: false,
75
+                    mediaGroup: undefined,
76
+                    muted: false,
77
+                    networkState: 0,
78
+                    paused: true,
79
+                    playbackRate: 1,
80
+                    preload: '',
81
+                    readyState: 0,
82
+                    seeking: false,
83
+                    src: '',
84
+                    startDate: undefined,
85
+                    volume: 1
86
+                });
87
+            });
88
+        });
89
+
90
+        it('should remove all event listeners from the video element when unmounted', () => {
91
+            const removeEventListenerSpy = jest.fn();
92
+            component = mount(
93
+                <Component autoPlay />
94
+            );
95
+            const updateState = component.instance().updateState;
96
+            component.find('video').node.removeEventListener = removeEventListenerSpy;
97
+            expect(removeEventListenerSpy).not.toHaveBeenCalled();
98
+            component.unmount();
99
+            EVENTS.forEach((event) => {
100
+                expect(removeEventListenerSpy).toHaveBeenCalledWith(event.toLowerCase(), updateState);
101
+            });
102
+        });
103
+
104
+        it('should remove "error" event listener from the source element when unmounted', () => {
105
+            const removeEventListenerSpy = jest.fn();
106
+            component = mount(
107
+                <Component autoPlay />
108
+            );
109
+            const updateState = component.instance().updateState;
110
+            component.find('source').node.removeEventListener = removeEventListenerSpy;
111
+            expect(removeEventListenerSpy).not.toHaveBeenCalled();
112
+            component.unmount();
113
+            expect(removeEventListenerSpy).toHaveBeenCalledWith('error', updateState);
114
+        });
115
+    });
116
+
117
+    describe('mapping to props', () => {
118
+        let videoEl = {};
119
+
120
+        beforeAll(() => {
121
+            component = shallow(
122
+                <Component autoPlay />
123
+            );
124
+            // Emulate videoEl being present
125
+            // e.g. componentDidMount fired.
126
+            component.instance().videoEl = videoEl;
127
+            component.instance().forceUpdate();
128
+        });
129
+
130
+        beforeEach(() => {
131
+            // Reset spy
132
+            videoEl.play = jest.fn();
133
+        });
134
+
135
+        it('returns a component with it\'s ownProps', () => {
136
+            expect(component.find(TestVideo).prop('autoPlay'))
137
+                .toBe(true);
138
+        });
139
+
140
+        it('returns a component with a videoEl prop', () => {
141
+            expect(component.find(TestVideo).prop('videoEl'))
142
+                .toBe(videoEl);
143
+        });
144
+
145
+        it('returns a component with all of its state on the `video` prop', () => {
146
+            const state = {
147
+                html5: '1',
148
+                dom: 2,
149
+                properties: function() {
150
+                    return 3;
151
+                }
152
+            };
153
+            component.setState(state);
154
+            expect(component.find(TestVideo).prop('video'))
155
+                .toEqual(state);
156
+        });
157
+
158
+        it('can customise the mapping of props using mapToProps', () => {
159
+            const Component = video(TestVideo, (state, ownProps) => {
160
+                return {
161
+                    state,
162
+                    ownProps
163
+                };
164
+            });
165
+            const component = shallow(
166
+                <Component autoPlay />
167
+            );
168
+            component.setState({
169
+                paused: true
170
+            });
171
+            expect(component.find(TestVideo).prop('state').paused)
172
+                .toBe(true);
173
+            expect(component.find(TestVideo).prop('ownProps').autoPlay)
174
+                .toBe(true);
175
+        });
176
+
177
+        it('can map videoEl to props for creating custom API methods', () => {
178
+            const Component = video(TestVideo, undefined, (el, state, ownProps) => {
179
+                return {
180
+                    togglePlay: () => {
181
+                        el.play(ownProps.testProp);
182
+                    }
183
+                }
184
+            });
185
+            const component = shallow(
186
+                <Component autoPlay testProp="testValue" />
187
+            );
188
+            component.instance().videoEl = videoEl;
189
+            component.instance().forceUpdate();
190
+            component.find(TestVideo).prop('togglePlay')();
191
+            expect(videoEl.play).toHaveBeenCalledWith('testValue');
192
+        });
193
+
194
+        it('allows mapVideoElToProps to take precedence over mapStateToProps', () => {
195
+            const Component = video(TestVideo, () => ({
196
+                duplicateKey: 'mapStateToProps'
197
+            }), () => ({
198
+                duplicateKey: 'mapVideoElToProps'
199
+            }));
200
+            const component = shallow(
201
+                <Component />
202
+            );
203
+            expect(component.find(TestVideo).prop('duplicateKey')).toBe('mapVideoElToProps');
204
+        });
205
+
206
+        it('allows ownProps to take precedence over mapVideoElToProps and mapStateToProps', () => {
207
+            const Component = video(TestVideo, () => ({
208
+                duplicateKey: 'mapStateToProps'
209
+            }), () => ({
210
+                duplicateKey: 'mapVideoElToProps'
211
+            }));
212
+            const component = shallow(
213
+                <Component duplicateKey="ownProps" />
214
+            );
215
+            expect(component.find(TestVideo).prop('duplicateKey')).toBe('ownProps');
216
+        });
217
+
218
+        it('allows cusomtisation of merging ownProps, mapVideoElToProps and mapStateToProps to change the merging precedence', () => {
219
+            const Component = video(TestVideo, () => ({
220
+                duplicateKey: 'mapStateToProps'
221
+            }), () => ({
222
+                duplicateKey: 'mapVideoElToProps'
223
+            }), (stateProps, videoElProps, ownProps) =>
224
+                Object.assign({}, ownProps, stateProps, videoElProps));
225
+            const component = shallow(
226
+                <Component duplicateKey="ownProps" />
227
+            );
228
+            expect(component.find(TestVideo).prop('duplicateKey')).toBe('mapVideoElToProps');
229
+        });
230
+    });
231
+});
232
+
233
+
234
+

+ 3
- 0
webpack.config.js View File

@@ -0,0 +1,3 @@
1
+const generateConfig = require('./generateConfig');
2
+
3
+module.exports = generateConfig();