// ==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/m3u8-parser@4.7.1/dist/m3u8-parser.min.js // @require https://cdn.jsdelivr.net/npm/@warren-bank/ffmpeg@0.12.10-wasmbinary.3/dist/umd/ffmpeg.js // @resource classWorkerURL https://cdn.jsdelivr.net/npm/@warren-bank/ffmpeg@0.12.10-wasmbinary.3/dist/umd/258.ffmpeg.js // @resource coreURL https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.6/dist/umd/ffmpeg-core.js // @resource wasmURL https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.6/dist/umd/ffmpeg-core.wasm // @grant GM_addStyle // @grant GM_getResourceURL // @downloadURL none // ==/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 = `
`; 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 = `