Redirect to Invidious

Redirects YouTube videos to an Invidious instance.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name        Redirect to Invidious
// @author      André Kugland
// @description Redirects YouTube videos to an Invidious instance.
// @namespace   https://github.com/kugland
// @license     MIT
// @version     0.3.4
// @match       https://www.youtube.com/*
// @match       https://m.youtube.com/*
// @exclude     *://music.youtube.com/*
// @exclude     *://*.music.youtube.com/*
// @run-at      document-start
// @noframes
// @grant       GM_xmlhttpRequest
// @grant       GM.xmlhttpRequest
// @homepageURL https://greasyfork.org/scripts/477967-redirect-to-invidious
// ==/UserScript==

/* This script is transpiled from TypeScript, that’s why it looks a bit weird. For the original
   source code, see https://github.com/kugland/invidious-redirect/. */
(() => {
  // src/domhelper.ts
  function onload(callback) {
    if (document.readyState !== "loading") {
      callback();
    } else {
      document.addEventListener("DOMContentLoaded", callback);
    }
  }
  function element(html) {
    const template = document.createElement("template");
    template.innerHTML = html.trim();
    return template.content.firstChild;
  }

  // src/config.ts
  var DEFAULT_INSTANCE_URL = "https://invidious.lunar.icu";
  var INSTANCES_JSON_URL = "https://raw.githubusercontent.com/kugland/invidious-redirect/master/instances.json";
  var INSTANCE_URL_KEY = "invidious-redirect--instance";
  var INSTANCES_KEY = "invidious-redirect--public-instances";
  var UPDATED_KEY = "invidious-redirect--public-instances-updated";
  var instanceUrl = localStorage.getItem(INSTANCE_URL_KEY) || DEFAULT_INSTANCE_URL;
  var publicInstances = JSON.parse(localStorage.getItem(INSTANCES_KEY) || "{}");
  var instancesUpdated = parseInt(localStorage.getItem(UPDATED_KEY) || "0");
  function getInstanceUrl() {
    return instanceUrl.replace(/\/$/, "").replace(/^https:\/\//, "");
  }
  function getFullInstanceUrl() {
    if (instanceUrl.startsWith("http://")) {
      return instanceUrl;
    } else {
      return `https://${instanceUrl}`;
    }
  }
  function setInstanceUrl(url) {
    instanceUrl = url.replace(/\/$/, "").replace(/^https:\/\//, "");
    localStorage.setItem(INSTANCE_URL_KEY, url);
  }
  async function getInstances() {
    const now = Date.now();
    const expired = now - instancesUpdated > 864e5;
    if (Object.keys(publicInstances).length !== 0 && !expired) {
      return publicInstances;
    } else {
      publicInstances = await loadInstances();
      instancesUpdated = now;
      localStorage.setItem(INSTANCES_KEY, JSON.stringify(publicInstances));
      localStorage.setItem(UPDATED_KEY, instancesUpdated.toString());
      return publicInstances;
    }
  }
  async function loadInstances() {
    const options = {
      method: "GET",
      headers: { "Content-Type": "application/json" }
    };
    return new Promise((resolve) => {
      const gm_xmlHttpRequest = (typeof GM !== "undefined" ? GM?.xmlHttpRequest : null) || (typeof GM_xmlhttpRequest !== "undefined" ? GM_xmlhttpRequest : null);
      if (gm_xmlHttpRequest) {
        gm_xmlHttpRequest({
          ...options,
          nocache: true,
          url: INSTANCES_JSON_URL,
          onload: (response) => resolve(JSON.parse(response.responseText))
        });
      } else if (false) {
        fetch("/instances.json", { cache: "no-cache", ...options }).then(async (response) => resolve(await response.json()));
      } else {
        throw new Error(
          "Unable to load instances.json. Is the script running in a userscript manager?"
        );
      }
    }).then((instances) => instances);
  }
  function clearInstances() {
    publicInstances = {};
    instancesUpdated = 0;
    localStorage.removeItem(INSTANCES_KEY);
    localStorage.removeItem(UPDATED_KEY);
  }

  // assets/refresh.svg
  var refresh_default = '<svg width="13" height="13" viewBox="0 0 130 130"><path d="M22 63a8 8 0 0 1-16 0 59 59 0 0 1 97.1-45v-6.3a8 8 0 1 1 16.1 0V37a8 8 0 0 1-8 8l-24.4 2.2a8 8 0 1 1-1.4-16l8-.7A43 43 0 0 0 22 63zm22.8 19.6a8 8 0 0 1 1.5 16l-10 .9a43 43 0 0 0 71.7-32 8 8 0 0 1 16 0 59 59 0 0 1-95.6 46.2v4.2a8 8 0 0 1-16 0v-25a8 8 0 0 1 7.2-8l25.3-2.4z" style="fill:currentColor"/></svg>\n';

  // assets/new-tab.svg
  var new_tab_default = '<svg width="16" height="16" viewBox="0 0 96 96"><path d="M83 13 44 52m16-40h23v23M45 22H12v62h62l-0-32" style="fill:none;stroke:currentColor;stroke-width:var(--stroke-width);stroke-linecap:round;stroke-linejoin:round"/></svg>\n';

  // src/select.ts
  var INVIDIOUS_INSTANCE_CONTAINER = "invidious-instance-container";
  async function showDialog() {
    const instances = await getInstances();
    const tableHtml = Object.keys(instances).map((uri) => `
            <tr data-url="https://${uri}">
                <td>${uri}</td>
                <td>${instances[uri].toLowerCase()}</td>
                <td><a href="https://${uri}" target="_blank">${new_tab_default}</a></td>
            </tr>
        `).join("");
    const dialog = element(`
        <div id="${INVIDIOUS_INSTANCE_CONTAINER}" ondragstart="return false;">
            <div>
                <header>
                    <span>Select an Invidious instance</span>
                    <span class="refresh">${refresh_default}</span>
                    <span class="close">\u2715</span>
                    <a class="rateme" href="https://greasyfork.org/en/scripts/477967-redirect-to-invidious/feedback" target="_blank">
                        <div>
                            Rate this script! <span class="emoji">\u{1F60A}</span>
                        </div>
                    </a>
                </header>
                <table>${tableHtml}</table>
                <footer>
                    <div class="input-container">
                        <span class="input-helper">Add http:// if it\u2019s not an https:// URL.</span>
                        <input type="text" />
                    </div>
                    <button>Save</button>
                </footer>
            </div>
        </div>
    `);
    const input = dialog.querySelector("footer input");
    if (!input)
      return;
    input.value = getInstanceUrl() || "";
    input.placeholder = "invidious.snopyta.org";
    document.body.appendChild(dialog);
    dialog.addEventListener("click", (event) => {
      const target = event.target;
      if (!(target instanceof HTMLElement))
        return;
      const dialog2 = target.closest(`#${INVIDIOUS_INSTANCE_CONTAINER}`);
      const input2 = dialog2?.querySelector("footer input");
      if (!dialog2 || !input2)
        return;
      if (target.tagName != "A") {
        event.preventDefault();
        event.stopPropagation();
      }
      if (target.matches(".close")) {
        dialog2.remove();
      } else if (target.matches(".refresh")) {
        clearInstances();
        dialog2.remove();
        showDialog();
      } else if (target.matches("tr[data-url] *:not(a)")) {
        const url = target.closest("tr")?.getAttribute("data-url");
        if (url) {
          input2.value = url.replace(/^https:\/\//, "");
        }
      } else if (target.matches("footer button")) {
        try {
          new URL(`https://${input2.value}`);
          setInstanceUrl(input2.value);
          dialog2.remove();
        } catch (e) {
          alert("Invalid URL");
        }
      }
    }, true);
  }

  // assets/button.png
  var button_default = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwBAMAAAClLOS0AAAAJFBMVEXv8e7Z4ePn6+kWt/CZzvChpKFrbWrT1dJVV1WJjIm2uLXCxMH33HXYAAAAp0lEQVR4AeXNIQ7CMABG4ceSsXSYIXFVFaCAC5BwgblNV4HDkMwiIA0YDMnkDMHWoHY5PPwGSfjsE4+fNbZIyXIBOszR1iu+lICWFmiuRGsOaPURbXOyKINb6FDyR/AoZlefURyNnuwxelKR6YmHVk2yK3qSd+iJKdATB9Be+PAEPakATIi8STzISVaiJ2lET4YFejIBPbmDnEy3ETmZ9REARr3lP7wAXHImU2sAU14AAAAASUVORK5CYII=";

  // css/style.css
  var style_default = '#set-invidious-url{position:fixed;bottom:0;right:0;height:48px;width:48px;z-index:99998;margin:1rem;cursor:pointer;border-radius:50%;box-shadow:0px 0px 3px #000;opacity:.5}#set-invidious-url:hover{opacity:1 !important}#invidious-instance-container{position:fixed;top:0;left:0;right:0;bottom:0;background-color:rgba(0,0,0,.2);backdrop-filter:blur(2px);display:grid;place-content:center;z-index:99999;overflow-y:auto}#invidious-instance-container,#invidious-instance-container *{font-family:mono;font-size:12px;box-sizing:border-box}#invidious-instance-container>div{background-color:#fff;box-shadow:10px 10px 20px rgba(0,0,0,.5);border-radius:5px;user-select:none}#invidious-instance-container header,#invidious-instance-container footer{background-color:#eee}#invidious-instance-container header{border-radius:5px 5px 0 0;display:grid;grid-template:"title refresh close" auto "rateme rateme rateme"/1fr auto auto;align-items:center;border-bottom:1px solid #ccc}#invidious-instance-container header>span{padding-left:10px}#invidious-instance-container footer{border-radius:0 0 5px 5px;padding:10px;display:grid;grid-template-columns:1fr auto;gap:10px;border-top:1px solid #ccc}#invidious-instance-container footer input{padding:5px;border:1px solid #ccc;border-radius:5px;width:100%}#invidious-instance-container footer button{padding:5px 10px;border:1px solid #ccc;border-radius:5px;cursor:pointer;background-color:#eee}#invidious-instance-container footer button:hover,#invidious-instance-container footer button:focus{background-color:#ddd}#invidious-instance-container footer button:active{background-color:#ccc}#invidious-instance-container table{border-collapse:collapse;margin:3px 0}#invidious-instance-container td{padding:0 10px;cursor:pointer}#invidious-instance-container td a{position:relative;top:1px;color:#888;text-decoration:none;--stroke-width: 8}#invidious-instance-container td a:hover{color:#000;--stroke-width: 12}#invidious-instance-container tr:hover td{background-color:#eee}#invidious-instance-container .input-helper{opacity:0;font-size:12px;position:absolute;background-color:rgba(0,0,0,.8);color:#fff;bottom:20px;left:0;right:0;padding:5px 10px;margin:0 25px;pointer-events:none;border-radius:5px;text-align:center;transition:.5s ease all}#invidious-instance-container .input-helper::after{content:"";position:absolute;top:100%;left:50%;border:solid rgba(0,0,0,0);height:0;width:0;border-top-color:#000;border-width:8px;margin-left:-8px}#invidious-instance-container .input-container{position:relative}#invidious-instance-container .input-container:hover .input-helper{opacity:1;bottom:30px}#invidious-instance-container .refresh,#invidious-instance-container .close{cursor:pointer;color:#000;text-decoration:none;font-size:20px;padding:5px 10px;border-top-right-radius:5px}#invidious-instance-container .refresh:hover,#invidious-instance-container .refresh:focus,#invidious-instance-container .close:hover,#invidious-instance-container .close:focus{font-weight:bold}#invidious-instance-container .refresh:hover,#invidious-instance-container .close:hover{color:#fff;background-color:rgba(255,0,0,.5)}#invidious-instance-container .refresh{border-top-right-radius:0}#invidious-instance-container .refresh:hover{background-color:rgba(0,192,0,.5)}#invidious-instance-container .rateme{justify-self:stretch;grid-area:rateme;display:flex;justify-content:center;background-color:#ddd;padding:5px 10px;color:#000;text-decoration:none}#invidious-instance-container .rateme .emoji{font-variant-emoji:emoji}#invidious-instance-container .rateme:hover,#invidious-instance-container .rateme:focus{font-weight:bold;background-color:#ccc}\n';

  // src/videourl.ts
  function getVideoId(url) {
    try {
      const baseUrl = true ? window.location.origin : "https://www.youtube.com";
      const urlObj = new URL(url, baseUrl);
      let videoId = null;
      if (urlObj.pathname === "/watch") {
        videoId = urlObj.searchParams.get("v");
      } else if (urlObj.pathname.startsWith("/shorts/")) {
        videoId = urlObj.pathname.slice(8);
      } else if (urlObj.pathname.startsWith("/live/")) {
        videoId = urlObj.pathname.slice(6);
      } else if (urlObj.hostname === "youtu.be") {
        videoId = urlObj.pathname.slice(1);
      }
      if (videoId) {
        return videoId;
      }
    } catch (e) {
    }
    const error = new Error(`Unable to parse URL: ${url}`);
    throw error;
  }

  // src/redirect.ts
  var currentUrl = window.location.href;
  function tryNavigate(href, replace = true) {
    try {
      const url = `${getFullInstanceUrl()}/watch?v=${getVideoId(href)}`;
      if (replace) {
        window.location.replace(url);
      } else {
        window.location.assign(url);
      }
      return true;
    } catch (e) {
    }
    return false;
  }
  tryNavigate(window.location.href, true);
  document.addEventListener("click", (event) => {
    if (event.target instanceof HTMLElement) {
      const href = event.target.closest("a")?.getAttribute("href");
      if (href && tryNavigate(href, false)) {
        event.preventDefault();
        event.stopPropagation();
      }
    }
  }, true);
  setInterval(() => {
    if (window.location.href !== currentUrl) {
      currentUrl = window.location.href;
      tryNavigate(window.location.href, true);
    }
  }, 150);

  // src/main.ts
  onload(() => {
    if (true) {
      let styles = element(`<style>${style_default}</style>`);
      document.head.appendChild(styles);
    }
    const button = element(`<img id="set-invidious-url" src="${button_default}" tabindex="-1">`);
    button.addEventListener("click", () => showDialog());
    document.body.appendChild(button);
    if (false) {
      localStorage.clear();
      showDialog();
    }
  });
})();