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 }