export default modules => {
let self
const video = modules.globals.video // Shorthand
video.classList.add("hasCustomCaptions")
// Elements
const customStyle = document.createElement("style")
customStyle.setAttribute("type", "text/css")
document.head.appendChild(customStyle)
const captionBox = document.createElement("div")
captionBox.className = "caption-box"
video.parentNode.appendChild(captionBox)
const captionInner = document.createElement("div")
captionInner.className = "caption-inner"
captionBox.appendChild(captionInner)
// Parsing
const regexVVTStyle = /::cue\(([^\. \)]+)?([^\)]*)\)/g
const regexVVTCue = /([^\n]*)\n((?:\d\d:)?\d\d:\d\d\.\d{3}) --> ((?:\d\d:)?\d\d:\d\d\.\d{3}) ?([^\n]+)?\n((?:.+\n)+)/g
const regexTimedText = /<((?:\d\d:)?\d\d:\d\d\.\d{3})> *<([^>]+)>/g
const regexCueText = /<([^\. />]+)([^>]+?)>/g
const defaultStyle = `
.caption-box .cue-container [data-details=".white"] { color: rgba(255, 255, 255, 1); }
.caption-box .cue-container [data-details=".lime"] { color: rgba(0, 255, 0, 1); }
.caption-box .cue-container [data-details=".cyan"] { color: rgba(0, 255, 255, 1); }
.caption-box .cue-container [data-details=".red"] { color: rgba(255, 0, 0, 1); }
.caption-box .cue-container [data-details=".yellow"] { color: rgba(255, 255, 0, 1); }
.caption-box .cue-container [data-details=".magenta"] { color: rgba(255, 0, 255, 1); }
.caption-box .cue-container [data-details=".blue"] { color: rgba(0, 0, 255, 1); }
.caption-box .cue-container [data-details=".black"] { color: rgba(0, 0, 0, 1); }
.caption-box .cue-container [data-details=".bg_white"] { background-color: rgba(255, 255, 255, 1); }
.caption-box .cue-container [data-details=".bg_lime"] { background-color: rgba(0, 255, 0, 1); }
.caption-box .cue-container [data-details=".bg_cyan"] { background-color: rgba(0, 255, 255, 1); }
.caption-box .cue-container [data-details=".bg_red"] { background-color: rgba(255, 0, 0, 1); }
.caption-box .cue-container [data-details=".bg_yellow"] { background-color: rgba(255, 255, 0, 1); }
.caption-box .cue-container [data-details=".bg_magenta"] { background-color: rgba(255, 0, 255, 1); }
.caption-box .cue-container [data-details=".bg_blue"] { background-color: rgba(0, 0, 255, 1); }
.caption-box .cue-container [data-details=".bg_black"] { background-color: rgba(0, 0, 0, 1); }
.caption-box .cue-container timedtext.future { color: rgba(0, 0, 0, 0); }
`.replaceAll("\t", "")
function timecodeToSeconds(tc) {
let outNum = 0
const digits2 = tc.split(".")
const digits1 = digits2[0].split(":")
for (let i = digits1.length - 1; i >= 0; i--) {
const step = digits1.length - i
// Seconds
if (step == 1)
outNum += Number(digits1[i])
// Minutes
else if (step == 2)
outNum += Number(digits1[i]) * 60
// Hours
else if (step == 3)
outNum += Number(digits1[i]) * 3600
}
outNum += Number(`0.${digits2[1]}`)
return outNum
}
function parseVVTStyle(s) {
for (const match of s.matchAll(regexVVTStyle)) {
const wholeSelector = match[0]
let className = match[1]
const details = match[2] || null
if (className == ":past" || className == ":future")
className = className.replace(":", "timedtext.")
s = s.replace(wholeSelector, `.caption-box .cue-container ${className || ""}${details ? `[data-details="${details}"]` : ""}`)
}
return s
}
function parseVVTSource(s) {
// Newlines
s = s.replaceAll("\r\n", "\n").replaceAll("\r", "\n")
if (!s.endsWith("\n"))
s = s + "\n"
// Style
let style = ""
if (s.indexOf("\nStyle:\n") > -1) {
const origStyleBlock = s.split("\nStyle:\n")[1].split("\n##\n")[0]
let newStyleBlock = parseVVTStyle(origStyleBlock)
// Add default styles
newStyleBlock = defaultStyle + newStyleBlock
style = newStyleBlock
s.replace(origStyleBlock, newStyleBlock)
}
style = defaultStyle + style
// Cues
let cues = []
for (const match of s.matchAll(regexVVTCue)) {
const wholeCue = match[0]
const id = match[1]
const startTime = match[2]
const endTime = match[3]
let tags = match[4]
let text = match[5]
// Cue replacing would go here?
/*
let newCue = wholeCue
if (newCue != wholeCue)
s = s.replace(wholeCue, newCue)
*/
// Generate VTTCue
const cueObj = new VTTCue(timecodeToSeconds(startTime), timecodeToSeconds(endTime), text)
if (id)
cueObj.id = id
if (tags) {
for (const tag of tags.split(" ")) {
let results = null
if ((results = /^align:(start|middle|center|end|left|right)$/.exec(tag)))
cueObj.align = results[1]
else if ((results = /^vertical:(lr|rl)$/.exec(tag)))
cueObj.vertical = results[1]
else if ((results = /^size:([\d.]+)%$/.exec(tag)))
cueObj.size = Number(results[1])
else if ((results = /^position:([\d.]+)%(?:,(line-left|line-right|middle|center|start|end|auto))?$/.exec(tag))) {
cueObj.position = Number(results[1])
if (results[2])
cueObj.positionAlign = results[2]
// REGION??
//} else if ()
} else {
if ((results = /^line:([\d.]+)%(?:,(start|end|center))?$/.exec(tag))) {
cueObj.lineInterpretation = "percentage"
// interpret as percentage
cueObj.line = Number(results[1]);
if (results[2])
cueObj.lineAlign = results[2]
} else if (results = /^line:(-?\d+)(?:,(start|end|center))?$/.exec(tag)) {
cueObj.lineInterpretation = "number"
// interpret as line number
cueObj.line = Number(results[1]);
if (results[2])
cueObj.lineAlign = results[2]
}
}
}
}
cues.push(cueObj)
}
return { s, style, cues }
}
function parseCueText(t) {
if (!t)
return t
// Parse timestamp tags
let previousTimestamp = null
for (const match of t.matchAll(regexTimedText)) {
const wholeElement = match[0]
const timestamp = match[1]
t = t.replace(wholeElement, `${previousTimestamp ? `` : ""}`)
previousTimestamp = timestamp
}
if (previousTimestamp)
t = t + ""
// Parse normal cues
for (const match of t.matchAll(regexCueText)) {
const wholeElement = match[0]
const className = match[1]
const details = match[2]
if (wholeElement == "
")
continue
if (className == "timedtext")
continue
t = t.replace(wholeElement, `<${className} data-details="${details}">`)
}
return t
}
/////
let lastTrack = null
let timedCues = []
function cueChange(track) {
if (track.eirWasHere) {
if (lastTrack != track && track.style)
customStyle.innerHTML = track.style
handleTrack(track)
}
lastTrack = track
}
// Detect caption switching
// for (const track of video.textTracks)
// track.addEventListener("cuechange", () => cueChange(track))
// Timed cues
video.addEventListener("timeupdate", () => {
let currentCue
for (const cue of timedCues) {
const time = Number(cue.getAttribute("time"))
if (video.currentTime >= time) {
cue.className = "past"
currentCue = cue
} else
cue.className = "future"
}
if (currentCue)
currentCue.className = "current"
})
// Functionality
function handleTrack(track) {
self.clearCaptions()
for (const c of track.activeCues) {
const cueContainer = document.createElement("div")
cueContainer.className = "cue-container"
captionInner.appendChild(cueContainer)
const cuePadding = document.createElement("div")
cuePadding.className = "cue-padding"
cueContainer.appendChild(cuePadding)
const cueContainerV = document.createElement("div")
cueContainerV.className = "cue-container-v"
cueContainer.appendChild(cueContainerV)
const cuePaddingV = document.createElement("div")
cuePaddingV.className = "cue-padding"
cueContainerV.appendChild(cuePaddingV)
const cueInner = document.createElement("div")
cueInner.className = "cue-inner"
cueContainerV.appendChild(cueInner)
cueInner.innerHTML = parseCueText(c.text.replaceAll("\n", "
"))
for (const child of cueInner.childNodes) {
if (child.nodeName == "#text") {
const span = document.createElement("span")
span.innerText = child.textContent
cueInner.insertBefore(span, child)
cueInner.removeChild(child)
} else if (child.tagName == "TIMEDTEXT")
timedCues.push(child)
}
// Set styles???????
cueInner.style.whiteSpace = "pre-wrap"
let direction = "ltr"
/*
style.display = 'flex';
style.flexDirection = 'column';
style.alignItems = 'center';
if (cue.textAlign == Cue.textAlign.LEFT ||
cue.textAlign == Cue.textAlign.START) {
style.width = '100%';
style.alignItems = 'start';
} else if (cue.textAlign == Cue.textAlign.RIGHT ||
cue.textAlign == Cue.textAlign.END) {
style.width = '100%';
style.alignItems = 'end';
}
if (cue.displayAlign == Cue.displayAlign.BEFORE) {
style.justifyContent = 'flex-start';
} else if (cue.displayAlign == Cue.displayAlign.CENTER) {
style.justifyContent = 'center';
} else {
style.justifyContent = 'flex-end';
}
*/
cueInner.style.margin = "0"
cueInner.style.textAlign = c.align
if (c.vertical && c.vertical != "")
cueInner.style.writingMode = `vertical-${c.vertical}`
else
cueInner.style.writingMode = "horizontal-tb"
// Line
let line = c.line
let lineAlign = c.lineAlign
if (c.line == "auto") {
line = 100
lineAlign = "end"
}
// Converts line number to percentage
if (c.lineInterpretation == "number") {
let maxLines = 16
if ((Number(video.getAttribute("width")) / Number(video.getAttribute("height"))) < 1)
maxLines = 32
if (line < 0)
line = 100 + ((line / maxLines) * 100)
else
line = (line / maxLines) * 100
}
// Do percent
cueInner.style.position = "relative" // "absolute"
if (!c.vertical || c.vertical == "") { // Horizontal top to bottom
cueInner.style.width = "100%"
if (lineAlign == "start")
cueInner.style.top = `${line}%`
else if (lineAlign == "end")
cueInner.style.top = `${line}%` // cueInner.style.bottom = `${100 - line}%`
} else if (c.vertical == "lr") {
cueInner.style.height = "100%"
if (lineAlign == "start")
cueInner.style.left = `${line}%`
else if (lineAlign == "end")
cueInner.style.left = `${100 - line}%` // cueInner.style.right = `${line}%`
} else {
cueInner.style.height = "100%"
if (lineAlign == "start")
cueInner.style.left = `${100 - line}%` // cueInner.style.right = `${line}%`
else if (lineAlign == "end")
cueInner.style.left = `${line}%`
}
// Position align
let positionAlign = c.positionAlign
if (positionAlign == "auto") {
if (c.align == "left" || (c.align == "start" && direction == "ltr") || (c.align == "end" && direction == "rtl"))
positionAlign = "left"
else if (c.align == "right" || (c.align == "start" && direction == "rtl") || (c.align == "end" && direction == "ltr"))
positionAlign = "right"
else
positionAlign = "center"
}
if (positionAlign == "left") {
// cueInner.style.cssFloat = "left"
if (c.position != "auto") {
// cueInner.style.position = "relative" // "absolute"
if (!c.vertical || c.vertical == "") { // Horizontal top to bottom
// TODO: what if its not a number?
cueInner.style.left = `${100 - Number(c.position)}%`
cueInner.style.width = "auto"
} else
cueInner.style.top = `${100 - Number(c.position)}%`
}
} else if (positionAlign == "right") {
// cueInner.style.cssFloat = "right"
if (c.position != "auto") {
// cueInner.style.position = "relative" // "absolute"
if (!c.vertical || c.vertical == "") { // Horizontal top to bottom
// TODO: what if its not a number?
cueInner.style.left = `${100 - Number(c.position)}%` // cueInner.style.right = `${Number(c.position)}%`
cueInner.style.width = "auto"
} else
cueInner.style.top = `${100 - Number(c.position)}%` // cueInner.style.bottom = `${Number(c.position)}%`
}
} else {
cueInner.style.left = "50%"
if (c.position != "auto") {
// cueInner.style.position = "relative" // "absolute"
if (!c.vertical || c.vertical == "") { // Horizontal top to bottom
cueInner.style.left = `${100 - Number(c.position)}%`
cueInner.style.width = "auto"
} else
cueInner.style.top = `${100 - Number(c.position)}%`
}
}
if (c.size != "100") {
if (!c.vertical || c.vertical == "") // Horizontal top to bottom
cueInner.style.width = `${Number(c.size)}%`
else
cueInner.style.height = `${Number(c.size)}%`
}
/////
// ?????
let textOriginH = 0
if (c.vertical == "") { // Horizontal TB
if (positionAlign == "left")
textOriginH = -50
else if (positionAlign == "right")
textOriginH = 50
}
// let textOriginH = cueInner.getBoundingClientRect().width
// // let textOriginV = -cueInner.getBoundingClientRect().height / 2
// if (c.vertical == "") { // Horizontal TB
// if (c.align == "center")
// textOriginH = 0
// }
// textOriginH /= cueContainer.getBoundingClientRect().width
if (cueInner.style.left) {
const diff = 50 - (Number(cueInner.style.left.split("%")[0]) + textOriginH)
if (diff < 0)
cueContainer.style.flexDirection = "row-reverse"
cuePadding.style.width = `${Math.abs(diff)}%`
cueInner.style.left = null
}
if (cueInner.style.top) {
cuePaddingV.style.height = cueInner.style.top
cueInner.style.top = null
}
/*
if (positionAlign == "left") {
cueContainer.style.flexDirection = "row-reverse"
if (c.position != "auto") {
if (!c.vertical || c.vertical == "") { // Horizontal top to bottom
cuePadding.style.width = cueInner.style.left
cueInner.style.left = null
} else {
cueContainer.style.flexDirection = "column"
cuePadding.style.height = cueInner.style.top
cueInner.style.top = null
}
}
} else if (positionAlign == "right") {
if (c.position != "auto") {
if (!c.vertical || c.vertical == "") { // Horizontal top to bottom
cuePadding.style.width = cueInner.style.right
cueInner.style.right = null
} else {
cueContainer.style.flexDirection = "column-reverse"
cuePadding.style.height = cueInner.style.bottom
cueInner.style.bottom = null
}
}
} else {
if (c.position != "auto") {
if (!c.vertical || c.vertical == "") { // Horizontal top to bottom
cuePadding.style.width = cueInner.style.left
cueInner.style.left = null
} else {
cueContainer.style.flexDirection = "column"
cuePadding.style.height = cueInner.style.top
cueInner.style.top = null
}
}
}
cuePadding.style.left = cueInner.style.left
cueInner.style.left = null
*/
}
}
self = {
clearCaptions: () => {
captionInner.innerHTML = ""
timedCues = []
},
registerCueChange: (track, enable) => {
if (!enable) {
if (track.mode == "showing")
self.clearCaptions()
track.oncuechange = undefined
} else
track.oncuechange = () => cueChange(track)
track.mode = enable ? "showing" : "hidden"
},
replaceTrack: async index => {
const track = video.textTracks[index]
const sourceElem = video.querySelector(`track[label="${track.label}"]`)
let r = await fetch(sourceElem.src)
if (r.status == 429) { // Ratelimited
newToastWhenReady("red", "x", `Too many requests. You may download captions \"${sourceElem.getAttribute("label")}\" in ${r.headers.get("retry-after")} seconds...`)
modules.controls.clickCaptionBtn(document.getElementsByClassName("setting caption")[0])
throw this
}
r = await r.text()
// New elem
const newTrackElem = document.createElement("track")
newTrackElem.setAttribute("label", sourceElem.getAttribute("label"))
newTrackElem.setAttribute("kind", sourceElem.getAttribute("kind"))
newTrackElem.setAttribute("srclang", sourceElem.getAttribute("srclang"))
const newTrack = newTrackElem.track
newTrack.eirWasHere = true
// Parse original track source, add new cues
const parseOut = parseVVTSource(r)
newTrack.source = parseOut.s
newTrack.style = parseOut.style
for (const cue of parseOut.cues)
newTrack.addCue(cue)
video.insertBefore(newTrackElem, sourceElem)
sourceElem.remove()
return newTrack
},
}
return self
}