// ==UserScript== // @name Universal Video Share Button with M3U8 Support + MP4 Remux (Twitter/X Enhanced) // @namespace http://tampermonkey.net/ // @version 7.2 // @description Share button with M3U8 support. Downloads and converts to MP4. Includes special support for Twitter/X (inline buttons). // @author Minoa & Azuki (Inspiration) // @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(); var downloadedBlobs = new Map(); var debugMode = false; var debugConsole = null; var debugLogs = []; var longPressStartTime = 0; var ffmpegInstance = null; var ffmpegLoaded = false; var wasmBinaryCache = null; // Platform Detection const isTwitter = location.hostname.includes('twitter.com') || 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: '#1d9bf0' }; // ========================================== // STYLES // ========================================== GM_addStyle(` /* Universal Floating Button */ #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; box-shadow: 0 6px 20px rgba(0, 0, 0, 0.5) !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 */ .uvs-twitter-btn { display: flex; align-items: center; justify-content: center; width: 34px; height: 34px; border-radius: 9999px; transition: background-color 0.2s; cursor: pointer; color: rgb(113, 118, 123); /* Twitter Gray */ margin-left: 2px; } .uvs-twitter-btn:hover { background-color: rgba(29, 155, 240, 0.1); color: ${COLORS.twitter}; } .uvs-twitter-btn svg { width: 20px; height: 20px; fill: currentColor; } /* Dark mode adjustment if needed */ @media (prefers-color-scheme: dark) { .uvs-twitter-btn { color: rgb(113, 118, 123); } .uvs-twitter-btn:hover { color: ${COLORS.twitter}; } } `); // ========================================== // HELPERS // ========================================== var VIDEO_SELECTORS = [ 'video', '.video-player video', '.player video', '#player video', '.video-container video', '[class*="video"] video', '[class*="player"] video', 'iframe[src*="youtube.com"]', 'iframe[src*="vimeo.com"]', 'iframe[src*="dailymotion.com"]', 'iframe[src*="twitch.tv"]' ]; var VIDEO_EXTENSIONS = ['.mp4', '.webm', '.ogg', '.mov', '.avi', '.wmv', '.flv', '.mkv', '.m4v', '.3gp']; function sanitizeFilename(filename) { return filename.replace(/[<>:"\/\\|?*\x00-\x1F]/g, '').replace(/\s+/g, '_').replace(/_{2,}/g, '_').replace(/^\.+/, '').substring(0, 200); } function getFilenameFromPageTitle(extension = 'mp4', prefix = '') { const pageTitle = document.title || 'video'; const sanitized = sanitizeFilename(pageTitle); return (prefix ? prefix + '-' : '') + sanitized + '.' + extension; } // ========================================== // FFMPEG & CONVERSION // ========================================== const getWasmBinary = async () => { if (wasmBinaryCache) return wasmBinaryCache.slice(0); const wasmURL = GM_getResourceURL('wasmURL', false); let wasmBinary = null; if (wasmURL.startsWith('data:')) { const index = wasmURL.indexOf(','); const base64 = wasmURL.substring(index + 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; }; const getFFmpegLoadConfig = async () => ({ classWorkerURL: GM_getResourceURL('classWorkerURL', false), coreURL: GM_getResourceURL('coreURL', false), wasmBinary: await getWasmBinary(), createTrustedTypePolicy: true }); async function initFFmpeg() { if (ffmpegLoaded && ffmpegInstance) return ffmpegInstance; showNotification('⚙️ Loading FFmpeg...', 'info'); try { ffmpegInstance = new window.FFmpegWASM.FFmpeg(); ffmpegInstance.on('log', ({ message }) => debugLog('[FFMPEG LOG] ' + message)); ffmpegInstance.on('progress', ({ progress }) => { const percent = Math.round(progress * 100); updateProgress(percent); }); await ffmpegInstance.load(await getFFmpegLoadConfig()); ffmpegLoaded = true; showNotification('✅ FFmpeg loaded', 'success'); return ffmpegInstance; } catch(e) { showNotification('❌ FFmpeg failed to load', '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'; const inputData = new Uint8Array(await tsBlob.arrayBuffer()); showNotification('🔄 Converting...', 'info'); await ffmpeg.writeFile(inputName, inputData); 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) { showNotification('❌ Conversion failed', 'error'); throw e; } } // ========================================== // NETWORK INTERCEPTION & DETECTION // ========================================== function isM3U8Url(url) { if (!url) return false; const lowerUrl = url.toLowerCase(); return lowerUrl.includes('.m3u8') || lowerUrl.includes('.m3u'); } function isBlobUrl(url) { return url && url.startsWith('blob:'); } (function setupNetworkDetection() { const originalFetch = window.fetch; window.fetch = function(...args) { const promise = originalFetch.apply(this, args); const url = typeof args[0] === 'string' ? args[0] : args[0]?.url; promise.then(response => { if (url) { if (isM3U8Url(url)) detectM3U8(url); else if (!url.match(/seg-\d+-.*\.ts/i) && !url.endsWith('.ts') && !isBlobUrl(url)) checkUrlForVideo(url); } return response; }).catch(e => { throw e; }); return promise; }; const originalOpen = XMLHttpRequest.prototype.open; XMLHttpRequest.prototype.open = function(...args) { this.addEventListener("load", function() { try { const url = args[1]; if (url) { if (isM3U8Url(url)) detectM3U8(url); if (this.responseText && this.responseText.trim().startsWith("#EXTM3U")) detectM3U8(url); } } catch(e) {} }); return originalOpen.apply(this, args); }; })(); function checkUrlForVideo(url) { try { if (isBlobUrl(url) || url.match(/seg-\d+-.*\.ts/i) || url.endsWith('.ts') || isM3U8Url(url)) return; const lowerUrl = url.toLowerCase(); if (VIDEO_EXTENSIONS.some(ext => lowerUrl.includes(ext))) { const fullUrl = new URL(url, location.href).href; if (!allDetectedVideos.has(fullUrl)) { allDetectedVideos.set(fullUrl, { url: fullUrl, type: 'video', timestamp: Date.now(), title: 'Video - ' + fullUrl.split('/').pop() }); checkForVideos(); } } } catch(e) {} } async function detectM3U8(url) { try { if (isBlobUrl(url)) return; url = new URL(url, location.href).href; const urlWithoutQuery = url.split('?')[0]; // Avoid duplicates if (detectedM3U8Urls.includes(url)) return; // On Twitter, we often get multiple resolutions. We want to capture them but maybe group them later. // For now, let's capture everything. debugLog('[M3U8] Detected: ' + url); detectedM3U8Urls.push(url); const response = await fetch(url); const content = await response.text(); const parser = new m3u8Parser.Parser(); parser.push(content); parser.end(); const manifest = parser.manifest; // Handle Master Playlist (Variants) if (manifest.playlists && manifest.playlists.length > 0) { debugLog('[M3U8] Master playlist found. Fetching best variant.'); const bestVariant = manifest.playlists.sort((a, b) => (b.attributes.BANDWIDTH || 0) - (a.attributes.BANDWIDTH || 0))[0]; const baseUrl = url.substring(0, url.lastIndexOf('/') + 1); const variantUrl = bestVariant.uri.startsWith('http') ? bestVariant.uri : baseUrl + bestVariant.uri; detectM3U8(variantUrl); // Recursively fetch best variant return; } let duration = 0; if (manifest.segments) manifest.segments.forEach(s => duration += s.duration); const m3u8Data = { url: url, manifest: manifest, duration: duration, title: 'M3U8 Stream ' + (duration ? Math.ceil(duration) + 's' : ''), timestamp: Date.now() }; detectedM3U8s.push(m3u8Data); allDetectedVideos.set(url, { url: url, type: 'm3u8', timestamp: Date.now(), title: m3u8Data.title, m3u8Data: m3u8Data }); checkForVideos(); } catch(e) { debugLog('[ERROR] M3U8 parse: ' + e.message); } } // ========================================== // VIDEO COLLECTION LOGIC // ========================================== function getUniqueVideos() { var videos = []; var seenUrls = new Set(); // 1. Add captured M3U8s and Files from Network allDetectedVideos.forEach(function(videoData, url) { if (!seenUrls.has(url)) { seenUrls.add(url); videos.push(videoData); } }); // 2. Scan DOM for video elements // On Twitter, videos are often Blob URLs. We want to include them in the count/list // so the user knows something is there, even if we have to match it to a network M3U8 later. var domElements = document.querySelectorAll('video, iframe'); domElements.forEach(element => { var rect = element.getBoundingClientRect(); if (rect.width > 50 && rect.height > 50) { // Lower threshold for Twitter previews var src = element.currentSrc || element.src; // Special handling for Twitter Blobs if (isTwitter && isBlobUrl(src)) { // We don't add the blob URL directly to the download list because we can't download blobs easily // without XHR, but we want to ensure the button appears. // The actual download will use the captured M3U8s. // We check if we have any M3U8s captured. if (allDetectedVideos.size === 0) { // If we see a video but haven't caught the M3U8 yet, we can add a placeholder // or just rely on the network interceptor catching up. } return; } if (src && !isBlobUrl(src) && !seenUrls.has(src)) { seenUrls.add(src); videos.push({ type: 'video', element: element, url: src, title: document.title, timestamp: Date.now() }); } } }); // Sort: M3U8s first, then by timestamp return videos.sort((a, b) => { if (a.type === 'm3u8' && b.type !== 'm3u8') return -1; if (a.type !== 'm3u8' && b.type === 'm3u8') return 1; return b.timestamp - a.timestamp; }); } // ========================================== // TWITTER SPECIFIC MODULE // ========================================== function initTwitterSupport() { if (!isTwitter) return; debugLog('[TWITTER] Initializing Twitter module...'); const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if (mutation.addedNodes.length) { processTwitterTweets(); } }); }); observer.observe(document.body, { childList: true, subtree: true }); processTwitterTweets(); } function processTwitterTweets() { // Find all tweets const tweets = document.querySelectorAll('article[data-testid="tweet"]'); tweets.forEach(tweet => { // Find the action bar (Reply, Retweet, Like, Share...) const actionBar = tweet.querySelector('div[role="group"]'); if (!actionBar) return; // Check if we already injected if (actionBar.querySelector('.uvs-twitter-btn')) return; // Create Button const btn = document.createElement('div'); btn.className = 'uvs-twitter-btn'; btn.title = 'Download Media'; btn.innerHTML = ` `; // Insert before the share button (usually the last one) or at the end const shareBtn = actionBar.lastElementChild; if (shareBtn) { actionBar.insertBefore(btn, shareBtn); } else { actionBar.appendChild(btn); } // Click Handler btn.addEventListener('click', (e) => { e.stopPropagation(); handleTwitterDownload(tweet); }); }); } function handleTwitterDownload(tweetElement) { // 1. Identify the video in this tweet const videoElement = tweetElement.querySelector('video'); if (!videoElement) { showNotification('❌ No video found in this tweet', 'error'); return; } // 2. Try to match with captured M3U8s // Since Twitter uses Blobs for src, we can't match URL directly. // We assume the most recently captured M3U8s are relevant, or we show the selector. const videos = getUniqueVideos(); // Filter videos to find M3U8s const m3u8s = videos.filter(v => v.type === 'm3u8'); if (m3u8s.length === 0) { showNotification('⏳ Video stream not captured yet. Play the video first!', 'info'); return; } // If only one M3U8 captured, assume it's the one. if (m3u8s.length === 1) { downloadM3U8(m3u8s[0], true, getTwitterFilename(tweetElement)); } else { // If multiple, show selector but try to be smart showVideoSelector(m3u8s, 'download', getTwitterFilename(tweetElement)); } } function getTwitterFilename(tweetElement) { try { const timeEl = tweetElement.querySelector('time'); const userEl = tweetElement.querySelector('div[data-testid="User-Name"]'); let datePart = timeEl ? timeEl.getAttribute('datetime').split('T')[0] : 'twitter-video'; let userPart = userEl ? userEl.innerText.split('\n')[0].replace('@', '') : 'user'; return sanitizeFilename(`${userPart}_${datePart}`); } catch(e) { return 'twitter_video'; } } // ========================================== // UI & INTERACTION // ========================================== function createFloatingButton() { if (floatingButton) return floatingButton; const container = document.createElement('div'); container.id = 'universal-video-share-container'; // SVG Progress Circle const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('width', '50'); svg.setAttribute('height', '50'); svg.style.cssText = 'position: absolute; top: 0; left: 0; transform: rotate(-90deg); pointer-events: none;'; const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); circle.setAttribute('cx', '25'); circle.setAttribute('cy', '25'); circle.setAttribute('r', '22'); circle.setAttribute('fill', 'none'); circle.setAttribute('stroke', '#4ade80'); circle.setAttribute('stroke-width', '3'); circle.setAttribute('stroke-dasharray', '138'); circle.setAttribute('stroke-dashoffset', '138'); circle.id = 'progress-circle'; svg.appendChild(circle); container.appendChild(svg); floatingButton = document.createElement('div'); floatingButton.innerHTML = '▶'; floatingButton.id = 'universal-video-share-float'; floatingButton.progressCircle = circle; // Events const startPress = () => { isLongPress = false; longPressStartTime = Date.now(); pressTimer = setTimeout(() => { isLongPress = true; if (Date.now() - longPressStartTime >= 5000) { debugMode = !debugMode; showNotification(`Debug Mode: ${debugMode ? 'ON' : 'OFF'}`, 'info'); } }, 500); }; const endPress = (e) => { e.preventDefault(); clearTimeout(pressTimer); if (!isLongPress) handleShare(); }; floatingButton.addEventListener('mousedown', startPress); floatingButton.addEventListener('mouseup', endPress); floatingButton.addEventListener('touchstart', startPress); floatingButton.addEventListener('touchend', endPress); container.appendChild(floatingButton); document.body.appendChild(container); return floatingButton; } function updateProgress(percent) { if (!floatingButton || !floatingButton.progressCircle) return; floatingButton.progressCircle.setAttribute('stroke-dashoffset', 138 - (138 * percent / 100)); } function handleShare() { const videos = getUniqueVideos(); if (videos.length === 0) return showNotification('❌ No videos found', 'error'); if (videos.length === 1) { const v = videos[0]; if (v.type === 'm3u8') downloadM3U8(v, false); else shareVideo(v); } else { showVideoSelector(videos, 'share'); } } function showVideoSelector(videos, action, customFilenamePrefix = null) { 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; align-items: center; justify-content: center;'; const content = document.createElement('div'); content.style.cssText = 'background: #222; border: 1px solid #444; border-radius: 12px; padding: 20px; max-width: 500px; width: 90%; max-height: 80vh; overflow-y: auto; color: #fff;'; const title = document.createElement('h3'); title.textContent = `Select Video (${videos.length})`; title.style.marginTop = '0'; content.appendChild(title); videos.forEach(v => { const item = document.createElement('div'); item.style.cssText = 'padding: 10px; border-bottom: 1px solid #333; cursor: pointer; display: flex; justify-content: space-between; align-items: center;'; item.innerHTML = `
${v.type.toUpperCase()}
${v.title}
${v.type === 'm3u8' ? '📥' : '🔗'}
`; item.onmouseover = () => item.style.background = '#333'; item.onmouseout = () => item.style.background = 'transparent'; item.onclick = () => { popup.remove(); if (v.type === 'm3u8') downloadM3U8(v, true, customFilenamePrefix); else if (action === 'share') shareVideo(v); else copyVideoUrl(v); }; content.appendChild(item); }); const closeBtn = document.createElement('button'); closeBtn.textContent = 'Close'; closeBtn.style.cssText = 'margin-top: 15px; width: 100%; padding: 8px; background: #444; border: none; color: white; border-radius: 6px; cursor: pointer;'; closeBtn.onclick = () => popup.remove(); content.appendChild(closeBtn); popup.appendChild(content); document.body.appendChild(popup); } // ========================================== // DOWNLOAD & SHARE LOGIC // ========================================== async function downloadM3U8(videoData, forceDownload = false, customFilenamePrefix = null) { const cached = downloadedBlobs.get(videoData.url); if (cached) return shareOrDownloadBlob(cached.blob, cached.filename); showNotification('📥 Downloading segments...', 'info'); updateProgress(0); 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 segmentData = []; let totalSize = 0; for (let i = 0; i < segments.length; i++) { const segUrl = segments[i].uri.startsWith('http') ? segments[i].uri : baseUrl + segments[i].uri; const res = await fetch(segUrl); const data = await res.arrayBuffer(); segmentData.push(data); totalSize += data.byteLength; updateProgress(Math.floor(((i + 1) / segments.length) * 100)); } const merged = new Blob(segmentData, { type: 'video/mp2t' }); const filename = getFilenameFromPageTitle('mp4', customFilenamePrefix); const converted = await convertTStoMP4(merged, filename); downloadedBlobs.set(videoData.url, converted); shareOrDownloadBlob(converted.blob, converted.filename); } catch(e) { showNotification('❌ Error: ' + e.message, 'error'); updateProgress(0); } } async function shareOrDownloadBlob(blob, filename) { 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' })], title: filename }); showNotification('✅ Shared!', 'success'); return; } catch(e) { /* Fallback to download */ } } const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = filename; document.body.appendChild(a); a.click(); setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(a.href); }, 1000); showNotification('✅ Downloaded', 'success'); updateProgress(0); } function shareVideo(videoData) { if (navigator.share) { navigator.share({ title: document.title, url: videoData.url }).catch(() => copyVideoUrl(videoData)); } else { copyVideoUrl(videoData); } } function copyVideoUrl(videoData) { navigator.clipboard.writeText(videoData.url).then(() => showNotification('✅ URL Copied', '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' ? 'rgba(239,68,68,0.9)' : 'rgba(74,222,128,0.9)'}; color: white; padding: 10px 14px; border-radius: 8px; font-weight: bold; font-size: 13px; backdrop-filter: blur(5px); box-shadow: 0 4px 12px rgba(0,0,0,0.3); `; document.body.appendChild(div); setTimeout(() => div.remove(), 3000); } function debugLog(msg) { if (debugMode) console.log(msg); } function checkForVideos() { const videos = getUniqueVideos(); if (videos.length > 0) { const btn = createFloatingButton(); btn.innerHTML = videos.length > 1 ? '⇓' : (videos[0].type === 'm3u8' ? '⇣' : '↯'); } else if (floatingButton) { floatingButton.innerHTML = '▶'; } } // ========================================== // INIT // ========================================== function init() { debugLog('[INIT] Universal Video Downloader v7.0'); setTimeout(checkForVideos, 1000); checkInterval = setInterval(checkForVideos, 2000); if (isTwitter) { initTwitterSupport(); } } init(); })();