Universal Video Downloader

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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();
        }
    }

})();