Greasy Fork

Greasy Fork is available in English.

Universal Video Downloader (Media Selection UI)

Production-ready video downloader. Sorts by size/quality. Beautiful "Media Selection" menu.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Universal Video Downloader (Media Selection UI)
// @namespace    http://tampermonkey.net/
// @version      11.0
// @description  Production-ready video downloader. Sorts by size/quality. Beautiful "Media Selection" menu.
// @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
// @run-at       document-start
// ==/UserScript==

(function() {
    'use strict';

    // ==========================================
    // CONFIGURATION
    // ==========================================
    let floatingButton = null;
    let hiddenToggle = null;
    
    // State
    let isHidden = GM_getValue('uvs_hidden', false);
    let pressStartTime = 0;
    
    // Data Stores
    const detectedUrls = new Set();
    const allDetectedVideos = new Map(); 
    const downloadedBlobs = new Map();
    
    // Settings
    const CONCURRENCY = 3;
    const MAX_RETRIES = 3;
    const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
    const isTwitter = location.hostname.includes('twitter.com') || location.hostname.includes('x.com');

    // Theme
    const THEME = {
        bg: 'rgba(20, 20, 20, 0.75)', // Glass background
        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(`
        /* Main Button */
        #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); }
        
        /* Stealth Toggle */
        #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); }

        /* Notifications */
        .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); } }
        
        /* Modal Overlay */
        #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; } }

        /* Modal Content */
        #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); } }

        /* Header */
        .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}; }

        /* List */
        .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; }

        /* Item Content */
        .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; }
        
        /* Badges */
        .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}; }

        /* Action Icon */
        .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 */
        .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; }
        .uvs-tw-btn:hover { background: rgba(212, 163, 115, 0.1); color: ${THEME.accent}; }
        .uvs-tw-btn svg { width: 20px; height: 20px; fill: currentColor; }
    `);

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

    // ==========================================
    // DETECTION LOGIC
    // ==========================================
    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, // Estimated size in bytes
            timestamp: Date.now(),
            manifest: data.manifest || null
        };
        allDetectedVideos.set(fullUrl, videoObj);
        if (!isHidden) notify(`✓ Video Detected`, 'success');
        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);

            // Estimate Size: Duration (s) * Bandwidth (bits/s) / 8
            // We look for a bandwidth attribute in the parent playlist usually, but here we might just have segments.
            // If we can't find it, we assume a decent bitrate (e.g., 2Mbps) for sorting purposes or leave as 0.
            let estimatedSize = 0;
            // Note: In a media playlist, bandwidth isn't usually explicitly stated unless we parsed the master.
            // We can try to guess from segment length if available, but for now let's prioritize duration.

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

    // ==========================================
    // SORTING & UI
    // ==========================================
    function getSortedVideos() {
        return Array.from(allDetectedVideos.values()).sort((a, b) => {
            // 1. Sort by Size (if known)
            if (a.size > 0 || b.size > 0) return b.size - a.size;
            
            // 2. Sort by Resolution (Width * Height)
            const resA = (a.width || 0) * (a.height || 0);
            const resB = (b.width || 0) * (b.height || 0);
            if (resB !== resA) return resB - resA;

            // 3. Sort by Duration
            const durA = a.duration || 0;
            const durB = b.duration || 0;
            if (durB !== durA) return durB - durA;

            // 4. M3U8 Preference
            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 = '⬇';
        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 = '👁️';
            }, 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('✕ 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 ? '☰' : '⬇';
    }

    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 = 'ℹ';
        if (type === 'success') { div.style.borderColor = THEME.success; div.style.color = THEME.success; icon = '✓'; }
        if (type === 'error') { div.style.borderColor = THEME.error; div.style.color = THEME.error; icon = '✕'; }
        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';
        
        // Header
        const header = document.createElement('div');
        header.className = 'uvs-header';
        header.innerHTML = `
            <span class="uvs-title">Media Selection</span>
            <button class="uvs-close">✕</button>
        `;
        header.querySelector('.uvs-close').onclick = () => overlay.remove();
        modal.appendChild(header);

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

        videos.forEach(v => {
            const item = document.createElement('div');
            item.className = 'uvs-item';
            
            // Badges
            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">⬇</div>
            `;
            item.onclick = () => { overlay.remove(); processVideo(v); };
            list.appendChild(item);
        });
        
        modal.appendChild(list);
        overlay.appendChild(modal);
        document.body.appendChild(overlay);
        
        // Close on outside click
        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('⟳ 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('⟳ 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('⟳ 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('⟳ 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';

        if (isMobile && navigator.share && blob) {
            const file = new File([blob], finalName, { type: 'video/mp4' });
            if (navigator.canShare && navigator.canShare({ files: [file] })) {
                navigator.share({ files: [file], title: finalName }).catch(() => {});
                return;
            }
        }

        if (!isMobile) {
            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');
                return;
            } catch(e) {}
        }

        const textToCopy = url || "Video Blob";
        navigator.clipboard.writeText(textToCopy).then(() => notify('✓ Link Copied', 'success')).catch(() => notify('✕ Save Failed', 'error'));
    }

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

    if (isTwitter) {
        const twObs = new MutationObserver(() => {
            document.querySelectorAll('article[data-testid="tweet"]').forEach(tweet => {
                const grp = tweet.querySelector('div[role="group"]');
                if (!grp || grp.querySelector('.uvs-tw-btn')) return;
                const btn = document.createElement('div'); btn.className = 'uvs-tw-btn';
                btn.innerHTML = `<svg viewBox="0 0 24 24"><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"></path></svg>`;
                btn.onclick = (e) => {
                    e.stopPropagation();
                    const vids = getSortedVideos().filter(v => v.type === 'm3u8');
                    if(vids.length) processVideo(vids[0], sanitizeFilename(`twitter_${Date.now()}`));
                    else notify('ℹ Play video first', 'info');
                };
                grp.appendChild(btn);
            });
        });
        twObs.observe(document.body, { childList: true, subtree: true });
    }

})();