import videojs from 'video.js';
import { document } from 'global';
import { version as VERSION } from '../package.json';
const VjsComponent = videojs.getComponent('Component');
const VjsPlugin = videojs.getPlugin('plugin');
const VjsMenuButton = videojs.getComponent('MenuButton');
const VjsMenuItem = videojs.getComponent('MenuItem');
const VjsTextTrackMenuItem = videojs.getComponent('TextTrackMenuItem');
/**
* Bitmap Subtitle Menu Button
*/
class BitmapMenuButton extends VjsMenuButton {
/**
* Bitmap subtitle wrapper component
*
* @param {Player} player - A Video.js Player instance.
* @param {Object} [options={}] - object of option names and values
* @param {string} [option.name='bitmapsub-menu-button'] - component name
*/
constructor(player, options) {
// Default components options
const _defaultOptions = {
name: 'bitmapsubMenuButton'
};
super(player, options);
this.player = player;
this.options = options;
this.options = videojs.obj.merge(_defaultOptions, options);
// Append subtitle icon
this.addClass('vjs-subtitles-button');
}
/**
* Bitmap Subtitle Menu Builder,
* must return a array of menuItem.
*
* @return {menuItem[]} - menu items
*/
createItems() {
if (this.menuItems) {
return this.menuItems;
}
const { bitmapTracks = [] } = this.options_;
if (bitmapTracks) {
return bitmapTracks;
}
}
/**
* Deselect menu item from bitmap menu
*/
deselectItems() {
this.menu.children()
.forEach(item => item.removeClass('vjs-selected'));
}
/**
* Select a menu item from bitmap menu
*
* @param {Object} item - item object bitmapTextTrackMenuItem
*/
selectItem(item) {
this.deselectItems();
item.addClass('vjs-selected');
}
}
/**
* Bitmap subtitle container component
*/
class BitmapSubtitleContainer extends VjsComponent {
/**
* Bitmap subtitle wrapper component
*
* @param {Player} player - A Video.js Player instance.
* @param {Object} [options={}] - object of option names and values
* @param {string} [option.name='bitmapsub-container'] - component name
*/
constructor(player, options) {
// Default components options
const _defaultOptions = {
name: 'bitmapsub-container'
};
super(player, options);
this.player = player;
this.options = videojs.obj.merge(_defaultOptions, options);
// Dynamic style for this component
this.buildDynamicStyle();
// Append listener for video size changes
this.player.on('playerresize', this.scaleTo);
}
/**
* Create bitmap subtitle container DOM parts
*
* @return {DOM} container - bitmap subtitle container
*/
createEl() {
const container = videojs.dom.createEl('div', { className: 'bitmapsub-container' });
const subtitle = videojs.dom.createEl('div', { className: 'bitmap-subtitle' });
container.appendChild(subtitle);
return container;
}
/**
* Create dynamic styles for each plugin instance.
* Make it unique with helps of ${player.id}
*
* @return {Object} - unique DOM style element
*
*/
buildDynamicStyle() {
const style = document.createElement('style');
const id = this.player.id();
style.id = `css-bitmap-${id}`;
style.textContent = `#${id} .bitmapsub-container{}`;
document.head.appendChild(style);
return style;
}
/**
* Adjust bitmap subtitle container size against
* ${player.textTrackDisplay} on player resize event
*
* @param {number} value - scale value
*/
scaleTo(value) {
this.el().style.scale = `${value}`;
}
/**
* Set bitmap subtitle container class name attribute,
* against bitmap subtitle variation, pgssub or vobsub.
*
* @param {string} variation - "pgssub" or "vobsub"
*/
setBitmapVariation(variation) {
if (variation === 'pgssub') {
this.removeClass('vobsub');
this.addClass('pgssub');
} else {
this.addClass('vobsub');
this.removeClass('pgssub');
}
}
}
/**
* Bitmap subtitle wrapper component.
*
* This component try to follow video boundaries.
* It helps to positionate bitmap subtitle.
* Actually does not work with picture-in-picture mode.
*/
class BitmapVideoWindow extends VjsComponent {
/** Bitmap Video Window component constructor
*
* @param {Player} player - A Video.js Player instance.
* @param {Object} [options={}] - object of option names and values
* @param {string} [option.name='bitmapsub-video-window'] - component name
*/
constructor(player, options = {}) {
const _defaultOptions = {
name: 'bitmapsub-video-window'
};
super(player, options);
this.options = videojs.obj.merge(_defaultOptions, options);
this.player = player;
[this.playerSize, this.videoSize] = [{}, {}];
this.player.on('loadeddata', e => {
this.setVideoSize();
// Before first play, controlBar height was 0
this.player.one('play', this.setVideoWindowProperties.bind(this));
this.player.on('playerresize', this.setVideoWindowProperties.bind(this));
});
}
/**
* Create bitmap subtitle container DOM parts
*
* @return {DOM} container - bitmap subtitle container
*/
createEl() {
this.videoWindow = videojs.dom.createEl('div', { className: 'bitmapsub-video-window' });
return this.videoWindow;
}
/**
* Save current video size into this.videoSize
* {width:int , height: int and ratio:int}
*/
setVideoSize() {
this.videoSize = { width: this.player.videoWidth(), height: this.player.videoHeight() };
this.videoSize.ratio = this.videoSize.width / this.videoSize.height;
}
/**
* Save player size into this.playerSize. Can be different from videoSize
* {width:int, height:int, ratio:int}
*/
setPlayerSize() {
const { width, height } = this.player.el().getBoundingClientRect();
[this.playerSize.width, this.playerSize.height] = [width, height];
this.playerSize.ratio = this.playerSize.width / this.playerSize.height;
}
/**
* Set Video Window position and size on playerresize event.
*
* Compute extra bitmap subtitle bottom padding.
* This padding is composed of an arbitrary value, and when
* player is in fullscreen, additionnal black bars, depending
* on screen and video aspect ratio.
*
* Also, at the end of this method, component is considered ready
* and trigger corresponding event.
*/
setVideoWindowProperties() {
// update player size informations
this.setPlayerSize();
let [videoWindowWidth, videoWindowHeight, videoWindowLeft, videoWindowTop, adjustment] = [0, 0, 0, 0, 0];
const ctrlBarHeight = this.player.getChild('ControlBar').height();
// Test for black bars around video window
if (this.videoSize.ratio > this.playerSize.ratio) {
// Horizontal black bars, player taller than video
videoWindowWidth = this.playerSize.width;
videoWindowHeight = Math.round(videoWindowWidth / this.videoSize.ratio);
videoWindowTop = Math.round((this.playerSize.height - videoWindowHeight) / 2);
// Compute control bar adjustment.
// Append adjustment if horizontal black bars height
// are smaller than player[ControlBar] height
if (videoWindowTop < ctrlBarHeight) {
adjustment = ctrlBarHeight - videoWindowTop;
}
} else {
// Vertical black bars, player is wider than video.
// No adjustment needed in this context
videoWindowHeight = this.playerSize.height;
videoWindowWidth = Math.round(videoWindowHeight * this.videoSize.ratio);
videoWindowLeft = Math.round((this.playerSize.width - videoWindowWidth) / 2);
adjustment = ctrlBarHeight;
}
// Append extra arbitrary height to subtitle's padding
const padding = videoWindowHeight / 32;
Object.assign(this.el().style, {
width: videoWindowWidth + 'px',
height: videoWindowHeight + 'px',
top: videoWindowTop + 'px',
left: videoWindowLeft + 'px'
});
this.el().style.setProperty('--adjustment', adjustment + 'px');
this.el().style.setProperty('--padding', padding + 'px');
// After that, component is considered ready
this.trigger('ready');
this.off('ready');
}
}
/**
* Bitmap Subtitle plugin
*/
class BitmapSubtitle extends VjsPlugin {
/** Bitmap Subtitle Plugin constructor
*
* @param {Player} player - A Video.js Player instance.
* @param {Object} [options={}] - object of option names and values
* @param {string} [option.pathPrefix='/bitmapsub'] - pathPrefix: where to find image subtitles tiled
* @param {string} [option.labelPrefix=''] - labelPrefix: menu item label prefix
* @param {string} [option.labelSuffix=' ⋅BMP'] - labelPrefix: menu item label suffix
* @param {string} [option.name='bitmapsub'] - component name
*/
constructor(player, options) {
// Default options for the plugin
const _pluginDefaults = {
pathPrefix: '/bitmapsub/',
labelPrefix: '',
labelSuffix: ' ⋅BMP',
name: 'bitmapsub'
};
super(player, options);
this.player = player;
this.options = videojs.obj.merge(_pluginDefaults, options);
// Handle only bitmap subtitle tracks
this.bitmapTracks = [];
// Save current subtitle track with associated event listener state
this.currentSubtitle = { listener: false, track: false };
this.appendComponent();
this.player.on('loadeddata', e => {
this.updateBitmapMenu();
['addtrack', 'removetrack']
.forEach(eventName => this.player.textTracks().on(eventName, evt => {
this.updateBitmapMenu();
}));
this.player.on('playerresize', evt => {
this.scaleSubtitle();
});
});
}
/**
* Append bitmap subtitle extra plugin components to video.js UI
*/
appendComponent() {
// Instantiate Bitmap Subtitle Components
// First: global video window element
this.bmpVideoWindow = new BitmapVideoWindow(this.player, this.options);
// Second: bitmap subtitle wrapper element
this.bmpSubContainer = new BitmapSubtitleContainer(this.player, this.options);
// get reference of bitmap subtitle element
this.subtitleElement = this.bmpSubContainer.el().querySelector('.bitmap-subtitle');
// Third: append bitmap menu into video.js controlbar
this.bitmapMenu = new BitmapMenuButton(this.player);
this.bmpVideoWindow.addChild(this.bmpSubContainer);
this.player.addChild(this.bmpVideoWindow);
// Place bitmapMenuButton after SubsCapsMenuButton
const bitmapMenuButtonPlacement = this.player.controlBar.children()
.indexOf(this.player.controlBar.getChild('SubsCapsButton')) + 1;
this.player.controlBar.addChild(this.bitmapMenu, null, bitmapMenuButtonPlacement);
}
/**
* Populate bitmap subtitle menu with track items if any
*/
updateBitmapMenu() {
this.bitmapTracks = this.getBitmapTracks();
this.bitmapMenu.menuItems = this.buildTrackMenuItems();
this.bitmapMenu.update();
}
/**
* Returns a list of textTrack from all tracks filtered by kind
* 'metadata' type and label starting with 'pgssub' or 'vobsub'
* prefix.
*
* @return {textTracks[]} - list of filtered textTracks
*/
getBitmapTracks() {
const allTracks = this.player.textTracks();
const bitmapTracks = [];
for (let i = 0; i < allTracks.length; i++) {
if (allTracks[i].kind === 'metadata' && allTracks[i].label.match(/^(pgs|vob)sub:(\d+):/)) {
bitmapTracks.push(allTracks[i]);
}
}
return bitmapTracks;
}
/**
* Extra controls items for bitmap subtitle menu:
* - settings panel
* - off bitmap subtitle
*
* @return {MenuItem[]} - settings and off menu items
*/
menuControlItems() {
const offBitmapOptions = { label: 'Bitmap Off', name: 'bitmap-off', id: 'bitmap-off' };
const offBitmap = new VjsMenuItem(this.player, offBitmapOptions);
offBitmap.handleClick = () => {
// const menu = this.player.controlBar.getChild('BitmapMenuButton').menu;
const menu = this.bitmapMenu.menu;
// Deselect all items from bitmap menu items
menu.children().forEach(item => {
item.removeClass('vjs-selected');
});
// Disable all tracks from this.bitmapTracks
this.bitmapTracks.forEach(track => {
track.mode = 'disabled';
});
// Select current items into bitmap menu
offBitmap.addClass('vjs-selected');
// Hide bitmap subtitle container
this.bmpSubContainer.hide();
// Remove handler on this.currentSubtitle.track
this.listenCueChange(false);
};
return [offBitmap];
}
/**
* Compute bitmap track menu items and its corresponding
* click handler for bitmap subtitle menu
*
* @return {textTrackMenuItem[]} - list of textTrackMenuItem filtered
*/
buildTrackMenuItems() {
let items = [];
this.bitmapTracks.map(track => {
const [bitmapVariation, videoSize, labelName] = track.label.split(':');
const label = this.options.labelPrefix + labelName + this.options.labelSuffix;
const item = new VjsTextTrackMenuItem(this.player, {
label,
track: {
label,
language: track.language,
id: track.id,
default: track.default
}
});
// Append native bitmap subtitle video size
track.bitmapsub = { width: videoSize };
if (track.default) {
track.mode = 'hidden';
this.currentSubtitle.track = track;
this.bitmapMenu.selectItem(item);
this.listenCueChange();
this.bmpSubContainer.setBitmapVariation(bitmapVariation);
} else {
track.mode = 'disabled';
}
item.handleClick = () => {
this.bitmapMenu.selectItem(item);
this.changeToTrack(item.track.id);
this.bmpSubContainer.setBitmapVariation(bitmapVariation);
};
// Append item to subtitle menu
items.push(item);
});
// If at least one item, append controls items,
// because, by default, menu is hidden if contains 0 items.
if (items.length) {
items = [...this.menuControlItems(), ...items];
}
return items;
}
/**
* From a large image of tiled bitmap subtitle, pick up one
* of them by creating a mask window around it.
* Subtitle container visibility is related to current track.activeCue.
*
* Metadata track current cue define window size and position as
* following format: bitmap_image.png:width:height:driftX:driftY
*/
updateSubtitle() {
if (this.currentSubtitle.track.activeCues.length) {
// Bitmap subtitle become visible
const [image, width, height, driftX, driftY] = this.currentSubtitle.track.activeCues[0].text.split(':');
const backgroundImage = [this.options.pathPrefix, image].join('/');
let style;
style = `width:${width}px;height:${height}px;background-image:url(${backgroundImage});`;
style += `background-position-x:-${driftX}px;background-position-y:-${driftY}px`;
this.subtitleElement.style = style;
this.bmpSubContainer.el().style.opacity = 1;
} else {
// Hide bitmap subtitle
this.bmpSubContainer.el().style.opacity = 0;
}
}
/**
* Append or remove 'cuechange' event listener on current track
*
* @param {boolean} [state=true] - true: append, false: remove if this.currentSubtitle.listener
*/
listenCueChange(state = true) {
if (state) {
this.currentSubtitle.track.addEventListener('cuechange', this.updateSubtitle.bind(this));
this.currentSubtitle.listener = true;
} else if (this.currentSubtitle.listener) {
this.currentSubtitle.track.removeEventListener('cuechange', this.updateSubtitle.bind(this));
this.currentSubtitle.listener = false;
}
}
/**
* Change bitmap subtitle to track with id ${id}
*
* @param {string} id - track id
*/
changeToTrack(id) {
this.bitmapTracks.forEach(track => {
if (track.id !== id) {
track.mode = 'disabled';
return;
}
track.mode = 'hidden';
this.listenCueChange(false);
this.currentSubtitle.track = track;
this.listenCueChange();
});
}
/**
* Scale subtitle container against current displayed video witdh
*/
scaleSubtitle() {
if (!this.currentSubtitle.track) {
return;
}
const scaleSize = (this.bmpVideoWindow.dimension('width') / this.currentSubtitle.track.bitmapsub.width).toFixed(2);
this.bmpSubContainer.scaleTo(scaleSize);
}
}
BitmapSubtitle.VERSION = VERSION;
videojs.registerComponent('bitmapVideoWindow', BitmapVideoWindow);
videojs.registerComponent('bitmapSubtitleContainer', BitmapSubtitleContainer);
videojs.registerComponent('bitmapMenuButton', BitmapMenuButton);
videojs.registerPlugin('bitmapSubtitle', BitmapSubtitle);
export { BitmapSubtitle, BitmapMenuButton, BitmapSubtitleContainer, BitmapVideoWindow };