Download and manage X media with video quality selection, progress tracking, image zoom, gallery, and more.
// ==UserScript==
// @name X Media Pro
// @namespace https://greasyfork.org/users/arshanarchi
// @version 16.2.1
// @description Download and manage X media with video quality selection, progress tracking, image zoom, gallery, and more.
// @author arshanarchi
// @license MIT
// @match https://x.com/*
// @match https://twitter.com/*
// @run-at document-start
// @grant GM_xmlhttpRequest
// @connect cdn.syndication.twimg.com
// @connect pbs.twimg.com
// @connect video.twimg.com
// ==/UserScript==
(() => {
'use strict';
const cache = new Map();
const hoverButtons = new WeakMap();
let lastTweet = null;
let activePicker = null;
let toastTimer = null;
let overlay = null;
let stage = null;
let viewerMedia = null;
let gallery = [];
let galleryIndex = 0;
let savedScrollX = 0;
let savedScrollY = 0;
let zoom = 1, panX = 0, panY = 0;
let dragging = false, dragX = 0, dragY = 0, startPanX = 0, startPanY = 0;
const ZOOMS = [0.25,0.5,0.75,1,1.25,1.5,2,3,5];
let progressBox = null;
let progressFill = null;
let progressTitle = null;
let progressText = null;
let progressPercent = null;
const sleep = ms => new Promise(r => setTimeout(r, ms));
const tweetOf = el => el?.closest?.('article[data-testid="tweet"]') || null;
function tweetId(tweet) {
if (!tweet) return null;
for (const a of tweet.querySelectorAll('a[href*="/status/"]')) {
const m = (a.getAttribute('href') || a.href || '').match(/\/status\/(\d+)/);
if (m) return m[1];
}
return null;
}
function username(tweet) {
if (!tweet) return 'X_User';
for (const a of tweet.querySelectorAll('a[href^="/"]')) {
const h = a.getAttribute('href');
if (h && /^\/[^/]+$/.test(h)) {
const n = h.slice(1);
if (!['home','explore','notifications','messages','search','settings','compose','i'].includes(n)) return n;
}
}
return 'X_User';
}
function clean(v) {
return String(v || '').replace(/[<>:"/\\|?*\x00-\x1F]/g,'').replace(/\s+/g,' ').trim().slice(0,150) || 'X-media';
}
function bytes(n) {
if (!Number.isFinite(n) || n < 0) return '0 B';
if (n < 1024) return `${n} B`;
if (n < 1048576) return `${(n/1024).toFixed(1)} KB`;
if (n < 1073741824) return `${(n/1048576).toFixed(1)} MB`;
return `${(n/1073741824).toFixed(2)} GB`;
}
function originalImage(url) {
try {
const u = new URL(url);
if (!u.hostname.includes('pbs.twimg.com') || !u.pathname.includes('/media/')) return url;
let format = u.searchParams.get('format');
if (!format) format = (u.pathname.match(/\.(jpg|jpeg|png|webp)$/i) || [,'jpg'])[1];
u.searchParams.set('format', format);
u.searchParams.set('name', 'orig');
return u.toString();
} catch { return url; }
}
function ext(url, fallback='jpg') {
try {
const m = new URL(url).pathname.match(/\.([a-z0-9]+)$/i);
return m ? m[1].toLowerCase() : fallback;
} catch { return fallback; }
}
function dedupe(items) {
const s = new Set();
return items.filter(x => x?.url && !s.has(x.url) && s.add(x.url));
}
function token(id) {
return ((Number(id)/1e15)*Math.PI).toString(36).replace(/(0+|\.)/g,'');
}
function tweetData(id) {
id = String(id);
if (cache.has(id)) return cache.get(id);
const p = new Promise((resolve,reject) => {
GM_xmlhttpRequest({
method:'GET',
url:`https://cdn.syndication.twimg.com/tweet-result?id=${encodeURIComponent(id)}&token=${encodeURIComponent(token(id))}&lang=en`,
timeout:30000,
headers:{Accept:'application/json'},
onload:r => {
if (r.status < 200 || r.status >= 400) return reject(new Error(`HTTP ${r.status}`));
try { resolve(JSON.parse(r.responseText)); } catch(e) { reject(e); }
},
onerror:() => reject(new Error('Network error')),
ontimeout:() => reject(new Error('Request timed out'))
});
});
cache.set(id,p);
return p;
}
function resolution(v) {
let w = Number(v?.width || 0), h = Number(v?.height || 0);
const m = String(v?.url || '').match(/\/(\d{2,5})x(\d{2,5})\//);
if ((!w || !h) && m) { w = Number(m[1]); h = Number(m[2]); }
return {width:w,height:h};
}
function variants(media) {
if (!media || (media.type !== 'video' && media.type !== 'gif')) return [];
const raw = Array.isArray(media.variants) ? media.variants : [];
const mp4 = raw.filter(v => v?.url && v.content_type === 'video/mp4').map(v => ({...v,...resolution(v)}));
const byH = new Map();
for (const v of mp4) {
const key = v.height > 0 ? `h-${v.height}` : `u-${v.url}`;
const old = byH.get(key);
if (!old || Number(v.bitrate||0) > Number(old.bitrate||0)) byH.set(key,v);
}
return [...byH.values()].sort((a,b) => Number(b.height||0)-Number(a.height||0) || Number(b.bitrate||0)-Number(a.bitrate||0));
}
function extract(data) {
const out = [];
for (const item of Array.isArray(data?.mediaDetails) ? data.mediaDetails : []) {
if (!item) continue;
if (item.type === 'photo' && item.media_url_https) {
out.push({type:'image',url:originalImage(item.media_url_https)});
} else if (item.type === 'video' || item.type === 'animated_gif') {
const vs = (Array.isArray(item.video_info?.variants) ? item.video_info.variants : [])
.filter(v => v?.url && v.content_type === 'video/mp4')
.map(v => ({...v,...resolution(v)}))
.sort((a,b) => Number(b.height||0)-Number(a.height||0) || Number(b.bitrate||0)-Number(a.bitrate||0));
if (vs.length) out.push({
type:item.type === 'animated_gif' ? 'gif' : 'video',
url:vs[0].url,
width:vs[0].width,
height:vs[0].height,
bitrate:Number(vs[0].bitrate||0),
fps:Number(item.video_info?.fps || item.fps || 0) || null,
variants:vs
});
}
}
return dedupe(out);
}
function hasMedia(tweet) {
return !!tweet?.querySelector('img[src*="pbs.twimg.com/media/"],video,a[href*="/photo/"],a[href*="/video/"]');
}
function createProgress() {
if (progressBox) return;
progressBox = document.createElement('div');
progressBox.id = 'xmd-download-progress';
progressBox.innerHTML = `<div class="xmd-progress-inner"><div id="xmd-progress-title" class="xmd-progress-title">Downloading...</div><div class="xmd-progress-info"><span id="xmd-progress-text">Starting...</span><span id="xmd-progress-percent">0%</span></div><div class="xmd-progress-track"><div id="xmd-progress-fill" class="xmd-progress-fill"></div></div></div>`;
document.body.appendChild(progressBox);
progressFill = document.getElementById('xmd-progress-fill');
progressTitle = document.getElementById('xmd-progress-title');
progressText = document.getElementById('xmd-progress-text');
progressPercent = document.getElementById('xmd-progress-percent');
}
function progressShow(title) {
createProgress();
progressBox.classList.add('xmd-progress-visible');
progressTitle.textContent = title || 'Downloading...';
progressText.textContent = 'Starting download...';
progressPercent.textContent = '0%';
progressFill.style.width = '0%';
progressFill.classList.remove('xmd-progress-indeterminate');
}
function progressUpdate(loaded,total) {
if (!progressBox) return;
if (Number.isFinite(total) && total > 0) {
const p = Math.min(100,Math.max(0,loaded/total*100));
progressFill.classList.remove('xmd-progress-indeterminate');
progressFill.style.width = `${p.toFixed(1)}%`;
progressPercent.textContent = `${Math.round(p)}%`;
progressText.textContent = `${bytes(loaded)} / ${bytes(total)}`;
} else {
progressFill.classList.add('xmd-progress-indeterminate');
progressPercent.textContent = '...';
progressText.textContent = `${bytes(loaded)} downloaded`;
}
}
function progressDone(ok) {
if (!progressBox) return;
progressFill.classList.remove('xmd-progress-indeterminate');
if (ok) {
progressFill.style.width='100%';
progressPercent.textContent='100%';
progressText.textContent='Download complete';
} else progressText.textContent='Download failed';
setTimeout(() => progressBox?.classList.remove('xmd-progress-visible'), ok ? 700 : 1200);
}
function getBlob(url,title) {
return new Promise((resolve,reject) => {
progressShow(title);
GM_xmlhttpRequest({
method:'GET',url,responseType:'blob',timeout:120000,headers:{Accept:'*/*'},
onprogress:e=>progressUpdate(Number(e.loaded||0),Number(e.total||0)),
onload:r=>{ if(r.status>=200&&r.status<400&&r.response){progressDone(true);resolve(r.response);}else{progressDone(false);reject(new Error(`HTTP ${r.status}`));}},
onerror:()=>{progressDone(false);reject(new Error('Network error'));},
ontimeout:()=>{progressDone(false);reject(new Error('Download timed out'));}
});
});
}
function save(blob,name) {
const u=URL.createObjectURL(blob); const a=document.createElement('a');
a.href=u;a.download=name;a.style.display='none';document.body.appendChild(a);a.click();a.remove();
setTimeout(()=>URL.revokeObjectURL(u),30000);
}
async function downloadMedia(media,user,id,index=0,variant=null) {
let url=media.url;
if ((media.type==='video'||media.type==='gif') && variant?.url) url=variant.url;
const type=media.type==='image'?'image':media.type==='gif'?'gif':'video';
const q=variant?.height>0?`-${variant.height}p`:'';
const filename=clean(`${user}-${id}-${type}${q}-${index+1}.${type==='image'?ext(url,'jpg'):'mp4'}`);
try { const blob=await getBlob(url,`Downloading ${type}${variant?.height?` ${variant.height}p`:''}...`); save(blob,filename); return true; }
catch(e){ console.error('[X Downloader]',e); return false; }
}
function qualityLabel(v) { return v.height>0 ? `${v.height}p` : 'Highest'; }
function qualityOptions(videos) {
const map=new Map();
for(const m of videos) for(const v of variants(m)) if(v.height>0 && !map.has(v.height)) map.set(v.height,{height:v.height,label:qualityLabel(v)});
const r=[...map.values()].sort((a,b)=>b.height-a.height);
return r.length?r:[{height:null,label:'Highest'}];
}
function chooseVariant(media,h) {
const vs=variants(media); if(!vs.length) return null; if(!h) return vs[0];
return vs.find(v=>v.height===h) || vs.filter(v=>v.height>0&&v.height<=h).sort((a,b)=>b.height-a.height)[0] || vs[vs.length-1];
}
function closePicker() {
activePicker?.remove(); activePicker=null;
document.removeEventListener('mousedown',outsidePicker,true);
}
function outsidePicker(e){ if(activePicker&&!activePicker.contains(e.target)) closePicker(); }
function videoInfo(media) {
const v=variants(media)[0];
if(!v) return {res:'Unavailable',fps:'FPS unavailable',bitrate:'Unavailable',format:'MP4'};
const r=resolution(v);
const fps=Number(media?.fps||0);
return {
res:r.width&&r.height?`${r.width} × ${r.height}`:'Resolution unavailable',
fps:fps>0?`${fps} FPS`:'FPS unavailable',
bitrate:Number(v.bitrate||0)>0?`${(v.bitrate/1000000).toFixed(1)} Mbps`:'Unavailable',
format:'MP4'
};
}
function showPicker(anchor,videos,user,id,allMedia=videos) {
closePicker();
const p=document.createElement('div'); p.id='xmd-video-quality-picker';
const t=document.createElement('div'); t.className='xmd-quality-title'; t.textContent='Download video'; p.appendChild(t);
const info=videoInfo(videos[0]);
const box=document.createElement('div'); box.className='xmd-video-info';
box.innerHTML=`<div class="xmd-video-info-row">${info.res}</div><div class="xmd-video-info-row">${info.fps}</div><div class="xmd-video-info-row">${info.bitrate}</div><div class="xmd-video-info-row">${info.format}</div>`;
p.appendChild(box);
for(const opt of qualityOptions(videos)) {
const b=document.createElement('button'); b.type='button'; b.className='xmd-quality-option'; b.textContent=opt.label;
b.addEventListener('click',async e=>{
e.preventDefault();e.stopPropagation();closePicker();
let ok=0;
for(let i=0;i<allMedia.length;i++){
const m=allMedia[i];
const v=(m.type==='video'||m.type==='gif')?chooseVariant(m,opt.height):null;
if(await downloadMedia(m,user,id,i,v)) ok++;
await sleep(300);
}
toast(ok===allMedia.length?(ok===1?'Download started':`Downloaded ${ok} files`):`Downloaded ${ok}/${allMedia.length}`);
});
p.appendChild(b);
}
document.body.appendChild(p); activePicker=p;
const r=anchor?.getBoundingClientRect?.();
if(r){
let left=r.left, top=r.bottom+8, pr=p.getBoundingClientRect();
if(left+pr.width>innerWidth-10) left=innerWidth-pr.width-10;
if(top+pr.height>innerHeight-10) top=r.top-pr.height-8;
p.style.left=`${Math.max(10,left)}px`; p.style.top=`${Math.max(10,top)}px`;
} else { p.style.left='50%';p.style.top='50%';p.style.transform='translate(-50%,-50%)'; }
setTimeout(()=>document.addEventListener('mousedown',outsidePicker,true),0);
}
function toast(msg){
let t=document.getElementById('xmd-toast');
if(!t){t=document.createElement('div');t.id='xmd-toast';document.body.appendChild(t);}
t.textContent=msg;t.classList.add('xmd-visible');clearTimeout(toastTimer);toastTimer=setTimeout(()=>t.classList.remove('xmd-visible'),3000);
}
// ---------------- SHARE MENU ----------------
function visibleMenu(){
return [...document.querySelectorAll('[role="menu"]')].filter(m=>{const r=m.getBoundingClientRect(),s=getComputedStyle(m);return r.width>0&&r.height>0&&s.display!=='none'&&s.visibility!=='hidden';}).pop()||null;
}
function createMenuItem(tweet){
const item=document.createElement('div'); item.className='xmd-download-item';item.setAttribute('role','menuitem');item.tabIndex=0;
item.innerHTML='<div class="xmd-menu-icon"><svg viewBox="0 0 24 24"><path d="M12 3v11.17l4.59-4.58L18 11l-6 6-6-6 1.41-1.41L10 14.17V3h2zM5 19h14v2H5v-2z"/></svg></div><span class="xmd-menu-text">Download media</span>';
const start=async e=>{
e.preventDefault();e.stopPropagation();item.classList.add('xmd-loading');
try{
const id=tweetId(tweet);if(!id)return toast('Could not find tweet ID');
toast('Getting media information...');const media=extract(await tweetData(id));if(!media.length)return toast('No downloadable media found');
const vids=media.filter(m=>m.type==='video'||m.type==='gif');
const user=username(tweet);
if(vids.length) return showPicker(item,vids,user,id,media);
let ok=0;for(let i=0;i<media.length;i++){if(await downloadMedia(media[i],user,id,i))ok++;await sleep(300);} toast(ok===media.length?(ok===1?'Download started':`Downloaded ${ok} files`):`Downloaded ${ok}/${media.length}`);
}catch(e){console.error(e);toast('Could not get media information');}finally{item.classList.remove('xmd-loading');}
};
item.addEventListener('click',start,true);item.addEventListener('keydown',e=>{if(e.key==='Enter'||e.key===' ')start(e);});return item;
}
function addMenuItem(){const m=visibleMenu();if(!m||!lastTweet||m.querySelector('.xmd-download-item')||!hasMedia(lastTweet))return;m.insertBefore(createMenuItem(lastTweet),m.firstChild);}
document.addEventListener('click',e=>{
const el=e.target instanceof Element?e.target:null;if(!el)return;
const b=el.closest('button,[role="button"],[data-testid]');if(!b)return;
const label=(b.getAttribute('aria-label')||b.getAttribute('title')||b.getAttribute('data-testid')||'').toLowerCase();
if(!label.includes('share'))return;
const tweet=tweetOf(b);if(!tweet)return;
lastTweet=hasMedia(tweet)?tweet:null;if(!lastTweet)return;
[0,50,150,300,600].forEach(ms=>setTimeout(addMenuItem,ms));
},true);
// ---------------- HOVER DOWNLOAD ----------------
function createHoverButton(mediaEl){
if(hoverButtons.has(mediaEl))return hoverButtons.get(mediaEl);
const b=document.createElement('button');b.type='button';b.className='xmd-hover-download';b.title='Download media';b.setAttribute('aria-label','Download media');b.innerHTML='<svg viewBox="0 0 24 24"><path d="M12 3v11.17l4.59-4.58L18 11l-6 6-6-6 1.41-1.41L10 14.17V3h2zM5 19h14v2H5v-2z"/></svg>';
b.addEventListener('click',async e=>{
e.preventDefault();e.stopPropagation();e.stopImmediatePropagation();
const id=findMediaTweetId(mediaEl);if(!id)return toast('Could not find tweet');
try{
const media=extract(await tweetData(id));if(!media.length)return toast('No downloadable media found');
const scope=findMediaScope(mediaEl);const dom=scope?[...scope.querySelectorAll('img[src*="pbs.twimg.com/media/"],video')]:[];let idx=dom.indexOf(mediaEl);if(idx<0)idx=0;const item=media[idx]||media[0];const user=username(tweetOf(mediaEl));
if(item.type==='video'||item.type==='gif')return showPicker(b,[item],user,id,[item]);
toast('Downloading media...');toast(await downloadMedia(item,user,id,idx)?'Download started':'Download failed');
}catch(err){console.error(err);toast('Could not get media information');}
},true);
hoverButtons.set(mediaEl,b);return b;
}
function installHover(el){
if(el.dataset.xmdHoverInstalled==='1')return;el.dataset.xmdHoverInstalled='1';
const w=el.parentElement;if(!w)return;if(getComputedStyle(w).position==='static')w.style.position='relative';
const b=createHoverButton(el);if(b.parentElement!==w)w.appendChild(b);
el.addEventListener('mouseenter',()=>b.classList.add('xmd-hover-visible'));el.addEventListener('mouseleave',()=>setTimeout(()=>{if(!b.matches(':hover'))b.classList.remove('xmd-hover-visible')},100));
b.addEventListener('mouseenter',()=>b.classList.add('xmd-hover-visible'));b.addEventListener('mouseleave',()=>b.classList.remove('xmd-hover-visible'));
}
function scanHover(){document.querySelectorAll('article[data-testid="tweet"] img[src*="pbs.twimg.com/media/"],article[data-testid="tweet"] video').forEach(installHover);}
function findMediaTweetId(el){
if(!el)return null;let cur=el;
while(cur&&cur!==document.body){for(const a of cur.querySelectorAll?.('a[href*="/status/"]')||[]){const m=(a.getAttribute('href')||a.href||'').match(/\/status\/(\d+)/);if(m)return m[1];}cur=cur.parentElement;}
return tweetId(tweetOf(el));
}
function findMediaScope(el){return el?.closest?.('[data-testid="quoteTweet"],[data-testid="tweetQuote"],[data-testid="tweetPhoto"],[data-testid="videoPlayer"]')||tweetOf(el);}
// ---------------- IMAGE VIEWER ----------------
function createViewer(){
if(overlay)return;
overlay=document.createElement('div');overlay.id='xmd-zoom-overlay';
overlay.innerHTML=`<div id="xmd-zoom-backdrop"></div><div id="xmd-zoom-container">
<button id="xmd-zoom-close" type="button" title="Close"><svg viewBox="0 0 24 24"><path d="M18.3 5.71L12 12l6.3 6.3-1.4 1.4-6.3-6.3-6.3 6.3-1.4-1.4 6.3-6.3-6.3-6.3 1.4-1.4 6.3 6.3 6.3-6.3z"/></svg></button>
<button id="xmd-gallery-prev" type="button" title="Previous image"><svg viewBox="0 0 24 24"><path d="M15.41 7.41 14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></svg></button>
<button id="xmd-gallery-next" type="button" title="Next image"><svg viewBox="0 0 24 24"><path d="m8.59 16.59 1.41 1.41 6-6-6-6-1.41 1.41L13.17 12z"/></svg></button>
<div id="xmd-zoom-stage"></div><div id="xmd-gallery-counter">1 / 1</div>
<div id="xmd-zoom-controls"><button id="xmd-zoom-minus" title="Zoom out"><svg viewBox="0 0 24 24"><path d="M5 11h14v2H5z"/></svg></button><span id="xmd-zoom-level">100%</span><button id="xmd-zoom-plus" title="Zoom in"><svg viewBox="0 0 24 24"><path d="M19 11h-6V5h-2v6H5v2h6v6h2v-6h6z"/></svg></button>
<button id="xmd-zoom-copy" title="Copy image URL"><svg viewBox="0 0 24 24"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg><span class="xmd-copy-text">Copy URL</span></button>
<button id="xmd-open-original" title="Open original"><svg viewBox="0 0 24 24"><path d="M14 3h7v7h-2V6.41l-9.29 9.3-1.42-1.42L17.59 5H14V3zM19 19H5V5h7V3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2v-7h-2v7z"/></svg><span class="xmd-original-text">Open original</span></button><button id="xmd-zoom-reset">Reset</button></div></div>`;
document.body.appendChild(overlay);stage=document.getElementById('xmd-zoom-stage');
document.getElementById('xmd-zoom-close').onclick=closeViewer;document.getElementById('xmd-zoom-backdrop').onclick=closeViewer;
document.getElementById('xmd-gallery-prev').onclick=()=>showGallery(galleryIndex-1);document.getElementById('xmd-gallery-next').onclick=()=>showGallery(galleryIndex+1);
document.getElementById('xmd-zoom-minus').onclick=()=>zoomStep(-1);document.getElementById('xmd-zoom-plus').onclick=()=>zoomStep(1);document.getElementById('xmd-zoom-reset').onclick=resetZoom;
document.getElementById('xmd-zoom-copy').onclick=copyCurrent;document.getElementById('xmd-open-original').onclick=openOriginal;
stage.addEventListener('wheel',e=>{e.preventDefault();e.stopPropagation();const r=stage.getBoundingClientRect();zoomAt(e.deltaY<0?1:-1,e.clientX-(r.left+r.width/2),e.clientY-(r.top+r.height/2));},{passive:false});
stage.addEventListener('mousedown',e=>{if(e.target!==viewerMedia)return;dragging=true;dragX=e.clientX;dragY=e.clientY;startPanX=panX;startPanY=panY;e.preventDefault();});
window.addEventListener('mousemove',e=>{if(!dragging)return;panX=startPanX+e.clientX-dragX;panY=startPanY+e.clientY-dragY;transform();});window.addEventListener('mouseup',()=>{dragging=false;stage.classList.remove('xmd-dragging')});
stage.addEventListener('mousedown',()=>stage.classList.add('xmd-dragging'));stage.addEventListener('dblclick',e=>{if(e.target!==viewerMedia)return;e.preventDefault();zoom<=1?setZoom(2):resetZoom();});
}
function openGallery(el){
if(el.tagName==='VIDEO')return;
const id=findMediaTweetId(el);if(!id)return toast('Could not find tweet');
savedScrollX=scrollX;savedScrollY=scrollY;toast('Loading media...');
tweetData(id).then(data=>{
gallery=extract(data).filter(m=>m.type==='image');if(!gallery.length)return toast('No images found');
let idx=0;const src=originalImage(el.currentSrc||el.src||'');const found=gallery.findIndex(m=>originalImage(m.url)===src);if(found>=0)idx=found;galleryIndex=idx;resetZoom();overlay.classList.add('xmd-zoom-open');showGallery(idx);scrollTo(savedScrollX,savedScrollY);
}).catch(e=>{console.error(e);toast('Could not load media');});
}
function showGallery(i){if(!gallery.length)return;if(i<0)i=gallery.length-1;if(i>=gallery.length)i=0;galleryIndex=i;const m=gallery[i];stage.innerHTML='';viewerMedia=document.createElement('img');viewerMedia.src=originalImage(m.url);viewerMedia.className='xmd-zoom-media';viewerMedia.draggable=false;stage.appendChild(viewerMedia);resetZoom();document.getElementById('xmd-gallery-counter').style.display=gallery.length>1?'block':'none';document.getElementById('xmd-gallery-prev').style.display=gallery.length>1?'flex':'none';document.getElementById('xmd-gallery-next').style.display=gallery.length>1?'flex':'none';document.getElementById('xmd-gallery-counter').textContent=`${i+1} / ${gallery.length}`;}
function transform(){if(!viewerMedia)return;viewerMedia.style.transform=`translate3d(${panX}px,${panY}px,0) scale(${zoom})`;const l=document.getElementById('xmd-zoom-level');if(l)l.textContent=`${Math.round(zoom*100)}%`;}
function resetZoom(){zoom=1;panX=0;panY=0;transform();}
function zoomIndex(){let bi=0,bd=Infinity;ZOOMS.forEach((v,i)=>{const d=Math.abs(v-zoom);if(d<bd){bd=d;bi=i;}});return bi;}
function zoomStep(d){const n=Math.min(ZOOMS.length-1,Math.max(0,zoomIndex()+d));setZoom(ZOOMS[n]);}
function setZoom(v){zoom=Math.min(5,Math.max(.25,v));if(zoom===1){panX=0;panY=0;}transform();}
function zoomAt(d,x,y){const old=zoom,n=Math.min(ZOOMS.length-1,Math.max(0,zoomIndex()+d)),next=ZOOMS[n];if(next===old)return;const ratio=next/old;panX=x-(x-panX)*ratio;panY=y-(y-panY)*ratio;zoom=next;if(zoom===1){panX=0;panY=0;}transform();}
function closeViewer(){overlay.classList.remove('xmd-zoom-open');viewerMedia=null;gallery=[];galleryIndex=0;scrollTo(savedScrollX,savedScrollY);setTimeout(()=>scrollTo(savedScrollX,savedScrollY),50);}
async function copyCurrent(){if(!gallery[galleryIndex])return;try{await navigator.clipboard.writeText(originalImage(gallery[galleryIndex].url));toast('Image URL copied');}catch{toast('Could not copy image URL');}}
function openOriginal(){if(!gallery[galleryIndex])return;window.open(originalImage(gallery[galleryIndex].url),'_blank','noopener,noreferrer');}
document.addEventListener('click',e=>{
if(overlay?.classList.contains('xmd-zoom-open'))return;
const el=e.target instanceof Element?e.target:null;if(!el||el.closest('.xmd-hover-download'))return;
const video=el.closest('video');if(video&&tweetOf(video))return;
const img=el.closest('img');
if(img&&img.closest('article[data-testid="tweet"]')&&(img.currentSrc||img.src||'').includes('pbs.twimg.com/media/')){if(el.closest('button,[role="button"]'))return;e.preventDefault();e.stopPropagation();e.stopImmediatePropagation();openGallery(img);}
},true);
document.addEventListener('keydown',e=>{
if(!overlay?.classList.contains('xmd-zoom-open'))return;
if(e.key==='Escape'){e.preventDefault();closeViewer();return;}
if(e.key==='ArrowLeft'){e.preventDefault();showGallery(galleryIndex-1);return;}
if(e.key==='ArrowRight'){e.preventDefault();showGallery(galleryIndex+1);return;}
if(e.key==='+'||e.key==='='){e.preventDefault();zoomStep(1);return;}
if(e.key==='-'){e.preventDefault();zoomStep(-1);return;}
if(e.key==='0'){e.preventDefault();resetZoom();return;}
if(e.key.toLowerCase()==='c'){e.preventDefault();copyCurrent();return;}
if(e.key.toLowerCase()==='o'){e.preventDefault();openOriginal();return;}
},true);
const observer=new MutationObserver(()=>{if(lastTweet)addMenuItem();scanHover();});
observer.observe(document.documentElement,{childList:true,subtree:true});
function styles(){
if(document.getElementById('xmd-all-styles'))return;
const s=document.createElement('style');s.id='xmd-all-styles';s.textContent=`
.xmd-download-item{min-height:48px;width:100%;box-sizing:border-box;display:flex;align-items:center;padding:12px 16px;cursor:pointer;color:#e7e9ea;background:transparent;font:400 15px/20px -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;user-select:none}.xmd-download-item:hover,.xmd-download-item:focus{background:rgba(231,233,234,.1);outline:0}.xmd-menu-icon{width:24px;height:24px;margin-right:12px;display:flex;align-items:center;justify-content:center;flex:0 0 24px}.xmd-menu-icon svg{width:20px;height:20px;fill:currentColor}.xmd-loading{opacity:.5;pointer-events:none}
.xmd-hover-download{position:absolute;top:10px;right:10px;width:38px;height:38px;padding:0;margin:0;border:0;border-radius:9999px;display:flex;align-items:center;justify-content:center;color:#fff;background:rgba(15,20,25,.88);box-shadow:0 2px 8px rgba(0,0,0,.35);cursor:pointer;opacity:0;visibility:hidden;transform:scale(.92);transition:.15s;z-index:999;pointer-events:none}.xmd-hover-download.xmd-hover-visible{opacity:1;visibility:visible;transform:scale(1);pointer-events:auto}.xmd-hover-download:hover{background:rgb(29,155,240)}.xmd-hover-download svg{width:19px;height:19px;fill:currentColor}
#xmd-video-quality-picker{position:fixed;min-width:210px;max-width:280px;padding:8px;background:rgba(15,20,25,.98);border:1px solid rgba(255,255,255,.12);border-radius:14px;box-shadow:0 8px 30px rgba(0,0,0,.5);z-index:2147483647;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif}.xmd-quality-title{padding:8px 10px 10px;color:#e7e9ea;font-size:15px;font-weight:700}.xmd-video-info{margin:0 4px 8px;padding:9px 10px;border-radius:9px;background:rgba(255,255,255,.06);color:rgba(255,255,255,.72)}.xmd-video-info-row{font-size:12px;line-height:18px;white-space:nowrap}.xmd-quality-option{width:100%;min-height:42px;padding:8px 12px;margin:2px 0;border:0;border-radius:9px;background:transparent;color:#e7e9ea;text-align:left;font:500 15px inherit;cursor:pointer}.xmd-quality-option:hover{background:rgba(255,255,255,.1)}
#xmd-download-progress{position:fixed;right:24px;bottom:24px;width:360px;max-width:calc(100vw - 32px);padding:14px;box-sizing:border-box;background:rgba(15,20,25,.97);border:1px solid rgba(255,255,255,.12);border-radius:14px;box-shadow:0 8px 35px rgba(0,0,0,.5);z-index:2147483647;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;opacity:0;visibility:hidden;transform:translateY(12px);transition:.18s;pointer-events:none}#xmd-download-progress.xmd-progress-visible{opacity:1;visibility:visible;transform:translateY(0)}.xmd-progress-title{color:#fff;font-size:14px;font-weight:700;line-height:18px;margin-bottom:9px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.xmd-progress-info{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:8px;color:rgba(255,255,255,.72);font-size:12px}.xmd-progress-track{width:100%;height:7px;overflow:hidden;border-radius:9999px;background:rgba(255,255,255,.13)}.xmd-progress-fill{width:0;height:100%;border-radius:inherit;background:rgb(29,155,240);transition:width .12s linear}.xmd-progress-fill.xmd-progress-indeterminate{width:35%!important;animation:xmd-progress-slide 1s ease-in-out infinite}@keyframes xmd-progress-slide{0%{transform:translateX(-140%)}50%{transform:translateX(180%)}100%{transform:translateX(420%)}}
#xmd-toast{position:fixed;left:50%;bottom:30px;transform:translate(-50%,20px);padding:10px 16px;border-radius:9999px;background:rgba(15,20,25,.96);color:#e7e9ea;font:14px/18px -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;opacity:0;pointer-events:none;z-index:2147483647;transition:.15s}#xmd-toast.xmd-visible{opacity:1;transform:translate(-50%,0)}
#xmd-zoom-overlay{position:fixed;inset:0;width:100vw;height:100vh;display:none;z-index:2147483647;overscroll-behavior:none;touch-action:none}#xmd-zoom-overlay.xmd-zoom-open{display:block}#xmd-zoom-backdrop{position:absolute;inset:0;background:rgba(0,0,0,.95)}#xmd-zoom-container{position:relative;width:100%;height:100%;display:flex;align-items:center;justify-content:center;overflow:hidden}#xmd-zoom-stage{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;overflow:hidden;touch-action:none}.xmd-zoom-media{display:block;max-width:92vw;max-height:88vh;width:auto;height:auto;object-fit:contain;transform-origin:center;user-select:none;-webkit-user-drag:none;will-change:transform;transition:transform .08s ease-out;border-radius:4px;box-shadow:0 10px 50px rgba(0,0,0,.4)}
#xmd-zoom-close,#xmd-gallery-prev,#xmd-gallery-next{position:absolute;width:46px;height:46px;padding:0;border:0;border-radius:9999px;display:flex;align-items:center;justify-content:center;background:rgba(15,20,25,.88);color:#fff;cursor:pointer;z-index:20;box-shadow:0 3px 14px rgba(0,0,0,.35)}#xmd-zoom-close{top:18px;right:18px}#xmd-gallery-prev{top:50%;left:20px;transform:translateY(-50%)}#xmd-gallery-next{top:50%;right:20px;transform:translateY(-50%)}#xmd-zoom-close svg,#xmd-gallery-prev svg,#xmd-gallery-next svg{width:24px;height:24px;fill:currentColor}
#xmd-gallery-counter{position:absolute;top:20px;left:50%;transform:translateX(-50%);padding:7px 12px;border-radius:9999px;background:rgba(15,20,25,.9);color:#fff;font:600 14px -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;z-index:20}#xmd-zoom-controls{position:absolute;left:50%;bottom:20px;transform:translateX(-50%);display:flex;align-items:center;gap:5px;padding:6px;background:rgba(15,20,25,.94);border-radius:9999px;z-index:20;box-shadow:0 4px 20px rgba(0,0,0,.4)}#xmd-zoom-controls button{min-width:36px;height:36px;padding:0;border:0;border-radius:9999px;background:transparent;color:#fff;display:flex;align-items:center;justify-content:center;cursor:pointer}#xmd-zoom-controls button:hover{background:rgba(255,255,255,.12)}#xmd-zoom-controls button svg{width:20px;height:20px;fill:currentColor}#xmd-zoom-level{min-width:60px;text-align:center;color:#fff;font:600 14px -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif}#xmd-zoom-copy,#xmd-open-original,#xmd-zoom-reset{width:auto!important;padding:0 10px!important;gap:6px}#xmd-zoom-copy .xmd-copy-text,#xmd-open-original .xmd-original-text{font-size:14px;font-weight:600;white-space:nowrap}
@media(prefers-color-scheme:light){.xmd-download-item{color:rgb(15,20,25)}.xmd-download-item:hover,.xmd-download-item:focus{background:rgba(15,20,25,.08)}#xmd-video-quality-picker,#xmd-download-progress{background:rgba(255,255,255,.98);border-color:rgba(15,20,25,.12)}.xmd-quality-title,.xmd-quality-option{color:rgb(15,20,25)}.xmd-quality-option:hover{background:rgba(15,20,25,.08)}.xmd-video-info{background:rgba(15,20,25,.06);color:rgba(15,20,25,.65)}#xmd-toast{background:#fff;color:rgb(15,20,25)}#xmd-progress-title,#xmd-progress-percent{color:rgb(15,20,25)}.xmd-progress-info{color:rgba(15,20,25,.65)}}
@media(max-width:600px){.xmd-hover-download{width:36px;height:36px;top:8px;right:8px}#xmd-gallery-prev{left:8px}#xmd-gallery-next{right:8px}#xmd-zoom-copy .xmd-copy-text,#xmd-open-original .xmd-original-text{display:none}#xmd-zoom-copy,#xmd-open-original{width:36px!important;padding:0!important}#xmd-download-progress{right:16px;bottom:16px;width:calc(100vw - 32px)}#xmd-video-quality-picker{min-width:190px;max-width:calc(100vw - 20px)}}`;
document.documentElement.appendChild(s);
}
function init(){
styles();
const ready=()=>{createViewer();createProgress();scanHover();};
if(document.body)ready();else document.addEventListener('DOMContentLoaded',ready,{once:true});
console.log('[X Media Downloader + Zoom + Gallery] v16.2.1 loaded');
}
init();
})();