Greasy Fork is available in English.

X Media Pro

Download and manage X media with video quality selection, progress tracking, image zoom, gallery, and more.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

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