Last active
March 21, 2026 10:49
-
-
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
Show hidden characters
| { | |
| "compilerOptions": { | |
| "checkJs": true, | |
| "target": "ES2020", | |
| "lib": ["ES2020", "DOM", "DOM.Iterable"] | |
| }, | |
| "include": ["script.js"] | |
| } |
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.2.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/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"; | |
| // ┌──────────────────────────────────────────────────────────────┐ | |
| // │ ARCHITECTURE OVERVIEW │ | |
| // │ │ | |
| // │ This userscript autoplays Telegram videos in Inoreader's │ | |
| // │ card view. It works in three stages: │ | |
| // │ │ | |
| // │ 1. OBSERVE: MutationObservers detect when a card becomes │ | |
| // │ the "current" article, and when article detail view │ | |
| // │ opens/closes. │ | |
| // │ │ | |
| // │ 2. FETCH: For the current card, extract the Telegram post │ | |
| // │ URL, fetch its embed page via CORS proxy, and parse │ | |
| // │ the <video> source URL. │ | |
| // │ │ | |
| // │ 3. PLAY: Create or reuse a <video> element in the card, │ | |
| // │ autoplay it, and pause any previously playing video. │ | |
| // │ │ | |
| // │ When the user opens article detail view, video playback │ | |
| // │ in the card list is paused. │ | |
| // └──────────────────────────────────────────────────────────────┘ | |
| // ── Section 1: SELECTORS & CONSTANTS ────────────────────────── | |
| const SELECTORS = { | |
| readerPane: "#reader_pane", | |
| articleRoot: ".article_full_contents .article_content", | |
| videoDiv: ".article_video_div", | |
| placedVideo: ".article_tile_content_wraper > a[href*='t.me'] > video[src*='cdn-telegram.org']", | |
| telegramLink: ".article_tile_content_wraper > a[href*='t.me']", | |
| tilePicture: ".article_tile_content_wraper > a[href*='t.me'] > .article_tile_picture[style*='background-image']", | |
| }; | |
| // ── Section 2: CONFIGURATION ────────────────────────────────── | |
| /** | |
| * @typedef {Object} CorsProxy | |
| * @property {string} prefixUrl | |
| * @property {"direct" | "corsSh" | "corsAnywhere" | "corsFlare"} corsType | |
| * @property {string} [token] | |
| * @property {boolean} [hidden] | |
| */ | |
| const appConfig = { | |
| /** @type {CorsProxy[]} */ | |
| 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", | |
| }, | |
| ], | |
| }; | |
| // ── Section 3: APPLICATION STATE ────────────────────────────── | |
| const appState = { | |
| articleViewOpened: false, | |
| videoPlacingInProgress: false, | |
| /** @type {string | null} */ | |
| currentArticleId: null, | |
| videoNowPlaying: { | |
| /** @type {HTMLVideoElement | null} */ | |
| currentPlayElement: null, | |
| /** | |
| * Switch to a new video: pause the previous one, then play the new one. | |
| * @param {HTMLVideoElement | null} video | |
| */ | |
| switchTo: (video) => { | |
| if (video) { | |
| const previousVideo = appState.videoNowPlaying.currentPlayElement; | |
| if (previousVideo && previousVideo !== video) { | |
| previousVideo.pause(); | |
| } | |
| appState.videoNowPlaying.currentPlayElement = video; | |
| // Reset playback and re-trigger browser autoplay logic. | |
| // Some browsers require a fresh play() after programmatic pause(). | |
| 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; | |
| } | |
| }, | |
| stop: () => { | |
| appState.videoNowPlaying.currentPlayElement?.pause(); | |
| appState.videoNowPlaying.currentPlayElement = null; | |
| }, | |
| }, | |
| }; | |
| // ── Section 4: DOM HELPERS ──────────────────────────────────── | |
| /** | |
| * Find an already-placed video element in the article card. | |
| * @param {HTMLDivElement} article | |
| * @returns {HTMLVideoElement | null} | |
| */ | |
| function findPlacedVideo(article) { | |
| return article.querySelector(SELECTORS.placedVideo); | |
| } | |
| /** | |
| * Find a previously created (but maybe not yet visible) video element. | |
| * @param {HTMLDivElement} article | |
| * @returns {HTMLVideoElement | null} | |
| */ | |
| function findExistingVideo(article) { | |
| /** @type {HTMLAnchorElement | null} */ | |
| const poster = article.querySelector(SELECTORS.telegramLink); | |
| if (poster) { | |
| return poster.querySelector("video[src]"); | |
| } | |
| return null; | |
| } | |
| /** | |
| * @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; | |
| } | |
| /** | |
| * Append a video element to the article card, hiding the cover image. | |
| * @param {HTMLDivElement} article | |
| * @param {HTMLVideoElement} videoElement | |
| */ | |
| function placeVideo(article, videoElement) { | |
| /** @type {HTMLAnchorElement | null} */ | |
| const poster = article.querySelector(SELECTORS.telegramLink); | |
| /** @type {HTMLDivElement | null} */ | |
| const cover = article.querySelector(SELECTORS.tilePicture); | |
| if (poster) { | |
| poster.appendChild(videoElement); | |
| if (cover?.style) { | |
| cover.style.display = "none"; | |
| } | |
| videoElement.style.display = "block"; | |
| } | |
| } | |
| /** | |
| * Extract the Telegram post URL from an article's anchor element. | |
| * @param {HTMLDivElement} node | |
| * @returns {string} | |
| */ | |
| function getTelegramPostUrl(node) { | |
| /** @type {HTMLAnchorElement | null} */ | |
| const ahrefElement = node.querySelector("a[href*='t.me']"); | |
| const telegramPostUrl = ahrefElement?.href ?? ""; | |
| try { | |
| const url = new URL(telegramPostUrl); | |
| return url.origin + url.pathname; | |
| } catch (error) { | |
| return telegramPostUrl.split("?")[0]; | |
| } | |
| } | |
| // ── Section 5: TELEGRAM API ────────────────────────────────── | |
| /** | |
| * Select the preferred CORS proxy by type, with fallbacks. | |
| * @returns {CorsProxy} | |
| */ | |
| function getPreferredCorsProxy() { | |
| return appConfig.corsProxies.find(p => p.corsType === "corsFlare") | |
| || appConfig.corsProxies.find(p => !p.hidden) | |
| || appConfig.corsProxies[0]; | |
| } | |
| /** | |
| * Fetch a Telegram post's embed page via CORS proxy and return it as a Document. | |
| * @param {string} telegramPostUrl | |
| * @returns {Promise<Document>} | |
| */ | |
| async function fetchTelegramEmbed(telegramPostUrl) { | |
| const url = new URL(telegramPostUrl); | |
| url.searchParams.append("embed", "1"); | |
| const proxy = getPreferredCorsProxy(); | |
| const requestUrl = proxy.prefixUrl + url.toString(); | |
| const response = await fetch(requestUrl); | |
| if (!response.ok) { | |
| throw new Error(`CORS proxy returned HTTP ${response.status} for ${telegramPostUrl}`); | |
| } | |
| const html = await response.text(); | |
| const parser = new DOMParser(); | |
| return parser.parseFromString(html, "text/html"); | |
| } | |
| /** | |
| * Extract the video source URL from a Telegram embed page. | |
| * @param {Document} doc | |
| * @returns {string | undefined} | |
| */ | |
| function extractVideoUrlFromEmbed(doc) { | |
| /** @type {HTMLVideoElement | null} */ | |
| const video = doc.querySelector("video[src*='cdn-telegram.org']") || doc.querySelector("video[src*='telesco.pe']"); | |
| return video?.src; | |
| } | |
| /** | |
| * Fetch the video URL for a Telegram post linked in the given article. | |
| * @param {HTMLDivElement} target | |
| * @returns {Promise<string>} | |
| */ | |
| async function fetchVideoUrlForArticle(target) { | |
| const telegramPostUrl = getTelegramPostUrl(target); | |
| if (!telegramPostUrl) { | |
| throw new Error("No Telegram post URL found in the article"); | |
| } | |
| const doc = await fetchTelegramEmbed(telegramPostUrl); | |
| const videoUrl = extractVideoUrlFromEmbed(doc); | |
| if (!videoUrl) { | |
| throw new Error("No video found in the Telegram post"); | |
| } | |
| return videoUrl; | |
| } | |
| // ── Section 6: ORCHESTRATION ───────────────────────────────── | |
| /** | |
| * Main entry point: fetch and autoplay video for the current article card. | |
| * @param {Element} node | |
| */ | |
| async function autoplayVideoInArticleList(node) { | |
| const target = /** @type {HTMLDivElement} */ (node); | |
| setCurrentFocusedArticle(target); | |
| const existingVideo = findPlacedVideo(target); | |
| if (existingVideo) { | |
| appState.videoNowPlaying.switchTo(existingVideo); | |
| return; | |
| } | |
| if (appState.videoPlacingInProgress) { | |
| return; | |
| } | |
| appState.videoPlacingInProgress = true; | |
| try { | |
| const videoUrl = await fetchVideoUrlForArticle(target); | |
| let videoToPlay = findExistingVideo(target); | |
| if (!videoToPlay) { | |
| videoToPlay = createVideoElement(videoUrl); | |
| placeVideo(target, videoToPlay); | |
| } | |
| if (target.id === appState.currentArticleId && !appState.articleViewOpened) { | |
| appState.videoNowPlaying.switchTo(videoToPlay); | |
| } | |
| } catch (error) { | |
| // Video fetch/place failed — silently continue | |
| } finally { | |
| appState.videoPlacingInProgress = false; | |
| } | |
| } | |
| /** | |
| * Track which article is currently focused. | |
| * @param {HTMLDivElement} element | |
| */ | |
| function setCurrentFocusedArticle(element) { | |
| if (element.classList.contains("article_current")) { | |
| appState.currentArticleId = element.id; | |
| } else { | |
| appState.currentArticleId = null; | |
| } | |
| } | |
| /** | |
| * Pause card-list video when the article detail view is opened. | |
| */ | |
| function stopVideo() { | |
| const articleRoot = document.querySelector(SELECTORS.articleRoot); | |
| if (articleRoot) { | |
| appState.articleViewOpened = true; | |
| appState.videoNowPlaying.stop(); | |
| } else { | |
| appState.articleViewOpened = false; | |
| } | |
| } | |
| /** | |
| * Check if a DOM element is the current article with a video, and trigger autoplay. | |
| * @param {Element} element | |
| */ | |
| function handleArticleMutation(element) { | |
| console.log(`Mutation observed on element by ID ${element.id}: class=${element.className}, containsArticleCurrent=${element.classList.contains("article_current")}, containsAr=${element.classList.contains("ar")}, hasVideoDiv=${!!element.querySelector(SELECTORS.videoDiv)}`); | |
| if ( | |
| readerPane.classList.contains("reader_pane_view_style_3") && | |
| element.classList.contains("ar") && | |
| element.classList.contains("article_current") && | |
| element.id && | |
| element.querySelector(SELECTORS.videoDiv) | |
| ) { | |
| autoplayVideoInArticleList(element); | |
| } else { | |
| stopVideo(); | |
| } | |
| } | |
| // ── Section 7: OBSERVERS & INITIALIZATION ──────────────────── | |
| const readerPane = document.querySelector(SELECTORS.readerPane); | |
| if (readerPane) { | |
| // Handle articles already in the DOM | |
| readerPane.querySelectorAll(".ar").forEach(handleArticleMutation); | |
| const observer = new MutationObserver((mutations) => { | |
| for (const mutation of mutations) { | |
| if (mutation.type === "attributes" && mutation.attributeName === "class") { | |
| handleArticleMutation(/** @type {Element} */ (mutation.target)); | |
| } else if (mutation.type === "childList") { | |
| mutation.addedNodes.forEach((node) => { | |
| if (node.nodeType === Node.ELEMENT_NODE) { | |
| const element = /** @type {Element} */ (node); | |
| if (element.classList.contains("ar")) { | |
| handleArticleMutation(element); | |
| } | |
| element.querySelectorAll(".ar").forEach(handleArticleMutation); | |
| } | |
| }); | |
| } | |
| } | |
| }); | |
| observer.observe(readerPane, { | |
| attributes: true, | |
| childList: true, | |
| subtree: true, | |
| attributeFilter: ["class"], | |
| }); | |
| } else { | |
| stopVideo(); | |
| } | |
| // Detect article detail view opening/closing | |
| const bodyObserver = new MutationObserver((mutations) => { | |
| for (const mutation of mutations) { | |
| if (mutation.addedNodes.length > 0 || mutation.removedNodes.length > 0) { | |
| stopVideo(); | |
| break; | |
| } | |
| } | |
| }); | |
| bodyObserver.observe(document.body, { | |
| childList: true, | |
| subtree: true, | |
| }); | |
| stopVideo(); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment