Universal Video Downloader

Download videos. Supports M3U8. Twitter API integration. Works everywhere.

Bu betiği kurabilmeniz için Tampermonkey, Greasemonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

You will need to install an extension such as Tampermonkey to install this script.

Bu betiği kurabilmeniz için Tampermonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği kurabilmeniz için Tampermonkey ya da Userscripts gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği indirebilmeniz için ayrıca Tampermonkey gibi bir eklenti kurmanız gerekmektedir.

Bu komut dosyasını yüklemek için bir kullanıcı komut dosyası yöneticisi uzantısı yüklemeniz gerekecek.

(Zaten bir kullanıcı komut dosyası yöneticim var, kurmama izin verin!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Zateb bir user-style yöneticim var, yükleyeyim!)

// ==UserScript==
// @name         Universal Video Downloader
// @namespace    http://tampermonkey.net/
// @version      19.0
// @description  Download videos. Supports M3U8. Twitter API integration. Works everywhere.
// @author       Minoa
// @license      MIT
// @match        *://*/*
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/m3u8-parser.min.js
// @require      https://cdn.jsdelivr.net/npm/@warren-bank/[email protected]/dist/umd/ffmpeg.js
// @resource     classWorkerURL  https://cdn.jsdelivr.net/npm/@warren-bank/[email protected]/dist/umd/258.ffmpeg.js
// @resource     coreURL         https://cdn.jsdelivr.net/npm/@ffmpeg/[email protected]/dist/umd/ffmpeg-core.js
// @resource     wasmURL         https://cdn.jsdelivr.net/npm/@ffmpeg/[email protected]/dist/umd/ffmpeg-core.wasm
// @grant        GM_addStyle
// @grant        GM_getResourceURL
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_xmlhttpRequest
// @connect      twitter.com
// @connect      x.com
// @connect      api.twitter.com
// @connect      api.x.com
// @connect      video.twimg.com
// @connect      pbs.twimg.com
// @connect      *
// @run-at       document-start
// ==/UserScript==

