├── index.html ├── readme.md └── subtitles.js /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Subtitle 5 | 6 | 7 |
8 | 9 | 10 | 32 | 33 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Clappr Subtitle 2 | 3 | Simple Usage 4 | 5 | ```js 6 | var player = new Clappr.Player({ 7 | source: 'video.mp4', 8 | baseUrl: '/latest', 9 | mute: true, 10 | height: 360, 11 | width: 640, 12 | plugins: [ClapprSubtitle], 13 | subtitle : "video.srt" // URL to subtitle 14 | }); 15 | player.attachTo(document.getElementById('player')); 16 | ``` 17 | 18 | Styling the subtitles 19 | 20 | ```js 21 | var player = new Clappr.Player({ 22 | source: 'video.mp4', 23 | baseUrl: '/latest', 24 | mute: true, 25 | height: 360, 26 | width: 640, 27 | plugins: [ClapprSubtitle], 28 | subtitle : { 29 | src : "video.srt", 30 | auto : true, // automatically loads subtitle 31 | backgroundColor : 'transparent', 32 | fontWeight : 'normal', 33 | fontSize : '14px', 34 | color: 'yellow', 35 | textShadow : '1px 1px #000' 36 | }, 37 | }); 38 | player.attachTo(document.getElementById('player')); 39 | ``` 40 | 41 | # SRT FORMAT 42 | 43 | ``` 44 | 1 45 | 00:00:12,598 --> 00:00:13,891 46 | Do not try and bend the spoon. That's impossible. Instead... only try to realize the truth. 47 | 48 | 2 49 | 00:00:14,212 --> 00:00:17,846 50 | - What truth? 51 | - There is no spoon. 52 | 53 | 3 54 | 00:00:18,102 --> 00:00:24,317 55 | - There is no spoon? 56 | - Then you'll see, that it is not the spoon that bends, it is only yourself. 57 | ``` 58 | 59 | # WARNING 60 | 61 | This is a very early version. You can't select subtitle tracks, and long srt files may run slow. 62 | -------------------------------------------------------------------------------- /subtitles.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * 3 | * ClapprSubtitle 4 | * Copyright 2016 JMV Technology. All rights reserved. 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); 7 | * you may not use this file except in compliance with the License. 8 | * You may obtain a copy of the License at 9 | * 10 | * https://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, software 13 | * distributed under the License is distributed on an "AS IS" BASIS, 14 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | * See the License for the specific language governing permissions and 16 | * limitations under the License 17 | */ 18 | (function() { 19 | 20 | var BLOCK_REGEX = /[0-9]+(?:\r\n|\r|\n)([0-9]{2}:[0-9]{2}:[0-9]{2}(?:,|\.)[0-9]{3}) --> ([0-9]{2}:[0-9]{2}:[0-9]{2}(?:,|\.)[0-9]{3})(?:\r\n|\r|\n)((?:.*(?:\r\n|\r|\n))*?)(?:\r\n|\r|\n)/g; 21 | 22 | window.ClapprSubtitle = Clappr.CorePlugin.extend({ 23 | 24 | subtitles : [], 25 | 26 | element : null, 27 | 28 | active : false, 29 | 30 | options : { 31 | src : null, 32 | auto : false, 33 | backgroundColor : 'rgba(0, 0, 0, .8)', 34 | color : '#FFF', 35 | fontSize : '16px', 36 | fontWeight : 'bold', 37 | textShadow : 'rgb(0,0,0) 2px 2px 2px', 38 | }, 39 | 40 | lastMediaControlButtonClick : new Date(), 41 | 42 | /** 43 | * @constructor 44 | */ 45 | initialize : function() { 46 | 47 | this.subtitles = []; 48 | 49 | // register polyfills 50 | this.polyfill(); 51 | 52 | // check options 53 | if (!'subtitle' in this._options) 54 | return; 55 | 56 | var options = this._options.subtitle; 57 | 58 | // override src and style 59 | // if 'options' is object 60 | if (typeof(options) === "object") { 61 | if('src' in options) 62 | this.options.src = options.src; 63 | 64 | if('auto' in options) { 65 | this.options.auto = options.auto === true; 66 | if(this.options.auto) { 67 | this.active = true; 68 | } 69 | } 70 | 71 | if('backgroundColor' in options) 72 | this.options.backgroundColor = options.backgroundColor; 73 | 74 | if('color' in options) 75 | this.options.color = options.color; 76 | 77 | if('fontSize' in options) 78 | this.options.fontSize = options.fontSize; 79 | 80 | if('fontWeight' in options) 81 | this.options.fontWeight = options.fontWeight; 82 | 83 | if('textShadow' in options) 84 | this.options.textShadow = options.textShadow; 85 | 86 | // override src if 'options' is string 87 | } else if (typeof(options) === "string") { 88 | this.options.src = options; 89 | this.options.auto = true; 90 | } else { 91 | return; 92 | } 93 | 94 | // initialize subtitle on DOM 95 | this.initializeElement(); 96 | 97 | // fetch subtitles 98 | this.fetchSubtitle(this.onSubtitlesFetched.bind(this)); 99 | }, 100 | 101 | /** 102 | * Add event listeners 103 | */ 104 | bindEvents : function() { 105 | this.listenTo(this.core, Clappr.Events.CORE_CONTAINERS_CREATED, this.containersCreated); 106 | this.listenTo(this.core.mediaControl, Clappr.Events.MEDIACONTROL_RENDERED, this.addButtonToMediaControl); 107 | this.listenTo(this.core.mediaControl, Clappr.Events.MEDIACONTROL_SHOW, this.onMediaControlShow); 108 | this.listenTo(this.core.mediaControl, Clappr.Events.MEDIACONTROL_HIDE, this.onMediaControlHide); 109 | this.listenTo(this.core.mediaControl, Clappr.Events.MEDIACONTROL_CONTAINERCHANGED, this.onContainerChanged); 110 | }, 111 | 112 | /** 113 | * Add event listeners after containers were created 114 | */ 115 | containersCreated : function() { 116 | // append element to container 117 | this.core.containers[0].$el.append(this.element); 118 | // run 119 | this.listenTo(this.core.containers[0].playback, Clappr.Events.PLAYBACK_TIMEUPDATE, this.run); 120 | }, 121 | 122 | /** 123 | * On container changed 124 | */ 125 | onContainerChanged : function() { 126 | // container changed is fired right off the bat 127 | // so we should bail if subtitles aren't yet loaded 128 | if (this.subtitles.length === 0) 129 | return; 130 | 131 | // kill the current element 132 | this.element.parentNode.removeChild(this.element); 133 | 134 | // clear subtitles 135 | this.subtitle = []; 136 | 137 | // initialize stuff again 138 | this.initialize(); 139 | 140 | // trigger containers created 141 | this.containersCreated(); 142 | }, 143 | 144 | /** 145 | * Subtitles fetched 146 | */ 147 | onSubtitlesFetched : function(data) { 148 | // parse subtitle 149 | this.parseSubtitle(data); 150 | }, 151 | 152 | /** 153 | * Polyfill 154 | */ 155 | polyfill : function() { 156 | if (!Array.prototype.find) { 157 | Array.prototype.find = function(predicate) { 158 | if (this === null) { 159 | throw new TypeError('Array.prototype.find called on null or undefined'); 160 | } 161 | if (typeof predicate !== 'function') { 162 | throw new TypeError('predicate must be a function'); 163 | } 164 | var list = Object(this); 165 | var length = list.length >>> 0; 166 | var thisArg = arguments[1]; 167 | var value; 168 | 169 | for (var i = 0; i < length; i++) { 170 | value = list[i]; 171 | if (predicate.call(thisArg, value, i, list)) { 172 | return value; 173 | } 174 | } 175 | return undefined; 176 | }; 177 | } 178 | }, 179 | 180 | /** 181 | * AJAX request to the subtitles source 182 | * @param {function} callback 183 | */ 184 | fetchSubtitle : function(cb) { 185 | var r = new XMLHttpRequest(); 186 | r.open("GET", this.options.src, true); 187 | r.onreadystatechange = function () { 188 | // nothing happens if request 189 | // fails or is not ready 190 | if (r.readyState != 4 || r.status != 200) 191 | return; 192 | 193 | // callback 194 | if(cb) 195 | cb(r.responseText); 196 | }; 197 | r.send(); 198 | }, 199 | 200 | /** 201 | * Parse subtitle 202 | * @param {string} data 203 | */ 204 | parseSubtitle : function(datas) { 205 | 206 | // Get blocks and loop through them 207 | blocks = datas.match(BLOCK_REGEX); 208 | 209 | for(var i = 0; i < blocks.length; i++) { 210 | 211 | var startTime = null; 212 | var endTime = null; 213 | var text = ""; 214 | 215 | // Break the block in lines 216 | var block = blocks[i]; 217 | var lines = block.split(/(?:\r\n|\r|\n)/); 218 | 219 | // The second line is the time line. 220 | // We parse the start and end time. 221 | var time = lines[1].split(' --> '); 222 | var startTime = this.humanDurationToSeconds(time[0].trim()); 223 | var endTime = this.humanDurationToSeconds(time[1].trim()); 224 | 225 | // As for the rest of the lines, we loop through 226 | // them and append the to the text, 227 | for (var j = 2; j < lines.length; j++) { 228 | var line = lines[j].trim(); 229 | 230 | if (text.length > 0) { 231 | text += "
"; 232 | } 233 | 234 | text += line; 235 | } 236 | 237 | // Then we push it to the subtitles 238 | this.subtitles.push({ 239 | startTime: startTime, 240 | endTime: endTime, 241 | text: text 242 | }); 243 | } 244 | }, 245 | 246 | /** 247 | * Converts human duration time (00:00:00) to seconds 248 | * @param {string} human time 249 | * @return {float} 250 | */ 251 | humanDurationToSeconds : function(duration) { 252 | duration = duration.split(":"); 253 | 254 | var hours = duration[0], 255 | minutes = duration[1], 256 | seconds = duration[2].replace(",", "."); 257 | 258 | var result = 0.00; 259 | result += parseFloat(hours) * 60 * 60; 260 | result += parseFloat(minutes) * 60; 261 | result += parseFloat(seconds); 262 | 263 | return result; 264 | }, 265 | 266 | /** 267 | * Initializes the subtitle on the dom 268 | */ 269 | initializeElement : function() { 270 | var el = document.createElement('div'); 271 | el.style.display = 'block'; 272 | el.style.position = 'absolute'; 273 | el.style.left = '50%'; 274 | el.style.bottom = '50px'; 275 | el.style.color = this.options.color; 276 | el.style.backgroundColor = this.options.backgroundColor; 277 | el.style.fontSize = this.options.fontSize; 278 | el.style.fontWeight = this.options.fontWeight; 279 | el.style.textShadow = this.options.textShadow; 280 | el.style.transform = 'translateX(-50%)'; 281 | el.style.boxSizing = 'border-box'; 282 | el.style.padding = '7px'; 283 | el.style.opacity = '0'; 284 | el.style.pointerEvents = 'none'; 285 | el.style.maxWidth = '90%'; 286 | el.style.whiteSpace = 'normal'; 287 | el.style.zIndex = 1; 288 | this.element = el; 289 | }, 290 | 291 | /** 292 | * Add button to media control 293 | */ 294 | addButtonToMediaControl : function() { 295 | var bar = this.core 296 | .mediaControl 297 | .$el 298 | .children('.media-control-layer') 299 | .children('.media-control-right-panel'); 300 | 301 | // create icon 302 | var button = document.createElement('button'); 303 | button.classList.add('media-control-button'); 304 | button.classList.add('media-control-icon'); 305 | button.classList.add('media-control-subtitle-toggler'); 306 | button.innerHTML = this.getMediaControlButtonSVG(); 307 | 308 | // if active, glow 309 | if(this.active) 310 | button.style.opacity = '1'; 311 | 312 | // append to bar 313 | bar.append(button); 314 | 315 | // add listener 316 | this.core.$el.on('click', this.onMediaControlButtonClick.bind(this)); 317 | }, 318 | 319 | /** 320 | * Button SGV 321 | * @return {string} 322 | */ 323 | getMediaControlButtonSVG : function() { 324 | return '' + 325 | ' Svg Vector Icons : http://www.onlinewebfonts.com/icon ' + 326 | '' + 327 | ''; 328 | }, 329 | 330 | /** 331 | * on button click 332 | */ 333 | onMediaControlButtonClick : function(mouseEvent) { 334 | // this is a bit of a hack 335 | // it's ugly, I know, but I have yet to figure out how the click events work 336 | // so I'm bailing if click's target does not have the class 'media-control-subtitle-toggler' 337 | // I used this class 'media-control-subtitle-toggler' to identify the right element 338 | if(!mouseEvent.target.classList.contains('media-control-subtitle-toggler')) 339 | return; 340 | 341 | // This is also a bit of a hack. 342 | // We are preventing double clicks by checking the time the last click happened 343 | // if it's less then 300 miliseconds ago, we bail 344 | if (new Date() - this.lastMediaControlButtonClick < 300) 345 | return; 346 | 347 | // update lastMediaControlButtonClick 348 | this.lastMediaControlButtonClick = new Date(); 349 | 350 | // toggle active on/off 351 | if(this.active) { 352 | this.active = false; 353 | mouseEvent.target.style.opacity = '.5'; 354 | this.hideElement(); 355 | } else { 356 | this.active = true; 357 | mouseEvent.target.style.opacity = '1'; 358 | } 359 | }, 360 | 361 | 362 | /** 363 | * Hides the subtitle element 364 | */ 365 | hideElement : function() { 366 | this.element.style.opacity = '0'; 367 | }, 368 | 369 | /** 370 | * Shows the subtitle element with text 371 | * @param {string} text 372 | */ 373 | showElement : function(text) { 374 | if(!this.active) 375 | return; 376 | this.element.innerHTML = text; 377 | this.element.style.opacity = '1'; 378 | }, 379 | 380 | /** 381 | * Subtitle element moves up 382 | * to give space to the controls 383 | */ 384 | onMediaControlShow : function() { 385 | if(this.element) 386 | this.element.style.bottom = '100px'; 387 | }, 388 | 389 | /** 390 | * Subtitle element moves down 391 | * when controls hide 392 | */ 393 | onMediaControlHide : function() { 394 | if(this.element) 395 | this.element.style.bottom = '50px'; 396 | }, 397 | 398 | /** 399 | * Show subtitles as media is playing 400 | */ 401 | run : function(time) { 402 | var subtitle = this.subtitles.find(function(subtitle) { 403 | return time.current >= subtitle.startTime && time.current <= subtitle.endTime 404 | }); 405 | 406 | if(subtitle) { 407 | this.showElement(subtitle.text); 408 | } else { 409 | this.hideElement(); 410 | } 411 | }, 412 | 413 | }); 414 | })(); 415 | --------------------------------------------------------------------------------