Language reactor subtitle extender

So that Yomitan (or other popup dictionary) can pick up full sentence.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Language reactor subtitle extender
// @namespace    http://tampermonkey.net/
// @version      3.5
// @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 observeSelector(selector, callback) {
    function startObserving(element) {
      const observer = new MutationObserver((mutations) => {
        callback(mutations, element);
      });
      observer.observe(element, {
        childList: true,
        subtree: true,
        characterData: true
      });
      console.log(`Now observing: ${selector}`);
    }
    const existingElement = document.querySelector(selector);
    if (existingElement) {
      startObserving(existingElement);
      return;
    }
    const bodyObserver = new MutationObserver((mutations, observer) => {
      const element = document.querySelector(selector);
      if (element) {
        startObserving(element);
        observer.disconnect();
      }
    });
    bodyObserver.observe(document.body, {
      childList: true,
      subtree: true
    });
  }
  const getCurrentSubtitleLanguage = getCurrentSubtitleLanguageFactory();
  const getPlayerInstance = getPlayerInstanceFactory();
  function addSubtitleExtensionElements() {
    if (document.querySelector(".subtitle-extension-before")) {
      return;
    }
    const originalSubtitleEle = document.querySelector("#lln-subs");
    if (!originalSubtitleEle) {
      console.error(`No #lln-subs found`);
      return;
    }
    const currentSubtitleLanguage = getCurrentSubtitleLanguage();
    if (!currentSubtitleLanguage) {
      console.error(`Got empty, null or undefined language code from body attribute: "lln-sublangcode_g", current languageCode: ${currentSubtitleLanguage}. `);
      return;
    }
    console.log(`Current language: ${currentSubtitleLanguage}`);
    function findClosestLanguageCode(target) {
      const subtitleMapKeys = Array.from(subtitleMap.keys());
      if (subtitleMapKeys.includes(target)) {
        return target;
      }
      return subtitleMapKeys.filter((key) => {
        if (key.includes(target)) {
          return true;
        }
      }).sort((a, b) => a.length - b.length)[0] || null;
    }
    const closestLanguageCode = findClosestLanguageCode(currentSubtitleLanguage);
    if (!closestLanguageCode) {
      console.error(`No closest language code found in the subtitleMap for current language: ${currentSubtitleLanguage}. `);
      return;
    }
    console.log(`Current language **code**: ${closestLanguageCode}`);
    const targetTimedTextRes = subtitleMap.get(closestLanguageCode);
    if (!targetTimedTextRes) {
      console.error(`No corresponded XHR found for current language code: ${currentSubtitleLanguage}.`);
      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(" ");
    function hideElement(ele) {
      ele.style.display = "inline-block";
      ele.style.width = "0";
      ele.style.height = "0";
      ele.style.overflow = "hidden";
    }
    const fullBeforeText = joinTimedText(beforeSegments);
    const fullAfterText = joinTimedText(afterSegments);
    const beforeText = fullBeforeText.substring(fullBeforeText.length - 500);
    const afterText = fullAfterText.substring(0, 500);
    const spanBefore = document.createElement("span");
    spanBefore.className = "subtitle-extension-before";
    spanBefore.textContent = beforeText + (beforeText.endsWith(" ") ? "" : " ");
    hideElement(spanBefore);
    const spanAfter = document.createElement("span");
    spanAfter.className = "subtitle-extension-after";
    spanAfter.textContent = (afterText.startsWith(" ") ? "" : " ") + 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);
    }
  }
  observeSelector("#lln-main-subs", () => {
    addSubtitleExtensionElements();
  });
})();
function getCurrentSubtitleLanguageFactory() {
  const body = document.body;
  return () => {
    return body.getAttribute("lln-sublangcode_g");
  };
}
function getPlayerInstanceFactory() {
  let player = document.getElementById("movie_player");
  return function() {
    if (!player) {
      player = document.getElementById("movie_player");
    }
    return player;
  };
}