Last active
January 18, 2026 13:27
-
-
Save Kenya-West/5f067902765af2911e9d2810cd691efa to your computer and use it in GitHub Desktop.
InoReader autoplay video in card view - autoplays Telegram video generated by RSS-Bridge feed when user chooses "card view" (press `4`)
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 InoReader autoplay video in card view | |
| // @namespace http://tampermonkey.net/ | |
| // @version 0.1.0 | |
| // @description Autoplays Telegram video generated by RSS-Bridge feed when user chooses "card view" (press `4`) | |
| // @author Kenya-West | |
| // @match https://*.inoreader.com/feed* | |
| // @match https://*.inoreader.com/article* | |
| // @match https://*.inoreader.com/folder* | |
| // @match https://*.inoreader.com/starred* | |
| // @match https://*.inoreader.com/library* | |
| // @match https://*.inoreader.com/dashboard* | |
| // @match https://*.inoreader.com/web_pages* | |
| // @match https://*.inoreader.com/trending* | |
| // @match https://*.inoreader.com/commented* | |
| // @match https://*.inoreader.com/recent* | |
| // @match https://*.inoreader.com/search* | |
| // @match https://*.inoreader.com/channel* | |
| // @match https://*.inoreader.com/teams* | |
| // @match https://*.inoreader.com/dashboard* | |
| // @match https://*.inoreader.com/pocket* | |
| // @match https://*.inoreader.com/liked* | |
| // @match https://*.inoreader.com/tags* | |
| // @icon https://inoreader.com/favicon.ico?v=8 | |
| // @license MIT | |
| // ==/UserScript== | |
| // @ts-check | |
| (async function () { | |
| "use strict"; | |
| const querySelectorPathArticleRoot = ".article_full_contents .article_content"; | |
| const hasVideoElementQuerySelector = ".article_video_div"; | |
| const readerPane = document.querySelector("#reader_pane"); | |
| /** | |
| * @typedef {Object} appConfig | |
| * @property {Array<{ | |
| * prefixUrl: string, | |
| * corsType: "direct" | "corsSh" | "corsAnywhere" | "corsFlare", | |
| * token?: string, | |
| * hidden?: boolean | |
| * }>} corsProxies | |
| */ | |
| const appConfig = { | |
| corsProxies: [ | |
| { | |
| prefixUrl: "https://corsproxy.io/?", | |
| corsType: "direct", | |
| }, | |
| { | |
| prefixUrl: "https://proxy.cors.sh/", | |
| corsType: "corsSh", | |
| token: undefined, | |
| hidden: true, | |
| }, | |
| { | |
| prefixUrl: "https://cors-anywhere.herokuapp.com/", | |
| corsType: "corsAnywhere", | |
| hidden: true, | |
| }, | |
| { | |
| prefixUrl: "https://cors-1.kenyawest.workers.dev/?upstream_url=", | |
| corsType: "corsFlare", | |
| }, | |
| ], | |
| }; | |
| /** | |
| * Represents the application state. | |
| * @typedef {Object} AppState | |
| * @property {boolean} articleViewOpened - Indicates whether the article view is opened. | |
| * @property {boolean} videoPlacingInProgress - Indicates whether the video placing is in progress. | |
| * @property {Object} videoNowPlaying - Represents the currently playing video. | |
| * @property {HTMLVideoElement | null} videoNowPlaying.currentVideoElement - The current video element being played. | |
| * @property {function} videoNowPlaying.set - Sets the current video element and pauses the previous one. | |
| * @property {function} videoNowPlaying.get - Retrieves the current video element. | |
| */ | |
| const appState = { | |
| articleViewOpened: false, | |
| videoPlacingInProgress: false, | |
| /** | |
| * Represents the focused video element. | |
| * @type {string | null} | |
| */ | |
| currentArticleId: null, | |
| /** | |
| * Represents the previously focused video element. | |
| * @type {string | null} | |
| */ | |
| previousArticleId: null, | |
| videoNowPlaying: { | |
| /** | |
| * Represents the currently playing video. | |
| * @type {HTMLVideoElement | null} | |
| */ | |
| currentPlayElement: null, | |
| /** | |
| * | |
| * @param {HTMLVideoElement | null} video | |
| */ | |
| set: (video) => { | |
| if (video) { | |
| const previousVideo = appState.videoNowPlaying.currentPlayElement; | |
| if (previousVideo && previousVideo !== video) { | |
| previousVideo.pause(); | |
| } | |
| appState.videoNowPlaying.currentPlayElement = video; | |
| appState.videoNowPlaying.currentPlayElement?.pause(); | |
| appState.videoNowPlaying.currentPlayElement | |
| ?.play() | |
| .then(() => {}) | |
| .catch((error) => { | |
| appState.videoNowPlaying.currentPlayElement = null; | |
| console.error(`Error playing the video: ${error}`); | |
| }); | |
| } else { | |
| appState.videoNowPlaying.currentPlayElement = null; | |
| } | |
| }, | |
| /** | |
| * | |
| * @returns {HTMLVideoElement | null} | |
| */ | |
| get: () => { | |
| return appState.videoNowPlaying.currentPlayElement; | |
| }, | |
| stopPlaying: () => { | |
| appState.videoNowPlaying.currentPlayElement?.pause(); | |
| appState.videoNowPlaying.currentPlayElement = null; | |
| }, | |
| }, | |
| }; | |
| /** | |
| * Checks if the element is an article and triggers video logic | |
| * @param {Element} element | |
| */ | |
| function handleArticleMutation(element) { | |
| // @ts-ignore | |
| if (element.classList.contains("ar") && element.classList.contains("article_current") && element.id && element.querySelector(hasVideoElementQuerySelector)) { | |
| // @ts-ignore | |
| autoplayVideoInArticleList(element); | |
| } | |
| } | |
| if (readerPane) { | |
| // Handle existing articles | |
| readerPane.querySelectorAll(".ar").forEach(handleArticleMutation); | |
| const observer = new MutationObserver((mutations) => { | |
| mutations.forEach((mutation) => { | |
| if (mutation.type === "attributes" && mutation.attributeName === "class") { | |
| const target = /** @type {Element} */ (mutation.target); | |
| // @ts-ignore | |
| handleArticleMutation(target); | |
| } else if (mutation.type === "childList") { | |
| mutation.addedNodes.forEach((node) => { | |
| if (node.nodeType === Node.ELEMENT_NODE) { | |
| const element = /** @type {Element} */ (node); | |
| // @ts-ignore | |
| if (element.classList.contains("ar")) { | |
| handleArticleMutation(element); | |
| } | |
| // Handle nested articles if any | |
| // @ts-ignore | |
| element.querySelectorAll?.(".ar").forEach(handleArticleMutation); | |
| } | |
| }); | |
| } | |
| }); | |
| }); | |
| observer.observe(readerPane, { | |
| attributes: true, | |
| childList: true, | |
| subtree: true, | |
| attributeFilter: ["class"], | |
| }); | |
| } | |
| // Monitor for Article View opening/closing | |
| const bodyObserver = new MutationObserver((mutations) => { | |
| let checkNeeded = false; | |
| for (const mutation of mutations) { | |
| if (mutation.addedNodes.length > 0 || mutation.removedNodes.length > 0) { | |
| checkNeeded = true; | |
| break; | |
| } | |
| } | |
| if (checkNeeded) { | |
| stopVideoInArticleListWhenArticleViewIsOpened(); | |
| } | |
| }); | |
| bodyObserver.observe(document.body, { | |
| childList: true, | |
| subtree: true, | |
| }); | |
| // Initial check | |
| stopVideoInArticleListWhenArticleViewIsOpened(); | |
| // | |
| // FIRST PART - RESTORE IMAGES IN ARTICLE LIST | |
| // | |
| // | |
| // | |
| /** | |
| * | |
| * @param {Node} node | |
| * @returns {Promise<void>} | |
| */ | |
| async function autoplayVideoInArticleList(node) { | |
| setCurrentFocusedArticleVideo(node); | |
| /** | |
| * @type {HTMLDivElement} | |
| */ | |
| // @ts-ignore | |
| const target = node; | |
| if (target.querySelector(hasVideoElementQuerySelector)) { | |
| const videoElement = checkVideoIsPlaced(target); | |
| if (!videoElement) { | |
| if (!appState.videoPlacingInProgress) { | |
| appState.videoPlacingInProgress = true; | |
| try { | |
| const videoUrl = await checkVideoExistingInTgPost(target); | |
| if (videoUrl) { | |
| let videoToPlay = findExistingVideo(target); | |
| if (!videoToPlay) { | |
| const videoElement = createVideoElement(videoUrl); | |
| placeVideo(target, videoElement); | |
| videoToPlay = videoElement; | |
| } | |
| if (target.id === appState.currentArticleId) { | |
| if (videoToPlay && !appState.articleViewOpened) { | |
| appState.videoNowPlaying.set(videoToPlay); | |
| } | |
| } | |
| } | |
| appState.videoPlacingInProgress = false; | |
| } catch (error) { | |
| appState.videoPlacingInProgress = false; | |
| } | |
| } | |
| } else { | |
| appState.videoNowPlaying.set(videoElement); | |
| } | |
| } else if (target.id !== appState.currentArticleId && target.querySelector(hasVideoElementQuerySelector)) { | |
| if (checkVideoIsPlaced(target)) { | |
| /** | |
| * @type {HTMLVideoElement | null} | |
| */ | |
| const videoElement = checkVideoIsPlaced(target); | |
| if (videoElement) { | |
| stopVideo(videoElement); | |
| } | |
| } | |
| } | |
| /** | |
| * | |
| * @param {HTMLDivElement} article | |
| * @returns {HTMLVideoElement | null} | |
| */ | |
| function checkVideoIsPlaced(article) { | |
| return article.querySelector(".article_tile_content_wraper > a[href*='t.me'] > video[src*='cdn-telegram.org']"); | |
| } | |
| /** | |
| * | |
| * @param {HTMLDivElement} target | |
| * @returns {Promise<string>} | |
| */ | |
| async function checkVideoExistingInTgPost(target) { | |
| const telegramPostUrl = commonGetTelegramPostUrl(target); | |
| if (telegramPostUrl) { | |
| const tgPost = await commonFetchTgPostEmbed(telegramPostUrl); | |
| const videoUrl = commonGetVideoUrlFromTgPost(tgPost); | |
| if (videoUrl) { | |
| return videoUrl; | |
| } else { | |
| return Promise.reject("No video found in the telegram post"); | |
| } | |
| } else { | |
| return Promise.reject("No telegram post found in the article"); | |
| } | |
| } | |
| /** | |
| * | |
| * @param {string} videoUrl | |
| * @returns {HTMLVideoElement} | |
| */ | |
| function createVideoElement(videoUrl) { | |
| const videoElement = document.createElement("video"); | |
| videoElement.src = videoUrl; | |
| videoElement.autoplay = false; | |
| videoElement.loop = true; | |
| videoElement.muted = false; | |
| videoElement.volume = 0.6; | |
| videoElement.style.width = "100%"; | |
| videoElement.style.pointerEvents = "none"; | |
| videoElement.style.display = "none"; | |
| return videoElement; | |
| } | |
| /** | |
| * | |
| * @param {HTMLDivElement} article | |
| * @returns {HTMLVideoElement | null} | |
| */ | |
| function findExistingVideo(article) { | |
| /** | |
| * @type {HTMLAnchorElement | null} | |
| */ | |
| const poster = article.querySelector(".article_tile_content_wraper > a[href*='t.me']"); | |
| if (poster) { | |
| return poster.querySelector("video[src]"); | |
| } | |
| return null; | |
| } | |
| /** | |
| * | |
| * @param {HTMLDivElement} article | |
| * @param {HTMLVideoElement} videoElement | |
| * @returns {HTMLVideoElement | null} | |
| */ | |
| function placeVideo(article, videoElement) { | |
| /** | |
| * @type {HTMLAnchorElement | null} | |
| */ | |
| const poster = article.querySelector(".article_tile_content_wraper > a[href*='t.me']"); | |
| /** | |
| * @type {HTMLDivElement | null} | |
| */ | |
| const cover = article.querySelector(".article_tile_content_wraper > a[href*='t.me'] > .article_tile_picture[style*='background-image']"); | |
| if (poster) { | |
| poster.appendChild(videoElement); | |
| if (cover?.style) { | |
| cover.style.display = "none"; | |
| } | |
| videoElement.style.display = "block"; | |
| } | |
| return videoElement; | |
| } | |
| /** | |
| * | |
| * @param {HTMLVideoElement} videoElement | |
| */ | |
| function stopVideo(videoElement) { | |
| const video = videoElement; | |
| if (video) { | |
| video.pause(); | |
| } | |
| } | |
| /** | |
| * | |
| * @param {Node & HTMLDivElement} node | |
| * @returns {string} | |
| */ | |
| function getTelegramPostUrl(node) { | |
| if (!node) { | |
| return ""; | |
| } | |
| return getFromNode(node) ?? ""; | |
| /** | |
| * | |
| * @param {Node & HTMLDivElement} node | |
| * @returns {string} | |
| */ | |
| function getFromNode(node) { | |
| /** | |
| * @type {HTMLDivElement} | |
| */ | |
| // @ts-ignore | |
| const nodeElement = node; | |
| /** | |
| * @type {HTMLAnchorElement | null} | |
| */ | |
| const ahrefElement = nodeElement.querySelector("a[href*='t.me']"); | |
| const telegramPostUrl = ahrefElement?.href ?? ""; | |
| // try to get rid of urlsearchparams. If it fails, get rid of the question mark and everything after it | |
| try { | |
| return new URL(telegramPostUrl).origin + new URL(telegramPostUrl).pathname; | |
| } catch (error) { | |
| return telegramPostUrl?.split("?")[0]; | |
| } | |
| } | |
| } | |
| /** | |
| * | |
| * @param {HTMLDivElement} div | |
| */ | |
| function getImageLink(div) { | |
| const backgroundImageUrl = div?.style.backgroundImage; | |
| return commonGetUrlFromBackgroundImage(backgroundImageUrl); | |
| } | |
| } | |
| /** | |
| * | |
| * @param {string} telegramPostUrl | |
| * @returns {Promise<Document>} | |
| */ | |
| async function commonFetchTgPostEmbed(telegramPostUrl) { | |
| // add ?embed=1 to the end of the telegramPostUrl by constructing URL object | |
| const telegramPostUrlObject = new URL(telegramPostUrl); | |
| telegramPostUrlObject.searchParams.append("embed", "1"); | |
| const requestUrl = appConfig.corsProxies[3].prefixUrl ? appConfig.corsProxies[3].prefixUrl + telegramPostUrlObject.toString() : telegramPostUrlObject; | |
| const response = await fetch(requestUrl); | |
| try { | |
| const html = await response.text(); | |
| const parser = new DOMParser(); | |
| const doc = parser.parseFromString(html, "text/html"); | |
| return Promise.resolve(doc); | |
| } catch (error) { | |
| console.error(`Error parsing the HTML from the telegram post. Error: ${error}`); | |
| return Promise.reject(error); | |
| } | |
| } | |
| // | |
| // | |
| // SECOND PART - STOP VIDEO IN ARTICLE LIST IF ARTICLE VIEW IS OPENED | |
| // | |
| // | |
| // | |
| function stopVideoInArticleListWhenArticleViewIsOpened() { | |
| const articleRoot = document.querySelector(querySelectorPathArticleRoot); | |
| if (articleRoot) { | |
| appState.articleViewOpened = true; | |
| appState.videoNowPlaying.stopPlaying(); | |
| } else { | |
| appState.articleViewOpened = false; | |
| } | |
| } | |
| /** | |
| * @param {Node} node | |
| */ | |
| function setCurrentFocusedArticleVideo(node) { | |
| /** | |
| * @type {Node & HTMLDivElement} | |
| */ | |
| // @ts-ignore | |
| const element = node; | |
| if (element.classList.contains("article_current")) { | |
| appState.currentArticleId = element.id; | |
| if (appState.previousArticleId && appState.previousArticleId !== appState.currentArticleId) { | |
| // unbind arriveJS event listener | |
| const previousArticleElement = document.getElementById(appState.previousArticleId); | |
| if (previousArticleElement) { | |
| // @ts-ignore | |
| previousArticleElement.unbindArrive(); | |
| } | |
| } | |
| appState.previousArticleId = appState.currentArticleId; | |
| } else { | |
| appState.currentArticleId = null; | |
| } | |
| } | |
| /** | |
| * | |
| * @param {string} backgroundImageUrl | |
| * @returns {string | undefined} | |
| */ | |
| function commonGetUrlFromBackgroundImage(backgroundImageUrl) { | |
| /** | |
| * @type {string | undefined} | |
| */ | |
| let imageUrl; | |
| try { | |
| imageUrl = backgroundImageUrl?.match(/url\("(.*)"\)/)?.[1]; | |
| } catch (error) { | |
| imageUrl = backgroundImageUrl?.slice(5, -2); | |
| } | |
| if (!imageUrl || imageUrl == "undefined") { | |
| return; | |
| } | |
| if (!imageUrl?.startsWith("http")) { | |
| console.error(`The image could not be parsed. Image URL: ${imageUrl}`); | |
| return; | |
| } | |
| return imageUrl; | |
| } | |
| /** | |
| * | |
| * @param {Document} doc | |
| * @returns {string | undefined} imageUrl | |
| */ | |
| function commonGetVideoUrlFromTgPost(doc) { | |
| /** | |
| * @type {HTMLVideoElement | null} | |
| */ | |
| const video = doc.querySelector("video[src*='cdn-telegram.org']") || doc.querySelector("video[src*='telesco.pe']"); | |
| const videoUrl = video?.src; | |
| return videoUrl; | |
| } | |
| /** | |
| * | |
| * @param {Node & HTMLDivElement} node | |
| * @returns {string} | |
| */ | |
| function commonGetTelegramPostUrl(node) { | |
| if (!node) { | |
| return ""; | |
| } | |
| return getFromNode(node) ?? ""; | |
| /** | |
| * | |
| * @param {Node & HTMLDivElement} node | |
| * @returns {string} | |
| */ | |
| function getFromNode(node) { | |
| /** | |
| * @type {HTMLDivElement} | |
| */ | |
| // @ts-ignore | |
| const nodeElement = node; | |
| /** | |
| * @type {HTMLAnchorElement | null} | |
| */ | |
| const ahrefElement = nodeElement.querySelector("a[href*='t.me']"); | |
| const telegramPostUrl = ahrefElement?.href ?? ""; | |
| // try to get rid of urlsearchparams. If it fails, get rid of the question mark and everything after it | |
| try { | |
| return new URL(telegramPostUrl).origin + new URL(telegramPostUrl).pathname; | |
| } catch (error) { | |
| return telegramPostUrl?.split("?")[0]; | |
| } | |
| } | |
| } | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment