Twitter/X Max Image

Load max-res Twitter/X images, auto-hide when idle

// ==UserScript==
// @name         Twitter/X Max Image
// @namespace    http://tampermonkey.net/
// @version      1.9
// @description  Load max-res Twitter/X images, auto-hide when idle
// @match        https://pbs.twimg.com/*
// @grant        GM_xmlhttpRequest
// @grant        GM_download
// @connect      pbs.twimg.com
// @run-at       document-start
// @author       nereids
// @icon         https://icons.duckduckgo.com/ip3/x.com.ico
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  // --------- Redirect logic (loop-safe, try orig/original/no-name) ----------
  if (location.hostname === 'pbs.twimg.com') {
    const triedKey = 'pbs-max-redirected';
    if (sessionStorage.getItem(triedKey) !== '1') {
      const currentURL = new URL(location.href);
      const currName = currentURL.searchParams.get('name');
      if (!['orig', 'original', null].includes(currName)) {
        const candidates = ['orig', 'original', null];

        function head(url) {
          return new Promise(resolve => {
            try {
              GM_xmlhttpRequest({
                method: 'HEAD',
                url,
                headers: { 'Accept': 'image/*' },
                onload: (res) => resolve(res && res.status === 200),
                onerror: () => resolve(false),
                ontimeout: () => resolve(false)
              });
            } catch (e) { resolve(false); }
          });
        }

        (async () => {
          for (const name of candidates) {
            const u = new URL(location.href);
            if (name === null) u.searchParams.delete('name');
            else u.searchParams.set('name', name);
            if (u.href === location.href) continue;
            const ok = await head(u.href);
            if (ok) {
              sessionStorage.setItem(triedKey, '1');
              location.replace(u.href);
              return;
            }
          }
          sessionStorage.setItem(triedKey, '1');
        })();
      } else {
        sessionStorage.setItem(triedKey, '1');
      }
    }
  }

  // --------- Add SVG download button ----------
  function addDownloadButton() {
    if (location.hostname !== 'pbs.twimg.com') return;
    if (document.getElementById('pbs-svg-dl-btn')) return;

    const imageUrl = location.href;

    const btn = document.createElement('button');
    btn.id = 'pbs-svg-dl-btn';
    btn.type = 'button';
    btn.title = 'Download image';
    Object.assign(btn.style, {
      position: 'fixed',
      right: '22px',
      bottom: '22px',
      width: '48px',
      height: '48px',
      borderRadius: '50%',
      border: 'none',
      padding: '0',
      display: 'flex',
      alignItems: 'center',
      justifyContent: 'center',
      cursor: 'pointer',
      zIndex: 2147483647,
      boxShadow: '0 8px 18px rgba(2,6,23,0.35)',
      background: '#1d9bf0',
      backdropFilter: 'blur(4px)',
      transition: 'transform .12s ease, box-shadow .12s ease, opacity 0.4s ease',
    });

    // --- CHANGE THIS SVG TO YOUR OWN IF YOU WANT ---
    btn.innerHTML = `
      <svg xmlns="http://www.w3.org/2000/svg" height="36px" viewBox="0 -960 960 960" width="36px" fill="#e3e3e3">
        <path d="M433-398v-329q0-20 13.5-33.5T480-774q20 0 33.5 13.5T527-727v331l118-118q14-14 33-14t33 14q14 14 14 33t-14 33L513-250q-14 14-34 14t-34-14L247-448q-14-14-14-33t14-33q14-14 33.5-14.5T314-515l119 117Z"/>
      </svg>
    `;

    // Download logic
    btn.addEventListener('click', async (e) => {
      e.stopPropagation();
      e.preventDefault();

      try {
        const urlObj = new URL(imageUrl);
        let fmt = (urlObj.searchParams.get('format') || '').toLowerCase();
        if (fmt === 'jfif' || fmt === 'jpeg') fmt = 'jpg';
        if (!fmt) {
          const pathExtMatch = urlObj.pathname.match(/\.([a-zA-Z0-9]+)$/);
          if (pathExtMatch) fmt = pathExtMatch[1].toLowerCase();
        }
        if (!fmt) fmt = 'jpg';

        let base = urlObj.pathname.split('/').pop() || 'image';
        base = base.replace(/\.[^/.]+$/, '');
        let filename = `${base}.${fmt}`;

        if (typeof GM_download === 'function') {
          try {
            GM_download({
              url: imageUrl,
              name: filename,
              onerror: () => fetchAndSave(imageUrl, filename)
            });
            return;
          } catch (err) { /* fallback */ }
        }
        await fetchAndSave(imageUrl, filename);
      } catch (err) {
        console.error('Download failed', err);
      }
    });

    function fetchAndSave(url, filename) {
      return fetch(url, { mode: 'cors' })
        .then(resp => {
          if (!resp.ok) throw new Error('Network not ok');
          return resp.blob().then(blob => {
            const ct = resp.headers.get('content-type') || '';
            let ext = filename.split('.').pop().toLowerCase();
            if (ct.includes('jpeg')) ext = 'jpg';
            if (ct.includes('png')) ext = 'png';
            if (ct.includes('webp')) ext = 'webp';
            if (!filename.toLowerCase().endsWith('.' + ext)) {
              filename = filename.replace(/\.[^/.]+$/, '') + '.' + ext;
            }
            const blobUrl = URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.href = blobUrl;
            a.download = filename;
            document.body.appendChild(a);
            a.click();
            a.remove();
            setTimeout(() => URL.revokeObjectURL(blobUrl), 5000);
          });
        })
        .catch(err => {
          console.error('fetchAndSave failed', err);
          window.open(url, '_blank', 'noopener');
        });
    }

    // Append button
    const attach = () => { document.body && document.body.appendChild(btn); };
    if (document.body) attach();
    else document.addEventListener('DOMContentLoaded', attach);

    // --- Auto-hide logic ---
    let hideTimeout;
    function showButton() {
      btn.style.opacity = '1';
      btn.style.pointerEvents = 'auto';
      clearTimeout(hideTimeout);
      hideTimeout = setTimeout(hideButton, 1500); // hide after 1.5s
    }
    function hideButton() {
      btn.style.opacity = '0';
      btn.style.pointerEvents = 'none';
    }
    showButton();
    document.addEventListener('mousemove', showButton);
  }

  // Wait for DOM
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', addDownloadButton, { once: true });
  } else {
    setTimeout(addDownloadButton, 120);
  }

})();