InoReader autoplay video in card view

Autoplays Telegram video generated by RSS-Bridge feed when user chooses "card view" (press `4`)

// ==UserScript==
// @name         InoReader autoplay video in card view
// @namespace    http://tampermonkey.net/
// @version      0.0.2
// @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

(function () {
    "use strict";

    /**
     * @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} readerPaneMutationObserverLinked - Indicates whether the reader pane mutation observer is linked.
     * @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 = {
        readerPaneMutationObserverLinked: false,
        articleViewOpened: false,
        videoPlacingInProgress: false,
        videoNowPlaying: {
            /**
             * Represents the currently playing video.
             * @type {HTMLVideoElement | null}
             */
            currentVideoElement: null,
            /**
             *
             * @param {HTMLVideoElement | null} video
             */
            set: (video) => {
                const previousVideo = appState.videoNowPlaying.currentVideoElement;
                appState.videoNowPlaying.currentVideoElement?.pause();
                appState.videoNowPlaying.currentVideoElement = video;
                appState.videoNowPlaying.currentVideoElement?.play();
            },
            /**
             *
             * @returns {HTMLVideoElement | null}
             */
            get: () => {
                return appState.videoNowPlaying.currentVideoElement;
            },
            stopPlaying: () => {
                appState.videoNowPlaying.currentVideoElement?.pause();
                appState.videoNowPlaying.currentVideoElement = null;
            },
        },
    };

    // Select the node that will be observed for mutations
    const targetNode = document.body;

    // Options for the observer (which mutations to observe)
    const mutationObserverGlobalConfig = {
        attributes: false,
        childList: true,
        subtree: true,
    };

    const querySelectorPathArticleRoot = ".article_full_contents .article_content";

    /**
     * Callback function to execute when mutations are observed
     * @param {MutationRecord[]} mutationsList - List of mutations observed
     * @param {MutationObserver} observer - The MutationObserver instance
     */
    const callback = function (mutationsList, observer) {
        for (let i = 0; i < mutationsList.length; i++) {
            if (mutationsList[i].type === "childList") {
                mutationsList[i].addedNodes.forEach(function (node) {
                    if (node.nodeType === Node.ELEMENT_NODE) {
                        autoplayVideoInArticleList(node);
                        stopVideoInArticleList();
                    }
                });
            }
        }
    };

    //
    //
    // FIRST PART - RESTORE IMAGES IN ARTICLE LIST
    //
    //
    //

    /**
     *
     * @param {Node} node
     * @returns {void}
     */
    function autoplayVideoInArticleList(node) {
        /**
         * @type {MutationObserver | undefined}
         */
        let tmObserverImageRestoreReaderPane;
        const readerPane = document.body.querySelector("#reader_pane");
        if (readerPane) {
            if (!appState.readerPaneMutationObserverLinked) {
                appState.readerPaneMutationObserverLinked = true;

                /**
                 * Callback function to execute when mutations are observed
                 * @param {MutationRecord[]} mutationsList - List of mutations observed
                 * @param {MutationObserver} observer - The MutationObserver instance
                 */
                const callback = function (mutationsList, observer) {
                    // filter mutations by having id on target and to have only unique id attribute values
                    let filteredMutations = mutationsList
                        // @ts-ignore
                        .filter((mutation) => mutation.target?.id.includes("article_"))
                        // @ts-ignore
                        .filter((mutation, index, self) => self.findIndex((t) => t.target?.id === mutation.target?.id) === index);

                    if (filteredMutations.length === 2) {
                        // check to have only two mutations: one that has .article_current class and one should not
                        const firstMutation = filteredMutations[0];
                        const secondMutation = filteredMutations[1];
                        // sort by abscence of .article_current class
                        filteredMutations = [firstMutation, secondMutation].sort((a, b) => {
                            // @ts-ignore
                            return a.target?.classList?.contains("article_current") ? 1 : -1;
                        });

                        // @ts-ignore
                        if (firstMutation.target?.classList?.contains("article_current") && !secondMutation.target?.classList?.contains("article_current")) {
                            filteredMutations = [];
                        }
                    }

                    for (let mutation of filteredMutations) {
                        if (mutation.type === "attributes") {
                            if (mutation.attributeName === "class") {
                                /**
                                 * @type {HTMLDivElement}
                                 */
                                // @ts-ignore
                                const target = mutation.target;

                                if (
                                    target.classList.contains("article_current") &&
                                    target.querySelector(".article_tile_content_wraper [class*='icon-youtube']")
                                ) {
                                    const videoElement = checkVideoIsPlaced(target);
                                    if (!videoElement) {
                                        if (!appState.videoPlacingInProgress) {
                                            appState.videoPlacingInProgress = true;
                                            checkVideoExistingInTgPost(target)
                                                .then((videoUrl) => {
                                                    const videoElement = createVideoElement(videoUrl);
                                                    placeVideo(target, videoElement);
                                                    if (target.classList.contains("article_current")) {
                                                        playVideo(videoElement);
                                                    }
                                                })
                                                .finally(() => {
                                                    appState.videoPlacingInProgress = false;
                                                });
                                        }
                                    } else {
                                        playVideo(videoElement);
                                    }
                                } else if (
                                    !target.classList.contains("article_current") &&
                                    target.querySelector(".article_tile_content_wraper [class*='icon-youtube']")
                                ) {
                                    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) {
                                        return commonFetchTgPostEmbed(telegramPostUrl).then((tgPost) => {
                                            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.height = "100%";
                                    videoElement.style.pointerEvents = "none";
                                    videoElement.style.display = "none";

                                    return videoElement;
                                }

                                /**
                                 *
                                 * @param {HTMLDivElement} article
                                 * @param {HTMLVideoElement} videoElement
                                 */
                                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";
                                    }
                                }

                                /**
                                 *
                                 * @param {HTMLVideoElement} videoElement
                                 */
                                function playVideo(videoElement) {
                                    const video = videoElement;
                                    if (video && !appState.articleViewOpened) {
                                        appState.videoNowPlaying.set(video);
                                    }
                                }

                                /**
                                 *
                                 * @param {HTMLVideoElement} videoElement
                                 */
                                function stopVideo(videoElement) {
                                    const video = videoElement;
                                    if (video) {
                                        video.pause();
                                    }
                                }
                            }
                        }
                    }
                };

                // Options for the observer (which mutations to observe)
                const mutationObserverLocalConfig = {
                    attributes: true,
                    attributeFilter: ["class"],
                    childList: false,
                    subtree: true,
                };

                // Create an observer instance linked to the callback function
                tmObserverImageRestoreReaderPane = new MutationObserver(callback);

                // Start observing the target node for configured mutations
                tmObserverImageRestoreReaderPane.observe(readerPane, mutationObserverLocalConfig);
            }
        } else {
            appState.readerPaneMutationObserverLinked = false;
            tmObserverImageRestoreReaderPane?.disconnect();
        }

        /**
         *
         * @param {Node} node
         */
        function start(node) {
            /**
             * @type {Node & HTMLDivElement}
             */
            // @ts-ignore
            const element = node;
            if (element.hasChildNodes() && element.id.includes("article_") && element.classList.contains("ar")) {
                const imageElement = getImageElement(element);
                if (imageElement) {
                    const telegramPostUrl = getTelegramPostUrl(element);
                    const imageUrl = getImageLink(imageElement);
                    if (imageUrl) {
                        testImageLink(imageUrl).then(async () => {
                            const tgPost = await commonFetchTgPostEmbed(telegramPostUrl);
                            await replaceImageSrc(imageElement, tgPost);
                            await placeMediaCount(element, tgPost);
                        });
                    }
                }
            }
        }

        /**
         *
         * @param {Node & HTMLDivElement} node
         * @returns {HTMLDivElement | null}
         */
        function getImageElement(node) {
            const nodeElement = node;
            /**
             * @type {HTMLDivElement | null}
             */
            const divImageElement = nodeElement.querySelector("a[href*='t.me'] > div[style*='background-image']");
            return divImageElement ?? null;
        }

        /**
         *
         * @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} imageUrl
         * @returns {Promise<void>}
         */
        function testImageLink(imageUrl) {
            return new Promise((resolve, reject) => {
                const img = new Image();
                img.src = imageUrl;
                img.onload = function () {
                    reject();
                };
                img.onerror = function () {
                    resolve();
                };
            });
        }

        /**
         *
         * @param {HTMLDivElement} div
         * @param {Document} tgPost
         * @returns {Promise<void>}
         */
        async function replaceImageSrc(div, tgPost) {
            const doc = tgPost;
            const imgLink = commonGetImgUrlsFromTgPost(doc) ?? [];
            if (imgLink?.length > 0) {
                try {
                    div.style.backgroundImage = `url(${imgLink})`;
                } catch (error) {
                    console.error(`Error parsing the HTML from the telegram post. Error: ${error}`);
                }
            } else {
                console.error("No image link found in the telegram post");
            }
        }

        /**
         *
         * @param {HTMLDivElement} node
         * @param {Document} tgPost
         */
        async function placeMediaCount(node, tgPost) {
            const mediaCount = commonGetImgUrlsFromTgPost(tgPost);
            if (mediaCount.length > 1) {
                placeElement(mediaCount.length);
            }

            /**
             * @param {string | number} total
             */
            function placeElement(total) {
                // Create the new element
                const mediaCountElement = document.createElement("span");
                mediaCountElement.className = "article_tile_comments";
                mediaCountElement.title = "";
                mediaCountElement.style.backgroundColor = "rgba(0,0,0,0.5)";
                mediaCountElement.style.padding = "0.1rem";
                mediaCountElement.style.borderRadius = "5px";
                mediaCountElement.style.marginLeft = "0.5rem";
                mediaCountElement.textContent = `1/${total}`;

                // Find the target wrapper
                let wrapper = node.querySelector(".article_tile_comments_wrapper.flex");

                // If the wrapper doesn't exist, create it
                if (!wrapper) {
                    wrapper = document.createElement("div");
                    wrapper.className = "article_tile_comments_wrapper flex";

                    // Find the parent element and append the new wrapper to it
                    const parent = node.querySelector(".article_tile_content_wraper");
                    if (parent) {
                        parent.appendChild(wrapper);
                    } else {
                        console.error("Parent element not found");
                        return;
                    }
                }

                // Append the new element to the wrapper
                wrapper.appendChild(mediaCountElement);
            }
        }
    }

    /**
     *
     * @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);
        }
    }

    /**
     *
     * @param {Document} doc
     * @returns {string[]} imageUrl
     */
    function commonGetImgUrlsFromTgPost(doc) {
        const imagesQuerySelectors = [
            ".tgme_widget_message_grouped_layer > a",
            "a[href^='https://t.me/'].tgme_widget_message_photo_wrap",
            ".tgme_widget_message_video_player[href^='https://t.me/'] > i[style*='background-image'].tgme_widget_message_video_thumb",
            ".tgme_widget_message_link_preview > i[style*='background-image'].link_preview_image",
        ];

        const imgUrls = [];

        for (let i = 0; i < imagesQuerySelectors.length; i++) {
            const images = doc.querySelectorAll(imagesQuerySelectors[i]);
            images.forEach((image) => {
                /**
                 * @type {HTMLAnchorElement}
                 */
                // @ts-ignore
                const element = image;
                const imageUrl = mediaElementParsingChooser(element);
                if (imageUrl) {
                    if (!imgUrls.includes(imageUrl)) {
                        imgUrls.push(imageUrl);
                    }
                }
            });
        }

        /**
         * @param {HTMLAnchorElement} element
         *
         * @returns {string | undefined} imageUrl
         */
        function mediaElementParsingChooser(element) {
            let link;

            if (element.classList?.contains("tgme_widget_message_photo_wrap") && element.href?.includes("https://t.me/")) {
                const url = getUrlFromPhoto(element);
                if (url) {
                    link = url;
                }
            } else if (element.classList?.contains("tgme_widget_message_video_thumb") && element.style.backgroundImage?.includes("cdn-telegram.org")) {
                const url = getUrlFromVideo(element);
                if (url) {
                    link = url;
                }
            } else if (element.classList?.contains("link_preview_image") && element.style.backgroundImage?.includes("cdn-telegram.org")) {
                const url = getUrlFromLinkPreview(element);
                if (url) {
                    link = url;
                }
            }

            return link;
        }

        /**
         *
         * @param {HTMLAnchorElement} element
         * @returns {string | undefined}
         */
        function getUrlFromPhoto(element) {
            const backgroundImageUrl = element?.style.backgroundImage;
            return commonGetUrlFromBackgroundImage(backgroundImageUrl);
        }

        /**
         *
         * @param {HTMLAnchorElement} element
         * @returns {string | undefined}
         */
        function getUrlFromVideo(element) {
            const backgroundImageUrl = element?.style.backgroundImage;
            return commonGetUrlFromBackgroundImage(backgroundImageUrl || "");
        }

        /**
         *
         * @param {HTMLElement} element
         * @returns
         */
        function getUrlFromLinkPreview(element) {
            const backgroundImageUrl = element?.style.backgroundImage;
            return commonGetUrlFromBackgroundImage(backgroundImageUrl);
        }

        return imgUrls;
    }

    //
    //
    // SECOND PART - STOP VIDEO IN ARTICLE LIST IF ARTICLE VIEW IS OPENED
    //
    //
    //

    function stopVideoInArticleList() {
        const articleRoot = document.querySelector(querySelectorPathArticleRoot);
        if (articleRoot) {
            appState.articleViewOpened = true;
            appState.videoNowPlaying.stopPlaying();
        } else {
            appState.articleViewOpened = false;
        }
    }

    /**
     *
     * @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']");
        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];
            }
        }
    }

    // Create an observer instance linked to the callback function
    const tmObserverImageRestore = new MutationObserver(callback);

    // Start observing the target node for configured mutations
    tmObserverImageRestore.observe(targetNode, mutationObserverGlobalConfig);
})();