Skip to content

Instantly share code, notes, and snippets.

@Kenya-West
Last active March 21, 2026 10:49
Show Gist options
  • Select an option

  • Save Kenya-West/5f067902765af2911e9d2810cd691efa to your computer and use it in GitHub Desktop.

Select an option

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`)
{
"compilerOptions": {
"checkJs": true,
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"]
},
"include": ["script.js"]
}
// ==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