Redirect to Invidious

Redirects YouTube videos to an Invidious instance.

当前为 2024-03-16 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Redirect to Invidious
  3. // @author André Kugland
  4. // @description Redirects YouTube videos to an Invidious instance.
  5. // @namespace https://github.com/kugland
  6. // @license MIT
  7. // @version 0.3.3
  8. // @match https://www.youtube.com/*
  9. // @match https://m.youtube.com/*
  10. // @exclude *://music.youtube.com/*
  11. // @exclude *://*.music.youtube.com/*
  12. // @run-at document-start
  13. // @noframes
  14. // @grant GM_xmlhttpRequest
  15. // @grant GM.xmlhttpRequest
  16. // @homepageURL https://greasyfork.org/scripts/477967-redirect-to-invidious
  17. // ==/UserScript==
  18.  
  19. /* This script is transpiled from TypeScript, that’s why it looks a bit weird. For the original
  20. source code, see https://github.com/kugland/invidious-redirect/. */
  21. (() => {
  22. // src/domhelper.ts
  23. function onload(callback) {
  24. if (document.readyState !== "loading") {
  25. callback();
  26. } else {
  27. document.addEventListener("DOMContentLoaded", callback);
  28. }
  29. }
  30. function element(html) {
  31. const template = document.createElement("template");
  32. template.innerHTML = html.trim();
  33. return template.content.firstChild;
  34. }
  35.  
  36. // src/config.ts
  37. var DEFAULT_INSTANCE_URL = "https://invidious.protokolla.fi";
  38. var INSTANCES_JSON_URL = "https://raw.githubusercontent.com/kugland/invidious-redirect/master/instances.json";
  39. var INSTANCE_URL_KEY = "invidious-redirect--instance";
  40. var INSTANCES_KEY = "invidious-redirect--public-instances";
  41. var UPDATED_KEY = "invidious-redirect--public-instances-updated";
  42. var instanceUrl = localStorage.getItem(INSTANCE_URL_KEY) || DEFAULT_INSTANCE_URL;
  43. var publicInstances = JSON.parse(localStorage.getItem(INSTANCES_KEY) || "{}");
  44. var instancesUpdated = parseInt(localStorage.getItem(UPDATED_KEY) || "0");
  45. function getInstanceUrl() {
  46. return instanceUrl.replace(/\/$/, "").replace(/^https:\/\//, "");
  47. }
  48. function getFullInstanceUrl() {
  49. if (instanceUrl.startsWith("http://")) {
  50. return instanceUrl;
  51. } else {
  52. return `https://${instanceUrl}`;
  53. }
  54. }
  55. function setInstanceUrl(url) {
  56. instanceUrl = url.replace(/\/$/, "").replace(/^https:\/\//, "");
  57. localStorage.setItem(INSTANCE_URL_KEY, url);
  58. }
  59. async function getInstances() {
  60. const now = Date.now();
  61. const expired = now - instancesUpdated > 864e5;
  62. if (Object.keys(publicInstances).length !== 0 && !expired) {
  63. return publicInstances;
  64. } else {
  65. publicInstances = await loadInstances();
  66. instancesUpdated = now;
  67. localStorage.setItem(INSTANCES_KEY, JSON.stringify(publicInstances));
  68. localStorage.setItem(UPDATED_KEY, instancesUpdated.toString());
  69. return publicInstances;
  70. }
  71. }
  72. async function loadInstances() {
  73. const options = {
  74. method: "GET",
  75. headers: { "Content-Type": "application/json" }
  76. };
  77. return new Promise((resolve) => {
  78. const gm_xmlHttpRequest = (typeof GM !== "undefined" ? GM?.xmlHttpRequest : null) || (typeof GM_xmlhttpRequest !== "undefined" ? GM_xmlhttpRequest : null);
  79. if (gm_xmlHttpRequest) {
  80. gm_xmlHttpRequest({
  81. ...options,
  82. nocache: true,
  83. url: INSTANCES_JSON_URL,
  84. onload: (response) => resolve(JSON.parse(response.responseText))
  85. });
  86. } else if (false) {
  87. fetch("/instances.json", { cache: "no-cache", ...options }).then(async (response) => resolve(await response.json()));
  88. } else {
  89. throw new Error(
  90. "Unable to load instances.json. Is the script running in a userscript manager?"
  91. );
  92. }
  93. }).then((instances) => instances);
  94. }
  95. function clearInstances() {
  96. publicInstances = {};
  97. instancesUpdated = 0;
  98. localStorage.removeItem(INSTANCES_KEY);
  99. localStorage.removeItem(UPDATED_KEY);
  100. }
  101.  
  102. // assets/refresh.svg
  103. 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';
  104.  
  105. // assets/new-tab.svg
  106. 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';
  107.  
  108. // src/select.ts
  109. var INVIDIOUS_INSTANCE_CONTAINER = "invidious-instance-container";
  110. async function showDialog() {
  111. const instances = await getInstances();
  112. const tableHtml = Object.keys(instances).map((uri) => `
  113. <tr data-url="https://${uri}">
  114. <td>${uri}</td>
  115. <td>${instances[uri].toLowerCase()}</td>
  116. <td><a href="https://${uri}" target="_blank">${new_tab_default}</a></td>
  117. </tr>
  118. `).join("");
  119. const dialog = element(`
  120. <div id="${INVIDIOUS_INSTANCE_CONTAINER}" ondragstart="return false;">
  121. <div>
  122. <header>
  123. <span>Select an Invidious instance</span>
  124. <span class="refresh">${refresh_default}</span>
  125. <span class="close">\u2715</span>
  126. <a class="rateme" href="https://greasyfork.org/en/scripts/477967-redirect-to-invidious/feedback" target="_blank">
  127. <div>
  128. Rate this script! <span class="emoji">\u{1F60A}</span>
  129. </div>
  130. </a>
  131. </header>
  132. <table>${tableHtml}</table>
  133. <footer>
  134. <div class="input-container">
  135. <span class="input-helper">Add http:// if it\u2019s not an https:// URL.</span>
  136. <input type="text" />
  137. </div>
  138. <button>Save</button>
  139. </footer>
  140. </div>
  141. </div>
  142. `);
  143. const input = dialog.querySelector("footer input");
  144. if (!input)
  145. return;
  146. input.value = getInstanceUrl() || "";
  147. input.placeholder = "invidious.snopyta.org";
  148. document.body.appendChild(dialog);
  149. dialog.addEventListener("click", (event) => {
  150. const target = event.target;
  151. if (!(target instanceof HTMLElement))
  152. return;
  153. const dialog2 = target.closest(`#${INVIDIOUS_INSTANCE_CONTAINER}`);
  154. const input2 = dialog2?.querySelector("footer input");
  155. if (!dialog2 || !input2)
  156. return;
  157. if (target.tagName != "A") {
  158. event.preventDefault();
  159. event.stopPropagation();
  160. }
  161. if (target.matches(".close")) {
  162. dialog2.remove();
  163. } else if (target.matches(".refresh")) {
  164. clearInstances();
  165. dialog2.remove();
  166. showDialog();
  167. } else if (target.matches("tr[data-url] *:not(a)")) {
  168. const url = target.closest("tr")?.getAttribute("data-url");
  169. if (url) {
  170. input2.value = url.replace(/^https:\/\//, "");
  171. }
  172. } else if (target.matches("footer button")) {
  173. try {
  174. new URL(`https://${input2.value}`);
  175. setInstanceUrl(input2.value);
  176. dialog2.remove();
  177. } catch (e) {
  178. alert("Invalid URL");
  179. }
  180. }
  181. }, true);
  182. }
  183.  
  184. // assets/button.png
  185. var button_default = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwBAMAAAClLOS0AAAAJFBMVEXv8e7Z4ePn6+kWt/CZzvChpKFrbWrT1dJVV1WJjIm2uLXCxMH33HXYAAAAp0lEQVR4AeXNIQ7CMABG4ceSsXSYIXFVFaCAC5BwgblNV4HDkMwiIA0YDMnkDMHWoHY5PPwGSfjsE4+fNbZIyXIBOszR1iu+lICWFmiuRGsOaPURbXOyKINb6FDyR/AoZlefURyNnuwxelKR6YmHVk2yK3qSd+iJKdATB9Be+PAEPakATIi8STzISVaiJ2lET4YFejIBPbmDnEy3ETmZ9REARr3lP7wAXHImU2sAU14AAAAASUVORK5CYII=";
  186.  
  187. // css/style.css
  188. 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';
  189.  
  190. // src/videourl.ts
  191. function getVideoId(url) {
  192. try {
  193. const baseUrl = true ? window.location.origin : "https://www.youtube.com";
  194. const urlObj = new URL(url, baseUrl);
  195. let videoId = null;
  196. if (urlObj.pathname === "/watch") {
  197. videoId = urlObj.searchParams.get("v");
  198. } else if (urlObj.pathname.startsWith("/shorts/")) {
  199. videoId = urlObj.pathname.slice(8);
  200. } else if (urlObj.pathname.startsWith("/live/")) {
  201. videoId = urlObj.pathname.slice(6);
  202. } else if (urlObj.hostname === "youtu.be") {
  203. videoId = urlObj.pathname.slice(1);
  204. }
  205. if (videoId) {
  206. return videoId;
  207. }
  208. } catch (e) {
  209. }
  210. const error = new Error(`Unable to parse URL: ${url}`);
  211. throw error;
  212. }
  213.  
  214. // src/redirect.ts
  215. var currentUrl = window.location.href;
  216. function tryNavigate(href, replace = true) {
  217. try {
  218. const url = `${getFullInstanceUrl()}/watch?v=${getVideoId(href)}`;
  219. if (replace) {
  220. window.location.replace(url);
  221. } else {
  222. window.location.assign(url);
  223. }
  224. return true;
  225. } catch (e) {
  226. }
  227. return false;
  228. }
  229. tryNavigate(window.location.href, true);
  230. document.addEventListener("click", (event) => {
  231. if (event.target instanceof HTMLElement) {
  232. const href = event.target.closest("a")?.getAttribute("href");
  233. if (href && tryNavigate(href, false)) {
  234. event.preventDefault();
  235. event.stopPropagation();
  236. }
  237. }
  238. }, true);
  239. setInterval(() => {
  240. if (window.location.href !== currentUrl) {
  241. currentUrl = window.location.href;
  242. tryNavigate(window.location.href, true);
  243. }
  244. }, 150);
  245.  
  246. // src/main.ts
  247. onload(() => {
  248. if (true) {
  249. let styles = element(`<style>${style_default}</style>`);
  250. document.head.appendChild(styles);
  251. }
  252. const button = element(`<img id="set-invidious-url" src="${button_default}" tabindex="-1">`);
  253. button.addEventListener("click", () => showDialog());
  254. document.body.appendChild(button);
  255. if (false) {
  256. localStorage.clear();
  257. showDialog();
  258. }
  259. });
  260. })();