Last active
January 31, 2026 16:45
-
-
Save Kladki/101f0da6511906460252e26ea78d8ec3 to your computer and use it in GitHub Desktop.
DeArrow user script with playlist sidebar support
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name DeArrow for Invidious | |
| // @namespace http://tampermonkey.net/ | |
| // @version 0.2.8 | |
| // @description Adds support for DeArrow in Invidious | |
| // @match https://yewtu.be/* | |
| // @match https://iv.ggtyler.dev/* | |
| // @match http://192.168.8.8:3000/* | |
| // @icon https://dearrow.ajay.app/logo.svg | |
| // @grant GM.xmlHttpRequest | |
| // @author Macic-Dev | |
| // @author Minion3665 | |
| // @author Basil | |
| // @author Ajay | |
| // @author Euphoriyy | |
| // @author Matthias Ahouansou | |
| // @author Pet (beesarefriends) | |
| // ==/UserScript== | |
| (function() { //Not async because the only needed await is now in its own function (replaceCurrentVideo) | |
| 'use strict'; | |
| /** | |
| * A simple fetch polyfill that uses the GreaseMonkey API | |
| * | |
| * @param {string} url - the URL to fetch | |
| */ | |
| function fetch(url) { | |
| return new Promise(resolve => { | |
| GM.xmlHttpRequest({ | |
| method: 'GET', | |
| url, | |
| responseType: 'blob', | |
| onload: res => { | |
| const headers = res.responseHeaders | |
| .split('\r\n') | |
| .reduce((prev, str) => { | |
| const [key, val] = str.split(/: (.*)/s); | |
| if (key === '') return prev; | |
| prev[key.toLowerCase()] = val; | |
| return prev; | |
| }, {}); | |
| resolve({ | |
| headers: { | |
| get: key => headers[key.toLowerCase()] | |
| }, | |
| status: res.status, | |
| blob: async () => res.response, | |
| json: async () => JSON.parse(await new Response(res.response).text()) | |
| }); | |
| } | |
| }); | |
| }); | |
| } | |
| /** | |
| * Converts a Blob to a data URL in order to get around CSP | |
| * | |
| * @param {Blob} blob - the URL to fetch | |
| */ | |
| function blobToDataURI(blob) { | |
| return new Promise(resolve => { | |
| const reader = new FileReader(); | |
| reader.readAsDataURL(blob); | |
| reader.onloadend = () => resolve(reader.result); | |
| }); | |
| } | |
| /** | |
| * Fetch data for a video, and then update the elements passed to reflect that | |
| * | |
| * @param {string} videoId - the YouTube ID of the video (the bit that comes after v= in watch URLs) | |
| * @param {HTMLElement} titleElement - the element containing the video title which will be updated if DeArrow has a title | |
| * @param {HTMLElement|undefined} thumbnailElement - the element containing the video thumbnail which will be updated if DeArrow has a thumbnail | |
| */ | |
| async function fetchAndUpdate(videoId, titleElement, thumbnailElement = undefined) { | |
| const oldTitle = titleElement.textContent; | |
| const oldThumbnail = thumbnailElement?.src; | |
| const cachedThumbnailAPIResponse = await fetch(`https://dearrow-thumb.ajay.app/api/v1/getThumbnail?videoID=${videoId}`); | |
| const cachedNewTitle = cachedThumbnailAPIResponse.headers.get('X-Title'); | |
| if (cachedNewTitle) replaceTitle(titleElement, cachedNewTitle); | |
| if (thumbnailElement !== undefined && cachedThumbnailAPIResponse.status === 200 && cachedThumbnailAPIResponse.headers.get("X-Timestamp") != "0.0") { | |
| const cachedNewThumbnail = await cachedThumbnailAPIResponse.blob(); | |
| thumbnailElement.src = await blobToDataURI(cachedNewThumbnail); | |
| } | |
| const brandingAPIResponse = await (await fetch(`https://sponsor.ajay.app/api/branding?videoID=${videoId}`)).json(); | |
| { | |
| let topTitle = brandingAPIResponse.titles[0]; | |
| let usedTitle = (topTitle && (topTitle.votes >= 0 || topTitle.locked)) ? topTitle : null; | |
| replaceTitle(titleElement, usedTitle?.title ?? oldTitle); | |
| } | |
| if (brandingAPIResponse.thumbnails.length > 0) { | |
| let topThumbnail = brandingAPIResponse.thumbnails[0]; | |
| let usedThumbnail = (topThumbnail && (topThumbnail.votes >= 0 || topThumbnail.locked)) ? topThumbnail : null; | |
| let randomTime = brandingAPIResponse.videoDuration ? brandingAPIResponse.videoDuration * brandingAPIResponse.randomTime : 0; | |
| if (usedThumbnail && usedThumbnail.original) { | |
| thumbnailElement.src = oldThumbnail; | |
| } else { | |
| const votedThumbnailAPIResponse = await fetch(`https://dearrow-thumb.ajay.app/api/v1/getThumbnail?videoID=${videoId}&time=${usedThumbnail?.timestamp ?? randomTime}`); | |
| thumbnailElement.src = await blobToDataURI(await votedThumbnailAPIResponse.blob()); | |
| } | |
| } | |
| } | |
| /** | |
| * Replaces the title of elements | |
| * | |
| * @param {HTMLElement} element - the element containing the video title | |
| * @param {HTMLElement} text - the text to replace the video title with | |
| */ | |
| function replaceTitle(element, text) { | |
| let formText = text; // If not using formatting below | |
| // let formText = text.replace(/([\u2700-\u27BF]|[\uE000-\uF8FF]|\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDFFF]|[\u2011-\u26FF]|\uD83E[\uDD10-\uDDFF])/g, '').toLowerCase(); // Remove emoji, make lowercase | |
| if (element.nodeName === 'H1') { | |
| for (const child of element.childNodes) { | |
| if (child.nodeName !== '#text') continue; | |
| //TODO: format title | |
| child.textContent = formText; | |
| break; | |
| } | |
| } else { | |
| element.textContent = formText; | |
| } | |
| } | |
| /** | |
| * Replaces the title and thumbnail of videos on the page (not the main loaded video) | |
| */ | |
| function replacePageVideos() { | |
| // console.log("replacing page videos"); | |
| const thumbnailDivs = document.querySelectorAll('div.thumbnail'); | |
| thumbnailDivs.forEach((div) => { | |
| if (div.parentNode.nodeName === 'DIV') { | |
| // First link. Selector could be better. | |
| const link = div.querySelector('a'); | |
| // Make sure we aren't fetching channel URLs | |
| if (link.href.startsWith('/channel/')) return; | |
| const videoUrl = new URL(link.href); | |
| const videoId = videoUrl.searchParams.get('v'); | |
| const titleP = div.parentNode.children[1].querySelector('p'); | |
| const thumbnailImg = div.querySelector('img.thumbnail'); | |
| fetchAndUpdate(videoId, titleP, thumbnailImg); | |
| } else { | |
| // Make sure we aren't fetching channel URLs | |
| if (div.parentNode.href.startsWith('/channel/')) return; | |
| const videoUrl = new URL(div.querySelector('a').href); | |
| const videoId = videoUrl.searchParams.get('v'); | |
| const titleP = [...div.parentNode.querySelectorAll('p')][1]; | |
| const thumbnailImg = div.querySelector('img.thumbnail'); | |
| fetchAndUpdate(videoId, titleP, thumbnailImg); | |
| } | |
| }); | |
| } | |
| /** | |
| * Replaces the title and thumbnail of the current (main) video | |
| */ | |
| async function replaceCurrentVideo() { | |
| const currentUrl = new URL(document.URL); | |
| if (currentUrl.pathname == '/watch') { | |
| const currentId = currentUrl.searchParams.get('v'); | |
| const titleH = document.querySelector('h1'); | |
| const thumbnailDiv = document.querySelector('.vjs-poster'); | |
| await fetchAndUpdate(currentId, titleH, thumbnailDiv); | |
| if (thumbnailDiv.src) thumbnailDiv.style.backgroundImage = `url(${thumbnailDiv.src})`; | |
| document.title = `${titleH.textContent} - Invidious`; | |
| } | |
| } | |
| replaceCurrentVideo(); | |
| replacePageVideos(); | |
| // Replaces the playlist video list which appears when playing a video as part of a playlist | |
| // This needs to be run after page load, as this list is loaded with javascript | |
| function replacePlaylistSidebarVideos() { | |
| const thumbnailDivs = document.querySelectorAll('div.thumbnail'); | |
| thumbnailDivs.forEach((div) => { | |
| if (div.parentNode.nodeName === 'A') { | |
| const videoUrl = new URL(div.parentNode.href); | |
| const videoId = videoUrl.searchParams.get('v'); | |
| const titleP = div.parentNode.children[1]; | |
| const thumbnailImg = div.querySelector('img.thumbnail'); | |
| fetchAndUpdate(videoId, titleP, thumbnailImg); | |
| } | |
| }); | |
| } | |
| // Use MutationObserver to watch for changes in the playlist div | |
| // https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver | |
| // https://stackoverflow.com/a/47406751 | |
| const playlistNode = document.getElementById("playlist"); | |
| (new MutationObserver(checkChange)).observe(playlistNode, {childList: true, subtree: true}); | |
| function checkChange(changes, observer) { | |
| if(playlistNode.querySelector('.playlist-restricted.pure-menu-scrollable.pure-menu')) { // If we find the scrollable menu in the playlist (ie it's loaded fully) | |
| observer.disconnect(); // Stop the observer | |
| replacePlaylistSidebarVideos(); | |
| } | |
| } | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment