410 lines
11 KiB
JavaScript
410 lines
11 KiB
JavaScript
import videojs from 'video.js'
|
|
import { version as VERSION } from '../package.json'
|
|
// import request from 'request';
|
|
|
|
// Default options for the plugin.
|
|
const defaults = {}
|
|
|
|
// Cross-compatibility for Video.js 5 and 6.
|
|
const registerPlugin = videojs.registerPlugin || videojs.plugin
|
|
// const dom = videojs.dom || videojs;
|
|
|
|
/**
|
|
* Function to invoke when the player is ready.
|
|
*
|
|
* This is a great place for your plugin to initialize itself. When this
|
|
* function is called, the player will have its DOM and child components
|
|
* in place.
|
|
*
|
|
* @function onPlayerReady
|
|
* @param {Player} player
|
|
* A Video.js player object.
|
|
*
|
|
* @param {Object} [options={}]
|
|
* A plain object containing options for the plugin.
|
|
*/
|
|
const onPlayerReady = (player, options) => {
|
|
player.addClass('vjs-vtt-thumbnails');
|
|
player.vttThumbnails = new vttThumbnailsPlugin(player, options)
|
|
}
|
|
|
|
/**
|
|
* A video.js plugin.
|
|
*
|
|
* In the plugin function, the value of `this` is a video.js `Player`
|
|
* instance. You cannot rely on the player being in a "ready" state here,
|
|
* depending on how the plugin is invoked. This may or may not be important
|
|
* to you; if not, remove the wait for "ready"!
|
|
*
|
|
* @function vttThumbnails
|
|
* @param {Object} [options={}]
|
|
* An object of options left to the plugin author to define.
|
|
*/
|
|
const vttThumbnails = function (options) {
|
|
this.ready(() => {
|
|
onPlayerReady(this, videojs.mergeOptions(defaults, options))
|
|
})
|
|
}
|
|
|
|
/**
|
|
* VTT Thumbnails class.
|
|
*
|
|
* This class performs all functions related to displaying the vtt
|
|
* thumbnails.
|
|
*/
|
|
class vttThumbnailsPlugin {
|
|
|
|
/**
|
|
* Plugin class constructor, called by videojs on
|
|
* ready event.
|
|
*
|
|
* @function constructor
|
|
* @param {Player} player
|
|
* A Video.js player object.
|
|
*
|
|
* @param {Object} [options={}]
|
|
* A plain object containing options for the plugin.
|
|
*/
|
|
constructor (player, options) {
|
|
this.player = player
|
|
this.options = options
|
|
this.listenForDurationChange();
|
|
this.initializeThumbnails();
|
|
this.registeredEvents = {};
|
|
return this;
|
|
}
|
|
|
|
src(source){
|
|
this.resetPlugin();
|
|
this.options.src = source;
|
|
this.initializeThumbnails();
|
|
}
|
|
|
|
detach() {
|
|
this.resetPlugin();
|
|
}
|
|
|
|
resetPlugin() {
|
|
this.thumbnailHolder && this.thumbnailHolder.parentNode.removeChild(this.thumbnailHolder);
|
|
this.progressBar && this.progressBar.removeEventListener('mouseenter', this.registeredEvents.progressBarMouseEnter);
|
|
this.progressBar && this.progressBar.removeEventListener('mouseleave', this.registeredEvents.progressBarMouseLeave);
|
|
this.progressBar && this.progressBar.removeEventListener('mousemove', this.registeredEvents.progressBarMouseMove);
|
|
delete this.registeredEvents.progressBarMouseEnter;
|
|
delete this.registeredEvents.progressBarMouseLeave;
|
|
delete this.registeredEvents.progressBarMouseMove;
|
|
delete this.progressBar;
|
|
delete this.vttData;
|
|
delete this.thumbnailHolder;
|
|
delete this.lastStyle;
|
|
}
|
|
|
|
listenForDurationChange() {
|
|
this.player.on('durationchange', () => {
|
|
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Bootstrap the plugin.
|
|
*/
|
|
initializeThumbnails () {
|
|
if (!this.options.src) {
|
|
return
|
|
}
|
|
const baseUrl = this.getBaseUrl()
|
|
const url = this.getFullyQualifiedUrl(this.options.src, baseUrl)
|
|
this.getVttFile(url)
|
|
.then((data) => {
|
|
this.vttData = this.processVtt(data)
|
|
this.setupThumbnailElement()
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Builds a base URL should we require one.
|
|
*
|
|
* @returns {string}
|
|
*/
|
|
getBaseUrl () {
|
|
return [
|
|
window.location.protocol,
|
|
'//',
|
|
window.location.hostname,
|
|
(window.location.port ? ':' + window.location.port : ''),
|
|
window.location.pathname
|
|
].join('').split(/([^\/]*)$/gi).shift()
|
|
}
|
|
|
|
/**
|
|
* Grabs the contents of the VTT file.
|
|
*
|
|
* @param url
|
|
* @returns {Promise}
|
|
*/
|
|
getVttFile (url) {
|
|
return new Promise((resolve, reject) => {
|
|
const req = new XMLHttpRequest()
|
|
req.data = {
|
|
resolve: resolve
|
|
}
|
|
req.addEventListener('load', this.vttFileLoaded)
|
|
req.open('GET', url)
|
|
req.send()
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Callback for loaded VTT file.
|
|
*/
|
|
vttFileLoaded () {
|
|
this.data.resolve(this.responseText)
|
|
}
|
|
|
|
setupThumbnailElement (data) {
|
|
const mouseDisplay = this.player.$('.vjs-mouse-display')
|
|
this.progressBar = this.player.$('.vjs-progress-control')
|
|
const thumbHolder = document.createElement('div')
|
|
thumbHolder.setAttribute('class', 'vjs-vtt-thumbnail-display')
|
|
this.progressBar.appendChild(thumbHolder)
|
|
this.thumbnailHolder = thumbHolder
|
|
mouseDisplay.classList.add('vjs-hidden')
|
|
this.registeredEvents.progressBarMouseEnter = () => { return this.onBarMouseenter() };
|
|
this.registeredEvents.progressBarMouseLeave = () => { return this.onBarMouseleave() };
|
|
this.progressBar.addEventListener('mouseenter', this.registeredEvents.progressBarMouseEnter)
|
|
this.progressBar.addEventListener('mouseleave', this.registeredEvents.progressBarMouseLeave)
|
|
}
|
|
|
|
onBarMouseenter () {
|
|
this.mouseMoveCallback = (e) => { this.onBarMousemove(e) }
|
|
this.registeredEvents.progressBarMouseMove = this.mouseMoveCallback;
|
|
this.progressBar.addEventListener('mousemove', this.registeredEvents.progressBarMouseMove)
|
|
this.showThumbnailHolder()
|
|
}
|
|
|
|
onBarMouseleave () {
|
|
if(this.registeredEvents.progressBarMouseMove) {
|
|
this.progressBar.removeEventListener('mousemove', this.registeredEvents.progressBarMouseMove)
|
|
}
|
|
this.hideThumbnailHolder()
|
|
}
|
|
|
|
onBarMousemove (event) {
|
|
this.updateThumbnailStyle(
|
|
event.clientX - (this.progressBar.offsetLeft + this.player.el().offsetLeft),
|
|
this.progressBar.offsetWidth
|
|
)
|
|
}
|
|
|
|
getStyleForTime (time) {
|
|
for (let i = 0; i < this.vttData.length; ++i) {
|
|
let item = this.vttData[i]
|
|
if (time >= item.start && time < item.end) {
|
|
return item.css
|
|
}
|
|
}
|
|
}
|
|
|
|
showThumbnailHolder () {
|
|
this.thumbnailHolder.style.opacity = '1'
|
|
}
|
|
|
|
hideThumbnailHolder () {
|
|
this.thumbnailHolder.style.opacity = '0'
|
|
}
|
|
|
|
updateThumbnailStyle (x, width) {
|
|
const duration = this.player.duration()
|
|
const time = ((1 - ((width - x) / width))) * duration
|
|
const currentStyle = this.getStyleForTime(time)
|
|
|
|
if (!currentStyle) {
|
|
return this.hideThumbnailHolder()
|
|
}
|
|
|
|
const xPos = ((1 - ((width - x) / width))) * width
|
|
|
|
this.thumbnailHolder.style.transform = 'translateX(' + xPos + 'px)'
|
|
this.thumbnailHolder.style.marginLeft = '-' + (parseInt(currentStyle.width) / 2) + 'px'
|
|
|
|
if (this.lastStyle && this.lastStyle === currentStyle) {
|
|
return
|
|
}
|
|
this.lastStyle = currentStyle
|
|
|
|
for (let style in currentStyle) {
|
|
if (currentStyle.hasOwnProperty(style)) {
|
|
this.thumbnailHolder.style[style] = currentStyle[style]
|
|
}
|
|
}
|
|
}
|
|
|
|
processVtt (data) {
|
|
const processedVtts = []
|
|
const vttDefinitions = data.split(/[\r\n][\r\n]/i)
|
|
vttDefinitions.forEach((vttDef) => {
|
|
if (vttDef.match(/([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]{1}.*/gi)) {
|
|
let vttDefSplit = vttDef.split(/[\r\n]/i)
|
|
let vttTiming = vttDefSplit[0]
|
|
let vttTimingSplit = vttTiming.split(/ ?--> ?/i)
|
|
let vttTimeStart = vttTimingSplit[0]
|
|
let vttTimeEnd = vttTimingSplit[1]
|
|
let vttImageDef = vttDefSplit[1]
|
|
let vttCssDef = this.getVttCss(vttImageDef)
|
|
|
|
processedVtts.push({
|
|
start: this.getSecondsFromTimestamp(vttTimeStart),
|
|
end: this.getSecondsFromTimestamp(vttTimeEnd),
|
|
css: vttCssDef
|
|
})
|
|
|
|
}
|
|
})
|
|
return processedVtts
|
|
}
|
|
|
|
getFullyQualifiedUrl (path, base) {
|
|
if (path.indexOf('//') >= 0) {
|
|
// We have a fully qualified path.
|
|
return path
|
|
}
|
|
if (base.indexOf('//') === 0) {
|
|
// We don't have a fully qualified path, but need to
|
|
// be careful with trimming.
|
|
return [
|
|
base.replace(/\/$/gi, ''),
|
|
this.trim(path, '/')
|
|
].join('/')
|
|
}
|
|
if (base.indexOf('//') > 0) {
|
|
// We don't have a fully qualified path, and should
|
|
// trim both sides of base and path.
|
|
return [
|
|
this.trim(base, '/'),
|
|
this.trim(path, '/')
|
|
].join('/')
|
|
}
|
|
|
|
// If all else fails.
|
|
return path
|
|
}
|
|
|
|
getPropsFromDef (def) {
|
|
const imageDefSplit = def.split(/#xywh=/i)
|
|
const imageUrl = imageDefSplit[0]
|
|
const imageCoords = imageDefSplit[1]
|
|
const splitCoords = imageCoords.match(/[0-9]+/gi)
|
|
return {
|
|
x: splitCoords[0],
|
|
y: splitCoords[1],
|
|
w: splitCoords[2],
|
|
h: splitCoords[3],
|
|
image: imageUrl
|
|
}
|
|
}
|
|
|
|
getVttCss (vttImageDef) {
|
|
|
|
const cssObj = {}
|
|
|
|
// If there isn't a protocol, use the VTT source URL.
|
|
let baseSplit
|
|
if (this.options.src.indexOf('//') >= 0) {
|
|
baseSplit = this.options.src.split(/([^\/]*)$/gi).shift()
|
|
} else {
|
|
baseSplit = this.getBaseUrl() + this.options.src.split(/([^\/]*)$/gi).shift()
|
|
}
|
|
|
|
vttImageDef = this.getFullyQualifiedUrl(vttImageDef, baseSplit)
|
|
|
|
if (!vttImageDef.match(/#xywh=/i)) {
|
|
cssObj.background = 'url("' + vttImageDef + '")'
|
|
return cssObj
|
|
}
|
|
|
|
const imageProps = this.getPropsFromDef(vttImageDef)
|
|
cssObj.background = 'url("' + imageProps.image + '") no-repeat -' + imageProps.x + 'px -' + imageProps.y + 'px'
|
|
cssObj.width = imageProps.w + 'px'
|
|
cssObj.height = imageProps.h + 'px'
|
|
|
|
return cssObj
|
|
}
|
|
|
|
doconstructTimestamp (timestamp) {
|
|
const splitStampMilliseconds = timestamp.split('.')
|
|
const timeParts = splitStampMilliseconds[0]
|
|
const timePartsSplit = timeParts.split(':')
|
|
return {
|
|
milliseconds: parseInt(splitStampMilliseconds[1]) || 0,
|
|
seconds: parseInt(timePartsSplit.pop()) || 0,
|
|
minutes: parseInt(timePartsSplit.pop()) || 0,
|
|
hours: parseInt(timePartsSplit.pop()) || 0,
|
|
}
|
|
|
|
}
|
|
|
|
getSecondsFromTimestamp (timestamp) {
|
|
const timestampParts = this.doconstructTimestamp(timestamp)
|
|
return parseInt((timestampParts.hours * (60 * 60)) +
|
|
(timestampParts.minutes * 60) +
|
|
timestampParts.seconds +
|
|
(timestampParts.milliseconds / 1000))
|
|
}
|
|
|
|
trim (str, charlist) {
|
|
let whitespace = [
|
|
' ',
|
|
'\n',
|
|
'\r',
|
|
'\t',
|
|
'\f',
|
|
'\x0b',
|
|
'\xa0',
|
|
'\u2000',
|
|
'\u2001',
|
|
'\u2002',
|
|
'\u2003',
|
|
'\u2004',
|
|
'\u2005',
|
|
'\u2006',
|
|
'\u2007',
|
|
'\u2008',
|
|
'\u2009',
|
|
'\u200a',
|
|
'\u200b',
|
|
'\u2028',
|
|
'\u2029',
|
|
'\u3000'
|
|
].join('')
|
|
let l = 0
|
|
let i = 0
|
|
str += ''
|
|
if (charlist) {
|
|
whitespace = (charlist + '').replace(/([[\]().?/*{}+$^:])/g, '$1')
|
|
}
|
|
l = str.length
|
|
for (i = 0; i < l; i++) {
|
|
if (whitespace.indexOf(str.charAt(i)) === -1) {
|
|
str = str.substring(i)
|
|
break
|
|
}
|
|
}
|
|
l = str.length
|
|
for (i = l - 1; i >= 0; i--) {
|
|
if (whitespace.indexOf(str.charAt(i)) === -1) {
|
|
str = str.substring(0, i + 1)
|
|
break
|
|
}
|
|
}
|
|
return whitespace.indexOf(str.charAt(0)) === -1 ? str : ''
|
|
}
|
|
|
|
}
|
|
|
|
// Register the plugin with video.js.
|
|
registerPlugin('vttThumbnails', vttThumbnails)
|
|
|
|
// Include the version number.
|
|
vttThumbnails.VERSION = VERSION
|
|
|
|
export default vttThumbnails
|