// ==UserScript==
// @name Language reactor subtitle extender
// @namespace http://tampermonkey.net/
// @version 3.0
// @license MIT
// @description So that Yomitan (or other popup dictionary) can pick up full sentence.
// @author Birudo
// @match *://www.youtube.com/watch*
// @grant none
// @run-at document-body
// ==/UserScript==
// src/interceptor.ts
var interceptors = {
onResponseReady: []
};
function proxy(config) {
if (config.onResponseReady) {
interceptors.onResponseReady.push(config.onResponseReady);
}
return () => {
interceptors.onResponseReady = interceptors.onResponseReady.filter((handler) => handler !== config.onResponseReady);
};
}
function main() {
if (window.__XHR_INTERCEPTOR_INSTALLED__)
return;
window.__XHR_INTERCEPTOR_INSTALLED__ = true;
const origOpen = XMLHttpRequest.prototype.open;
const origSend = XMLHttpRequest.prototype.send;
const origSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader;
function onRequestOpen(xhr, method, url, async, user, password) {}
function onRequestSend(xhr, body) {
return { proceed: true, newBody: body };
}
function onResponseReady(xhr) {}
XMLHttpRequest.prototype.open = function(method, url, async = true, user, password) {
try {
this._method = method;
} catch (e) {}
try {
this._url = url;
} catch (e) {}
try {
this._async = async;
} catch (e) {}
try {
this._openArgs = { method, url, async, user, password };
} catch (e) {}
try {
onRequestOpen(this, method, url, async, user, password);
} catch (e) {
console.error("onRequestOpen error", e);
}
return origOpen.apply(this, arguments);
};
XMLHttpRequest.prototype.setRequestHeader = function(name, value) {
try {
if (!this._reqHeaders)
this._reqHeaders = {};
this._reqHeaders[name] = value;
} catch (e) {}
return origSetRequestHeader.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function(body) {
let decision = { proceed: true, newBody: body };
try {
decision = onRequestSend(this, body) || decision;
} catch (e) {
console.error("onRequestSend error", e);
}
if (!decision.proceed) {
try {
this.abort();
} catch (e) {}
try {
const ev = new Event("error");
this.dispatchEvent(ev);
} catch (e) {}
return;
}
const _this = this;
const handler = function() {
if (_this.readyState === 4) {
let respDecision = {
modify: false
};
try {
interceptors.onResponseReady.reduce((acc, handler2) => {
const result = handler2(_this);
return {
modify: acc.modify || result.modify,
newResponseText: result.newResponseText || acc.newResponseText
};
}, respDecision);
} catch (e) {
console.error("onResponseReady error", e);
}
if (respDecision && respDecision.modify) {
try {
const newText = respDecision.newResponseText === undefined ? "" : String(respDecision.newResponseText);
try {
Object.defineProperty(_this, "responseText", {
configurable: true,
enumerable: true,
get() {
return newText;
}
});
} catch (e) {
console.warn("Could not override responseText:", e);
}
try {
Object.defineProperty(_this, "response", {
configurable: true,
enumerable: true,
get() {
return newText;
}
});
} catch (e) {
console.warn("Could not override response:", e);
}
} catch (e) {
console.error("Failed to apply response override", e);
}
}
}
};
this.addEventListener("readystatechange", handler);
return origSend.call(this, decision.newBody);
};
try {
const proto = XMLHttpRequest.prototype;
if (!proto.__xhrPatchedToString) {
proto.__xhrPatchedToString = true;
const origToString = proto.toString;
proto.toString = function() {
try {
return origToString.call(this);
} catch (e) {
return "[object XMLHttpRequest]";
}
};
}
} catch (e) {}
}
main();
// src/index.user.ts
var subtitleMap = new Map;
(() => {
proxy({
onResponseReady: (xhr) => {
if (!xhr.responseURL.includes("/timedtext")) {
return { modify: false };
}
const url = new URL(xhr.responseURL);
const lang = url.searchParams.get("lang");
const tlang = url.searchParams.get("tlang");
if (lang) {
subtitleMap.set(tlang ?? lang, xhr);
} else {
console.error("No language found in /timedtext request.");
}
return { modify: false };
}
});
function onLlnSubsWrapAdd(callback) {
const observer = new MutationObserver((mutationsList, observer2) => {
mutationsList.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType !== Node.ELEMENT_NODE || !(node instanceof Element) || !node.matches(".lln-subs-wrap")) {
return;
}
callback(node);
});
});
});
observer.observe(document.body, {
childList: true,
subtree: true,
characterData: true
});
}
const getCurrentSubtitleLanguage = getCurrentSubtitleLanguageFactory();
const getPlayerInstance = getPlayerInstanceFactory();
onLlnSubsWrapAdd((llnSubsWrap) => {
const originalSubtitleEle = llnSubsWrap.querySelector("#lln-subs");
if (!originalSubtitleEle) {
console.error(`No #lln-subs found`);
return;
}
const currentSubtitleLanguageCode = getCurrentSubtitleLanguage();
if (!currentSubtitleLanguageCode) {
console.error(`Got empty or undefined language code from player instance, languageCode: ${currentSubtitleLanguageCode}. `);
return;
}
const targetTimedTextRes = subtitleMap.get(currentSubtitleLanguageCode);
if (!targetTimedTextRes) {
console.error(`No corresponded XHR found for current language code: ${currentSubtitleLanguageCode}.`);
return;
}
const player = getPlayerInstance();
if (!player) {
console.error("No player instance found;");
return;
}
const timedTextData = (() => {
try {
return JSON.parse(targetTimedTextRes.responseText);
} catch (error) {
console.error("Error when parser timedText response to JSON.");
}
})();
if (!timedTextData) {
console.error("timedText response is empty.");
return;
}
const currentVideoMs = player.getCurrentTime() * 1000;
const currentSegIndex = timedTextData.events.findIndex(({ dDurationMs: durationMs, tStartMs: startMs }) => currentVideoMs >= startMs && currentVideoMs <= startMs + durationMs);
const { afterSegments, beforeSegments } = timedTextData.events.reduce((acc, current) => {
if (current.tStartMs + current.dDurationMs < currentVideoMs) {
return {
...acc,
beforeSegments: [...acc.beforeSegments, current]
};
}
if (current.tStartMs > currentVideoMs) {
return {
...acc,
afterSegments: [...acc.afterSegments, current]
};
}
return acc;
}, {
beforeSegments: [],
afterSegments: []
});
const joinTimedText = (segments) => segments.map((e) => e.segs[0].utf8).join(" ");
const beforeText = joinTimedText(beforeSegments);
const afterText = joinTimedText(afterSegments);
function hideElement(ele) {
ele.style.display = "inline-block";
ele.style.width = "0";
ele.style.height = "0";
ele.style.overflow = "hidden";
}
const spanBefore = document.createElement("span");
spanBefore.className = "subtitle-extension-before";
spanBefore.textContent = beforeText;
hideElement(spanBefore);
const spanAfter = document.createElement("span");
spanAfter.className = "subtitle-extension-after";
spanAfter.textContent = " " + afterText;
hideElement(spanAfter);
const firstChild = originalSubtitleEle.firstChild;
if (firstChild && firstChild.nextSibling) {
originalSubtitleEle.insertBefore(spanBefore, firstChild.nextSibling);
} else if (firstChild) {
originalSubtitleEle.appendChild(spanBefore);
} else {
originalSubtitleEle.appendChild(spanBefore);
}
const lastChild = originalSubtitleEle.lastChild;
if (lastChild && lastChild.previousSibling) {
originalSubtitleEle.insertBefore(spanAfter, lastChild);
} else {
originalSubtitleEle.appendChild(spanAfter);
}
});
})();
function getCurrentSubtitleLanguageFactory() {
const player = document.getElementById("movie_player");
return () => {
if (!player) {
console.error("no player instance found.");
return;
}
try {
const res = player.getPlayerResponse();
return res.captions.playerCaptionsTracklistRenderer.captionTracks[0]?.languageCode;
} catch (error) {
console.error("error when getting current subtitle language from player instance.", error);
}
};
}
function getPlayerInstanceFactory() {
let player = document.getElementById("movie_player");
return function() {
if (!player) {
player = document.getElementById("movie_player");
}
return player;
};
}