520 lines
16 KiB
JavaScript
Executable File
520 lines
16 KiB
JavaScript
Executable File
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 ? `</timedtext>` : ""}<timedtext class="future" time="${timecodeToSeconds(timestamp)}">`)
|
|
previousTimestamp = timestamp
|
|
}
|
|
if (previousTimestamp)
|
|
t = t + "</timedtext>"
|
|
|
|
// Parse normal cues
|
|
for (const match of t.matchAll(regexCueText)) {
|
|
const wholeElement = match[0]
|
|
const className = match[1]
|
|
const details = match[2]
|
|
|
|
if (wholeElement == "<br>")
|
|
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", "<br>"))
|
|
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
|
|
}
|