Greasy Fork

Greasy Fork is available in English.

Universal Video Downloader (X/Twitter Support + M3U8)

Universal video downloader with special X/Twitter support. Downloads M3U8, MP4, and intercepts network streams.

当前为 2025-12-17 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Universal Video Downloader (X/Twitter Support + M3U8)
// @namespace    http://tampermonkey.net/
// @version      7.1
// @description  Universal video downloader with special X/Twitter support. Downloads M3U8, MP4, and intercepts network streams.
// @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_xmlhttpRequest
// ==/UserScript==

(function() {
    'use strict';

    // --- Configuration & State ---
    var floatingButton = null;
    var pressTimer = null;
    var isLongPress = false;
    var detectedM3U8s = []; // Array of objects
    var detectedM3U8Urls = []; // Array of strings (URLs)
    var allDetectedVideos = new Map(); // Key: URL, Value: Object
    var downloadedBlobs = new Map();
    var debugMode = false;
    var debugConsole = null;
    var debugLogs = [];
    var longPressStartTime = 0;
    var ffmpegInstance = null;
    var ffmpegLoaded = false;
    var wasmBinaryCache = null;

    // Detect Platform
    const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
    const isTwitter = window.location.hostname.includes('twitter.com') || window.location.hostname.includes('x.com');

    // Color scheme
    const COLORS = {
        button: 'rgba(85, 66, 61, 0.7)',
        buttonHover: 'rgba(107, 86, 81, 0.85)',
        icon: '#ffc0ad',
        text: '#fff3ec',
        twitter: 'rgba(29, 161, 242, 1.0)'
    };

    // --- Styles ---
    GM_addStyle(`
        #universal-video-share-container {
            position: fixed !important; top: 15px !important; left: 15px !important;
            width: 50px !important; height: 50px !important; z-index: 2147483647 !important;
            pointer-events: auto !important; isolation: isolate !important;
        }
        #universal-video-share-float {
            position: absolute !important; top: 2px !important; left: 2px !important;
            width: 46px !important; height: 46px !important;
            background: ${COLORS.button} !important;
            backdrop-filter: blur(12px) !important; -webkit-backdrop-filter: blur(12px) !important;
            color: ${COLORS.icon} !important; border: 2px solid rgba(255, 255, 255, 0.3) !important;
            border-radius: 50% !important; font-size: 18px !important; cursor: pointer !important;
            display: flex !important; align-items: center !important; justify-content: center !important;
            transition: all 0.2s ease !important; user-select: none !important;
            box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4) !important; z-index: 2147483647 !important;
        }
        #universal-video-share-float:hover { transform: scale(1.1) !important; }
        #progress-circle { pointer-events: none !important; }
        .universal-video-notification { z-index: 2147483646 !important; }
        #video-selector-popup { z-index: 2147483645 !important; }
        
        /* Twitter Button */
        .tmd-down {
            margin-left: 2px !important; order: 99; display: flex; align-items: center; justify-content: center;
            width: 34.75px; height: 34.75px; border-radius: 9999px; transition: background-color 0.2s; cursor: pointer;
        }
        .tmd-down:hover { background-color: rgba(29, 161, 242, 0.1); }
        .tmd-down svg { color: rgb(113, 118, 123); width: 20px; height: 20px; }
        .tmd-down:hover svg { color: ${COLORS.twitter}; }
        .tmd-down.downloading svg { animation: spin 1s linear infinite; color: ${COLORS.twitter}; }
        @keyframes spin { 0% {transform: rotate(0deg);} 100% {transform: rotate(360deg);} }
    `);

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

    function getFilenameFromPageTitle(extension = 'mp4') {
        const pageTitle = document.title || 'video';
        return sanitizeFilename(pageTitle) + '.' + extension;
    }

    function isElementVisible(el) {
        if (!el) return false;
        const rect = el.getBoundingClientRect();
        return (
            rect.top >= 0 && rect.left >= 0 &&
            rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
            rect.right <= (window.innerWidth || document.documentElement.clientWidth) &&
            rect.width > 0 && rect.height > 0
        );
    }

    // --- FFmpeg Logic ---
    const getWasmBinary = async () => {
        if (wasmBinaryCache) return wasmBinaryCache.slice(0);
        
        // Robust resource loading
        let wasmURL = '';
        try {
            wasmURL = GM_getResourceURL('wasmURL');
        } catch(e) { debugLog('GM_getResourceURL failed, using fallback'); }

        // Fallback if GM_getResourceURL returns undefined or fails
        if (!wasmURL) {
            wasmURL = 'https://cdn.jsdelivr.net/npm/@ffmpeg/[email protected]/dist/umd/ffmpeg-core.wasm';
        }

        debugLog('[FFMPEG] Loading WASM from: ' + wasmURL.substring(0, 30) + '...');

        let wasmBinary = null;
        if (wasmURL.startsWith('data:')) {
            const base64 = wasmURL.substring(wasmURL.indexOf(',') + 1);
            const binaryString = atob(base64);
            const bytes = new Uint8Array(binaryString.length);
            for (let i = 0; i < binaryString.length; i++) bytes[i] = binaryString.charCodeAt(i);
            wasmBinary = bytes.buffer;
        } else {
            // Fetch blob or http url
            const res = await fetch(wasmURL);
            wasmBinary = await res.arrayBuffer();
        }
        
        if (wasmBinary) wasmBinaryCache = wasmBinary.slice(0);
        return wasmBinary;
    };

    async function initFFmpeg() {
        if (ffmpegLoaded && ffmpegInstance) return ffmpegInstance;
        debugLog('[FFMPEG] Initializing...');
        showNotification('⚙️ Loading FFmpeg...', 'info');
        
        try {
            ffmpegInstance = new window.FFmpegWASM.FFmpeg();
            ffmpegInstance.on('log', ({ message }) => debugLog('[FFMPEG LOG] ' + message));
            ffmpegInstance.on('progress', ({ progress }) => updateProgress(Math.round(progress * 100)));
            
            // Get URLs with fallbacks
            const classWorkerURL = GM_getResourceURL('classWorkerURL') || 'https://cdn.jsdelivr.net/npm/@warren-bank/[email protected]/dist/umd/258.ffmpeg.js';
            const coreURL = GM_getResourceURL('coreURL') || 'https://cdn.jsdelivr.net/npm/@ffmpeg/[email protected]/dist/umd/ffmpeg-core.js';

            await ffmpegInstance.load({
                classWorkerURL: classWorkerURL,
                coreURL: coreURL,
                wasmBinary: await getWasmBinary()
            });
            ffmpegLoaded = true;
            showNotification('✅ FFmpeg loaded', 'success');
            return ffmpegInstance;
        } catch(e) {
            showNotification('❌ FFmpeg failed: ' + e.message, 'error');
            throw e;
        }
    }

    async function convertTStoMP4(tsBlob, baseFilename) {
        try {
            const ffmpeg = await initFFmpeg();
            const inputName = 'input.ts';
            const outputName = baseFilename.endsWith('.mp4') ? baseFilename : baseFilename + '.mp4';
            
            await ffmpeg.writeFile(inputName, new Uint8Array(await tsBlob.arrayBuffer()));
            showNotification('🔄 Converting...', 'info');
            await ffmpeg.exec(['-i', inputName, '-c', 'copy', '-movflags', 'faststart', outputName]);
            
            const data = await ffmpeg.readFile(outputName, 'binary');
            await ffmpeg.deleteFile(inputName);
            await ffmpeg.deleteFile(outputName);
            
            return { blob: new Blob([data.buffer], { type: 'video/mp4' }), filename: outputName };
        } catch(e) {
            debugLog('[ERROR] Convert failed: ' + e.message);
            throw e;
        }
    }

    // --- Network Sniffing ---
    (function setupNetworkDetection() {
        const handleUrl = (url) => {
            if (!url) return;
            if (url.includes('.m3u8') || url.includes('.m3u')) detectM3U8(url);
            else if (url.match(/\.(mp4|webm|mov)(\?|$)/i)) checkUrlForVideo(url);
        };

        const originalFetch = window.fetch;
        window.fetch = function(...args) {
            const url = typeof args[0] === 'string' ? args[0] : args[0]?.url;
            handleUrl(url);
            return originalFetch.apply(this, args).then(res => {
                // Clone response to check for M3U8 content type or text
                res.clone().text().then(t => { if(t && t.startsWith("#EXTM3U")) detectM3U8(url); }).catch(()=>{});
                return res;
            });
        };

        const originalOpen = XMLHttpRequest.prototype.open;
        XMLHttpRequest.prototype.open = function(...args) {
            this.addEventListener("load", () => {
                handleUrl(args[1]);
                if (this.responseText && this.responseText.trim().startsWith("#EXTM3U")) detectM3U8(args[1]);
            });
            return originalOpen.apply(this, args);
        };
    })();

    function checkUrlForVideo(url) {
        if (url.startsWith('blob:') || url.includes('.m3u8')) return;
        const fullUrl = new URL(url, location.href).href;
        if (!allDetectedVideos.has(fullUrl)) {
            allDetectedVideos.set(fullUrl, {
                url: fullUrl,
                type: 'video',
                timestamp: Date.now(),
                title: 'Video File',
                isOnScreen: false
            });
            checkForVideos();
        }
    }

    async function detectM3U8(url) {
        try {
            if (url.startsWith('blob:')) return;
            url = new URL(url, location.href).href;
            
            // Avoid duplicates but allow refreshing if it's been a while
            if (detectedM3U8Urls.includes(url)) return;
            
            detectedM3U8Urls.push(url);
            debugLog('[M3U8] Detected: ' + url);

            const response = await fetch(url);
            const content = await response.text();
            const parser = new m3u8Parser.Parser();
            parser.push(content);
            parser.end();

            // If Master Playlist, find best variant and recurse
            if (parser.manifest.playlists && parser.manifest.playlists.length > 0) {
                const best = parser.manifest.playlists.sort((a,b) => (b.attributes.BANDWIDTH || 0) - (a.attributes.BANDWIDTH || 0))[0];
                const nextUrl = new URL(best.uri, url).href;
                debugLog('[M3U8] Master playlist found, switching to variant: ' + nextUrl);
                detectM3U8(nextUrl);
                return;
            }

            const m3u8Data = {
                url: url,
                manifest: parser.manifest,
                title: 'Stream ' + (detectedM3U8s.length + 1),
                timestamp: Date.now()
            };
            
            detectedM3U8s.push(m3u8Data);
            allDetectedVideos.set(url, {
                url: url,
                type: 'm3u8',
                timestamp: Date.now(),
                title: m3u8Data.title,
                m3u8Data: m3u8Data,
                isOnScreen: false
            });
            checkForVideos();
        } catch(e) { debugLog('[M3U8] Error: ' + e.message); }
    }

    // --- Video Gathering Logic ---
    function getUniqueVideos() {
        const videos = [];
        const seenUrls = new Set();
        const domVideos = [];

        // 1. Scan DOM
        document.querySelectorAll('video, iframe').forEach(el => {
            let src = el.currentSrc || el.src;
            if (!src && el.tagName === 'VIDEO') {
                const source = el.querySelector('source');
                if (source) src = source.src;
            }
            if (src) {
                domVideos.push({
                    element: el,
                    src: src,
                    isBlob: src.startsWith('blob:'),
                    isVisible: isElementVisible(el)
                });
            }
        });

        // 2. Map Network Requests to DOM
        allDetectedVideos.forEach((data, url) => {
            data.isOnScreen = false;
            
            // Direct match
            const match = domVideos.find(v => v.src === url);
            if (match) {
                data.isOnScreen = match.isVisible;
            } 
            // Indirect match (M3U8 vs Blob)
            else if (data.type === 'm3u8') {
                const visibleBlob = domVideos.find(v => v.isBlob && v.isVisible);
                // If we have a visible blob video and this M3U8 was detected recently (last 15s), assume match
                if (visibleBlob && (Date.now() - data.timestamp < 15000)) {
                    data.isOnScreen = true;
                }
            }

            if (!seenUrls.has(url)) {
                seenUrls.add(url);
                videos.push(data);
            }
        });

        // 3. Add direct MP4s from DOM if missed
        domVideos.forEach(v => {
            if (!v.isBlob && !seenUrls.has(v.src)) {
                seenUrls.add(v.src);
                videos.push({
                    url: v.src,
                    type: 'video',
                    timestamp: Date.now(),
                    title: document.title,
                    isOnScreen: v.isVisible
                });
            }
        });

        return videos.sort((a, b) => {
            if (a.isOnScreen && !b.isOnScreen) return -1;
            if (!a.isOnScreen && b.isOnScreen) return 1;
            return b.timestamp - a.timestamp;
        });
    }

    // --- UI: Floating Button ---
    function createFloatingButton() {
        if (floatingButton) return floatingButton;
        const container = document.createElement('div');
        container.id = 'universal-video-share-container';
        container.innerHTML = `
            <svg width="50" height="50" style="position:absolute;top:0;left:0;transform:rotate(-90deg);pointer-events:none;">
                <circle id="progress-circle" cx="25" cy="25" r="22" fill="none" stroke="#4ade80" stroke-width="3" stroke-dasharray="138" stroke-dashoffset="138" stroke-linecap="round" style="transition: stroke-dashoffset 0.3s ease;"></circle>
            </svg>
            <div id="universal-video-share-float">▶</div>
        `;
        
        floatingButton = container.querySelector('#universal-video-share-float');
        floatingButton.progressCircle = container.querySelector('#progress-circle');

        const handlePress = (e) => {
            e.preventDefault();
            isLongPress = false;
            longPressStartTime = Date.now();
            pressTimer = setTimeout(() => {
                isLongPress = true;
                floatingButton.innerHTML = '🐛';
                floatingButton.style.background = 'rgba(239, 68, 68, 0.8)';
                debugMode = true;
                debugLog('[DEBUG] Enabled');
            }, 3000);
        };

        const handleRelease = (e) => {
            e.preventDefault();
            clearTimeout(pressTimer);
            floatingButton.style.background = COLORS.button;
            if (Date.now() - longPressStartTime >= 3000) createDebugConsole();
            else if (!isLongPress) handleShare();
        };

        floatingButton.addEventListener('mousedown', handlePress);
        floatingButton.addEventListener('touchstart', handlePress);
        floatingButton.addEventListener('mouseup', handleRelease);
        floatingButton.addEventListener('touchend', handleRelease);

        document.body.appendChild(container);
        return floatingButton;
    }

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

    // --- UI: Video Selector ---
    function showVideoSelector(videos) {
        document.getElementById('video-selector-popup')?.remove();
        const popup = document.createElement('div');
        popup.id = 'video-selector-popup';
        popup.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.7);backdrop-filter:blur(5px);display:flex;justify-content:center;align-items:center;padding:20px;';
        
        const content = document.createElement('div');
        content.style.cssText = 'background:#1a1a1a;border:1px solid #333;border-radius:12px;padding:20px;max-width:500px;width:100%;max-height:80vh;overflow-y:auto;color:#fff;';
        content.innerHTML = `<h3 style="margin:0 0 15px;">Select Video (${videos.length})</h3>`;

        const createList = (list, label) => {
            if (list.length === 0) return;
            const title = document.createElement('div');
            title.textContent = label;
            title.style.cssText = 'font-size:12px;text-transform:uppercase;color:#888;margin:10px 0 5px;font-weight:bold;';
            content.appendChild(title);

            list.forEach(v => {
                const item = document.createElement('div');
                item.style.cssText = 'padding:12px;background:#2a2a2a;margin-bottom:8px;border-radius:6px;cursor:pointer;border:1px solid transparent;transition:0.2s;';
                item.onmouseover = () => item.style.borderColor = '#4ade80';
                item.onmouseout = () => item.style.borderColor = 'transparent';
                item.innerHTML = `
                    <div style="display:flex;justify-content:space-between;margin-bottom:4px;">
                        <span style="background:${v.type==='m3u8'?'#ef4444':'#3b82f6'};padding:2px 6px;border-radius:4px;font-size:10px;font-weight:bold;">${v.type.toUpperCase()}</span>
                        <span style="font-size:11px;color:#888;">${new Date(v.timestamp).toLocaleTimeString()}</span>
                    </div>
                    <div style="font-size:13px;word-break:break-all;">${v.title || 'Unknown Video'}</div>
                    <div style="font-size:10px;color:#666;margin-top:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${v.url}</div>
                `;
                item.onclick = () => { popup.remove(); processVideoAction(v); };
                content.appendChild(item);
            });
        };

        createList(videos.filter(v => v.isOnScreen), '👁️ On Screen Now');
        createList(videos.filter(v => !v.isOnScreen), '📚 Other Detected Videos');

        const closeBtn = document.createElement('button');
        closeBtn.textContent = 'Close';
        closeBtn.style.cssText = 'width:100%;padding:10px;margin-top:10px;background:#333;border:none;color:#fff;border-radius:6px;cursor:pointer;';
        closeBtn.onclick = () => popup.remove();
        content.appendChild(closeBtn);

        popup.appendChild(content);
        document.body.appendChild(popup);
    }

    function handleShare() {
        const videos = getUniqueVideos();
        if (videos.length === 0) return showNotification('❌ No videos found', 'error');
        if (videos.length > 1 || isTwitter) showVideoSelector(videos);
        else processVideoAction(videos[0]);
    }

    function processVideoAction(videoData) {
        if (videoData.type === 'm3u8') {
            downloadM3U8(videoData);
        } else {
            // Check if it's a blob url
            if (videoData.url.startsWith('blob:')) {
                showNotification('❌ Cannot download blob URL directly. Please wait for M3U8 detection.', 'error');
                return;
            }
            shareOrDownloadBlob(null, getFilenameFromPageTitle('mp4'), videoData);
        }
    }

    // --- Download Logic ---
    async function downloadM3U8(videoData) {
        const cached = downloadedBlobs.get(videoData.url);
        if (cached) return shareOrDownloadBlob(cached.blob, cached.filename, videoData);

        updateProgress(0);
        showNotification('📥 Downloading Stream...', 'info');

        try {
            const manifest = videoData.m3u8Data.manifest;
            const baseUrl = videoData.url.substring(0, videoData.url.lastIndexOf('/') + 1);
            const segments = manifest.segments;

            if (!segments || !segments.length) throw new Error("No segments found");

            const chunks = [];
            let total = 0;

            for (let i = 0; i < segments.length; i++) {
                const uri = segments[i].uri.startsWith('http') ? segments[i].uri : baseUrl + segments[i].uri;
                const res = await fetch(uri);
                const buff = await res.arrayBuffer();
                chunks.push(buff);
                total += buff.byteLength;
                updateProgress(Math.floor(((i + 1) / segments.length) * 100));
            }

            showNotification('🔄 Processing...', 'info');
            const merged = new Blob(chunks, { type: 'video/mp2t' });
            const converted = await convertTStoMP4(merged, getFilenameFromPageTitle('mp4'));
            
            downloadedBlobs.set(videoData.url, converted);
            shareOrDownloadBlob(converted.blob, converted.filename, videoData);
        } catch(e) {
            showNotification('❌ Error: ' + e.message, 'error');
            updateProgress(0);
        }
    }

    async function shareOrDownloadBlob(blob, filename, videoData) {
        if (!blob && videoData.url) {
            try {
                showNotification('📥 Fetching video...', 'info');
                const res = await fetch(videoData.url);
                blob = await res.blob();
                filename = getFilenameFromPageTitle('mp4');
            } catch(e) {
                window.open(videoData.url, '_blank');
                return;
            }
        }

        if (navigator.share && navigator.canShare && navigator.canShare({ files: [new File([blob], filename, { type: 'video/mp4' })] })) {
            try {
                await navigator.share({ files: [new File([blob], filename, { type: 'video/mp4' })] });
                showNotification('✅ Shared', 'success');
            } catch(e) { triggerDownload(blob, filename); }
        } else {
            triggerDownload(blob, filename);
        }
        updateProgress(0);
    }

    function triggerDownload(blob, filename) {
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = filename;
        document.body.appendChild(a);
        a.click();
        setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(url); }, 1000);
        showNotification('✅ Downloaded', 'success');
    }

    function showNotification(msg, type) {
        const div = document.createElement('div');
        div.className = 'universal-video-notification';
        div.textContent = msg;
        div.style.cssText = `position:fixed;top:75px;left:15px;background:${type==='error'?'#ef4444':'#4ade80'};color:#fff;padding:10px;border-radius:8px;box-shadow:0 4px 12px rgba(0,0,0,0.3);font-weight:bold;z-index:999999;`;
        document.body.appendChild(div);
        setTimeout(() => div.remove(), 3000);
    }

    function checkForVideos() {
        const videos = getUniqueVideos();
        if (videos.length > 0) {
            const btn = createFloatingButton();
            const hasOnScreen = videos.some(v => v.isOnScreen);
            btn.innerHTML = hasOnScreen ? '👁️' : (videos.length > 1 ? '⇓' : '▶');
            btn.style.borderColor = hasOnScreen ? '#4ade80' : 'rgba(255,255,255,0.3)';
        } else if (floatingButton) {
            floatingButton.parentNode.remove();
            floatingButton = null;
        }
    }

    // --- X/Twitter Specific Module ---
    function initTwitterSupport() {
        if (!isTwitter) return;
        debugLog('[TWITTER] Initializing X/Twitter module');

        const downloadIconSVG = `<g><path d="M12 16 17.7 10.3 16.29 8.88 13 12.18 V2.59 h-2 v9.59 L7.7 8.88 6.29 10.3 Z M21 15l-.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" fill="currentColor"></path></g>`;

        const processTweet = (article) => {
            const group = article.querySelector('div[role="group"]');
            if (!group || group.querySelector('.tmd-down')) return;

            const btn = document.createElement('div');
            btn.className = 'tmd-down';
            btn.innerHTML = `<svg viewBox="0 0 24 24">${downloadIconSVG}</svg>`;
            btn.title = 'Download Media';
            
            const shareBtn = group.lastElementChild;
            if (shareBtn) group.insertBefore(btn, shareBtn);

            btn.onclick = async (e) => {
                e.stopPropagation();
                if (btn.classList.contains('downloading')) return;
                btn.classList.add('downloading');
                
                // Find media
                const videos = article.querySelectorAll('video');
                const images = article.querySelectorAll('img[src*="pbs.twimg.com/media"]');
                
                let targetData = null;

                if (videos.length > 0) {
                    // It's a video. Twitter uses blob URLs. We MUST find the matching M3U8.
                    // Strategy: Find the most recent M3U8 detected.
                    // Since the user just clicked, the M3U8 likely loaded recently or is playing.
                    const m3u8s = Array.from(allDetectedVideos.values())
                        .filter(v => v.type === 'm3u8')
                        .sort((a,b) => b.timestamp - a.timestamp);
                    
                    if (m3u8s.length > 0) {
                        // Pick the most recent one.
                        targetData = m3u8s[0];
                    } else {
                        showNotification('❌ Video stream not detected yet. Play the video first.', 'error');
                    }
                } else if (images.length > 0) {
                    const url = images[0].src.replace(/name=[^&]+/, 'name=orig');
                    targetData = { url: url, type: 'video', title: 'Twitter Image' };
                }

                if (targetData) {
                    processVideoAction(targetData);
                }

                setTimeout(() => btn.classList.remove('downloading'), 1000);
            };
        };

        const observer = new MutationObserver((mutations) => {
            for (const m of mutations) {
                m.addedNodes.forEach(node => {
                    if (node.nodeType === 1) {
                        if (node.matches('article[data-testid="tweet"]')) processTweet(node);
                        node.querySelectorAll?.('article[data-testid="tweet"]').forEach(processTweet);
                    }
                });
            }
        });
        observer.observe(document.body, { childList: true, subtree: true });
        document.querySelectorAll('article[data-testid="tweet"]').forEach(processTweet);
    }

    // --- Debug Console ---
    function debugLog(msg) {
        const log = `[${new Date().toLocaleTimeString()}] ${msg}`;
        debugLogs.push(log);
        if (debugMode && debugConsole) {
            const el = document.createElement('div');
            el.textContent = log;
            el.style.borderBottom = '1px solid #333';
            debugConsole.appendChild(el);
            debugConsole.scrollTop = debugConsole.scrollHeight;
        }
    }

    function createDebugConsole() {
        if (debugConsole) return;
        debugConsole = document.createElement('div');
        debugConsole.id = 'debug-console';
        debugConsole.style.cssText = 'position:fixed;bottom:10px;left:10px;right:10px;height:200px;background:rgba(0,0,0,0.9);color:#0f0;font-family:monospace;font-size:10px;overflow-y:auto;padding:10px;border:1px solid #0f0;';
        const close = document.createElement('button');
        close.textContent = 'CLOSE';
        close.style.cssText = 'position:sticky;top:0;float:right;background:red;color:white;border:none;cursor:pointer;';
        close.onclick = () => { debugConsole.remove(); debugConsole = null; debugMode = false; };
        debugConsole.appendChild(close);
        debugLogs.forEach(l => {
            const d = document.createElement('div');
            d.textContent = l;
            d.style.borderBottom = '1px solid #333';
            debugConsole.appendChild(d);
        });
        document.body.appendChild(debugConsole);
    }

    function init() {
        debugLog('Script Initialized');
        initTwitterSupport();
        setInterval(checkForVideos, 2000);
    }

    init();
})();