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.0
// @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
// ==/UserScript==

(function() {
    'use strict';

    // --- Configuration & State ---
    var floatingButton = null;
    var pressTimer = null;
    var isLongPress = false;
    var checkInterval = null;
    var detectedM3U8s = [];
    var detectedM3U8Urls = [];
    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 isMobile = isIOS || /Android|webOS|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
    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 Button Styles */
        #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 {
            background: ${COLORS.buttonHover} !important;
            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; }
        #debug-console { z-index: 2147483644 !important; }

        /* Twitter Specific Styles */
        .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);} }
    `);

    // Common video selectors
    var VIDEO_SELECTORS = [
        'video',
        '.video-player video',
        '.player video',
        '#player video',
        'iframe[src*="youtube.com"]',
        'iframe[src*="vimeo.com"]',
        'iframe[src*="dailymotion.com"]'
    ];

    var VIDEO_EXTENSIONS = ['.mp4', '.webm', '.ogg', '.mov', '.avi', '.wmv', '.flv', '.mkv', '.m4v', '.3gp'];

    // --- 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);
        const wasmURL = GM_getResourceURL('wasmURL', false);
        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 if (wasmURL.startsWith('blob:')) {
            wasmBinary = await fetch(wasmURL).then(res => 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)));
            await ffmpegInstance.load({
                classWorkerURL: GM_getResourceURL('classWorkerURL', false),
                coreURL: GM_getResourceURL('coreURL', false),
                wasmBinary: await getWasmBinary()
            });
            ffmpegLoaded = true;
            showNotification('✅ FFmpeg loaded', 'success');
            return ffmpegInstance;
        } catch(e) {
            showNotification('❌ FFmpeg failed', '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 => {
                res.clone().text().then(t => { if(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;
            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 (parser.manifest.playlists && parser.manifest.playlists.length > 0) {
                // Master playlist - get best quality
                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;
                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();

        // 1. Check DOM Elements (and determine visibility)
        const domVideos = [];
        document.querySelectorAll('video, iframe').forEach(el => {
            let src = el.currentSrc || el.src;
            // Try to find source tags
            if (!src && el.tagName === 'VIDEO') {
                const source = el.querySelector('source');
                if (source) src = source.src;
            }
            
            if (src) {
                const isBlob = src.startsWith('blob:');
                const isVisible = isElementVisible(el);
                
                domVideos.push({
                    element: el,
                    src: src,
                    isBlob: isBlob,
                    isVisible: isVisible
                });
            }
        });

        // 2. Map DOM elements to Network Requests
        allDetectedVideos.forEach((data, url) => {
            // Reset visibility
            data.isOnScreen = false;
            
            // Heuristic: If we found a DOM element with this URL, use its visibility
            const match = domVideos.find(v => v.src === url);
            if (match) {
                data.isOnScreen = match.isVisible;
            } 
            // Heuristic: If DOM has a blob URL, and we have an M3U8 that was detected around the same time
            else if (data.type === 'm3u8') {
                // Check if any visible blob video exists
                const visibleBlob = domVideos.find(v => v.isBlob && v.isVisible);
                if (visibleBlob) {
                    // Weak association: if we have a visible blob video and this m3u8 is recent, assume match
                    // This is imperfect but works for Twitter/YouTube often
                    if (Date.now() - data.timestamp < 30000) {
                        data.isOnScreen = true;
                    }
                }
            }

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

        // 3. Add DOM videos that weren't caught by network sniffing (e.g. direct MP4 src)
        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
                });
            }
        });

        // Sort: OnScreen first, then by time
        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, action = 'download') {
        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;';
        
        const header = document.createElement('div');
        header.innerHTML = `<h3 style="margin:0 0 15px;">Select Video (${videos.length})</h3>`;
        content.appendChild(header);

        // Group videos
        const onscreen = videos.filter(v => v.isOnScreen);
        const others = videos.filter(v => !v.isOnScreen);

        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(onscreen, '👁️ On Screen Now');
        createList(others, '📚 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 multiple videos, OR if we are on Twitter (where context matters), show selector
        if (videos.length > 1 || isTwitter) {
            showVideoSelector(videos);
        } else {
            processVideoAction(videos[0]);
        }
    }

    function processVideoAction(videoData) {
        if (videoData.type === 'm3u8') {
            downloadM3U8(videoData);
        } else {
            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");

            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 no blob (regular video file), try to fetch it to blob for sharing/downloading
        if (!blob && videoData.url) {
            try {
                showNotification('📥 Fetching video...', 'info');
                const res = await fetch(videoData.url);
                blob = await res.blob();
                filename = getFilenameFromPageTitle('mp4');
            } catch(e) {
                // Fallback to direct link open
                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();
            // Update icon based on onscreen status
            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) => {
            // Find the action bar (Reply, Retweet, Like, Share...)
            const group = article.querySelector('div[role="group"]');
            if (!group || group.querySelector('.tmd-down')) return;

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

            // Insert before the share button (usually the last one)
            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');
                
                // 1. Find media in this specific tweet
                const videos = article.querySelectorAll('video');
                const images = article.querySelectorAll('img[src*="pbs.twimg.com/media"]');
                
                let targetUrl = null;
                let isM3U8 = false;

                // Prioritize Video
                if (videos.length > 0) {
                    // Try to find the highest quality video URL
                    // Twitter often uses blob: URLs for videos now. 
                    // We need to match this blob to our captured M3U8s.
                    const videoEl = videos[0];
                    if (videoEl.src.startsWith('blob:')) {
                        // Look in our captured M3U8s for one that was detected recently
                        const recentM3U8 = Array.from(allDetectedVideos.values())
                            .filter(v => v.type === 'm3u8')
                            .sort((a,b) => b.timestamp - a.timestamp)[0];
                        
                        if (recentM3U8) {
                            targetUrl = recentM3U8.url;
                            isM3U8 = true;
                        }
                    } else {
                        targetUrl = videoEl.src;
                    }
                } else if (images.length > 0) {
                    // Fallback to Image (High Res)
                    targetUrl = images[0].src.replace(/name=[^&]+/, 'name=orig');
                }

                if (targetUrl) {
                    const videoData = isM3U8 
                        ? allDetectedVideos.get(targetUrl) 
                        : { url: targetUrl, type: 'video', title: 'Twitter Media' };
                    
                    if (videoData) {
                        processVideoAction(videoData);
                    } else {
                        // Create temp data object for direct image/video
                        processVideoAction({ url: targetUrl, type: 'video', title: 'Twitter Media' });
                    }
                } else {
                    showNotification('❌ No media found in this tweet', 'error');
                }

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

        const observer = new MutationObserver((mutations) => {
            for (const m of mutations) {
                m.addedNodes.forEach(node => {
                    if (node.nodeType === 1) {
                        // Check if node is a tweet or contains tweets
                        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 });
        // Initial check
        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);
    }

    // --- Initialization ---
    function init() {
        debugLog('Script Initialized');
        initTwitterSupport();
        setInterval(checkForVideos, 2000); // Check periodically
    }

    init();

})();