pupcaps
Version:
PupCaps! : A script to add stylish captions to your videos.
476 lines (465 loc) • 21.1 kB
JavaScript
(function (vue) {
'use strict';
function toMillis(timecodes) {
const parts = timecodes.split(/[:,]/).map(Number);
const hours = parts[0];
const minutes = parts[1];
const seconds = parts[2];
const milliseconds = parts[3];
return hours * 3_600_000 // hours to millis
+ minutes * 60_000 // minutes to millis
+ seconds * 1000 // second to millis
+ milliseconds;
}
function haveSameWords(caption1, caption2) {
if (caption1.words.length != caption2.words.length) {
return false;
}
for (let i = 0; i < caption1.words.length; i++) {
if (caption1.words[i].rawWord != caption2.words[i].rawWord) {
return false;
}
}
return true;
}
class CaptionRenderer {
cssProcessor;
constructor(cssProcessor) {
this.cssProcessor = cssProcessor;
}
renderCaption(caption) {
const captionDiv = document.createElement('div');
captionDiv.setAttribute('id', `caption_${caption.index}`);
captionDiv.setAttribute('class', 'caption');
caption.words
.map(word => this.renderWord(word, caption))
.forEach(spanElem => captionDiv.appendChild(spanElem));
const captionWords = caption.words.map(word => word.rawWord);
return this.cssProcessor.applyDynamicClasses(captionDiv, caption.index, caption.startTimeMs, captionWords);
}
renderWord(word, caption) {
const cssClasses = CaptionRenderer.wordSpanClasses(word);
const wordSpan = document.createElement('span');
wordSpan.textContent = word.rawWord;
wordSpan.classList.add(...cssClasses);
return this.cssProcessor.applyDynamicClasses(wordSpan, caption.index, caption.startTimeMs, [word.rawWord]);
}
static wordSpanClasses(word) {
const cssClasses = new Set(['word']);
if (word.isHighlighted) {
cssClasses.add(word.highlightClass || 'highlighted');
}
if (word.isBeforeHighlighted) {
cssClasses.add('before-highlighted');
}
if (word.isAfterHighlighted) {
cssClasses.add('after-highlighted');
}
return cssClasses;
}
}
class Player {
videoElem;
captions;
cssProcessor;
onStop = () => { };
captionsContainer;
rendered = [];
timeoutIds = [];
displayedCaptionId = 0;
constructor(videoElem, captions, cssProcessor, renderer) {
this.videoElem = videoElem;
this.captions = captions;
this.cssProcessor = cssProcessor;
this.captionsContainer = this.videoElem.querySelector('.captions');
for (let i = 0; i < captions.length; i++) {
const caption = captions[i];
this.rendered[caption.index] = i > 0 && haveSameWords(caption, captions[i - 1])
? this.rendered[caption.index - 1]
: renderer.renderCaption(caption);
}
}
play() {
this.rendered.forEach(captionElem => captionElem.remove());
if (this.captions.length === 0) {
return;
}
for (let i = 0; i < this.captions.length; i++) {
const caption = this.captions[i];
const displayTimeoutId = setTimeout(() => {
this.displayCaption(caption.index);
}, caption.startTimeMs);
this.timeoutIds.push(displayTimeoutId);
if (i < this.captions.length - 1) {
const nextCaption = this.captions[i + 1];
if (!haveSameWords(caption, nextCaption)) {
const hideTimeoutId = setTimeout(() => {
this.hideCaption(caption.index);
}, caption.endTimeMs);
this.timeoutIds.push(hideTimeoutId);
}
}
else {
const hideTimeoutId = setTimeout(() => {
this.hideCaption(caption.index);
this.stop();
}, caption.endTimeMs);
this.timeoutIds.push(hideTimeoutId);
}
}
}
stop() {
if (this.displayedCaptionId) {
this.rendered[this.displayedCaptionId].remove();
this.displayedCaptionId = 0;
}
while (this.timeoutIds.length) {
clearTimeout(this.timeoutIds.pop());
}
this.onStop();
}
prec() {
if (!this.isBeginning) {
let precId = this.displayedCaptionId - 1;
if (precId) {
this.displayCaption(precId);
}
else {
this.hideCaption(this.displayedCaptionId);
}
}
}
next() {
if (!this.isEnd) {
this.displayCaption(this.displayedCaptionId + 1);
}
}
get isBeginning() {
return this.displayedCaptionId === 0;
}
get isEnd() {
return this.displayedCaptionId === this.captions.length;
}
displayCaption(index) {
if (this.displayedCaptionId === index) {
return; // Displayed already, do nothing
}
if (this.displayedCaptionId) {
const displayedCaption = this.captions[this.displayedCaptionId - 1];
const nextCaption = this.captions[index - 1];
if (haveSameWords(displayedCaption, nextCaption)) {
const renderedCaption = this.rendered[this.displayedCaptionId];
const captionWords = nextCaption.words.map(word => word.rawWord);
this.cssProcessor.applyDynamicClasses(renderedCaption, index, nextCaption.startTimeMs, captionWords);
const renderedWords = renderedCaption.querySelectorAll('.word');
for (let i = 0; i < renderedWords.length; i++) {
const word = nextCaption.words[i];
const renderedWord = renderedWords[i];
const cssClasses = CaptionRenderer.wordSpanClasses(word);
const existingClasses = new Set([...renderedWord.classList.values()]);
const classesToRemove = existingClasses.difference(cssClasses);
const classesToAdd = cssClasses.difference(existingClasses);
renderedWord.classList.remove(...classesToRemove);
renderedWord.classList.add(...classesToAdd);
this.cssProcessor.applyDynamicClasses(renderedWord, index, nextCaption.startTimeMs, [word.rawWord]);
}
}
else {
this.rendered[this.displayedCaptionId].remove();
this.captionsContainer.appendChild(this.rendered[index]);
}
}
else {
this.captionsContainer.appendChild(this.rendered[index]);
}
this.dynamicallyStyleContainers(index);
this.displayedCaptionId = index;
}
dynamicallyStyleContainers(index) {
this.videoElem.setAttribute('class', '');
this.captionsContainer.setAttribute('class', 'captions');
const caption = this.captions[index - 1];
const captionWords = caption.words.map(word => word.rawWord);
this.cssProcessor.applyDynamicClasses(this.videoElem, index, caption.startTimeMs, captionWords);
this.cssProcessor.applyDynamicClasses(this.captionsContainer, index, caption.startTimeMs, captionWords);
}
hideCaption(index) {
if (this.displayedCaptionId != index) {
return; // Removed already, do nothing
}
this.rendered[index].remove();
this.displayedCaptionId = 0;
}
}
const _hoisted_1 = {
id: "player-controller",
class: "section is-small"
};
const _hoisted_2 = { class: "field has-addons has-addons-centered" };
const _hoisted_3 = { class: "control" };
const _hoisted_4 = ["disabled"];
const _hoisted_5 = { class: "control" };
const _hoisted_6 = { class: "icon is-small" };
const _hoisted_7 = { class: "control" };
const _hoisted_8 = ["disabled"];
var script = /*@__PURE__*/ vue.defineComponent({
__name: 'player.component',
setup(__props) {
const playerState = vue.reactive({
isPlaying: false,
isBeginning: window.Player.isBeginning,
isEnd: window.Player.isEnd,
});
window.Player.onStop = () => {
playerState.isPlaying = false;
};
function prec() {
window.Player.prec();
updateState();
}
function next() {
window.Player.next();
updateState();
}
function togglePlay() {
if (!playerState.isPlaying) {
window.Player.play();
playerState.isPlaying = true;
}
else {
window.Player.stop();
}
}
function updateState() {
playerState.isBeginning = window.Player.isBeginning;
playerState.isEnd = window.Player.isEnd;
}
return (_ctx, _cache) => {
return (vue.openBlock(), vue.createElementBlock("section", _hoisted_1, [
vue.createElementVNode("div", _hoisted_2, [
vue.createElementVNode("p", _hoisted_3, [
vue.createElementVNode("button", {
class: "button is-rounded",
disabled: playerState.isBeginning || playerState.isPlaying,
onClick: _cache[0] || (_cache[0] = ($event) => (prec()))
}, _cache[3] || (_cache[3] = [
vue.createElementVNode("span", { class: "icon is-small" }, [
vue.createElementVNode("i", { class: "fa fa-backward" })
], -1 /* HOISTED */),
vue.createElementVNode("span", null, "Prec", -1 /* HOISTED */)
]), 8 /* PROPS */, _hoisted_4)
]),
vue.createElementVNode("p", _hoisted_5, [
vue.createElementVNode("button", {
class: vue.normalizeClass(["button", [playerState.isPlaying ? 'is-danger' : 'is-primary']]),
onClick: _cache[1] || (_cache[1] = ($event) => (togglePlay()))
}, [
vue.createElementVNode("span", _hoisted_6, [
vue.createElementVNode("i", {
class: vue.normalizeClass(["fa", [playerState.isPlaying ? 'fa-stop' : 'fa-play']])
}, null, 2 /* CLASS */)
]),
_cache[4] || (_cache[4] = vue.createElementVNode("span", null, "Play", -1 /* HOISTED */))
], 2 /* CLASS */)
]),
vue.createElementVNode("p", _hoisted_7, [
vue.createElementVNode("button", {
class: "button is-rounded",
disabled: playerState.isEnd || playerState.isPlaying,
onClick: _cache[2] || (_cache[2] = ($event) => (next()))
}, _cache[5] || (_cache[5] = [
vue.createElementVNode("span", { class: "icon is-small" }, [
vue.createElementVNode("i", { class: "fa fa-forward" })
], -1 /* HOISTED */),
vue.createElementVNode("span", null, "Next", -1 /* HOISTED */)
]), 8 /* PROPS */, _hoisted_8)
])
])
]));
};
}
});
script.__file = "src/player/components/player.component.vue";
function normalizeTimecode(timecode) {
const [hh, mm, ss, ms] = timecode.split(/[^\d]+/);
return `${hh}:${mm}:${ss}.${ms}`;
}
class AbstractDynamicCssRule {
targetSelectors;
appliedCssClass;
constructor(targetSelectors, appliedCssClass) {
this.targetSelectors = targetSelectors;
this.appliedCssClass = appliedCssClass;
}
isApplied(target, captionIndex, timeMs, words) {
let targetClasses = target.getAttribute('class')?.split(' ') || [];
for (const targetSelector of this.targetSelectors) {
if (targetSelector.startsWith('#')) {
const idSelector = targetSelector.slice(1);
if (target.getAttribute('id') != idSelector) {
return false;
}
}
else if (targetSelector.startsWith('.')) {
const classSelector = targetSelector.slice(1);
if (!targetClasses.includes(classSelector)) {
return false;
}
}
else {
throw new Error(`Unsupported target selector: '${targetSelector}'`);
}
}
return true;
}
}
class IndexesDynamicCssRule extends AbstractDynamicCssRule {
startIndexInclusive;
endIndexInclusive;
constructor(targetSelectors, appliedCssClass, startIndexInclusive, endIndexInclusive) {
super(targetSelectors, appliedCssClass);
this.startIndexInclusive = startIndexInclusive;
this.endIndexInclusive = endIndexInclusive;
}
isApplied(target, captionIndex, timeMs, words) {
return super.isApplied(target, captionIndex, timeMs, words)
&& this.startIndexInclusive <= captionIndex
&& (this.endIndexInclusive ? this.endIndexInclusive >= captionIndex : true);
}
}
class TimecodesDynamicCssRule extends AbstractDynamicCssRule {
startTimeMsInclusive;
endTimeMsInclusive;
constructor(targetSelectors, appliedCssClass, startTimeMsInclusive, endTimeMsInclusive) {
super(targetSelectors, appliedCssClass);
this.startTimeMsInclusive = startTimeMsInclusive;
this.endTimeMsInclusive = endTimeMsInclusive;
}
isApplied(target, captionIndex, timeMs, words) {
return super.isApplied(target, captionIndex, timeMs, words)
&& this.startTimeMsInclusive <= timeMs
&& (this.endTimeMsInclusive ? this.endTimeMsInclusive >= timeMs : true);
}
}
function createDynamicCssRule(targetSelectors, filter) {
switch (filter.type) {
case 'indexes':
const [startIndex, endIndex] = filter.args.map(arg => Number(arg));
return new IndexesDynamicCssRule(targetSelectors, filter.cssClass, startIndex, endIndex);
case 'timecodes':
const [startMs, endMs] = filter.args.map(normalizeTimecode).map(toMillis);
return new TimecodesDynamicCssRule(targetSelectors, filter.cssClass, startMs, endMs);
default:
throw new Error(`Unknown filter type '${filter.type}'!`);
}
}
const dynamicCssClassPrefix = 'pup-';
const dynamicCssClassPattern = /^\.pup-(\w+)((?:-[^-]+)+)$/;
class CssProcessor {
dynamicCssRules = [];
constructor() {
for (const styleSheet of document.styleSheets) {
for (const styleRule of styleSheet.cssRules) {
const selectorText = styleRule.selectorText || '';
if (selectorText.includes('.pup-')) {
const selectors = CssProcessor.parseSelectors(selectorText);
const targetSelectors = [];
let filter = null;
for (const selector of selectors) {
if (selector.match(dynamicCssClassPattern)) {
if (filter) {
throw new Error(`Only one dynamic CSS class is allowed per style rule.
Two dynamic classes were found: .${filter.cssClass} and ${selector}`);
}
filter = CssProcessor.parseFilter(selector);
}
else {
targetSelectors.push(selector);
}
}
const rule = createDynamicCssRule(targetSelectors, filter);
this.dynamicCssRules.push(rule);
}
}
}
}
applyDynamicClasses(target, captionIndex, timeMs, words) {
const dynamicCssClasses = this.dynamicCssClasses(target, captionIndex, timeMs, words);
const existingDynamicClasses = CssProcessor.getDynamicCssClassesFromElem(target);
const classesToRemove = existingDynamicClasses.difference(dynamicCssClasses);
const classesToAdd = dynamicCssClasses.difference(classesToRemove);
target.classList.remove(...classesToRemove);
target.classList.add(...classesToAdd);
return target;
}
dynamicCssClasses(target, captionIndex, timeMs, words) {
const cssClasses = this.dynamicCssRules
.filter(rule => rule.isApplied(target, captionIndex, timeMs, words))
.map(rule => rule.appliedCssClass);
return new Set(cssClasses);
}
static getDynamicCssClassesFromElem(elem) {
const dynamicCssClasses = [...elem.classList.values()]
.filter(cssClass => cssClass.startsWith(dynamicCssClassPrefix));
return new Set(dynamicCssClasses);
}
static parseFilter(dynamicCssClass) {
const match = dynamicCssClass.match(dynamicCssClassPattern);
if (!match) {
throw new Error(`CSS class ${dynamicCssClass} do not match required pattern!`);
}
const cssClass = dynamicCssClass.slice(1);
const filterType = match[1];
const filterArgs = match[2].split('-').slice(1);
return {
cssClass,
type: filterType,
args: filterArgs,
};
}
static parseSelectors(selectorText) {
const selectors = [];
let currentToken = '';
let lastIsEscaped = false;
for (let i = 0; i < selectorText.length; i++) {
const char = selectorText[i];
if ((char === '.' || char === '#') && !lastIsEscaped) {
if (currentToken) {
selectors.push(currentToken);
}
currentToken = char;
}
else if (char === '\\') {
currentToken += char;
lastIsEscaped = true;
}
else if (lastIsEscaped) {
currentToken += char;
lastIsEscaped = false;
}
else {
currentToken += char;
}
}
if (currentToken) {
selectors.push(currentToken);
}
return selectors;
}
}
window.ready = new Promise((resolve, reject) => {
window.onload = () => {
const videoElem = document.getElementById('video');
const cssProcessor = new CssProcessor();
const renderer = new CaptionRenderer(cssProcessor);
window.Player = new Player(videoElem, window.captions, cssProcessor, renderer);
if (window.playerArgs.isPreview) {
vue.createApp({})
.component('player', script)
.mount('#player-controller');
}
resolve();
};
});
})(Vue);
//# sourceMappingURL=index.js.map