// ==UserScript== // @name Twitter Video Saver // @namespace https://f66.dev // @version 1.5.1 // @description Adds a "Save Video" context menu option to Twitter videos. // @author VanillaSixtySix // @match https://twitter.com/* // @match https://x.com/* // @run-at document-idle // @icon https://www.google.com/s2/favicons?domain=twitter.com // @updateURL https://raw.githubusercontent.com/FlyingSixtySix/twitter-video-saver/main/twitter-video-saver.user.js // @downloadURL https://raw.githubusercontent.com/FlyingSixtySix/twitter-video-saver/main/twitter-video-saver.user.js // @homepageURL https://github.com/FlyingSixtySix/twitter-video-saver // @grant GM.xmlHttpRequest // @connect api.f66.dev // ==/UserScript== (async () => { const isFirefox = navigator.userAgent.includes("Firefox"); setInterval(async () => { const videos = document.querySelectorAll('video'); for (const video of videos) { if (video.getAttribute('data-twtdl-injected')) continue; video.parentNode.parentNode.parentNode.addEventListener('contextmenu', async () => { await new Promise(res => setTimeout(res, 0)); const rightClickMenu = video.parentNode.parentNode.parentNode.lastChild.lastChild.lastChild; if (rightClickMenu.getAttribute('data-twtdl-injected') != null) return; const newButton = rightClickMenu.children[0].cloneNode(true); const reactElem = video.parentNode.parentNode; let react; if (isFirefox) { // For some reason, Firefox 122 doesn't let us access object properties of a DOM element // It just returns an empty array ???? unsafeWindow.reactElem = reactElem; await new Promise(res => { const scriptElement = document.createElement('script'); scriptElement.src = `data:text/javascript,window.react = window.reactElem[Object.getOwnPropertyNames(window.reactElem).find(n => n.startsWith('__reactFiber'))]`; document.body.appendChild(scriptElement); setTimeout(res); }); react = unsafeWindow.react; } else { const propertyNames = Object.getOwnPropertyNames(reactElem); const internalPropName = propertyNames.find(name => name.startsWith('__reactFiber')); react = reactElem[internalPropName]; } const playerState = react.sibling.memoizedProps.playerState; const id = playerState.source.id; const variants = [...playerState.tracks[0].variants]; let videoSource = variants .filter(variant => variant.type === 'video/mp4' && variant.src) .reduce((acc, variant) => (acc.bitrate > variant.bitrate ? acc : variant), { bitrate: 0 }); videoSource = videoSource || playerState.tracks[0].variants[0]; console.debug(videoSource, variants); const contentType = playerState.tracks[0].contentType; switch (contentType) { case 'gif': newButton.children[0].children[0].innerText = 'Save Gif (Convert)'; rightClickMenu.style = ` display: flex; flex-direction: column; padding: 0; `; [...rightClickMenu.children].forEach(child => { child.style = ` padding: 12px 16px; `; }); newButton.style = ` padding: 12px 16px; `; break; case 'media_entity': newButton.children[0].children[0].innerText = 'Save Video'; break; default: newButton.children[0].children[0].innerText = 'Save Other (Video)'; break; } newButton.onclick = async event => { document.body.click(); const progressElement = document.createElement('div'); progressElement.style = ` position: absolute; top: 12px; left: 12px; height: 20px; pointer-events: none !important; background: rgba(0, 0, 0, 0.77); color: rgb(255, 255, 255); font-size: 13px; font-weight: 700; font-family: TwitterChirp, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; border-radius: 4px; padding: 0 4px 0 4px; line-height: 20px; `; progressElement.innerText = '0%'; video.parentNode.parentNode.nextSibling.firstChild.children[1].appendChild(progressElement); const a = document.createElement('a'); if (contentType === 'gif') { let res; try { res = await GM.xmlHttpRequest({ url: 'https://api.f66.dev/mediaconv', method: 'POST', headers: { 'Content-Type': 'application/json' }, responseType: 'blob', data: JSON.stringify({ url: videoSource.src, to: 'gif' }) }); } catch (err) { progressElement.innerText = 'ERROR'; console.error('Failed to download video:', err.error || 'Unknown', err); setTimeout(() => { progressElement.remove(); }, 3000); return; } const blob = res.response; const url = URL.createObjectURL(blob); a.href = url; a.download = id + '.gif'; a.click(); URL.revokeObjectURL(url); console.info(`Successfully downloaded video "${id}.gif"`); setTimeout(() => { progressElement.remove(); }, 500); return; } const xhr = new XMLHttpRequest(); xhr.responseType = 'blob'; xhr.open('GET', videoSource.src, true); xhr.onload = () => { if (xhr.status !== 200) { xhr.onerror(new Error('status code ' + xhr.status)); return; } const url = URL.createObjectURL(new Blob([xhr.response], { type: videoSource.type })); a.href = url; a.download = id + '.mp4'; a.click(); URL.revokeObjectURL(url); console.info(`Successfully downloaded video "${id}.mp4"`); setTimeout(() => { progressElement.remove(); }, 500); } xhr.onprogress = info => { let progress = Math.floor((info.loaded / info.total) * 100); if (progress < 0 || progress > 100) progress = 100; progressElement.innerText = progress + '%'; }; xhr.onerror = err => { progressElement.innerText = 'ERROR'; console.error('Failed to download video:', err.error || 'Unknown', err); setTimeout(() => { progressElement.remove(); }, 3000); }; xhr.send(); }; rightClickMenu.children[0].setAttribute('tabindex', rightClickMenu.children.length); newButton.setAttribute('tabindex', 0); rightClickMenu.insertBefore(newButton, rightClickMenu.firstChild); rightClickMenu.setAttribute('data-twtdl-injected', 1); }); video.setAttribute('data-twtdl-injected', 1); } }, 500); })();