├── .babelrc
├── .editorconfig
├── .eslintrc.yml
├── .gitignore
├── .jshintrc
├── .npmignore
├── .travis.yml
├── Gruntfile.js
├── LICENSE
├── README.md
├── asset
├── img
│ ├── chapter_1.png
│ ├── chapter_2.png
│ ├── chapter_3.png
│ ├── chapter_4.png
│ ├── chapter_5.png
│ └── example.png
├── video
│ └── oceans-clip.mp4
└── vtt
│ └── chapters.vtt
├── grunt
└── bump.js
├── index.html
├── karma.conf.js
├── package.json
├── src
├── menu
│ ├── chapter-thumbnail-menu-button.js
│ ├── chapter-thumbnail-menu-item.js
│ └── chapter-thumbnail-menu.js
├── track
│ └── text-track.js
├── videojs-chapter-thumbnail-template.js
├── videojs-chapter-thumbnail.js
└── videojs-chapter-thumbnail.scss
├── test
├── menu
│ └── chapter-thumbnail-menu-item.spec.js
├── tests.webpack.js
├── videojs-chapter-thumbnail-template.spec.js
└── videojs-chapter-thumbnail.spec.js
└── webpack
├── webpack-dev-server.js
├── webpack.config.js
└── webpack.config.production.js
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "plugins": [
3 | "transform-object-rest-spread",
4 | "transform-runtime"
5 | ],
6 | "presets": [
7 | "es2015"
8 | ]
9 | }
10 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # http://editorconfig.org
2 | root = true
3 |
4 | [*]
5 | indent_style = space
6 | indent_size = 2
7 | end_of_line = lf
8 | charset = utf-8
9 | trim_trailing_whitespace = true
10 | insert_final_newline = true
11 |
12 | [*.md]
13 | trim_trailing_whitespace = false
14 |
--------------------------------------------------------------------------------
/.eslintrc.yml:
--------------------------------------------------------------------------------
1 | root: true
2 |
3 | extends: "airbnb-base"
4 | parser: "babel-eslint"
5 |
6 | env:
7 | browser: true
8 | es6: true
9 | jasmine: true
10 | node: true
11 |
12 | rules:
13 | import/no-extraneous-dependencies: ['error', {'devDependencies': true}]
14 | no-underscore-dangle: 0
15 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | dist
2 | node_modules
3 | npm-debug.log
4 | tmp
5 |
--------------------------------------------------------------------------------
/.jshintrc:
--------------------------------------------------------------------------------
1 | {
2 | "esnext": true
3 | }
4 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | **/*
2 | !dist/*
3 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 |
3 | node_js:
4 | - 6.10.1
5 |
6 | before_install:
7 | - npm install -g grunt-cli
8 |
9 | install:
10 | - npm install
11 |
12 | script:
13 | - npm run lint:js
14 |
15 | notifications:
16 | email:
17 | - carey.hinoki@gmail.com
18 |
--------------------------------------------------------------------------------
/Gruntfile.js:
--------------------------------------------------------------------------------
1 | module.exports = function (grunt) {
2 | // load grunt tasks automatically
3 | require('load-grunt-tasks')(grunt);
4 |
5 | // time how long tasks take. can help when optimizing build times
6 | require('time-grunt')(grunt);
7 |
8 | grunt.initConfig({
9 | pkg: grunt.file.readJSON('package.json'),
10 |
11 | banner: [
12 | '/**',
13 | ' * <%= _.titleize(pkg.name) %> v<%= pkg.version %>',
14 | ' *',
15 | ' * @date: <%= grunt.template.today("yyyy-mm-dd") %>',
16 | ' */\n\n'
17 | ].join('\n')
18 | });
19 |
20 | grunt.loadTasks('grunt');
21 | };
22 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2015 Carey Hinoki
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
23 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # videojs-chapter-thumbnails
2 |
3 | [](https://travis-ci.org/chemoish/videojs-chapter-thumbnails)
4 |
5 | > Video.js plugin for supporting **WebVTT** chapter thumbnails.
6 |
7 | 
8 |
9 | ## Getting Started
10 |
11 | #### Include
12 |
13 | ```html
14 |
15 |
16 |
17 |
18 | ```
19 |
20 | #### Enable
21 |
22 | ```js
23 | videojs('player_id').chapter_thumbnails({
24 | src: '/path/to/chapters.vtt'
25 | });
26 | ```
27 |
28 | > Note: There are multiple ways to enable plugins. For more information, please visit [Video.js](https://github.com/videojs/video.js).
29 |
30 | ## Options
31 |
32 | #### label
33 |
34 | Type: `string`
35 | Default: `English`
36 |
37 | #### language
38 |
39 | Type: `string`
40 | Default: `en`
41 |
42 | #### src
43 |
44 | Type: `string`
45 |
46 | #### template
47 |
48 | Type: `Function`
49 | Default:
50 |
51 | ```js
52 | template(cue = {}, textTrack) {
53 | let cueText;
54 |
55 | // NOTE: if `cue.text` isn't parseable, just send it through instead of blowing up.
56 | // DRAGON: this probably opens up a possible script injection
57 | try {
58 | cueText = JSON.parse(cue.text || '{}');
59 | } catch (e) {
60 | cueText = cue.text;
61 | }
62 |
63 | const {
64 | image,
65 | title,
66 | } = cueText;
67 |
68 | const template = document.createElement('div');
69 | template.className = 'vjs-chapters-thumbnails-item';
70 |
71 | if (image) {
72 | const img = document.createElement('img');
73 | img.className = 'vjs-chapters-thumbnails-item-image';
74 | img.src = image;
75 |
76 | template.appendChild(img);
77 | }
78 |
79 | if (title) {
80 | const span = document.createElement('span');
81 | span.className = 'vjs-chapters-thumbnails-item-title';
82 | span.innerHTML = title;
83 |
84 | template.appendChild(span);
85 | }
86 |
87 | return template;
88 | },
89 | ```
90 |
91 | Provides for custom chapter templating. Must return either `HTMLElement` or `string`.
92 |
93 | ## Example WebVTT file
94 |
95 | > Define chapters plugin by specifying a [WebVTT](http://dev.w3.org/html5/webvtt/) spec.
96 |
97 | ```
98 | WEBVTT
99 |
100 | Chapter 1
101 | 00:00:00.000 --> 00:00:10.000
102 | {
103 | "title":"Chapter 1",
104 | "image":"asset/img/chapter_1.png"
105 | }
106 | ```
107 |
108 | ## Contributing + Example
109 |
110 | ```bash
111 | npm install -g grunt-cli
112 |
113 | npm install
114 |
115 | npm start
116 | ```
117 |
118 | ## License
119 |
120 | Code licensed under [The MIT License](https://github.com/chemoish/videojs-chapter-thumbnails/blob/master/LICENSE).
121 |
--------------------------------------------------------------------------------
/asset/img/chapter_1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chemoish/videojs-chapter-thumbnails/a29577da8a27ff4c86e7228ea131c74b30a705d7/asset/img/chapter_1.png
--------------------------------------------------------------------------------
/asset/img/chapter_2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chemoish/videojs-chapter-thumbnails/a29577da8a27ff4c86e7228ea131c74b30a705d7/asset/img/chapter_2.png
--------------------------------------------------------------------------------
/asset/img/chapter_3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chemoish/videojs-chapter-thumbnails/a29577da8a27ff4c86e7228ea131c74b30a705d7/asset/img/chapter_3.png
--------------------------------------------------------------------------------
/asset/img/chapter_4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chemoish/videojs-chapter-thumbnails/a29577da8a27ff4c86e7228ea131c74b30a705d7/asset/img/chapter_4.png
--------------------------------------------------------------------------------
/asset/img/chapter_5.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chemoish/videojs-chapter-thumbnails/a29577da8a27ff4c86e7228ea131c74b30a705d7/asset/img/chapter_5.png
--------------------------------------------------------------------------------
/asset/img/example.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chemoish/videojs-chapter-thumbnails/a29577da8a27ff4c86e7228ea131c74b30a705d7/asset/img/example.png
--------------------------------------------------------------------------------
/asset/video/oceans-clip.mp4:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chemoish/videojs-chapter-thumbnails/a29577da8a27ff4c86e7228ea131c74b30a705d7/asset/video/oceans-clip.mp4
--------------------------------------------------------------------------------
/asset/vtt/chapters.vtt:
--------------------------------------------------------------------------------
1 | WEBVTT
2 |
3 | Chapter 1
4 | 00:00:00.000 --> 00:00:10.000
5 | {
6 | "title":"Chapter 1",
7 | "image":"asset/img/chapter_1.png"
8 | }
9 |
10 | Chapter 2
11 | 00:00:10.000 --> 00:00:20.000
12 | {
13 | "title":"Chapter 2",
14 | "image":"asset/img/chapter_2.png"
15 | }
16 |
17 | Chapter 3
18 | 00:00:20.000 --> 00:00:30.000
19 | {
20 | "title":"Chapter 3",
21 | "image":"asset/img/chapter_3.png"
22 | }
23 |
24 | Chapter 4
25 | 00:00:30.000 --> 00:00:40.000
26 | {
27 | "title":"Chapter 4",
28 | "image":"asset/img/chapter_4.png"
29 | }
30 |
31 | Chapter 5
32 | 00:00:40.000 --> 00:00:46.000
33 | {
34 | "title":"Chapter 5",
35 | "image":"asset/img/chapter_5.png"
36 | }
37 |
--------------------------------------------------------------------------------
/grunt/bump.js:
--------------------------------------------------------------------------------
1 | module.exports = function (grunt) {
2 | grunt.config('bump', {
3 | options: {
4 | commitFiles: [
5 | 'package.json'
6 | ],
7 |
8 | commitMessage: 'Release v%VERSION%',
9 |
10 | files: [
11 | 'package.json'
12 | ],
13 |
14 | pushTo: 'origin',
15 | tagName: 'v%VERSION%'
16 | }
17 | });
18 | };
19 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Video.js Chapters
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
27 |
28 |
29 |
30 |
31 |
41 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/karma.conf.js:
--------------------------------------------------------------------------------
1 | module.exports = (config) => {
2 | config.set({
3 | browsers: ['PhantomJS'],
4 |
5 | frameworks: ['jasmine'],
6 |
7 | files: [{
8 | pattern: 'test/tests.webpack.js',
9 | watched: false,
10 | }],
11 |
12 | preprocessors: {
13 | 'test/tests.webpack.js': ['webpack'],
14 | },
15 |
16 | webpack: {
17 | module: {
18 | loaders: [{
19 | loader: 'null-loader',
20 | test: /\.scss$/,
21 | }],
22 |
23 | postLoaders: [{
24 | exclude: /node_modules/,
25 | loader: 'babel-loader',
26 | test: /\.js$/,
27 | }],
28 | },
29 | },
30 |
31 | webpackMiddleware: {
32 | noInfo: true,
33 | },
34 | });
35 | };
36 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "videojs-chapter-thumbnails",
3 | "version": "3.2.0",
4 | "description": "Video.js plugin for supporting chapter thumbnails",
5 | "main": "./dist/videojs.chapter-thumbnails.min.js",
6 | "style": "./dist/videojs.chapter-thumbnails.min.css",
7 | "scripts": {
8 | "build:development": "webpack --config webpack/webpack.config",
9 | "build:production": "webpack --config webpack/webpack.config.production",
10 | "build": "npm run build:production && npm run build:development",
11 | "lint:js": "eslint karma.conf src/ test/ webpack/",
12 | "start": "webpack-dev-server --config webpack/webpack-dev-server",
13 | "test": "karma start --single-run"
14 | },
15 | "license": "MIT",
16 | "author": "Carey Hinoki ",
17 | "homepage": "https://github.com/chemoish/videojs-chapter-thumbnails",
18 | "repository": {
19 | "type": "git",
20 | "url": "https://github.com/chemoish/videojs-chapter-thumbnails.git"
21 | },
22 | "bugs": {
23 | "url": "https://github.com/chemoish/videojs-chapter-thumbnails/issues"
24 | },
25 | "keywords": [
26 | "html5",
27 | "player",
28 | "video",
29 | "videojs",
30 | "videojs-plugin"
31 | ],
32 | "dependencies": {
33 | "video.js": "^5.19.1"
34 | },
35 | "devDependencies": {
36 | "autoprefixer": "^6.7.7",
37 | "babel-core": "^6.24.0",
38 | "babel-eslint": "^7.2.1",
39 | "babel-loader": "^6.4.1",
40 | "babel-plugin-transform-object-rest-spread": "^6.23.0",
41 | "babel-plugin-transform-runtime": "^6.23.0",
42 | "babel-preset-es2015": "^6.24.0",
43 | "clean-webpack-plugin": "^0.1.16",
44 | "css-loader": "^0.28.0",
45 | "eslint": "^3.19.0",
46 | "eslint-config-airbnb-base": "^5.0.3",
47 | "eslint-loader": "^1.7.1",
48 | "eslint-plugin-import": "^1.16.0",
49 | "expose-loader": "^0.7.3",
50 | "extract-text-webpack-plugin": "^1.0.1",
51 | "grunt": "^1.0.1",
52 | "grunt-bump": "^0.8.0",
53 | "jasmine": "^2.5.2",
54 | "jasmine-core": "^2.5.2",
55 | "karma": "^1.5.0",
56 | "karma-jasmine": "^1.1.0",
57 | "karma-phantomjs-launcher": "^1.0.4",
58 | "karma-webpack": "^1.8.0",
59 | "load-grunt-tasks": "^3.5.2",
60 | "moment": "^2.14.1",
61 | "node-sass": "^3.8.0",
62 | "null-loader": "^0.1.1",
63 | "phantomjs": "^2.1.7",
64 | "postcss-loader": "^0.11.0",
65 | "resolve-url-loader": "^1.6.0",
66 | "sass-loader": "^3.2.3",
67 | "style-loader": "^0.12.4",
68 | "time-grunt": "^1.4.0",
69 | "webpack": "^1.13.2",
70 | "webpack-dev-server": "^1.15.0"
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/src/menu/chapter-thumbnail-menu-button.js:
--------------------------------------------------------------------------------
1 | /* global videojs */
2 |
3 | import { ChapterThumbnailMenu, CHAPTER_THUMBNAIL_MENU_NAME } from './chapter-thumbnail-menu';
4 | import ChapterThumbnailMenuItem from './chapter-thumbnail-menu-item';
5 | import TRACK_ID from '../track/text-track';
6 |
7 | const CHAPTER_THUMBNAIL_MENU_BUTTON_NAME = 'ChapterThumbnailMenuButton';
8 |
9 | /**
10 | * @name Chapter Thumbnails Button
11 | * @description
12 | * Define the chapter thumbnails menu button component.
13 | * Create the chapter thumbnails menu and attach it to the player.
14 | *
15 | * @param {Object} player VideoJS player
16 | * @param {Object} options={}
17 | * @param {string} options.name Component name
18 | * @param {string} [options.template]
19 | */
20 |
21 | const VjsMenuButton = videojs.getComponent('MenuButton');
22 |
23 | class ChapterThumbnailMenuButton extends VjsMenuButton {
24 | constructor(player, options = {}) {
25 | super(player, options);
26 |
27 | const tracks = this.player().remoteTextTracks();
28 |
29 | // hide the button if there are no items
30 | if (this.items.length <= 0) {
31 | this.hide();
32 | }
33 |
34 | // do not set any events unless tracks are available
35 | if (!tracks) {
36 | return;
37 | }
38 |
39 | // NOTE: https://github.com/videojs/video.js/blob/master/src/js/control-bar/text-track-controls/text-track-button.js
40 | // Events follow videojs.TextTrackButton
41 |
42 | this.update = this.update.bind(this);
43 |
44 | tracks.addEventListener('addtrack', this.update);
45 | tracks.addEventListener('removetrack', this.update);
46 |
47 | this.player().on('dispose', () => {
48 | tracks.removeEventListener('addtrack', this.update);
49 | tracks.removeEventListener('removetrack', this.update);
50 | });
51 |
52 | this.addClass('vjs-chapter-thumbnails-button');
53 | this.addClass('vjs-chapters-button');
54 |
55 | this.el_.setAttribute('aria-label', 'Chapters Menu');
56 | }
57 |
58 | /**
59 | * @name Create Menu
60 | * @description
61 | * Defined by videojs.MenuButton
62 | *
63 | * This method gets hit multiple times from multiple areas.
64 | * - constructor
65 | * - addtrack event
66 | * - removetrack event
67 | * - timeout hack
68 | */
69 |
70 | createMenu() {
71 | // need to initialize `this.items` because this gets called in a `super`
72 | // before this constructor gets called
73 | this.items = [];
74 |
75 | // NOTE: allow custom `ChapterThumbnailMenu`
76 | const Menu = videojs.getComponent('ChapterThumbnailMenu') || ChapterThumbnailMenu;
77 |
78 | const menu = new Menu(this.player(), {
79 | name: CHAPTER_THUMBNAIL_MENU_NAME,
80 | });
81 |
82 | const chapterTrack = this.findChaptersTrack();
83 |
84 | if (!chapterTrack) {
85 | return menu;
86 | }
87 |
88 | if (chapterTrack && chapterTrack.cues == null) {
89 | this.setTrack(chapterTrack);
90 | }
91 |
92 | // create menu if track cues are available
93 | if (chapterTrack && chapterTrack.cues && chapterTrack.cues.length > 0) {
94 | this.items = this.createItems(chapterTrack);
95 |
96 | for (let i = 0, length = this.items.length; i < length; i++) {
97 | // TODO: enables - onClick close menu
98 | // menu.addItem(this.items[i]);
99 |
100 | menu.addChild(this.items[i]);
101 | }
102 |
103 | if (this.items.length > 0) {
104 | this.show();
105 | }
106 | }
107 |
108 | return menu;
109 | }
110 |
111 | /**
112 | * @name Create Items
113 | * @description
114 | * Defined by videojs.MenuButton
115 | *
116 | */
117 |
118 | createItems(textTrack) {
119 | const items = [];
120 |
121 | if (!textTrack || textTrack.cues.length <= 0) {
122 | return items;
123 | }
124 |
125 | const {
126 | template,
127 | } = this.options_;
128 |
129 | // NOTE: allow custom `ChapterThumbnailMenuItem`
130 | const MenuItem = videojs.getComponent('ChapterThumbnailMenuItem') || ChapterThumbnailMenuItem;
131 |
132 | for (let i = 0, length = textTrack.cues.length; i < length; i++) {
133 | const cue = textTrack.cues[i];
134 |
135 | items.push(new MenuItem(this.player(), {
136 | cue,
137 | template,
138 | textTrack,
139 | }));
140 | }
141 |
142 | return items;
143 | }
144 |
145 | findChaptersTrack() {
146 | const tracks = this.player().remoteTextTracks() || [];
147 |
148 | for (let i = 0, length = tracks.length; i < length; i++) {
149 | const track = tracks[i];
150 |
151 | if (track.id === TRACK_ID) {
152 | return track;
153 | }
154 | }
155 |
156 | return undefined;
157 | }
158 |
159 | setTrack(track) {
160 | if (!track) {
161 | return;
162 | }
163 |
164 | this.track = track;
165 |
166 | this.track.mode = 'hidden';
167 |
168 | const remoteTextTrackEl = this.player().remoteTextTrackEls().getTrackElementByTrack_(
169 | this.track
170 | );
171 |
172 | if (!remoteTextTrackEl) {
173 | return;
174 | }
175 |
176 | remoteTextTrackEl.addEventListener('load', () => this.update());
177 | }
178 | }
179 |
180 | ChapterThumbnailMenuButton.prototype.controlText_ = 'Chapters';
181 |
182 | videojs.registerComponent('ChapterThumbnailMenuButton', ChapterThumbnailMenuButton);
183 |
184 | export {
185 | CHAPTER_THUMBNAIL_MENU_BUTTON_NAME,
186 | ChapterThumbnailMenuButton,
187 | };
188 |
--------------------------------------------------------------------------------
/src/menu/chapter-thumbnail-menu-item.js:
--------------------------------------------------------------------------------
1 | /* global videojs */
2 |
3 | import chapterThumbnailTemplate from '../videojs-chapter-thumbnail-template';
4 |
5 | /**
6 | * @name Chapter Thumnails Menu Item
7 | * @description
8 | * Define the chapter thumbnails menu item component.
9 | *
10 | * @param {Object} player VideoJS player
11 | * @param {Object} options={}
12 | * @param {TextTrackCue} options.cue
13 | * @param {Function} [options.template] Generates template for chapter thumbnail menu item
14 | * @param {TextTrack} options.textTrack
15 | */
16 |
17 | const VjsMenuItem = videojs.getComponent('MenuItem');
18 |
19 | class ChapterThumbnailMenuItem extends VjsMenuItem {
20 | constructor(player, options = {}) {
21 | const {
22 | cue,
23 | textTrack,
24 | } = options;
25 |
26 | const currentTime = player.currentTime();
27 |
28 | super(player, {
29 | ...options,
30 |
31 | selectable: true, // piggy back onto `MenuItem::vjs-selected`
32 | selected: (cue.startTime <= currentTime && currentTime < cue.endTime),
33 | template: chapterThumbnailTemplate(cue, options),
34 | });
35 |
36 | textTrack.addEventListener('cuechange', videojs.bind(this, this.onCueChange));
37 | }
38 |
39 | createEl(type, props, attrs) {
40 | const { template } = this.options_;
41 |
42 | const el = super.createEl('li', Object.assign({
43 | className: 'vjs-menu-item vjs-chapter-thumbnails-menu-item',
44 | innerHTML: '', // does this need to be localized?
45 | tabIndex: -1,
46 | }, props), attrs);
47 |
48 | // allow HTMLElement and string #iguess?
49 | if (template instanceof HTMLElement) {
50 | el.insertBefore(template, el.firstChild);
51 | } else if (typeof template === 'string') {
52 | el.innerHTML = template;
53 | }
54 |
55 | return el;
56 | }
57 |
58 | /**
59 | * @name Handle Click
60 | * @description
61 | * Defined by videojs.MenuItem
62 | */
63 |
64 | handleClick() {
65 | const cue = this.options_.cue;
66 | const isPaused = this.player().paused();
67 |
68 |
69 | if (!isPaused) {
70 | this.player().pause();
71 | }
72 |
73 | this.player().currentTime(cue.startTime);
74 |
75 | if (!isPaused) {
76 | this.player().play();
77 | }
78 |
79 | this.player().el().focus();
80 | }
81 |
82 | onCueChange() {
83 | this.update();
84 | }
85 |
86 | update() {
87 | const cue = this.options_.cue;
88 | const currentTime = this.player().currentTime();
89 |
90 | this.selected(cue.startTime <= currentTime && currentTime < cue.endTime);
91 | }
92 | }
93 |
94 | videojs.registerComponent('ChapterThumbnailMenuItem', ChapterThumbnailMenuItem);
95 |
96 | export default ChapterThumbnailMenuItem;
97 |
--------------------------------------------------------------------------------
/src/menu/chapter-thumbnail-menu.js:
--------------------------------------------------------------------------------
1 | /* global videojs */
2 |
3 | const CHAPTER_THUMBNAIL_MENU_NAME = 'ChapterThumbnailMenu';
4 |
5 | /**
6 | * @name Chapter Thumbnails Menu
7 | * @description
8 | * Define the chapter thumbnails menu component.
9 | *
10 | * @param {Object} player VideoJS player
11 | * @param {Object} options={}
12 | * @param {string} options.name Component name
13 | */
14 |
15 | const VjsMenu = videojs.getComponent('Menu');
16 |
17 | class ChapterThumbnailMenu extends VjsMenu {
18 | constructor(player, options = {}) {
19 | super(player, options);
20 |
21 | this.el_.id = 'vjs_chapter_thumbnails_menu';
22 |
23 | // NOTE: does not have a className property
24 | this.addClass('vjs-chapter-thumbnails-menu');
25 | }
26 | }
27 |
28 | videojs.registerComponent('ChapterThumbnailMenu', ChapterThumbnailMenu);
29 |
30 | export {
31 | CHAPTER_THUMBNAIL_MENU_NAME,
32 | ChapterThumbnailMenu,
33 | };
34 |
--------------------------------------------------------------------------------
/src/track/text-track.js:
--------------------------------------------------------------------------------
1 | const TRACK_ID = 'chapter_thumbnails_track';
2 |
3 | export default TRACK_ID;
4 |
--------------------------------------------------------------------------------
/src/videojs-chapter-thumbnail-template.js:
--------------------------------------------------------------------------------
1 | const defaults = {
2 | template(cue = {}) {
3 | let cueText;
4 |
5 | // NOTE: if `cue.text` isn't parseable, just send it through instead of blowing up.
6 | // DRAGON: this probably opens up a possible script injection
7 | try {
8 | cueText = JSON.parse(cue.text || '{}');
9 | } catch (e) {
10 | cueText = cue.text;
11 | }
12 |
13 | const {
14 | image,
15 | title,
16 | } = cueText;
17 |
18 | const template = document.createElement('div');
19 | template.className = 'vjs-chapters-thumbnails-item';
20 |
21 | if (image) {
22 | const img = document.createElement('img');
23 | img.className = 'vjs-chapters-thumbnails-item-image';
24 | img.src = image;
25 |
26 | template.appendChild(img);
27 | }
28 |
29 | if (title) {
30 | const span = document.createElement('span');
31 | span.className = 'vjs-chapters-thumbnails-item-title';
32 | span.innerHTML = title;
33 |
34 | template.appendChild(span);
35 | }
36 |
37 | return template;
38 | },
39 | };
40 |
41 | /**
42 | * @name Chapter Thumbnail Template
43 | * @description
44 | * Converts the WebVTT cue into an HTMLElement template.
45 | *
46 | * @example
47 | * chapterThumbnailTemplate({
48 | * text: '{"title":"Hello World"}',
49 | * }, {
50 | * template(cue, textTrack) {
51 | * let cueText;
52 | *
53 | * // NOTE: if `cue.text` isn't parseable, just send it through instead of blowing up.
54 | * // DRAGON: this probably opens up a possible script injection
55 | * try {
56 | * cueText = JSON.parse(cue.text || '{}');
57 | * } catch (e) {
58 | * cueText = cue.text;
59 | * }
60 | *
61 | * const template = document.createElement('div');
62 | *
63 | * template.innerHTML = cueText.title;
64 | *
65 | * return template;
66 | * }
67 | * });
68 | *
69 | * @param {TextTrackCue} cue={}
70 | * @param {Object} options={}
71 | * @param {Function} [options.template]
72 | * @returns {HTMLElement|string} template
73 | */
74 |
75 | function chapterThumbnailTemplate(cue = {}, options = {}) {
76 | const template = options.template || defaults.template;
77 |
78 | return template(cue, options.textTrack);
79 | }
80 |
81 | export default chapterThumbnailTemplate;
82 |
--------------------------------------------------------------------------------
/src/videojs-chapter-thumbnail.js:
--------------------------------------------------------------------------------
1 | /* global videojs */
2 |
3 | import './videojs-chapter-thumbnail.scss';
4 |
5 | import * as MenuChapterThumbnailMenuButton from './menu/chapter-thumbnail-menu-button';
6 | import TRACK_ID from './track/text-track';
7 |
8 | const {
9 | CHAPTER_THUMBNAIL_MENU_BUTTON_NAME,
10 | ChapterThumbnailMenuButton,
11 | } = MenuChapterThumbnailMenuButton;
12 |
13 | /**
14 | * @name Chapters Plugin
15 | * @description
16 | * Define chapters plugin by specifying a WebVTT spec.
17 | * http://dev.w3.org/html5/webvtt/
18 | *
19 | * Abide by the following:
20 | *
21 | * @example
22 | * WEBVTT
23 | *
24 | * 00:00:00.000 --> 00:10:00.000
25 | * {
26 | * "title":"Introduction",
27 | * "image":"http://www.example.com/example.jpg"
28 | * }
29 | *
30 | * @example
31 | * videojs('playerId', {
32 | * plugins: {
33 | * chapter_thumbnails: {
34 | * label: 'English',
35 | * language: 'en',
36 | * src: 'chapters.vtt'
37 | * }
38 | * }
39 | * });
40 | *
41 | * videojs('player_id').chapter_thumbnails({
42 | * label: 'English',
43 | * language: 'en',
44 | * src: 'chapters.vtt'
45 | * });
46 | *
47 | * @param {Object} player VideoJS player
48 | * @param {Object} options={}
49 | * @param {string} [options.label=English]
50 | * @param {string} [options.language=en]
51 | * @param {string} options.src
52 | * @param {Function} [options.template] Generates template for chapter thumbnail menu item
53 | */
54 |
55 | export default class ChapterThumbnails {
56 | constructor(player, options = {}) {
57 | const defaults = {
58 | label: 'English',
59 | language: 'en',
60 | };
61 |
62 | this.player = player;
63 |
64 | this.textTrack = videojs.mergeOptions(defaults, options, {
65 | default: true,
66 | kind: 'metadata',
67 | id: TRACK_ID,
68 | });
69 |
70 | this.template = this.textTrack.template;
71 | }
72 |
73 | addComponent() {
74 | const controlBar = this.player.getChild('controlBar');
75 |
76 | const chapterButton = controlBar.getChild('chaptersButton');
77 |
78 | let chapterThumbnailMenuButton = controlBar.getChild(CHAPTER_THUMBNAIL_MENU_BUTTON_NAME);
79 |
80 | // remove existing menu—menu button will be hidden if there are no items found
81 | if (chapterThumbnailMenuButton && chapterThumbnailMenuButton.menu) {
82 | chapterThumbnailMenuButton.menu.dispose();
83 |
84 | delete chapterThumbnailMenuButton.menu;
85 | } else {
86 | const MenuButton = (
87 | videojs.getComponent('ChapterThumbnailMenuButton') || ChapterThumbnailMenuButton
88 | );
89 |
90 | chapterThumbnailMenuButton = new MenuButton(this.player, {
91 | name: CHAPTER_THUMBNAIL_MENU_BUTTON_NAME,
92 | template: this.template,
93 | });
94 |
95 | // add component to end of control bar
96 | controlBar.addChild(chapterThumbnailMenuButton);
97 |
98 | // move component—there is no component index placement
99 | controlBar.el().insertBefore(chapterThumbnailMenuButton.el(), chapterButton.el());
100 | }
101 |
102 | this.addTextTrack();
103 | }
104 |
105 | addTextTrack() {
106 | const currentTextTrack = this.player.remoteTextTracks().getTrackById(TRACK_ID);
107 |
108 | // remove existing track
109 | if (currentTextTrack) {
110 | this.player.removeRemoteTextTrack(currentTextTrack);
111 | }
112 |
113 | // add new track
114 | this.player.addRemoteTextTrack(this.textTrack, false);
115 | }
116 | }
117 |
118 | videojs.plugin('chapter_thumbnails', function chapterThumbnails(options) {
119 | const chapterThumbnail = new ChapterThumbnails(this, options);
120 |
121 | chapterThumbnail.addComponent();
122 | });
123 |
--------------------------------------------------------------------------------
/src/videojs-chapter-thumbnail.scss:
--------------------------------------------------------------------------------
1 | /* Chapter Thumbnails Menu */
2 |
3 | .vjs-chapter-thumbnails-menu
4 | {
5 | .vjs-menu-button-popup &
6 | {
7 | left: auto;
8 | right: 0;
9 | width: auto;
10 | }
11 | }
12 |
13 | /* Chapter Thumbnails Menu Content */
14 |
15 | .vjs-chapter-thumbnails-menu .vjs-menu-content
16 | {
17 | /* Chapter Thumbnails Menu — Show when video has started */
18 |
19 | .vjs-has-started &
20 | {
21 | opacity: 1;
22 | transition: visibility 0.1s, opacity 0.1s;
23 | visibility: visible;
24 | }
25 |
26 | /* Chapter Thumbnails Menu — Hide when user becomes inactive */
27 |
28 | .vjs-has-started.vjs-user-inactive.vjs-playing &
29 | {
30 | opacity: 0;
31 | transition: visibility 1s, opacity 1s;
32 | visibility: visible;
33 | }
34 |
35 | .vjs-menu-button-popup &
36 | {
37 | border-radius: 3px;
38 | overflow-y: auto;
39 | right: 0;
40 | white-space: nowrap;
41 | width: 240px;
42 | }
43 | }
44 |
45 | /* Chapter Thumbnails Menu Item */
46 |
47 | .vjs-menu .vjs-chapter-thumbnails-menu-item
48 | {
49 | padding: 0;
50 | }
51 |
52 | /* Chapter Thumbnails Item */
53 |
54 | .vjs-chapters-thumbnails-item
55 | {
56 | cursor: pointer;
57 | overflow: hidden;
58 | padding: 5px 5px 10px;
59 | text-align: left;
60 | }
61 |
62 | .vjs-chapters-thumbnails-item-image
63 | {
64 | float: left;
65 | margin-right: 10px;
66 | width: 70px;
67 | }
68 |
69 | .vjs-chapters-thumbnails-item-title
70 | {
71 | display: block;
72 | text-transform: capitalize;
73 | }
74 |
--------------------------------------------------------------------------------
/test/menu/chapter-thumbnail-menu-item.spec.js:
--------------------------------------------------------------------------------
1 | /* global videojs */
2 |
3 | import ChapterThumbnailMenuItem from '../../src/menu/chapter-thumbnail-menu-item';
4 |
5 | function trim(string) {
6 | return string.replace(/>[\s]+<').trim();
7 | }
8 |
9 | describe('menu-item.js', () => {
10 | describe(':: template()', () => {
11 | let player;
12 |
13 | beforeEach(() => {
14 | // followed example — https://github.com/videojs/videojs-contrib-ads
15 |
16 | videojs.getComponent('Html5').isSupported = () => true;
17 |
18 | delete videojs.getComponent('Html5').prototype.setSource;
19 |
20 | const video = document.createElement('video');
21 |
22 | video.load = () => {};
23 | video.play = () => {};
24 |
25 | document.getElementById('video_fixture').innerHTML = video.outerHTML;
26 |
27 | player = videojs(video);
28 | });
29 |
30 | it('should initialize an unselected menu item.', () => {
31 | const menuItem = new ChapterThumbnailMenuItem(player, {
32 | cue: {
33 | startTime: 0,
34 | endTime: 0,
35 | text: JSON.stringify({
36 | image: 'http://example.com',
37 | title: 'example',
38 | }),
39 | },
40 |
41 | textTrack: {
42 | addEventListener: Function.prototype,
43 | },
44 | });
45 |
46 | expect(menuItem.options_.template.outerHTML).toBe(
47 | trim(`
48 |
49 |

50 |
example
51 |
52 | `)
53 | );
54 |
55 | expect(menuItem.options_.selected).toBe(false);
56 | expect(menuItem.hasClass('vjs-chapter-thumbnails-menu-item')).toBe(true);
57 | });
58 |
59 | it('should initialize a selected menu item.', () => {
60 | const menuItem = new ChapterThumbnailMenuItem(player, {
61 | cue: {
62 | startTime: 0,
63 | endTime: 1,
64 | text: JSON.stringify({
65 | image: 'http://example.com',
66 | title: 'example',
67 | }),
68 | },
69 |
70 | textTrack: {
71 | addEventListener: Function.prototype,
72 | },
73 | });
74 |
75 | expect(menuItem.options_.template.outerHTML).toBe(
76 | trim(`
77 |
78 |

79 |
example
80 |
81 | `)
82 | );
83 |
84 | expect(menuItem.options_.selected).toBe(true);
85 | expect(menuItem.hasClass('vjs-chapter-thumbnails-menu-item')).toBe(true);
86 | });
87 | });
88 | });
89 |
--------------------------------------------------------------------------------
/test/tests.webpack.js:
--------------------------------------------------------------------------------
1 | // NOTE: expose video.js globally to tests (not sure if better way)
2 | import 'expose?videojs!video.js'; // eslint-disable-line
3 |
4 | const context = require.context('.', true, /\.spec\.js$/);
5 |
6 | context.keys().forEach(context);
7 |
8 | /* fixtures */
9 |
10 | window.fixture = document.createElement('div');
11 | window.fixture.id = 'video_fixture';
12 |
13 | document.body.appendChild(window.fixture);
14 |
--------------------------------------------------------------------------------
/test/videojs-chapter-thumbnail-template.spec.js:
--------------------------------------------------------------------------------
1 | import chapterThumbnailTemplate from '../src/videojs-chapter-thumbnail-template';
2 |
3 | function trim(string) {
4 | return string.replace(/>[\s]+<').trim();
5 | }
6 |
7 | describe('chapter-thumbnail-template.js', () => {
8 | let customTemplate;
9 |
10 | beforeEach(() => {
11 | customTemplate = document.createElement('div');
12 | });
13 |
14 | it('should return an empty default template.', () => {
15 | expect(chapterThumbnailTemplate({
16 | text: null,
17 | }).outerHTML).toBe(
18 | trim(`
19 |
20 | `)
21 | );
22 | });
23 |
24 | it('should return the modified default template.', () => {
25 | expect(chapterThumbnailTemplate({
26 | text: JSON.stringify({
27 | image: 'http://example.com',
28 | title: 'example',
29 | }),
30 | }).outerHTML).toBe(
31 | trim(`
32 |
33 |

34 |
example
35 |
36 | `)
37 | );
38 |
39 | expect(chapterThumbnailTemplate({
40 | text: JSON.stringify({
41 | image: 'http://example.com',
42 | }),
43 | }).outerHTML).toBe(
44 | trim(`
45 |
46 |

47 |
48 | `)
49 | );
50 | });
51 |
52 | it('should return a custom template.', () => {
53 | const template = chapterThumbnailTemplate({
54 | text: JSON.stringify({
55 | description: 'example description',
56 | }),
57 | }, {
58 | template(cue) {
59 | let cueText;
60 |
61 | try {
62 | cueText = JSON.parse(cue.text || '{}');
63 | } catch (e) {
64 | cueText = cue.text;
65 | }
66 |
67 | const span = document.createElement('span');
68 |
69 | span.innerHTML = cueText.description;
70 |
71 | customTemplate.appendChild(span);
72 |
73 | return customTemplate;
74 | },
75 | });
76 |
77 | expect(template.outerHTML).toBe(
78 | trim(`
79 |
80 | example description
81 |
82 | `)
83 | );
84 | });
85 |
86 | it('should return an template for invalid JSON.', () => {
87 | const template = chapterThumbnailTemplate({
88 | text: 'example title',
89 | }, {
90 | template(cue) {
91 | let cueText;
92 |
93 | try {
94 | cueText = JSON.parse(cue.text || '{}');
95 | } catch (e) {
96 | cueText = cue.text;
97 | }
98 |
99 | customTemplate.innerHTML = cueText;
100 |
101 | return customTemplate;
102 | },
103 | });
104 |
105 | expect(template.outerHTML).toBe(
106 | trim(`
107 | example title
108 | `)
109 | );
110 | });
111 | });
112 |
--------------------------------------------------------------------------------
/test/videojs-chapter-thumbnail.spec.js:
--------------------------------------------------------------------------------
1 | /* global videojs */
2 |
3 | import ChapterThumbnails from '../src/videojs-chapter-thumbnail';
4 |
5 | import TRACK_ID from '../src/track/text-track';
6 |
7 | describe('chapter-thumbnail.js', () => {
8 | let player;
9 |
10 | beforeEach(() => {
11 | // followed example — https://github.com/videojs/videojs-contrib-ads
12 |
13 | videojs.getComponent('Html5').isSupported = () => true;
14 |
15 | delete videojs.getComponent('Html5').prototype.setSource;
16 |
17 | const video = document.createElement('video');
18 |
19 | video.load = () => {};
20 | video.play = () => {};
21 |
22 | document.getElementById('video_fixture').innerHTML = video.outerHTML;
23 |
24 | player = videojs(video, {
25 | html5: {
26 | nativeTextTracks: false,
27 | },
28 | });
29 | });
30 |
31 | it('should initialize with defaults.', () => {
32 | const chapterThumbnail = new ChapterThumbnails(player);
33 |
34 | chapterThumbnail.addTextTrack();
35 |
36 | const track = player.textTracks().getTrackById(TRACK_ID);
37 |
38 | expect(track.id).toBe(TRACK_ID);
39 | expect(track.kind).toBe('metadata');
40 | expect(track.label).toBe('English');
41 | expect(track.language).toBe('en');
42 | expect(track.mode).toBe('hidden');
43 | });
44 |
45 | it('should initialize with options.', () => {
46 | const chapterThumbnail = new ChapterThumbnails(player, {
47 | label: 'French',
48 | language: 'fr',
49 | });
50 |
51 | chapterThumbnail.addTextTrack();
52 |
53 | const track = player.textTracks().getTrackById(TRACK_ID);
54 |
55 | expect(track.label).toBe('French');
56 | expect(track.language).toBe('fr');
57 | });
58 |
59 | it('should contain one textTrack', () => {
60 | expect(player.textTracks().length).toBe(0);
61 |
62 | const chapterThumbnail = new ChapterThumbnails(player);
63 |
64 | chapterThumbnail.addTextTrack();
65 |
66 | expect(player.textTracks().length).toBe(1);
67 | });
68 |
69 | // TODO: no idea how to do other tests…
70 | });
71 |
--------------------------------------------------------------------------------
/webpack/webpack-dev-server.js:
--------------------------------------------------------------------------------
1 | const webpack = require('webpack');
2 | const webpackConfig = require('./webpack.config');
3 |
4 | webpackConfig.cache = true;
5 | webpackConfig.debug = true;
6 | webpackConfig.devtool = 'inline-source-map';
7 |
8 | webpackConfig.plugins.push(
9 | new webpack.HotModuleReplacementPlugin()
10 | );
11 |
12 | webpackConfig.devServer = {
13 | hot: true,
14 | inline: true,
15 | progress: true,
16 | };
17 |
18 | module.exports = webpackConfig;
19 |
--------------------------------------------------------------------------------
/webpack/webpack.config.js:
--------------------------------------------------------------------------------
1 | const autoprefixer = require('autoprefixer');
2 | const ExtractTextPlugin = require('extract-text-webpack-plugin');
3 | const moment = require('moment');
4 | const path = require('path');
5 | const pkg = require('../package.json');
6 | const webpack = require('webpack');
7 |
8 | module.exports = {
9 | entry: {
10 | 'videojs.chapter-thumbnails': './src/videojs-chapter-thumbnail',
11 | },
12 |
13 | output: {
14 | filename: '[name].js',
15 | libraryTarget: 'umd',
16 | path: 'dist',
17 | },
18 |
19 | module: {
20 | preLoaders: [{
21 | exclude: /node_modules/,
22 | loader: 'eslint',
23 | test: /\.js$/,
24 | }],
25 |
26 | loaders: [{
27 | include: /src/,
28 | loader: ExtractTextPlugin.extract(
29 | 'style',
30 | [
31 | 'css?sourceMap&importLoaders=3',
32 | 'postcss',
33 | 'resolve-url',
34 | 'sass',
35 | ].join('!')
36 | ),
37 | test: /\.scss$/,
38 | }],
39 |
40 | postLoaders: [{
41 | exclude: /node_modules/,
42 | loader: 'babel',
43 | test: /\.js$/,
44 | }],
45 | },
46 |
47 | plugins: [
48 | new ExtractTextPlugin('[name].css'),
49 |
50 | new webpack.BannerPlugin([
51 | '/**',
52 | ` * ${pkg.name} v${pkg.version}`,
53 | ' * ',
54 | ` * @author: ${pkg.author}`,
55 | ` * @date: ${moment().format('YYYY-MM-DD')}`,
56 | ' */',
57 | '',
58 | ].join('\n'), {
59 | raw: true,
60 | }),
61 | ],
62 |
63 | postcss() {
64 | return [
65 | autoprefixer,
66 | ];
67 | },
68 |
69 | sassLoader: {
70 | includePaths: [path.join(__dirname, '..', 'src')],
71 | sourceMap: true,
72 | },
73 | };
74 |
--------------------------------------------------------------------------------
/webpack/webpack.config.production.js:
--------------------------------------------------------------------------------
1 | const CleanWebpackPlugin = require('clean-webpack-plugin');
2 | const ExtractTextPlugin = require('extract-text-webpack-plugin');
3 | const path = require('path');
4 | const webpack = require('webpack');
5 | const webpackConfig = require('./webpack.config');
6 |
7 | webpackConfig.output.filename = '[name].min.js';
8 |
9 | webpackConfig.module.loaders = [{
10 | include: /src/,
11 | loader: ExtractTextPlugin.extract(
12 | 'style',
13 | [
14 | 'css?importLoaders=3',
15 | 'postcss',
16 | 'resolve-url',
17 | 'sass',
18 | ].join('!')
19 | ),
20 | test: /\.scss$/,
21 | }];
22 |
23 | webpackConfig.plugins = [
24 | new CleanWebpackPlugin('dist', {
25 | root: path.resolve(__dirname, '..'),
26 | }),
27 |
28 | new ExtractTextPlugin('[name].min.css'),
29 |
30 | new webpack.optimize.DedupePlugin(),
31 | new webpack.optimize.UglifyJsPlugin({
32 | comments: false,
33 | }),
34 | ];
35 |
36 | module.exports = webpackConfig;
37 |
--------------------------------------------------------------------------------