(function() {
    'use strict';

    // ==========================================
    // ICONS (UNICODE ONLY - NO EMOJIS)
    // ==========================================
    const ICONS = {
        down: '\u2193',
        menu: '\u2630',
        check: '\u2713',
        cross: '\u2715',
        info: '\u2139',
        eye: '\u2299',
        reload: '\u21BB',
        share: '\u2197',
        copy: '\u2398'
    };

    // ==========================================
    // CONFIGURATION
    // ==========================================
    let floatingButton = null;
    let hiddenToggle = null;

    let isHidden = GM_getValue('uvs_hidden', false);
    let pressStartTime = 0;
    let actionCycleIndex = 0;

    const detectedUrls = new Set();
    const allDetectedVideos = new Map();
    const downloadedBlobs = new Map();

    // Twitter video cache: tweetId -> video URLs
    const twitterVideoCache = new Map();

    const CONCURRENCY = 3;
    const MAX_RETRIES = 3;

    const checkMobile = (a) => {
        return /(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(a) || /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(a.substr(0,4));
    };
    const isMobile = checkMobile(navigator.userAgent || navigator.vendor || window.opera);
    const isTwitter = location.hostname.includes('twitter.com') || location.hostname.includes('x.com');

    const THEME = {
        bg: 'rgba(20, 20, 20, 0.75)',
        modalBg: 'rgba(30, 30, 30, 0.85)',
        border: 'rgba(255, 255, 255, 0.1)',
        text: '#ffffff',
        subText: '#aaaaaa',
        accent: '#d4a373',
        success: '#4ade80',
        error: '#f87171',
        info: '#60a5fa'
    };

    // ==========================================
    // STYLES
    // ==========================================
    GM_addStyle(`
        #uvs-container { position: fixed; top: 15px; left: 15px; width: 46px; height: 46px; z-index: 2147483647; isolation: isolate; pointer-events: auto; transition: opacity 0.3s; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
        #uvs-float { position: absolute; top: 3px; left: 3px; width: 40px; height: 40px; background: ${THEME.bg}; backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); color: ${THEME.accent}; border: 1px solid ${THEME.border}; border-radius: 50%; font-size: 18px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: transform 0.2s, background 0.2s; box-shadow: 0 4px 15px rgba(0,0,0,0.3); user-select: none; }
        #uvs-float:hover { transform: scale(1.05); background: rgba(50, 50, 50, 0.9); }
        #uvs-svg { position: absolute; top: 0; left: 0; pointer-events: none; transform: rotate(-90deg); }

        #uvs-hidden-toggle {
            position: fixed; top: 10px; right: 10px; width: 18px; height: 18px;
            border: 2px solid rgba(255, 255, 255, 0.4); background: rgba(0, 0, 0, 0.3);
            z-index: 2147483647; cursor: pointer; opacity: 0.5;
            transition: all 0.2s; border-radius: 4px;
        }
        #uvs-hidden-toggle:hover { opacity: 1; background: ${THEME.success}; border-color: #fff; transform: scale(1.1); }

        .uvs-notification { position: fixed; top: 75px; left: 15px; background: ${THEME.bg}; backdrop-filter: blur(12px); color: ${THEME.text}; padding: 10px 16px; border-radius: 12px; border: 1px solid ${THEME.border}; font-size: 13px; font-weight: 500; z-index: 2147483646; display: flex; align-items: center; gap: 10px; box-shadow: 0 8px 20px rgba(0,0,0,0.25); animation: uvs-slide-in 0.3s ease-out; }
        @keyframes uvs-slide-in { from { opacity: 0; transform: translateX(-10px); } to { opacity: 1; transform: translateX(0); } }

        #uvs-overlay {
            position: fixed; top: 0; left: 0; width: 100%; height: 100%;
            background: rgba(0,0,0,0.6); backdrop-filter: blur(6px);
            z-index: 2147483645; display: flex; align-items: center; justify-content: center;
            opacity: 0; animation: uvs-fade-in 0.2s forwards;
        }
        @keyframes uvs-fade-in { to { opacity: 1; } }

        #uvs-modal {
            background: ${THEME.modalBg}; border: 1px solid ${THEME.border};
            border-radius: 16px; width: 550px; max-width: 90%; max-height: 80vh;
            display: flex; flex-direction: column;
            box-shadow: 0 25px 50px rgba(0,0,0,0.5);
            transform: scale(0.95); animation: uvs-scale-in 0.2s forwards;
        }
        @keyframes uvs-scale-in { to { transform: scale(1); } }

        .uvs-header {
            padding: 16px 20px; border-bottom: 1px solid ${THEME.border};
            display: flex; justify-content: space-between; align-items: center;
        }
        .uvs-title { font-size: 16px; font-weight: 600; color: ${THEME.text}; letter-spacing: 0.5px; }
        .uvs-close {
            background: transparent; border: none; color: ${THEME.subText};
            font-size: 20px; cursor: pointer; padding: 4px; line-height: 1;
            transition: color 0.2s;
        }
        .uvs-close:hover { color: ${THEME.text}; }

        .uvs-list { overflow-y: auto; padding: 0; margin: 0; }
        .uvs-item {
            padding: 16px 20px; border-bottom: 1px solid rgba(255,255,255,0.05);
            cursor: pointer; display: flex; justify-content: space-between; align-items: center;
            transition: background 0.2s;
        }
        .uvs-item:hover { background: rgba(255,255,255,0.08); }
        .uvs-item:last-child { border-bottom: none; }

        .uvs-info { display: flex; flex-direction: column; gap: 6px; flex: 1; min-width: 0; margin-right: 15px; }
        .uvs-filename {
            font-size: 14px; color: ${THEME.text}; font-weight: 500;
            white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
        }
        .uvs-meta { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }

        .uvs-badge {
            font-size: 10px; padding: 3px 6px; border-radius: 4px;
            background: rgba(255,255,255,0.1); color: ${THEME.subText};
            font-weight: 600; letter-spacing: 0.3px;
        }
        .uvs-badge.hd { background: rgba(96, 165, 250, 0.2); color: ${THEME.info}; }
        .uvs-badge.size { background: rgba(74, 222, 128, 0.2); color: ${THEME.success}; }
        .uvs-badge.fmt { background: rgba(212, 163, 115, 0.2); color: ${THEME.accent}; }

        .uvs-action {
            width: 32px; height: 32px; border-radius: 50%;
            background: rgba(255,255,255,0.05); display: flex;
            align-items: center; justify-content: center;
            color: ${THEME.text}; font-size: 16px;
            transition: all 0.2s;
        }
        .uvs-item:hover .uvs-action { background: ${THEME.accent}; color: #000; }

        /* Twitter Button Styles */
        .uvs-tw-btn {
            display: flex; align-items: center; justify-content: center;
            width: 34px; height: 34px; border-radius: 999px; cursor: pointer;
            color: rgb(113, 118, 123); transition: 0.2s;
            background: transparent; border: none; padding: 0;
        }
        .uvs-tw-btn:hover { background: rgba(29, 155, 240, 0.1); color: rgb(29, 155, 240); }
        .uvs-tw-btn svg { width: 18.75px; height: 18.75px; fill: currentColor; }
        .uvs-tw-btn.loading svg { animation: uvs-spin 1s linear infinite; }
        .uvs-tw-btn.success { color: ${THEME.success}; }
        .uvs-tw-btn.error { color: ${THEME.error}; }
        @keyframes uvs-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
    `);

    // ==========================================
    // HELPERS
    // ==========================================
    const VIDEO_EXT = ['.mp4', '.webm', '.ogg', '.mov', '.avi', '.mkv', '.m4v'];

    function sanitizeFilename(name) {
        return (name || 'video').replace(/[<>:"\/\\|?*\x00-\x1F]/g, '').replace(/\s+/g, '_').substring(0, 150);
    }

    function getFilenameFromUrl(url) {
        try {
            const pathname = new URL(url).pathname;
            const name = pathname.substring(pathname.lastIndexOf('/') + 1);
            return decodeURIComponent(name) || 'video.mp4';
        } catch(e) { return 'video.mp4'; }
    }

    function resolveUrl(baseUrl, relativeUrl) {
        try { return new URL(relativeUrl, baseUrl).href; } catch (e) { return relativeUrl; }
    }

    function formatBytes(bytes, decimals = 1) {
        if (!bytes) return '';
        const k = 1024;
        const dm = decimals < 0 ? 0 : decimals;
        const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
        const i = Math.floor(Math.log(bytes) / Math.log(k));
        return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
    }

    function formatDuration(seconds) {
        if (!seconds) return '';
        const m = Math.floor(seconds / 60);
        const s = Math.floor(seconds % 60);
        return `${m}:${s.toString().padStart(2, '0')}`;
    }

    function getCookie(name) {
        const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
        return match ? decodeURIComponent(match[2]) : '';
    }

    // ==========================================
    // TWITTER VIDEO INTERCEPTION
    // ==========================================
    if (isTwitter) {
        const originalFetch = window.fetch;
        window.fetch = async function(...args) {
            const response = await originalFetch.apply(this, args);

            try {
                const url = typeof args[0] === 'string' ? args[0] : args[0]?.url;

                if (url && (url.includes('/TweetDetail') || url.includes('/TweetResultByRestId') ||
                    url.includes('/HomeTimeline') || url.includes('/UserTweets') ||
                    url.includes('/SearchTimeline') || url.includes('/ListLatestTweetsTimeline'))) {
                    const clone = response.clone();
                    clone.json().then(data => {
                        extractVideosFromAPIResponse(data);
                    }).catch(() => {});
                }
            } catch(e) {}

            return response;
        };

        const originalXHROpen = XMLHttpRequest.prototype.open;
        const originalXHRSend = XMLHttpRequest.prototype.send;

        XMLHttpRequest.prototype.open = function(method, url) {
            this._url = url;
            return originalXHROpen.apply(this, arguments);
        };

        XMLHttpRequest.prototype.send = function() {
            this.addEventListener('load', function() {
                try {
                    if (this._url && (this._url.includes('/TweetDetail') || this._url.includes('/TweetResultByRestId') ||
                        this._url.includes('/HomeTimeline') || this._url.includes('/UserTweets'))) {
                        const data = JSON.parse(this.responseText);
                        extractVideosFromAPIResponse(data);
                    }
                } catch(e) {}
            });
            return originalXHRSend.apply(this, arguments);
        };
    }

    function extractVideosFromAPIResponse(data) {
        try {
            findVideosRecursively(data);
        } catch(e) {
            console.error('Error extracting videos:', e);
        }
    }

    function findVideosRecursively(obj, depth = 0) {
        if (depth > 20 || !obj) return;

        if (Array.isArray(obj)) {
            obj.forEach(item => findVideosRecursively(item, depth + 1));
            return;
        }

        if (typeof obj !== 'object') return;

        if (obj.rest_id && obj.legacy?.extended_entities?.media) {
            const tweetId = obj.rest_id;
            const media = obj.legacy.extended_entities.media;

            const videos = [];
            for (const m of media) {
                if ((m.type === 'video' || m.type === 'animated_gif') && m.video_info?.variants) {
                    const mp4s = m.video_info.variants.filter(v => v.content_type === 'video/mp4');
                    if (mp4s.length > 0) {
                        const best = mp4s.reduce((a, b) => (a.bitrate || 0) > (b.bitrate || 0) ? a : b);
                        videos.push({
                            url: best.url,
                            bitrate: best.bitrate || 0,
                            type: m.type
                        });
                    }
                }
            }

            if (videos.length > 0) {
                twitterVideoCache.set(tweetId, videos);
            }
        }

        if (obj.tweet?.rest_id) {
            findVideosRecursively(obj.tweet, depth + 1);
        }

        for (const key of Object.keys(obj)) {
            findVideosRecursively(obj[key], depth + 1);
        }
    }

    // ==========================================
    // TWITTER API INTEGRATION (FALLBACK)
    // ==========================================
    const TWITTER_BEARER = "AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA";

    function createTwitterHeaders() {
        return {
            'authorization': `Bearer ${TWITTER_BEARER}`,
            'x-csrf-token': getCookie('ct0'),
            'x-twitter-auth-type': 'OAuth2Session',
            'x-twitter-active-user': 'yes',
            'x-twitter-client-language': 'en',
            'content-type': 'application/json'
        };
    }

    async function fetchTweetDataGraphQL(tweetId) {
        const queryIds = [
            'xOhkmRac04YFZmOzU9PJHg',
            'B9_KmbkLhXt6jRwGjJrweg',
            'DJS3BdhUhcaEpZ7B7irJDg',
        ];

        const features = {
            "creator_subscriptions_tweet_preview_api_enabled": true,
            "c9s_tweet_anatomy_moderator_badge_enabled": true,
            "tweetypie_unmention_optimization_enabled": true,
            "responsive_web_edit_tweet_api_enabled": true,
            "graphql_is_translatable_rweb_tweet_is_translatable_enabled": true,
            "view_counts_everywhere_api_enabled": true,
            "longform_notetweets_consumption_enabled": true,
            "responsive_web_twitter_article_tweet_consumption_enabled": true,
            "tweet_awards_web_tipping_enabled": false,
            "responsive_web_home_pinned_timelines_enabled": true,
            "freedom_of_speech_not_reach_fetch_enabled": true,
            "standardized_nudges_misinfo": true,
            "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": true,
            "longform_notetweets_rich_text_read_enabled": true,
            "longform_notetweets_inline_media_enabled": true,
            "responsive_web_graphql_exclude_directive_enabled": true,
            "verified_phone_label_enabled": false,
            "responsive_web_media_download_video_enabled": false,
            "responsive_web_graphql_skip_user_profile_image_extensions_enabled": false,
            "responsive_web_graphql_timeline_navigation_enabled": true,
            "responsive_web_enhance_cards_enabled": false
        };

        const variables = {
            "tweetId": tweetId,
            "withCommunity": false,
            "includePromotedContent": false,
            "withVoice": false
        };

        const fieldToggles = {
            "withArticleRichContentState": true,
            "withArticlePlainText": false,
            "withGrokAnalyze": false,
            "withDisallowedReplyControls": false
        };

        for (const queryId of queryIds) {
            try {
                const url = `https://x.com/i/api/graphql/${queryId}/TweetResultByRestId?variables=${encodeURIComponent(JSON.stringify(variables))}&features=${encodeURIComponent(JSON.stringify(features))}&fieldToggles=${encodeURIComponent(JSON.stringify(fieldToggles))}`;

                const result = await new Promise((resolve, reject) => {
                    GM_xmlhttpRequest({
                        method: 'GET',
                        url: url,
                        headers: createTwitterHeaders(),
                        responseType: 'json',
                        onload: (res) => {
                            if (res.status >= 200 && res.status < 300 && res.response?.data) {
                                resolve(res.response);
                            } else {
                                reject(new Error(`Status ${res.status}`));
                            }
                        },
                        onerror: reject
                    });
                });

                return result;
            } catch(e) {
                console.log(`Query ${queryId} failed:`, e.message);
                continue;
            }
        }

        throw new Error('All GraphQL queries failed');
    }

    async function fetchTweetDataLegacy(tweetId) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'GET',
                url: `https://api.twitter.com/1.1/statuses/show.json?id=${tweetId}&tweet_mode=extended&include_entities=true`,
                headers: createTwitterHeaders(),
                responseType: 'json',
                onload: (res) => {
                    if (res.status >= 200 && res.status < 300 && res.response) {
                        resolve({ legacy: res.response });
                    } else {
                        reject(new Error(`Legacy API: ${res.status}`));
                    }
                },
                onerror: reject
            });
        });
    }

    function extractVideoUrlFromTweet(tweetData) {
        try {
            let result = tweetData?.data?.tweetResult?.result;
            if (!result) {
                result = tweetData;
            }

            const tweet = result?.tweet || result;
            const legacy = tweet?.legacy || tweet;
            const extendedEntities = legacy?.extended_entities || tweet?.extended_entities;

            if (!extendedEntities?.media) return null;

            const videos = [];
            for (const media of extendedEntities.media) {
                if (media.type === 'video' || media.type === 'animated_gif') {
                    const variants = media.video_info?.variants || [];
                    const mp4Variants = variants.filter(v => v.content_type === 'video/mp4');
                    if (mp4Variants.length > 0) {
                        const best = mp4Variants.reduce((a, b) => (a.bitrate || 0) > (b.bitrate || 0) ? a : b);
                        videos.push({
                            url: best.url,
                            type: media.type === 'animated_gif' ? 'gif' : 'video',
                            width: media.original_info?.width || 0,
                            height: media.original_info?.height || 0,
                            duration: media.video_info?.duration_millis ? media.video_info.duration_millis / 1000 : 0
                        });
                    }
                }
            }
            return videos.length > 0 ? videos : null;
        } catch (e) {
            console.error('Error extracting video URL:', e);
            return null;
        }
    }

    function getTweetIdFromElement(tweet) {
        const links = tweet.querySelectorAll('a[href*="/status/"]');
        for (const link of links) {
            const match = link.href.match(/\/status\/(\d+)/);
            if (match) return match[1];
        }

        const timeLink = tweet.querySelector('time')?.parentElement;
        if (timeLink?.href) {
            const match = timeLink.href.match(/\/status\/(\d+)/);
            if (match) return match[1];
        }

        const article = tweet.closest('article');
        if (article) {
            const allLinks = article.querySelectorAll('a');
            for (const link of allLinks) {
                const match = link.href?.match(/\/status\/(\d+)/);
                if (match) return match[1];
            }
        }

        return null;
    }

    function getUserIdFromTweet(tweet) {
        const userLink = tweet.querySelector('a[href^="/"][role="link"] span');
        if (userLink) {
            const text = userLink.textContent;
            if (text?.startsWith('@')) return text.substring(1);
        }

        const links = tweet.querySelectorAll('a[href^="/"]');
        for (const link of links) {
            const match = link.href.match(/^https?:\/\/[^\/]+\/([^\/\?]+)$/);
            if (match && !['home', 'explore', 'notifications', 'messages', 'i', 'settings'].includes(match[1])) {
                return match[1];
            }
        }

        return 'video';
    }

    // ==========================================
    // DETECTION LOGIC (ALL SITES)
    // ==========================================
    function scanDOM() {
        const elements = document.querySelectorAll('video, audio, source');
        elements.forEach(el => {
            const src = el.src || el.currentSrc;
            if (src && !src.startsWith('blob:') && !src.startsWith('data:')) {
                let width = 0, height = 0, duration = 0;
                if (el.tagName === 'VIDEO') {
                    width = el.videoWidth; height = el.videoHeight; duration = el.duration;
                } else if (el.tagName === 'SOURCE' && el.parentElement?.tagName === 'VIDEO') {
                    width = el.parentElement.videoWidth; height = el.parentElement.videoHeight; duration = el.parentElement.duration;
                }
                registerVideo({ type: 'direct', url: src, width, height, duration, source: 'DOM' });
            }
        });
    }

    function isM3U8(url) { return url && (url.includes('.m3u8') || url.includes('.m3u')); }
    function isVideoUrl(url) {
        if (!url || url.startsWith('blob:') || url.startsWith('data:')) return false;
        const clean = url.split('?')[0].toLowerCase();
        return VIDEO_EXT.some(ext => clean.endsWith(ext));
    }

    const originalFetch = window.fetch;
    window.fetch = async function(...args) {
        const url = typeof args[0] === 'string' ? args[0] : args[0]?.url;
        const response = await originalFetch.apply(this, args);
        if (url) {
            if (isM3U8(url)) {
                const clone = response.clone();
                clone.text().then(text => handleM3U8(url, text)).catch(() => {});
            } else if (isVideoUrl(url)) {
                registerVideo({ type: 'direct', url: url, source: 'Network' });
            }
        }
        return response;
    };

    const originalOpen = XMLHttpRequest.prototype.open;
    XMLHttpRequest.prototype.open = function(method, url) {
        this.addEventListener('load', function() {
            try {
                if (url && isVideoUrl(url)) registerVideo({ type: 'direct', url: url, source: 'XHR' });
                if (url && (!this.responseType || this.responseType === 'text')) {
                    if (isM3U8(url) || (this.responseText && this.responseText.trim().startsWith('#EXTM3U'))) {
                        handleM3U8(url, this.responseText);
                    }
                }
            } catch(e) {}
        });
        return originalOpen.apply(this, arguments);
    };

    // ==========================================
    // REGISTRY
    // ==========================================
    function registerVideo(data) {
        const fullUrl = resolveUrl(location.href, data.url);
        if (allDetectedVideos.has(fullUrl)) {
            const existing = allDetectedVideos.get(fullUrl);
            if (!existing.width && data.width) existing.width = data.width;
            if (!existing.height && data.height) existing.height = data.height;
            if (!existing.duration && data.duration) existing.duration = data.duration;
            return;
        }
        if (data.url.includes('preview') && data.url.includes('.jpg')) return;

        const videoObj = {
            url: fullUrl,
            type: data.type,
            filename: getFilenameFromUrl(fullUrl),
            width: data.width || 0,
            height: data.height || 0,
            duration: data.duration || 0,
            size: data.size || 0,
            timestamp: Date.now(),
            manifest: data.manifest || null
        };
        allDetectedVideos.set(fullUrl, videoObj);
        updateButtonState();
    }

    function handleM3U8(url, content) {
        const fullUrl = resolveUrl(location.href, url);
        if (detectedUrls.has(fullUrl)) return;
        detectedUrls.add(fullUrl);

        try {
            const parser = new m3u8Parser.Parser();
            parser.push(content);
            parser.end();
            const manifest = parser.manifest;

            if (manifest.playlists && manifest.playlists.length > 0) return;

            let duration = 0;
            if (manifest.segments) manifest.segments.forEach(s => duration += s.duration);

            registerVideo({
                type: 'm3u8',
                url: fullUrl,
                manifest: manifest,
                duration: duration,
                size: 0,
                source: 'M3U8'
            });
        } catch(e) {}
    }

    // ==========================================
    // CLEAN UP DEAD VIDEOS
    // ==========================================
    function cleanupDeadVideos() {
        const currentVideos = new Set();
        
        // Check which video/source elements still exist
        document.querySelectorAll('video, source').forEach(el => {
            const src = el.src || el.currentSrc;
            if (src) currentVideos.add(src);
        });

        // Remove videos that no longer exist in the DOM
        for (const [url, video] of allDetectedVideos.entries()) {
            if (video.type === 'direct') {
                const stillExists = currentVideos.has(url) || 
                                   document.querySelector(`video[src="${url}"], source[src="${url}"]`);
                if (!stillExists) {
                    allDetectedVideos.delete(url);
                }
            }
        }
    }

    // ==========================================
    // SORTING & UI
    // ==========================================
    function getSortedVideos() {
        // Clean up before showing menu
        cleanupDeadVideos();
        
        return Array.from(allDetectedVideos.values()).sort((a, b) => {
            if (a.size > 0 || b.size > 0) return b.size - a.size;
            const resA = (a.width || 0) * (a.height || 0);
            const resB = (b.width || 0) * (b.height || 0);
            if (resB !== resA) return resB - resA;
            const durA = a.duration || 0;
            const durB = b.duration || 0;
            if (durB !== durA) return durB - durA;
            if (a.type === 'm3u8' && b.type !== 'm3u8') return -1;
            if (b.type === 'm3u8' && a.type !== 'm3u8') return 1;
            return b.timestamp - a.timestamp;
        });
    }

    function createUI() {
        if (floatingButton) return;

        hiddenToggle = document.createElement('div');
        hiddenToggle.id = 'uvs-hidden-toggle';
        hiddenToggle.title = 'Show Video Downloader (Alt+Shift+V)';
        if (!isHidden) hiddenToggle.style.display = 'none';
        hiddenToggle.onclick = () => toggleStealthMode(false);
        document.body.appendChild(hiddenToggle);

        const container = document.createElement('div');
        container.id = 'uvs-container';
        if (isHidden) container.style.display = 'none';

        const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
        svg.setAttribute('width', '46'); svg.setAttribute('height', '46');
        svg.id = 'uvs-svg';

        const track = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
        track.setAttribute('cx', '23'); track.setAttribute('cy', '23');
        track.setAttribute('r', '21'); track.setAttribute('fill', 'none');
        track.setAttribute('stroke', THEME.success);
        track.setAttribute('stroke-width', '2');
        track.setAttribute('stroke-opacity', '0.25');

        const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
        circle.setAttribute('cx', '23'); circle.setAttribute('cy', '23');
        circle.setAttribute('r', '21'); circle.setAttribute('fill', 'none');
        circle.setAttribute('stroke', THEME.success);
        circle.setAttribute('stroke-width', '2');
        circle.setAttribute('stroke-dasharray', '132');
        circle.setAttribute('stroke-dashoffset', '132');
        circle.setAttribute('stroke-linecap', 'round');

        svg.appendChild(track);
        svg.appendChild(circle);
        container.appendChild(svg);

        const btn = document.createElement('div');
        btn.id = 'uvs-float';
        btn.innerHTML = ICONS.down;
        btn.progressCircle = circle;

        let pressInterval;
        const startPress = (e) => {
            if (e.button !== 0 && e.type !== 'touchstart') return;
            pressStartTime = Date.now();
            pressInterval = setInterval(() => {
                const duration = Date.now() - pressStartTime;
                if (duration > 5000) btn.innerHTML = ICONS.eye;
            }, 100);
        };

        const endPress = (e) => {
            clearInterval(pressInterval);
            const duration = Date.now() - pressStartTime;
            updateButtonState();
            if (duration > 5000) toggleStealthMode(true);
            else if (duration < 500) handleClick();
        };

        btn.addEventListener('mousedown', startPress);
        btn.addEventListener('mouseup', endPress);
        btn.addEventListener('touchstart', startPress);
        btn.addEventListener('touchend', endPress);

        container.appendChild(btn);
        document.body.appendChild(container);
        floatingButton = btn;
    }

    function toggleStealthMode(hide) {
        if (hide === undefined) hide = !isHidden;
        isHidden = hide;
        GM_setValue('uvs_hidden', isHidden);
        const container = document.getElementById('uvs-container');
        if (hiddenToggle) hiddenToggle.style.display = isHidden ? 'block' : 'none';
        if (container) container.style.display = isHidden ? 'none' : 'block';
        if (!isHidden) notify('Restored');
    }

    window.addEventListener('keydown', (e) => {
        if (e.altKey && e.shiftKey && (e.key === 'V' || e.key === 'v')) toggleStealthMode();
    });

    function handleClick() {
        const videos = getSortedVideos();
        if (videos.length === 0) notify(ICONS.cross + ' No videos', 'error');
        else if (videos.length === 1) processVideo(videos[0]);
        else showPopup(videos);
    }

    function updateButtonState() {
        if (allDetectedVideos.size > 0 && !floatingButton) createUI();
        if (floatingButton) floatingButton.innerHTML = allDetectedVideos.size > 1 ? ICONS.menu : ICONS.down;
    }

    function updateProgress(percent) {
        if (!floatingButton) return;
        floatingButton.progressCircle.setAttribute('stroke-dashoffset', 132 - (132 * percent / 100));
    }

    function notify(msg, type = 'info') {
        if (isHidden) return;
        const div = document.createElement('div');
        div.className = 'uvs-notification';
        let icon = ICONS.info;
        if (type === 'success') { div.style.borderColor = THEME.success; div.style.color = THEME.success; icon = ICONS.check; }
        if (type === 'error') { div.style.borderColor = THEME.error; div.style.color = THEME.error; icon = ICONS.cross; }

        div.innerHTML = `<span style="font-size:16px">${icon}</span> <span>${msg}</span>`;
        document.body.appendChild(div);
        setTimeout(() => div.remove(), 3500);
    }

    function showPopup(videos) {
        document.getElementById('uvs-overlay')?.remove();

        const overlay = document.createElement('div');
        overlay.id = 'uvs-overlay';

        const modal = document.createElement('div');
        modal.id = 'uvs-modal';

        const header = document.createElement('div');
        header.className = 'uvs-header';
        header.innerHTML = `
            <span class="uvs-title">Media Selection</span>
            <button class="uvs-close">${ICONS.cross}</button>
        `;
        header.querySelector('.uvs-close').onclick = () => overlay.remove();
        modal.appendChild(header);

        const list = document.createElement('div');
        list.className = 'uvs-list';

        videos.forEach(v => {
            const item = document.createElement('div');
            item.className = 'uvs-item';

            let badges = '';
            if (v.type === 'm3u8') badges += `<span class="uvs-badge fmt">STREAM</span>`;
            else badges += `<span class="uvs-badge fmt">MP4</span>`;

            if (v.width && v.height) {
                const isHD = v.width >= 1280 || v.height >= 720;
                badges += `<span class="uvs-badge ${isHD ? 'hd' : ''}">${v.width}x${v.height}</span>`;
            }
            if (v.duration) badges += `<span class="uvs-badge">${formatDuration(v.duration)}</span>`;
            if (v.size) badges += `<span class="uvs-badge size">${formatBytes(v.size)}</span>`;

            item.innerHTML = `
                <div class="uvs-info">
                    <div class="uvs-filename" title="${v.filename}">${v.filename}</div>
                    <div class="uvs-meta">${badges}</div>
                </div>
                <div class="uvs-action">${ICONS.down}</div>
            `;
            item.onclick = () => { overlay.remove(); processVideo(v); };
            list.appendChild(item);
        });

        modal.appendChild(list);
        overlay.appendChild(modal);
        document.body.appendChild(overlay);

        overlay.onclick = (e) => { if(e.target === overlay) overlay.remove(); };
    }

    // ==========================================
    // DOWNLOAD LOGIC
    // ==========================================
    let ffmpegInstance = null;
    let ffmpegLoaded = false;
    let wasmBinaryCache = null;

    async function initFFmpeg() {
        if (ffmpegLoaded && ffmpegInstance) return ffmpegInstance;
        notify(ICONS.reload + ' Loading Engine...');
        try {
            if (!wasmBinaryCache) {
                const wasmURL = GM_getResourceURL('wasmURL', false);
                const resp = await fetch(wasmURL);
                wasmBinaryCache = await resp.arrayBuffer();
            }
            ffmpegInstance = new window.FFmpegWASM.FFmpeg();
            ffmpegInstance.on('progress', ({ progress }) => updateProgress(Math.round(progress * 100)));
            await ffmpegInstance.load({
                classWorkerURL: GM_getResourceURL('classWorkerURL', false),
                coreURL: GM_getResourceURL('coreURL', false),
                wasmBinary: wasmBinaryCache,
            });
            ffmpegLoaded = true;
            notify('Engine Ready', 'success');
            return ffmpegInstance;
        } catch(e) { notify('Engine Failed', 'error'); throw e; }
    }

    async function convertToMP4(tsBlob, filename) {
        const ffmpeg = await initFFmpeg();
        const inputName = 'input.ts';
        const outputName = filename.endsWith('.mp4') ? filename : filename + '.mp4';
        notify(ICONS.reload + ' Converting...');
        try {
            await ffmpeg.writeFile(inputName, new Uint8Array(await tsBlob.arrayBuffer()));
            await ffmpeg.exec(['-i', inputName, '-c', 'copy', '-movflags', 'faststart', outputName]);
            const data = await ffmpeg.readFile(outputName);
            await ffmpeg.deleteFile(inputName);
            await ffmpeg.deleteFile(outputName);
            return new Blob([data.buffer], { type: 'video/mp4' });
        } catch(e) { notify('Conversion Failed', 'error'); throw e; }
    }

    async function processVideo(video, customName = null) {
        const filename = customName || sanitizeFilename(document.title);
        if (video.type === 'direct') {
            handleFinalOutput(null, video.url, filename);
        } else {
            downloadM3U8(video, filename);
        }
    }

    async function downloadM3U8(video, filename) {
        if (downloadedBlobs.has(video.url)) return handleFinalOutput(downloadedBlobs.get(video.url), null, filename);

        notify(ICONS.reload + ' Downloading...');
        updateProgress(0);

        try {
            const segments = video.manifest.segments;
            const baseUrl = video.url;
            const results = new Array(segments.length);
            let completed = 0;
            let currentIndex = 0;
            let hasError = false;

            const worker = async () => {
                while (currentIndex < segments.length && !hasError) {
                    const i = currentIndex++;
                    const segUrl = resolveUrl(baseUrl, segments[i].uri);
                    let attempts = 0;
                    let success = false;
                    while(attempts < MAX_RETRIES && !success) {
                        try {
                            const res = await fetch(segUrl);
                            if (!res.ok) throw new Error(`Status ${res.status}`);
                            results[i] = await res.arrayBuffer();
                            success = true;
                        } catch(e) {
                            attempts++;
                            if (attempts === MAX_RETRIES) { hasError = true; throw e; }
                            await new Promise(r => setTimeout(r, 1000));
                        }
                    }
                    completed++;
                    updateProgress(Math.round((completed / segments.length) * 100));
                }
            };

            const workers = [];
            for (let k = 0; k < CONCURRENCY; k++) workers.push(worker());
            await Promise.all(workers);

            if (hasError) throw new Error("Network errors");

            notify(ICONS.reload + ' Stitching...');
            const mergedBlob = new Blob(results, { type: 'video/mp2t' });
            const mp4Blob = await convertToMP4(mergedBlob, filename);
            downloadedBlobs.set(video.url, mp4Blob);

            handleFinalOutput(mp4Blob, null, filename);
            updateProgress(0);
            notify('Complete', 'success');
        } catch(e) {
            notify('Error: ' + e.message, 'error');
            updateProgress(0);
        }
    }

    function handleFinalOutput(blob, url, filename) {
        const finalName = filename.endsWith('.mp4') ? filename : filename + '.mp4';

        const modes = isMobile
            ? ['share', 'copy', 'download']
            : ['copy', 'share', 'download'];

        const currentMode = modes[actionCycleIndex % 3];
        actionCycleIndex++;

        if (currentMode === 'share') {
            if (!navigator.share) {
                notify('Share not supported', 'error');
                return;
            }

            const shareData = { title: finalName };
            if (blob && navigator.canShare && navigator.canShare({ files: [new File([blob], finalName, { type: 'video/mp4' })] })) {
                shareData.files = [new File([blob], finalName, { type: 'video/mp4' })];
            } else {
                shareData.text = url || "Video File";
            }

            const startTime = Date.now();
            navigator.share(shareData)
                .then(() => notify('Shared', 'success'))
                .catch((err) => {
                    const elapsed = Date.now() - startTime;
                    if (elapsed < 300) {
                        notify('Share Error', 'error');
                    }
                });
            return;
        }

        if (currentMode === 'copy') {
            const textToCopy = url || "Video Blob (Cannot copy blob URL)";
            navigator.clipboard.writeText(textToCopy)
                .then(() => notify('Link Copied', 'success'))
                .catch(() => notify('Copy Failed', 'error'));
            return;
        }

        if (currentMode === 'download') {
            try {
                const downloadUrl = blob ? URL.createObjectURL(blob) : url;
                const a = document.createElement('a');
                a.href = downloadUrl;
                a.download = finalName;
                document.body.appendChild(a);
                a.click();
                setTimeout(() => {
                    document.body.removeChild(a);
                    if(blob) URL.revokeObjectURL(downloadUrl);
                }, 1000);
                notify('Saved to Disk', 'success');
            } catch(e) {
                notify('Download Error', 'error');
            }
            return;
        }
    }

    // ==========================================
    // TWITTER DOWNLOAD FUNCTION
    // ==========================================
    async function downloadTwitterVideo(btn, tweetId, userId) {
        btn.classList.add('loading');
        btn.innerHTML = `<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2" opacity="0.3"/><path d="M12 2a10 10 0 0 1 10 10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>`;

        try {
            let videos = null;

            if (twitterVideoCache.has(tweetId)) {
                console.log('Using cached video URL');
                videos = twitterVideoCache.get(tweetId);
            }

            if (!videos) {
                try {
                    console.log('Trying GraphQL API...');
                    const tweetData = await fetchTweetDataGraphQL(tweetId);
                    videos = extractVideoUrlFromTweet(tweetData);
                } catch(e) {
                    console.log('GraphQL failed:', e.message);
                }
            }

            if (!videos) {
                try {
                    console.log('Trying Legacy API...');
                    const legacyData = await fetchTweetDataLegacy(tweetId);
                    videos = extractVideoUrlFromTweet(legacyData);
                } catch(e) {
                    console.log('Legacy API failed:', e.message);
                }
            }

            if (!videos) {
                console.log('Trying page scrape...');
                videos = findVideoInPage(tweetId);
            }

            if (!videos || videos.length === 0) {
                throw new Error('No video found');
            }

            const video = videos[0];
            const filename = `${userId}_${tweetId}.mp4`;

            let videoUrl = video.url;
            if (videoUrl.includes('?')) {
                const urlObj = new URL(videoUrl);
                videoUrl = urlObj.origin + urlObj.pathname;
            }

            const response = await fetch(videoUrl);
            if (!response.ok) throw new Error('Network response was not ok');
            const blob = await response.blob();

            handleFinalOutput(blob, videoUrl, filename);

            btn.classList.remove('loading');
            btn.classList.add('success');
            btn.innerHTML = `<svg viewBox="0 0 24 24"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/></svg>`;
            setTimeout(() => {
                btn.classList.remove('success');
                btn.innerHTML = `<svg viewBox="0 0 24 24"><path d="M12 16l5.7-5.7-1.41-1.42L13 12.17V4h-2v8.17L7.71 8.88 6.3 10.3 12 16zm9-1l-.02 3.51c0 1.38-1.12 2.49-2.5 2.49H5.5C4.11 21 3 19.88 3 18.5V15h2v3.5c0 .28.22.5.5.5h12.98c.28 0 .5-.22.5-.5L19 15h2z"/></svg>`;
            }, 2000);

        } catch (e) {
            console.error('Twitter download error:', e);
            btn.classList.remove('loading');
            btn.classList.add('error');
            btn.innerHTML = `<svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/></svg>`;
            setTimeout(() => {
                btn.classList.remove('error');
                btn.innerHTML = `<svg viewBox="0 0 24 24"><path d="M12 16l5.7-5.7-1.41-1.42L13 12.17V4h-2v8.17L7.71 8.88 6.3 10.3 12 16zm9-1l-.02 3.51c0 1.38-1.12 2.49-2.5 2.49H5.5C4.11 21 3 19.88 3 18.5V15h2v3.5c0 .28.22.5.5.5h12.98c.28 0 .5-.22.5-.5L19 15h2z"/></svg>`;
            }, 2000);
        }
    }

    function findVideoInPage(tweetId) {
        const videos = document.querySelectorAll('video');
        for (const video of videos) {
            const src = video.src || video.currentSrc;
            if (src && src.includes('video.twimg.com') && src.includes('.mp4')) {
                const tweet = video.closest('article[data-testid="tweet"]');
                if (tweet) {
                    const id = getTweetIdFromElement(tweet);
                    if (id === tweetId) {
                        return [{ url: src, type: 'video' }];
                    }
                }
            }
        }

        const sources = document.querySelectorAll('source');
        for (const source of sources) {
            const src = source.src;
            if (src && src.includes('video.twimg.com')) {
                const tweet = source.closest('article[data-testid="tweet"]');
                if (tweet) {
                    const id = getTweetIdFromElement(tweet);
                    if (id === tweetId) {
                        return [{ url: src, type: 'video' }];
                    }
                }
            }
        }

        return null;
    }

    // ==========================================
    // TWITTER BUTTON INJECTION
    // ==========================================
    function hasVideoContent(tweet) {
        if (tweet.querySelector('div[data-testid="videoPlayer"]')) return true;
        if (tweet.querySelector('video')) return true;
        const images = tweet.querySelectorAll('img[src*="tweet_video_thumb"]');
        if (images.length > 0) return true;
        const videoThumbs = tweet.querySelectorAll('img[src*="ext_tw_video_thumb"], img[src*="amplify_video_thumb"]');
        if (videoThumbs.length > 0) return true;
        return false;
    }

    function addTwitterDownloadButtons() {
        const tweets = document.querySelectorAll('article[data-testid="tweet"]');

        tweets.forEach(tweet => {
            const actionGroup = tweet.querySelector('div[role="group"]');
            if (!actionGroup || actionGroup.querySelector('.uvs-tw-btn')) return;

            if (!hasVideoContent(tweet)) return;

            const tweetId = getTweetIdFromElement(tweet);
            const userId = getUserIdFromTweet(tweet);

            if (!tweetId) return;

            const btn = document.createElement('button');
            btn.className = 'uvs-tw-btn';
            btn.title = 'Download Video';
            btn.innerHTML = `<svg viewBox="0 0 24 24"><path d="M12 16l5.7-5.7-1.41-1.42L13 12.17V4h-2v8.17L7.71 8.88 6.3 10.3 12 16zm9-1l-.02 3.51c0 1.38-1.12 2.49-2.5 2.49H5.5C4.11 21 3 19.88 3 18.5V15h2v3.5c0 .28.22.5.5.5h12.98c.28 0 .5-.22.5-.5L19 15h2z"/></svg>`;

            btn.onclick = (e) => {
                e.preventDefault();
                e.stopPropagation();
                if (!btn.classList.contains('loading')) {
                    downloadTwitterVideo(btn, tweetId, userId);
                }
            };

            actionGroup.appendChild(btn);
        });
    }

    // ==========================================
    // INIT
    // ==========================================
    setInterval(scanDOM, 2000);
    const obs = new MutationObserver(scanDOM);
    
    const startObserver = () => {
        if (document.body) {
            obs.observe(document.body, { childList: true, subtree: true });
            scanDOM();
        } else {
            setTimeout(startObserver, 100);
        }
    };

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', startObserver);
    } else {
        startObserver();
    }

    if (isTwitter) {
        const twitterObserver = new MutationObserver(() => {
            addTwitterDownloadButtons();
        });

        const startTwitterObserver = () => {
            if (document.body) {
                twitterObserver.observe(document.body, { childList: true, subtree: true });
                addTwitterDownloadButtons();
            } else {
                setTimeout(startTwitterObserver, 100);
            }
        };

        if (document.readyState === 'loading') {
            document.addEventListener('DOMContentLoaded', startTwitterObserver);
        } else {
            startTwitterObserver();
        }
    }

})();