Pinterest Power Menu

All-in-one Pinterest power tool: original quality, download fixer, video downloader, board folder downloader, GIF hover/auto-play, remove videos, hide Visit Site, declutter, hide UI elements, hide shop posts, hide comments, scroll preservation

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Pinterest Power Menu
// @namespace    https://github.com/Angel2mp3
// @version      1.3.3
// @description  All-in-one Pinterest power tool: original quality, download fixer, video downloader, board folder downloader, GIF hover/auto-play, remove videos, hide Visit Site, declutter, hide UI elements, hide shop posts, hide comments, scroll preservation
// @author       Angel2mp3
// @icon         https://www.pinterest.com/favicon.ico
// @match        https://www.pinterest.com/*
// @match        https://pinterest.com/*
// @match        https://*.pinterest.com/*
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_setClipboard
// @connect      *
// @run-at       document-start
// ==/UserScript==

(function () {
  'use strict';

  // ═══════════════════════════════════════════════════════════════════
  //  SETTINGS
  // ═══════════════════════════════════════════════════════════════════
  const SETTINGS_KEY = 'pe_settings_v1';

  // ── Mobile / touch detection ─────────────────────────────────────────
  // Declared early so DEFAULTS can reference it (contextMenu off on mobile).
  // Gates features that are mouse-only or cause jank on touch devices.
  const IS_MOBILE = /android|iphone|ipad|ipod|mobile/i.test(navigator.userAgent)
    || (navigator.maxTouchPoints > 1 && /macintel/i.test(navigator.platform));

  const DEFAULTS = {
    originalQuality:  true,
    downloadFixer:    true,
    gifHover:         true,
    hideVisitSite:    true,
    boardDownloader:  true,
    declutter:        true,
    contextMenu:      !IS_MOBILE,  // mouse-only feature; off by default on mobile
    hideUpdates:      false,
    hideMessages:     false,
    hideShare:        false,
    gifAutoPlay:      false,
    removeVideos:     false,
    hideShopPosts:    false,
    hideComments:     false,
    videoDownloader:  true,
  };

  let _cfg = null;

  function loadCfg() {
    try {
      const raw = GM_getValue(SETTINGS_KEY, null);
      _cfg = raw ? { ...DEFAULTS, ...JSON.parse(raw) } : { ...DEFAULTS };
    } catch (_) {
      _cfg = { ...DEFAULTS };
    }
  }

  function saveCfg() {
    GM_setValue(SETTINGS_KEY, JSON.stringify(_cfg));
  }

  function get(key) {
    if (!_cfg) loadCfg();
    return key in _cfg ? _cfg[key] : DEFAULTS[key];
  }

  function set(key, val) {
    if (!_cfg) loadCfg();
    _cfg[key] = val;
    saveCfg();
  }

  loadCfg();

  // ─── Video URL interceptor ──────────────────────────────────────────────
  // On desktop, Pinterest uses HLS.js which sets video.src to a blob:
  // MediaSource URL — findPinterestVideoSrc() cannot read the actual CDN URL
  // from the DOM.  Intercept XHR/fetch at document-start to capture
  // v1.pinimg.com video URLs as they are requested by HLS.js, then use them
  // as a fallback in createVideoDlFab().
  const _interceptedVideoUrls = [];   // most-recently-seen first
  let _onVideoUrlCapture = null;      // set after createVideoDlFab is defined
  (function () {
    function captureVideoUrl(url) {
      if (typeof url !== 'string') return;
      if (!/v1\.pinimg\.com\/videos/i.test(url)) return;
      const idx = _interceptedVideoUrls.indexOf(url);
      if (idx !== -1) _interceptedVideoUrls.splice(idx, 1);
      _interceptedVideoUrls.unshift(url);                // newest first
      if (_interceptedVideoUrls.length > 20) _interceptedVideoUrls.pop();
      if (typeof _onVideoUrlCapture === 'function') _onVideoUrlCapture();
    }
    const _xOpen = XMLHttpRequest.prototype.open;
    XMLHttpRequest.prototype.open = function (m, url, ...a) {
      captureVideoUrl(String(url));
      return _xOpen.call(this, m, url, ...a);
    };
    const _oFetch = window.fetch;
    if (typeof _oFetch === 'function') {
      window.fetch = function (input) {
        captureVideoUrl(typeof input === 'string' ? input : (input && input.url) || '');
        return _oFetch.apply(this, arguments);
      };
    }
  })();

  // Utility: returns a debounced version of fn (resets timer on every call).
  function debounce(fn, ms) {
    let t;
    return function () { clearTimeout(t); t = setTimeout(fn, ms); };
  }


  // ═══════════════════════════════════════════════════════════════════
  //  MODULE: ORIGINAL QUALITY  (fast – no probe, no popup)
  // ═══════════════════════════════════════════════════════════════════
  // Directly rewrite pinimg.com thumbnail URLs → /originals/ with
  // an inline onerror fallback to /736x/ so zero extra requests are
  // made upfront and the "Optimizing…" overlay is never shown.

  const OQ_RE = /^(https?:\/\/i\.pinimg\.com)\/\d+x(\/[0-9a-f]{2}\/[0-9a-f]{2}\/[0-9a-f]{2}\/[0-9a-f]{32}\.(?:jpg|jpeg|png|gif|webp))$/i;

  function upgradeImg(img) {
    if (!get('originalQuality')) return;
    if (img.__peOQ || img.tagName !== 'IMG' || !img.src) return;
    const m = img.src.match(OQ_RE);
    if (!m) return;
    img.__peOQ = true;
    const origSrc = m[1] + '/originals' + m[2];
    const fallSrc = m[1] + '/736x'      + m[2];
    img.onerror = function () {
      if (img.src === origSrc) { img.onerror = null; img.src = fallSrc; }
    };
    if (img.getAttribute('data-src') === img.src) img.setAttribute('data-src', origSrc);
    img.src = origSrc;
  }

  function scanOQ(node) {
    if (!node || node.nodeType !== 1) return;
    if (node.tagName === 'IMG') upgradeImg(node);
    else node.querySelectorAll('img[src*="pinimg.com"]').forEach(upgradeImg);
  }

  // Start MutationObserver immediately (document-start) so we catch
  // images before they fire their first load event.
  const oqObs = new MutationObserver(records => {
    if (!get('originalQuality')) return;
    const process = () => records.forEach(r => {
      if (r.attributeName === 'src') upgradeImg(r.target);
      else r.addedNodes.forEach(scanOQ);
    });
    // On mobile, yield to the browser's render pipeline so scroll stays smooth
    if (IS_MOBILE && typeof requestIdleCallback === 'function') {
      requestIdleCallback(process, { timeout: 300 });
    } else {
      process();
    }
  });
  oqObs.observe(document.documentElement, {
    childList: true, subtree: true,
    attributes: true, attributeFilter: ['src'],
  });


  // ═══════════════════════════════════════════════════════════════════
  //  MODULE: HIDE VISIT SITE
  // ═══════════════════════════════════════════════════════════════════
  // Uses CSS classes on <body> so toggles are instant and zero-cost.
  function applyVisitSiteToggle() {
    if (!document.body) return;
    document.body.classList.toggle('pe-hide-visit', get('hideVisitSite'));
  }

  function applyNavToggles() {
    if (!document.body) return;
    document.body.classList.toggle('pe-hide-updates',    get('hideUpdates'));
    document.body.classList.toggle('pe-hide-messages',   get('hideMessages'));
    document.body.classList.toggle('pe-hide-share',      get('hideShare'));
    document.body.classList.toggle('pe-hide-comments',   get('hideComments'));
  }

  // Physically removes the Messages nav button from the DOM (not just hidden with CSS).
  // A MutationObserver re-removes it whenever Pinterest re-renders the nav (SPA navigation).
  let _messagesRemoverObs = null;
  function initMessagesRemover() {
    if (!get('hideMessages')) return;
    if (_messagesRemoverObs) return; // already running
    const SELS = [
      'div[aria-label="Messages"]',
      '[data-test-id="nav-bar-speech-ellipsis"]',
    ];
    function removeNow(root) {
      SELS.forEach(sel => {
        (root.querySelectorAll ? root.querySelectorAll(sel) : []).forEach(el => el.remove());
      });
    }
    removeNow(document);
    _messagesRemoverObs = new MutationObserver(recs => {
      if (!get('hideMessages')) { _messagesRemoverObs.disconnect(); _messagesRemoverObs = null; return; }
      recs.forEach(r => r.addedNodes.forEach(n => { if (n.nodeType === 1) removeNow(n); }));
    });
    _messagesRemoverObs.observe(document.documentElement, { childList: true, subtree: true });
  }

  // JS-based "Visit site" link removal – catches links that CSS alone misses
  // (e.g. <a rel="nofollow"><div>Visit site</div></a>)
  function initVisitSiteHider() {
    function hideInTree(root) {
      if (!get('hideVisitSite') || !root) return;
      const links = root.querySelectorAll ? root.querySelectorAll('a') : [];
      links.forEach(a => {
        if (a.__peVisitHidden) return;
        const text = a.textContent.trim();
        if (/^visit\s*site$/i.test(text)) {
          a.__peVisitHidden = true;
          a.style.setProperty('display', 'none', 'important');
        }
      });
    }
    hideInTree(document);
    new MutationObserver(recs => {
      if (!get('hideVisitSite')) return;
      recs.forEach(r => r.addedNodes.forEach(n => {
        if (n.nodeType === 1) hideInTree(n);
      }));
    }).observe(document.documentElement, { childList: true, subtree: true });
  }


  // ═══════════════════════════════════════════════════════════════════
  //  MODULE: SHARE URL OVERRIDE
  // ═══════════════════════════════════════════════════════════════════
  // Replaces Pinterest's shortened pin.it URLs in the share dialog
  // with the actual pin URL.  On closeup pages that's location.href;
  // on the grid we walk up from the share button to find the pin link.
  // Also intercepts "Copy link" and clicks on the URL input box.

  function initShareOverride() {
    const nativeSetter = Object.getOwnPropertyDescriptor(
      HTMLInputElement.prototype, 'value'
    ).set;

    let _sharePinUrl = null;

    // 1) Track share/send button clicks to capture the pin's real URL
    document.addEventListener('click', e => {
      const shareBtn = e.target.closest(
        '[data-test-id="sendPinButton"], button[aria-label="Send"], ' +
        '[data-test-id="closeup-share-button"], div[aria-label="Share"], ' +
        'button[aria-label="Share"]'
      );
      if (!shareBtn) return;

      // On a pin closeup page, location.href IS the pin URL
      if (/\/pin\/\d+/.test(location.pathname)) {
        _sharePinUrl = location.href;
        return;
      }

      // On grid: walk up from the share button to find the pin card link
      _sharePinUrl = null;
      let el = shareBtn;
      for (let i = 0; i < 30 && el; i++) {
        if (el.querySelector) {
          const link = el.querySelector('a[href*="/pin/"]');
          if (link) {
            _sharePinUrl = new URL(link.href, location.origin).href;
            break;
          }
        }
        el = el.parentElement;
      }
      if (!_sharePinUrl) _sharePinUrl = location.href;
    }, true);

    // 2) Watch for the share-popup URL input and override its value
    function fixShareInputs() {
      const realUrl = _sharePinUrl || location.href;
      document.querySelectorAll(
        'input#url-text, ' +
        '[data-test-id="copy-link-share-icon-auth"] input[type="text"], ' +
        'input[readonly][value*="pin.it"], ' +
        'input[readonly][value*="pinterest.com/pin/"]'
      ).forEach(input => {
        // Always re-fix if value doesn't match
        if (input.value !== realUrl) {
          nativeSetter.call(input, realUrl);
          input.dispatchEvent(new Event('input', { bubbles: true }));
        }
        if (!input.__peShareClick) {
          input.__peShareClick = true;
          // Intercept clicks on the input box itself
          input.addEventListener('click', ev => {
            ev.stopPropagation();
            const url = _sharePinUrl || location.href;
            navigator.clipboard.writeText(url).catch(() => {
              const ta = document.createElement('textarea');
              ta.value = url;
              ta.style.cssText = 'position:fixed;left:-9999px';
              document.body.appendChild(ta);
              ta.select();
              document.execCommand('copy');
              ta.remove();
            });
          }, true);
          // Re-fix if React re-renders the value
          new MutationObserver(() => {
            const url = _sharePinUrl || location.href;
            if (input.value !== url) {
              nativeSetter.call(input, url);
              input.dispatchEvent(new Event('input', { bubbles: true }));
            }
          }).observe(input, { attributes: true, attributeFilter: ['value'] });
        }
      });
    }

    new MutationObserver(fixShareInputs)
      .observe(document.documentElement, { childList: true, subtree: true });

    // 3) Intercept "Copy link" button clicks
    document.addEventListener('click', e => {
      const copyBtn = e.target.closest(
        'button[aria-label="Copy link"], ' +
        '[data-test-id="copy-link-share-icon-auth"] button'
      );
      if (!copyBtn) return;
      e.preventDefault();
      e.stopPropagation();
      e.stopImmediatePropagation();
      const realUrl = _sharePinUrl || location.href;
      navigator.clipboard.writeText(realUrl).then(() => {
        const txt = copyBtn.querySelector('div');
        if (txt) {
          const orig = txt.textContent;
          txt.textContent = 'Copied!';
          setTimeout(() => { txt.textContent = orig; }, 1500);
        }
      }).catch(() => {
        const ta = document.createElement('textarea');
        ta.value = realUrl;
        ta.style.cssText = 'position:fixed;left:-9999px';
        document.body.appendChild(ta);
        ta.select();
        document.execCommand('copy');
        ta.remove();
      });
    }, true);
  }


  // ═══════════════════════════════════════════════════════════════════
  //  MODULE: GIF / VIDEO HOVER PLAY
  // ═══════════════════════════════════════════════════════════════════
  // In the pin grid, Pinterest renders GIFs as static <img> elements
  // (showing a .jpg thumbnail) with the real .gif URL hidden in
  // srcset at "4x".  There is no <video> in the grid.
  //
  // Strategy:
  //  • On mouseover – walk up to [data-test-id="pinWrapper"], find
  //    img[srcset*=".gif"], extract the .gif URL, swap img.src to it.
  //  • On mouseout – restore the original .jpg src.
  //  • Only ONE gif plays at a time (previous is restored before new starts).
  //  • <video> elements (pin closeup / detail page) are still kept paused
  //    via the MutationObserver so they don't auto-play in the background.

  // Selector matching any img that carries a GIF URL in srcset, live src, or lazy data-src.
  // Used by both hover-play and auto-play modules.
  const GIF_IMG_SEL = 'img[srcset*=".gif"], img[src*=".gif"], img[data-src*=".gif"]';
  const GIF_PIN_CONTAINER_SEL = [
    '[data-test-id="pinWrapper"]',
    '[data-grid-item="true"]',
    '[data-test-id="pin"]',
    'div[role="listitem"]',
    '[data-test-id="pin-closeup-image"]',
  ].join(', ');

  let _gifActiveImg     = null;   // <img> currently showing a .gif
  let _gifOrigSrc       = null;   // original src to restore on leave
  let _gifOrigSrcset    = null;   // original srcset to restore on leave
  let _gifActiveCont    = null;   // pinWrapper of the active gif
  let _gifActiveVid     = null;   // <video> currently playing a GIF (mobile hover/tap)

  // Pinterest uses different card wrappers across home/search/closeup pages,
  // especially on mobile. Resolve the nearest usable pin container defensively.
  function findGifContainer(node) {
    if (!node || node.nodeType !== 1) return null;
    return node.closest(GIF_PIN_CONTAINER_SEL);
  }

  // Resolve a video source even when Pinterest lazy-loads into data-* attrs.
  function getVideoSrc(video) {
    if (!video) return '';
    const source = video.querySelector && video.querySelector('source');
    return video.src
      || video.getAttribute('src')
      || video.getAttribute('data-src')
      || (source && (source.src || source.getAttribute('src') || source.getAttribute('data-src')))
      || '';
  }

  // Ensure lazy mobile GIF videos have a concrete src before play() attempts.
  function hydrateVideoSource(video) {
    if (!video) return;
    if (!video.getAttribute('src')) {
      const ds = video.getAttribute('data-src');
      if (ds) video.setAttribute('src', ds);
    }
    const source = video.querySelector && video.querySelector('source');
    if (source && !source.getAttribute('src')) {
      const ds = source.getAttribute('data-src');
      if (ds) source.setAttribute('src', ds);
    }
  }

  // Classify whether a <video> is a GIF-like pin media.
  // Some mobile layouts use i.pinimg.com sources, others expose only
  // a PinTypeIdentifier badge with text "GIF".
  function isGifVideo(video, container) {
    if (!video) return false;
    const src = getVideoSrc(video);
    if (/i\.pinimg\.com/i.test(src)) return true;
    const wrap = container || findGifContainer(video);
    const badge = wrap && wrap.querySelector('[data-test-id="PinTypeIdentifier"]');
    if (!badge) return false;
    const t = (badge.textContent || '').trim().toLowerCase();
    if (t === 'gif' || t.includes('animated')) return true;
    if (t === 'video' || t.includes('watch')) return false;
    return false;
  }

  // Detect the mobile/touch layout GIF pin — Pinterest renders these with
  // JPEG-only srcset; the GIF container data-test-ids identify them reliably.
  function isMobileGifPin(container) {
    if (!container) return false;
    if (container.querySelector('[data-test-id="inp-perf-pinType-gif"]')) return true;
    if (container.querySelector('[data-test-id="pincard-gif-without-link"]')) return true;
    const badge = container.querySelector('[data-test-id="PinTypeIdentifier"]');
    if (badge) {
      const t = (badge.textContent || '').trim().toLowerCase();
      if (t === 'gif' || t.includes('animated')) return true;
    }
    return false;
  }

  // Convert a pinimg.com JPEG/WebP thumbnail URL to the /originals/ GIF URL.
  // e.g. …/236x/ab/cd/ef/hash.jpg → …/originals/ab/cd/ef/hash.gif
  function deriveGifUrl(jpegUrl) {
    if (!jpegUrl) return null;
    const m = jpegUrl.match(/^(https?:\/\/i\.pinimg\.com)\/[^/]+(\/.+?)(?:\.jpe?g|\.webp)(\?.*)?$/i);
    if (!m) return null;
    return m[1] + '/originals' + m[2] + '.gif';
  }

  // Extract the .gif URL from an img element, checking srcset, live src, and data-src.
  // On mobile Pinterest uses JPEG-only srcset for GIF pins; derive the .gif URL when needed.
  function getGifSrcFromImg(img) {
    if (!img) return null;
    // Prefer srcset (Pinterest hides the GIF at "4x"; also stored in __peAutoOrigSrcset)
    const srcset = img.getAttribute('srcset') || img.__peAutoOrigSrcset || '';
    for (const part of srcset.split(',')) {
      const url = part.trim().split(/\s+/)[0];
      if (url && /\.gif(\?|$)/i.test(url)) return url;
    }
    // GIF already in src (srcset was cleared and .gif URL was applied)
    if (/\.gif(\?|$)/i.test(img.src)) return img.src;
    // Lazy-loaded src attribute
    const ds = img.getAttribute('data-src') || '';
    if (/\.gif(\?|$)/i.test(ds)) return ds;
    // Mobile layout: GIF pins have JPEG-only srcset but carry inp-perf-pinType-gif /
    // pincard-gif-without-link in their container. Derive the originals .gif URL.
    const wrap = img.closest('[data-test-id="pinWrapper"], [data-grid-item="true"], [data-test-id="pin"]');
    if (isMobileGifPin(wrap)) {
      const jpegSrc = img.getAttribute('src') || img.src || '';
      if (jpegSrc) {
        const d = deriveGifUrl(jpegSrc);
        if (d) return d;
      }
      // Fallback: try highest-res srcset entry
      const parts = srcset.split(',').map(p => p.trim().split(/\s+/)[0]).filter(Boolean);
      for (let i = parts.length - 1; i >= 0; i--) {
        const d = deriveGifUrl(parts[i]);
        if (d) return d;
      }
    }
    return null;
  }

  function pauseActiveGif() {
    if (_gifActiveImg) {
      // Restore srcset FIRST so the browser doesn't re-pick from it
      // before we restore src
      if (_gifOrigSrcset !== null) _gifActiveImg.setAttribute('srcset', _gifOrigSrcset);
      if (_gifOrigSrc    !== null) _gifActiveImg.src = _gifOrigSrc;
    }
    if (_gifActiveVid) {
      try { _gifActiveVid.pause(); } catch (_) {}
      _gifActiveVid = null;
    }
    const prevCont    = _gifActiveCont;
    _gifActiveImg     = null;
    _gifOrigSrc       = null;
    _gifOrigSrcset    = null;
    _gifActiveCont    = null;
    // If GIF auto-play is active, let it take over this wrapper
    if (prevCont && get('gifAutoPlay') && _gifAutoIO) {
      setTimeout(() => {
        const r = prevCont.getBoundingClientRect();
        if (r.top < window.innerHeight && r.bottom > 0) startGifInView(prevCont);
      }, 50);
    }
  }

  // Keep any <video> elements (pin detail/closeup page) paused so they
  // don't auto-play in the background.
  function pauseVidOnAdd(v) {
    if (v.__pePaused || v.__peGifVid) return;
    // GIFs rendered as <video src="i.pinimg.com/…"> on mobile must NOT be paused here —
    // the GIF hover / auto-play modules manage those independently.
    const getSrc = () => getVideoSrc(v);
    const src = getSrc();
    const initialWrap = findGifContainer(v);
    if (isGifVideo(v, initialWrap)) {
      v.__peGifVid = true;
      return;
    }
    // src not yet assigned (lazy-load): observe for when it is set before deciding to pause.
    // Without this, Pinterest's async src assignment races with auto-play on mobile —
    // the deferred kill() calls would pause the video after auto-play had already started it.
    if (!src) {
      if (v.__peVidSrcObs) return; // observer already attached
      v.__peVidSrcObs = true;
      const obs = new MutationObserver(() => {
        const s = getSrc();
        if (!s) return; // still not set – keep waiting
        obs.disconnect();
        v.__peVidSrcObs = false;
        const wrap = findGifContainer(v);
        if (isGifVideo(v, wrap)) {
          // It's a mobile GIF video – let hover / auto-play manage it; never pause it
          v.__peGifVid = true;
          const pw = wrap;
          if (pw && _gifAutoIO) { pw.__peAutoObs = false; observeGifPins(); }
        } else {
          pauseVidOnAdd(v); // real video – go ahead and pause it
        }
      });
      obs.observe(v, { attributes: true, attributeFilter: ['src'], childList: true });
      return;
    }
    v.__pePaused = true;
    v.muted = true;
    const kill = () => { try { v.pause(); } catch (_) {} };
    kill(); setTimeout(kill, 60); setTimeout(kill, 250);
  }

  new MutationObserver(records => {
    records.forEach(r => r.addedNodes.forEach(function scan(n) {
      if (!n || n.nodeType !== 1) return;
      if (n.tagName === 'VIDEO') pauseVidOnAdd(n);
      n.querySelectorAll && n.querySelectorAll('video').forEach(pauseVidOnAdd);
    }));
  }).observe(document.documentElement, { childList: true, subtree: true });

  function initGifHover() {
    document.addEventListener('mouseover', e => {
      if (!get('gifHover')) return;

      const pinWrapper = findGifContainer(e.target);
      if (!pinWrapper || pinWrapper === _gifActiveCont) return;

      // Look for a GIF image inside this pin card (incl. mobile JPEG-srcset GIF pins)
      const img = pinWrapper.querySelector(GIF_IMG_SEL)
               || (isMobileGifPin(pinWrapper) ? pinWrapper.querySelector('img') : null);
      if (!img) return;
      const gifUrl = getGifSrcFromImg(img);
      if (!gifUrl) return;

      // Stop the previous gif first
      pauseActiveGif();

      // Start the new one.
      // IMPORTANT: browsers use srcset over src, so we must clear srcset
      // before setting src to the gif URL, otherwise src change is ignored.
      _gifActiveImg     = img;
      _gifOrigSrc       = img.src;
      _gifOrigSrcset    = img.getAttribute('srcset');
      _gifActiveCont    = pinWrapper;
      img.removeAttribute('srcset');   // prevent srcset overriding our src
      img.src = gifUrl;
    }, { passive: true });

    document.addEventListener('mouseout', e => {
      if (!get('gifHover') || !_gifActiveCont) return;
      const to = e.relatedTarget;
      // If the mouse moved to another element still inside the pin wrapper, keep playing
      if (to && _gifActiveCont.contains(to)) return;
      pauseActiveGif();
    }, { passive: true });

    // ── Touch: tap to preview GIF on mobile ──────────────────────────
    // First tap on a GIF pin starts playback; second tap (or tap elsewhere) stops it.
    // Scrolling never accidentally triggers GIF playback.
    let _gifTouchStartY = 0, _gifTouchScrolled = false;

    document.addEventListener('touchstart', e => {
      _gifTouchStartY   = e.touches[0].clientY;
      _gifTouchScrolled = false;
    }, { passive: true });

    document.addEventListener('touchmove', e => {
      if (Math.abs(e.touches[0].clientY - _gifTouchStartY) > 8) _gifTouchScrolled = true;
    }, { passive: true });

    document.addEventListener('touchend', e => {
      if (!get('gifHover') || _gifTouchScrolled) return;
      // Don't interfere when the context menu is open
      if (document.getElementById('pe-ctx-menu')) return;
      const touch = e.changedTouches[0];
      const el = document.elementFromPoint(touch.clientX, touch.clientY);
      if (!el) return;
      const pinWrapper = findGifContainer(el);
      if (!pinWrapper) { pauseActiveGif(); return; }
      const img    = pinWrapper.querySelector(GIF_IMG_SEL)
                  || (isMobileGifPin(pinWrapper) ? pinWrapper.querySelector('img') : null);
      const gifUrl = img ? getGifSrcFromImg(img) : null;
      if (!gifUrl) {
        // No img-based GIF – check for a mobile video-based GIF
        const vid   = pinWrapper.querySelector('video');
        if (vid) hydrateVideoSource(vid);
        if (!vid || !isGifVideo(vid, pinWrapper)) { pauseActiveGif(); return; }
        // Second tap on the same video GIF = stop
        if (pinWrapper === _gifActiveCont) { pauseActiveGif(); return; }
        pauseActiveGif();
        _gifActiveCont = pinWrapper;
        _gifActiveVid  = vid;
        vid.muted = true;
        vid.loop  = true;
        vid.playsInline = true;
        if (vid.readyState === 0) {
          try { vid.load(); } catch (_) {}
        }
        try { vid.play(); } catch (_) {}
        return;
      }
      // Second tap on the same GIF pin = stop
      if (pinWrapper === _gifActiveCont) { pauseActiveGif(); return; }
      pauseActiveGif();
      _gifActiveImg     = img;
      _gifOrigSrc       = img.src;
      _gifOrigSrcset    = img.getAttribute('srcset');
      _gifActiveCont    = pinWrapper;
      img.removeAttribute('srcset');
      img.src = gifUrl;
    }, { passive: true });
  }


  // ═══════════════════════════════════════════════════════════════════
  //  MODULE: GIF AUTO-PLAY (viewport-based)
  // ═══════════════════════════════════════════════════════════════════
  // Uses IntersectionObserver to play all GIFs currently visible on
  // screen and stop them when scrolled out of view to save CPU/memory.

  let _gifAutoIO = null;   // IntersectionObserver
  let _gifAutoMO = null;   // MutationObserver for new pins

  function startGifInView(wrapper) {
    // ── img-based GIF (desktop + most mobile, including mobile JPEG-srcset GIFs) ──
    const img = wrapper.querySelector(GIF_IMG_SEL)
             || (isMobileGifPin(wrapper) ? wrapper.querySelector('img') : null);
    if (img && !img.__peAutoPlaying) {
      const gifUrl = getGifSrcFromImg(img);
      if (gifUrl) {
        img.__peAutoOrigSrc    = img.src;
        img.__peAutoOrigSrcset = img.getAttribute('srcset');
        img.removeAttribute('srcset');
        img.src = gifUrl;
        img.__peAutoPlaying = true;
        return;
      }
    }
    // ── video-based GIF (mobile) ──
    const vid = wrapper.querySelector('video');
    if (vid && !vid.__peAutoPlaying) {
      hydrateVideoSource(vid);
      if (isGifVideo(vid, wrapper)) {
        vid.__peAutoPlaying = true;
        vid.muted = true;
        vid.loop  = true;
        vid.playsInline = true;
        if (vid.readyState === 0) {
          try { vid.load(); } catch (_) {}
        }
        try { vid.play(); } catch (_) {}
      }
    }
  }

  function stopGifInView(wrapper) {
    wrapper.querySelectorAll('img').forEach(img => {
      if (!img.__peAutoPlaying) return;
      // Don't interfere if hover is currently managing this img
      if (img === _gifActiveImg) { img.__peAutoPlaying = false; return; }
      if (img.__peAutoOrigSrcset) img.setAttribute('srcset', img.__peAutoOrigSrcset);
      if (img.__peAutoOrigSrc)    img.src = img.__peAutoOrigSrc;
      img.__peAutoPlaying = false;
    });
    // Stop video-based GIFs (mobile)
    wrapper.querySelectorAll('video').forEach(vid => {
      if (!vid.__peAutoPlaying) return;
      vid.__peAutoPlaying = false;
      if (vid === _gifActiveVid) return; // hover/tap is managing this video
      try { vid.pause(); } catch (_) {}
    });
  }

  function observeGifPins() {
    if (!_gifAutoIO) return;
    document.querySelectorAll(GIF_PIN_CONTAINER_SEL).forEach(wrapper => {
      if (wrapper.__peAutoObs) return;
      // Detect img-based GIF, video-based GIF, or mobile JPEG-srcset GIF
      const hasGifImg = !!wrapper.querySelector(GIF_IMG_SEL);
      const hasGifVid = (() => {
        const vid = wrapper.querySelector('video');
        if (!vid) return false;
        if (vid.__peGifVid) return true; // already confirmed as a GIF video
        return isGifVideo(vid, wrapper);
      })();
      const hasMobileGif = !hasGifImg && !hasGifVid && isMobileGifPin(wrapper);
      if (!hasGifImg && !hasGifVid && !hasMobileGif) return;
      wrapper.__peAutoObs = true;
      _gifAutoIO.observe(wrapper);
    });
  }

  function initGifAutoPlay() {
    if (_gifAutoIO) return;
    _gifAutoIO = new IntersectionObserver(entries => {
      // Skip when feature is off or tab is hidden (avoids playing on inactive tabs)
      if (!get('gifAutoPlay') || document.hidden) return;
      entries.forEach(entry => {
        if (entry.isIntersecting) startGifInView(entry.target);
        else                      stopGifInView(entry.target);
      });
    }, { threshold: 0.1 });

    observeGifPins();
    _gifAutoMO = new MutationObserver(observeGifPins);
    _gifAutoMO.observe(document.documentElement, { childList: true, subtree: true });
  }

  function stopGifAutoPlay() {
    if (_gifAutoIO) { _gifAutoIO.disconnect(); _gifAutoIO = null; }
    if (_gifAutoMO) { _gifAutoMO.disconnect(); _gifAutoMO = null; }
    document.querySelectorAll(GIF_PIN_CONTAINER_SEL).forEach(wrapper => {
      stopGifInView(wrapper);
      wrapper.__peAutoObs = false;
    });
  }

  // Pause all auto-playing GIFs when the tab/window is hidden to save resources,
  // and resume them when the user comes back.
  document.addEventListener('visibilitychange', () => {
    if (!get('gifAutoPlay')) return;
    if (document.hidden) {
      document.querySelectorAll(GIF_PIN_CONTAINER_SEL).forEach(stopGifInView);
    } else if (_gifAutoIO) {
      // Re-start GIFs that are still in the viewport
      document.querySelectorAll(GIF_PIN_CONTAINER_SEL).forEach(wrapper => {
        if (!wrapper.__peAutoObs) return;
        const r = wrapper.getBoundingClientRect();
        if (r.top < window.innerHeight && r.bottom > 0) startGifInView(wrapper);
      });
    }
  });


  // ═══════════════════════════════════════════════════════════════════
  //  MODULE: DECLUTTER  (no ads, no shopping, no blank spaces)
  // ═══════════════════════════════════════════════════════════════════
  // Collapses unwanted elements to zero size instead of display:none
  // so the masonry grid reflows cleanly with no empty slots.
  // Sets grid-auto-flow:dense on pin-list containers once per container.

  function collapseEl(el) {
    if (!el) return;
    el.style.setProperty('height',     '0',       'important');
    el.style.setProperty('width',      '0',       'important');
    el.style.setProperty('margin',     '0',       'important');
    el.style.setProperty('padding',    '0',       'important');
    el.style.setProperty('border',     'none',    'important');
    el.style.setProperty('overflow',   'hidden',  'important');
    el.style.setProperty('opacity',    '0',       'important');
    el.style.setProperty('min-height', '0',       'important');
    el.style.setProperty('min-width',  '0',       'important');
    // Make the parent grid fill the gap
    const grid = el.closest('div[role="list"]');
    if (grid && !grid.dataset.peDense) {
      grid.style.setProperty('grid-auto-flow', 'dense', 'important');
      grid.dataset.peDense = '1';
    }
  }

  function isDeclutterPin(pin) {
    // Sponsored
    if (pin.querySelector('div[title="Sponsored"]')) return true;
    // Shoppable Pin indicator
    if (pin.querySelector('[aria-label="Shoppable Pin indicator"]')) return true;
    // Shopping cards / "Shop" headings
    const h2 = pin.querySelector('h2#comments-heading');
    if (h2 && h2.textContent.trim().toLowerCase().startsWith('shop')) return true;
    const aLink = pin.querySelector('a');
    if (aLink && (aLink.getAttribute('aria-label') || '').toLowerCase().startsWith('shop')) return true;
    // Featured boards / window shopping promos
    const text = pin.textContent.trim().toLowerCase();
    if (text.startsWith('explore featured boards')) return true;
    if (text.startsWith('still window shopping'))  return true;
    // Quiz posts
    if (/\bquiz\b/i.test(pin.textContent)) return true;
    // Deleted / unavailable pins
    if (pin.querySelector('[data-test-id="unavailable-pin"]')) return true;
    // Product cards with price tags (individual Shop the look items)
    if (pin.querySelector('[data-test-id="product-price-text"]')) return true;
    if (pin.querySelector('[data-test-id="pincard-product-with-link"]')) return true;
    return false;
  }

  function filterPins(container) {
    if (!get('declutter')) return;
    container.querySelectorAll('div[role="listitem"]').forEach(pin => {
      if (!pin.__peDecluttered && isDeclutterPin(pin)) {
        pin.__peDecluttered = true;
        collapseEl(pin);
      }
    });
  }

  function removeDeclutterOneoffs() {
    if (!get('declutter')) return;
    // Shop tab on board tools bar
    document.querySelectorAll('[data-test-id="board-tools"] [data-test-id="Shop"]')
      .forEach(el => collapseEl(el.closest('div')));
    // Shop-by / sf-header banners
    document.querySelectorAll('[data-test-id="sf-header-heading"]').forEach(el => {
      collapseEl(el.closest('div[role="listitem"]') || el.parentElement);
    });
    // Download upsell popover
    document.querySelectorAll('[data-test-id="post-download-upsell-popover"]')
      .forEach(collapseEl);
    // Ad blocker modal
    document.querySelectorAll('div[aria-label="Ad blocker modal"]').forEach(el => {
      collapseEl(el);
      if (document.body.style.overflow === 'hidden') document.body.style.overflow = '';
    });
    // Explore-tab notification badge
    const todayTab = document.querySelector('a[data-test-id="today-tab"]');
    if (todayTab) {
      const iconWrap = todayTab.closest('div');
      const sidebarItem = iconWrap?.parentElement?.parentElement;
      const badge = sidebarItem?.parentElement?.querySelector('.MIw[style*="pointer-events: none"]');
      if (badge) collapseEl(badge);
    }
    // Pin card notification badges (the floating status dot on pins)
    document.querySelectorAll('[aria-label="Notifications"][role="status"]').forEach(el => {
      collapseEl(el.parentElement || el);
    });
    // Shopping spotlight carousel section
    document.querySelectorAll('[data-test-id="carousel-bubble-wrapper-shopping_spotlight"]').forEach(el => {
      collapseEl(el.closest('div[role="listitem"]') || el.parentElement?.parentElement?.parentElement || el.parentElement || el);
    });
    // Curated spotlight section (search page immersive header carousel)
    document.querySelectorAll('[data-test-id="search-story-suggestions-container"]:has([data-test-id="search-suggestion-curated-board-bubble"])').forEach(el => {
      collapseEl(el);
    });
    // Pin action bar: "Read it" / "Visit site" inline button on mobile closeup
    document.querySelectorAll('[data-test-id="pin-action-bar-container"]').forEach(el => {
      collapseEl(el.parentElement || el);
    });
    // Shop similar / Shop the look sections on pin closeup
    document.querySelectorAll(
      '[data-test-id="ShopTheLookSimilarProducts"],' +
      '[data-test-id="visual-search-shopping-bar"],' +
      '[data-test-id="related-products"],' +
      '[data-test-id="ShopTheLookAnnotations"]'
    ).forEach(el => {
      collapseEl(el.closest('div[role="listitem"]') || el.parentElement || el);
    });
    // Shop the look carousel grid items (full-width shopping module in feed)
    document.querySelectorAll('[data-test-id="shopping-module"]').forEach(el => {
      collapseEl(el.closest('div[role="listitem"]') || el.closest('[data-grid-item="true"]') || el.parentElement || el);
    });
  }

  let _declutterListObs = null;

  function initDeclutter() {
    if (!get('declutter')) return;

    // Observe the pin grid list(s) for new list items
    function attachListObserver(listEl) {
      if (listEl.__peDeclutterObs) return;
      listEl.__peDeclutterObs = true;
      filterPins(listEl);
      const onMutate = IS_MOBILE ? debounce(() => filterPins(listEl), 200) : () => filterPins(listEl);
      new MutationObserver(onMutate)
        .observe(listEl, { childList: true, subtree: true });
    }

    // Attach to any already-present lists
    document.querySelectorAll('div[role="list"]').forEach(attachListObserver);
    removeDeclutterOneoffs();

    // Watch for new lists added by SPA navigation or lazy load
    if (_declutterListObs) return;
    _declutterListObs = new MutationObserver(() => {
      document.querySelectorAll('div[role="list"]').forEach(attachListObserver);
      removeDeclutterOneoffs();
    });
    _declutterListObs.observe(document.documentElement, { childList: true, subtree: true });
  }


  // ═══════════════════════════════════════════════════════════════════
  //  MODULE: REMOVE VIDEOS (collapse to avoid blank spaces)
  // ═══════════════════════════════════════════════════════════════════
  // Detects video pins via their duration label (PinTypeIdentifier)
  // and collapses them using the same technique as Declutter to
  // avoid blank spaces in the grid.

  function isVideoPin(pin) {
    // PinTypeIdentifier badge appears on both GIFs and videos — check its text
    const badge = pin.querySelector('[data-test-id="PinTypeIdentifier"]');
    if (badge) {
      const t = badge.textContent.trim().toLowerCase();
      if (t === 'gif' || t.includes('animated')) return false; // it's a GIF, not a video
      if (t === 'video' || t.includes('watch')) return true;
    }
    // <video> elements: GIFs use i.pinimg.com, real videos use v.pinimg.com
    const vid = pin.querySelector('video');
    if (vid) {
      const src = vid.src
        || (vid.querySelector('source') && vid.querySelector('source').src)
        || '';
      if (/v\.pinimg\.com/i.test(src)) return true;  // Pinterest-hosted video
      if (/i\.pinimg\.com/i.test(src)) return false; // GIF rendered as video
      // Unknown CDN (e.g. YouTube embed inside an iframe) — treat as video
      if (src) return true;
    }
    // Explicit video-only indicators
    if (pin.querySelector('[data-test-id="video-pin-indicator"], [data-test-id="PinVideoIdentifier"]')) return true;
    return false;
  }

  function filterVideoPins(container) {
    if (!get('removeVideos')) return;
    container.querySelectorAll('div[role="listitem"]').forEach(pin => {
      if (!pin.__peVideoRemoved && isVideoPin(pin)) {
        pin.__peVideoRemoved = true;
        collapseEl(pin);
      }
    });
  }

  let _removeVideosObs = null;

  function initRemoveVideos() {
    if (!get('removeVideos') || _removeVideosObs) return;

    function attachListObserver(listEl) {
      if (listEl.__peVideoObs) return;
      listEl.__peVideoObs = true;
      filterVideoPins(listEl);
      const onMutate = IS_MOBILE ? debounce(() => filterVideoPins(listEl), 200) : () => filterVideoPins(listEl);
      new MutationObserver(onMutate)
        .observe(listEl, { childList: true, subtree: true });
    }

    document.querySelectorAll('div[role="list"]').forEach(attachListObserver);

    _removeVideosObs = new MutationObserver(() => {
      document.querySelectorAll('div[role="list"]').forEach(attachListObserver);
    });
    _removeVideosObs.observe(document.documentElement, { childList: true, subtree: true });
  }


  // ═══════════════════════════════════════════════════════════════════
  //  MODULE: HIDE SHOP POSTS (TeePublic, Redbubble, AliExpress, etc.)
  // ═══════════════════════════════════════════════════════════════════
  const SHOP_DOMAINS = [
    'teepublic.com', 'redbubble.com',
    'aliexpress.com', 'aliexpress.us', 'aliexpress.ru',
    'amazon.com', 'amazon.co.uk', 'amazon.ca', 'amazon.com.au', 'amazon.de',
    'etsy.com',
    'ebay.com', 'ebay.co.uk', 'ebay.ca', 'ebay.com.au',
  ];

  function isShopPost(pin) {
    const links = pin.querySelectorAll('a[href]');
    for (const a of links) {
      const href = (a.href || '').toLowerCase();
      if (SHOP_DOMAINS.some(d => href.includes(d))) return true;
    }
    const text = (pin.textContent || '').toLowerCase();
    return ['teepublic', 'redbubble', 'aliexpress', 'amazon', 'etsy', 'ebay'].some(name => text.includes(name));
  }

  function filterShopPosts(container) {
    if (!get('hideShopPosts')) return;
    container.querySelectorAll('div[role="listitem"]').forEach(pin => {
      if (!pin.__peShopHidden && isShopPost(pin)) {
        pin.__peShopHidden = true;
        collapseEl(pin);
      }
    });
  }

  let _hideShopPostsObs = null;

  function initHideShopPosts() {
    if (!get('hideShopPosts') || _hideShopPostsObs) return;

    function attachListObserver(listEl) {
      if (listEl.__peShopObs) return;
      listEl.__peShopObs = true;
      filterShopPosts(listEl);
      const onMutate = IS_MOBILE ? debounce(() => filterShopPosts(listEl), 200) : () => filterShopPosts(listEl);
      new MutationObserver(onMutate)
        .observe(listEl, { childList: true, subtree: true });
    }

    document.querySelectorAll('div[role="list"]').forEach(attachListObserver);

    _hideShopPostsObs = new MutationObserver(() => {
      document.querySelectorAll('div[role="list"]').forEach(attachListObserver);
    });
    _hideShopPostsObs.observe(document.documentElement, { childList: true, subtree: true });
  }


  // ═══════════════════════════════════════════════════════════════════
  //  MODULE: HIDE COMMENTS
  // ═══════════════════════════════════════════════════════════════════
  function hideCommentEditorWrapper() {
    if (!get('hideComments')) return;
    // Walk up from the known comment editor container ID to find
    // its bordered outer wrapper and hide the whole thing
    ['dweb-comment-editor-container', 'mweb-comment-editor-container'].forEach(id => {
      const el = document.getElementById(id);
      if (!el) return;
      let p = el.parentElement;
      for (let i = 0; i < 10 && p && p !== document.body; i++) {
        const style = p.getAttribute('style') || '';
        if (style.includes('border-color')) {
          p.style.setProperty('display', 'none', 'important');
          return;
        }
        p = p.parentElement;
      }
      el.style.setProperty('display', 'none', 'important');
    });
    // Hide mobile comment preview ("View all comments" text + snippet above it)
    document.querySelectorAll('div,span,a').forEach(el => {
      if (!el.children.length && /^view all comments$/i.test(el.textContent.trim())) {
        const container = el.parentElement && el.parentElement.parentElement;
        if (container && container !== document.body) {
          container.style.setProperty('display', 'none', 'important');
        } else if (el.parentElement) {
          el.parentElement.style.setProperty('display', 'none', 'important');
        }
      }
    });
  }

  function initHideComments() {
    if (!get('hideComments')) return;
    hideCommentEditorWrapper();
    new MutationObserver(() => hideCommentEditorWrapper())
      .observe(document.documentElement, { childList: true, subtree: true });
  }


  // ═══════════════════════════════════════════════════════════════════
  //  MODULE: SCROLL PRESERVATION
  //  Saves home-feed scroll position when navigating away and restores
  //  it on browser back (popstate).  Does NOT restore on explicit
  //  home-link clicks so fresh home navigation always goes to top.
  // ═══════════════════════════════════════════════════════════════════
  function initScrollPreservation() {
    let _homeScrollY = 0;
    let _homeClickIntent = false;

    // Continuously save scroll Y while on the home feed
    window.addEventListener('scroll', () => {
      if (location.pathname === '/') _homeScrollY = window.scrollY;
    }, { passive: true });

    // When the user explicitly clicks a home nav link, clear saved scroll
    // so that intentional "go home" always scrolls to top
    document.addEventListener('click', e => {
      const homeLink = e.target.closest(
        'a[href="/"], [data-test-id="home-tab"], [aria-label="Home"]'
      );
      if (homeLink) {
        _homeClickIntent = true;
        _homeScrollY = 0;
      }
    }, true);

    // On browser back/forward (popstate), restore scroll if returning to home
    window.addEventListener('popstate', () => {
      if (location.pathname === '/' && _homeScrollY > 0 && !_homeClickIntent) {
        // Delay so React finishes rendering the feed before we scroll
        setTimeout(() => window.scrollTo(0, _homeScrollY), 400);
      }
      _homeClickIntent = false;
    });
  }


  // ═══════════════════════════════════════════════════════════════════
  //  MODULE: DOWNLOAD FIXER  (original Angel2mp3 logic, intact)
  // ═══════════════════════════════════════════════════════════════════
  function detectFileType(arr) {
    if (arr.length < 12) return '.jpg';
    if (arr[0]===0x89 && arr[1]===0x50 && arr[2]===0x4E && arr[3]===0x47) return '.png';
    if (arr[0]===0xFF && arr[1]===0xD8 && arr[2]===0xFF) return '.jpg';
    if (arr[0]===0x47 && arr[1]===0x49 && arr[2]===0x46 && arr[3]===0x38) return '.gif';
    if (arr[0]===0x52 && arr[1]===0x49 && arr[2]===0x46 && arr[3]===0x46 &&
        arr[8]===0x57 && arr[9]===0x45 && arr[10]===0x42 && arr[11]===0x50) return '.webp';
    if (arr[4]===0x66 && arr[5]===0x74 && arr[6]===0x79 && arr[7]===0x70) return '.mp4';
    return '.jpg';
  }

  function sanitizeFilename(n) {
    if (!n) return null;
    let s = String(n).replace(/[<>:"/\\|?*\x00-\x1f\x80-\x9f]/g, '').trim();
    if (s.length > 200) s = s.slice(0, 200);
    return s.length ? s : null;
  }

  // Remove any trailing known image/video extension from a base name so that
  // the binary-detected extension is always the final (and only) one.
  // e.g. "photo.jpg" → "photo"  |  "photo.jpg.png" → "photo.jpg"  |  "jpg" → "jpg"
  // If stripping would leave an empty string we keep the original to avoid
  // producing a bare extension file (e.g. ".jpg").
  function stripKnownExt(name) {
    if (!name) return name;
    const stripped = name.replace(/\.(jpe?g|png|gif|webp|mp4|bmp|tiff?)$/i, '').trim();
    return stripped.length ? stripped : name;
  }

  function randDigits(len) {
    let r = '';
    for (let i = 0; i < len; i++) r += String(Math.floor(Math.random() * 10));
    return r;
  }

  function makeFallbackPinName() {
    return `Pin - ${randDigits(8)}`;
  }

  const PIN_TITLE_SELECTORS = [
    '[data-test-id="pin-title"]',
    '[data-test-id="closeup-title"] h1',
    '[data-test-id="pinrep-footer-organic-title"] a',
    '[data-test-id="pinrep-footer-organic-title"] h2',
    'h1[itemprop="name"]',
  ];

  function extractPinTitleFromScope(scope) {
    if (!scope || !scope.querySelector) return null;
    for (const s of PIN_TITLE_SELECTORS) {
      const el = scope.querySelector(s);
      const t = sanitizeFilename(el?.textContent?.trim());
      if (t) return t;
    }
    return null;
  }

  function extractPinTitle() {
    return extractPinTitleFromScope(document);
  }

  // Upgrade any pinimg thumbnail URL to /originals/ for max quality
  function upgradeToOriginal(url) {
    if (!url) return url;
    const m = url.match(OQ_RE);
    return m ? m[1] + '/originals' + m[2] : url;
  }

  function findMainImageUrl() {
    // Check video poster first (GIF/video pins)
    const closeupVid = document.querySelector(
      '[data-test-id="pin-closeup-image"] video, [elementtiming*="MainPinImage"] ~ video'
    );
    if (closeupVid?.poster) return upgradeToOriginal(closeupVid.poster);

    for (const s of [
      'img[elementtiming*="MainPinImage"]',
      '[data-test-id="pin-closeup-image"] img',
      'img.hCL',
      'img[fetchpriority="high"]',
    ]) {
      const img = document.querySelector(s);
      if (!img) continue;

      // ── GIF detection: check srcset/src for a .gif URL first ──────
      const gifUrl = getGifSrcFromImg(img);
      if (gifUrl) return gifUrl;
      // Also handle mobile GIF closeup: PinTypeIdentifier badge says "GIF"
      // but the img only has a JPEG src — derive the originals .gif URL
      if (!gifUrl) {
        const gifBadge = document.querySelector('[data-test-id="PinTypeIdentifier"]');
        if (gifBadge && /gif|animated/i.test(gifBadge.textContent)) {
          const derived = deriveGifUrl(img.currentSrc || img.src);
          if (derived) return derived;
        }
      }

      // Prefer srcset – pick highest declared width
      const srcset = img.getAttribute('srcset');
      if (srcset) {
        const best = srcset.split(',')
          .map(p => p.trim().split(/\s+/))
          .filter(p => p[0])
          .sort((a, b) => (parseInt(b[1]) || 0) - (parseInt(a[1]) || 0))[0];
        if (best) return upgradeToOriginal(best[0]);
      }
      return upgradeToOriginal(img.currentSrc || img.src);
    }
    return null;
  }

  function fetchBinary(url) {
    return new Promise((res, rej) => {
      GM_xmlhttpRequest({
        method: 'GET', url, responseType: 'arraybuffer',
        // Referer is required — without it Pinterest's CDN returns 403
        headers: {
          'Referer': location.href,
          'Accept':  'image/webp,image/apng,image/*,*/*;q=0.8',
        },
        onload:  r => (r.status >= 200 && r.status < 300)
          ? res(r.response)
          : rej(new Error('HTTP ' + r.status)),
        onerror: e => rej(new Error('Network error: ' + (e && e.error || e))),
      });
    });
  }

  // Build a descending-quality URL queue for a pinimg.com image.
  // Tries originals first, then 736x, then 564x so we always get *something*
  // even when the /originals/ path is access-restricted for a given pin.
  // Converts any v1.pinimg.com video URL to the highest reliably available quality.
  // mc channel → 720p direct MP4; iht channel (Idea Pins) → 720w expMp4.
  function getHighestQualityVideoUrl(src) {
    const m = src.match(/v1\.pinimg\.com\/videos\/(mc|iht)\/(?:expMp4|720p|hls)\/([a-f0-9]{2}\/[a-f0-9]{2}\/[a-f0-9]{2}\/[a-f0-9]{32,})/i);
    if (!m) return src;
    const [, channel, hash] = m;
    return channel === 'iht'
      ? `https://v1.pinimg.com/videos/iht/expMp4/${hash}_720w.mp4`
      : `https://v1.pinimg.com/videos/mc/720p/${hash}.mp4`;
  }

  function pinimgFallbackQueue(url) {
    if (!url) return [url];
    const m = url.match(
      /^(https?:\/\/i\.pinimg\.com)\/(?:originals|\d+x)(\/[0-9a-f]{2}\/[0-9a-f]{2}\/[0-9a-f]{2}\/.+)$/i
    );
    if (!m) return [url];
    const [, base, path] = m;
    // Deduplicate while preserving order
    return [
      base + '/originals' + path,
      base + '/736x'      + path,
      base + '/564x'      + path,
    ].filter((u, i, a) => a.indexOf(u) === i);
  }

  async function downloadSingle(imageUrl, filename) {
    if (!imageUrl) return;

    // Try originals → 736x → 564x until one succeeds
    let buf = null;
    for (const u of pinimgFallbackQueue(imageUrl)) {
      try { buf = await fetchBinary(u); break; } catch (_) {}
    }

    if (!buf) return;

    try {
      const ext      = detectFileType(new Uint8Array(buf));
      // Use only explicit pin titles. If no title exists, fall back to:
      // "Pin - 12345678".
      const explicitTitle = stripKnownExt(sanitizeFilename(filename || ''));
      const pageTitle     = stripKnownExt(extractPinTitle() || '');
      const basePart      = explicitTitle || pageTitle || makeFallbackPinName();
      const name     = basePart + ext;
      const blob     = new Blob([buf]);
      const a        = document.createElement('a');
      a.href         = URL.createObjectURL(blob);
      a.download     = name;
      a.click();
      setTimeout(() => URL.revokeObjectURL(a.href), 10000);
    } catch (_) {}
  }

  function initDownloadFixer() {
    if (!get('downloadFixer')) return;
    document.addEventListener('click', e => {
      if (!get('downloadFixer')) return;
      const target = e.target.closest(
        '[data-test-id*="download"], [aria-label*="ownload" i], ' +
        'button[id*="download"], [role="menuitem"]'
      );
      if (!target) return;
      const text   = (target.textContent || '').toLowerCase();
      const testId = target.getAttribute('data-test-id') || '';
      const aria   = (target.getAttribute('aria-label') || '').toLowerCase();
      const isDownload = text.includes('download') || testId.includes('download') || aria.includes('download');
      if (!isDownload) return;
      const url = findMainImageUrl();
      // Only intercept if we found the image URL; otherwise let Pinterest's native handler work
      if (url) {
        e.preventDefault();
        e.stopPropagation();
        downloadSingle(url);
      }
    }, true);
  }


  // ═══════════════════════════════════════════════════════════════════
  //  MODULE: BOARD DOWNLOADER
  // ═══════════════════════════════════════════════════════════════════
  function isBoardPage() {
    // URL heuristic: /username/boardname/  (exactly 2 non-empty path segments)
    const parts = location.pathname.replace(/\/$/, '').split('/').filter(Boolean);
    const skip  = new Set([
      'search','pin','_','settings','ideas','today','following',
      'explore','business','login','logout','create','about',
      'help','careers','news','collage-creation-tool',
    ]);
    const urlMatch = parts.length === 2 && !skip.has(parts[0]);
    // DOM confirmation: Pinterest board header is present
    const domMatch = !!document.querySelector(
      '[data-test-id="board-header-with-image"], [data-test-id="board-header-details"], [data-test-id="board-tools"]'
    );
    return urlMatch || domMatch;
  }

  // Snapshot whatever pin images are currently in the DOM into the
  // accumulator set.  Called repeatedly while scrolling so we catch
  // images before Pinterest's virtual list recycles those DOM nodes.
  // Also captures pin titles from title elements in each pin card.
  function snapshotPinUrls(seen, urls, names) {
    document.querySelectorAll('img[src*="i.pinimg.com"]').forEach(img => {
      // Skip tiny avatars/icons
      const w = img.naturalWidth || img.width;
      if (w && w < 80) return;
      // Skip images inside the "More Ideas" / suggested section at the bottom of boards
      if (img.closest('.moreIdeasOnBoard, [href*="more-ideas"], [href*="/_tools/"]')) return;
      let url = img.src;
      const m = url.match(OQ_RE);
      if (m) url = m[1] + '/originals' + m[2];
      if (!seen.has(url)) {
        const pinScope = img.closest(
          '[data-test-id="pinWrapper"], [data-grid-item="true"], [data-test-id="pin"], div[role="listitem"]'
        );
        seen.add(url);
        urls.push(url);
        names.set(url, extractPinTitleFromScope(pinScope));
      }
    });
  }

  // Snapshot video pins currently in the DOM into the accumulator.
  // Called alongside snapshotPinUrls so videos are captured before virtual-list recycling.
  function snapshotVideoUrls(vidSeen, vidItems) {
    document.querySelectorAll('video').forEach(vid => {
      const src = findPinterestVideoSrc(vid);
      if (!src || /i\.pinimg\.com/.test(src)) return; // skip GIFs
      const m = src.match(/v1\.pinimg\.com\/videos\/(mc|iht)\/(?:expMp4|720p|hls)\/([a-f0-9]{2}\/[a-f0-9]{2}\/[a-f0-9]{2}\/[a-f0-9]{32,})/i);
      if (!m) return;
      const key = m[1] + '/' + m[2];
      if (vidSeen.has(key)) return;
      vidSeen.add(key);
      const pinScope = vid.closest(
        '[data-test-id="pinWrapper"], [data-grid-item="true"], [data-test-id="pin"], div[role="listitem"]'
      );
      vidItems.push({ channel: m[1], hash: m[2], title: extractPinTitleFromScope(pinScope) });
    });
  }

  // Scroll to the bottom, snapshotting URLs at each tick so virtualised
  // DOM nodes are captured before they get removed.  Returns accumulated
  // URL array.  Stall threshold is intentionally generous (12 × 900ms =
  // 10.8 s) because Pinterest's lazy load can pause for several seconds.
  async function autoScrollAndCollect(setStatus) {
    const seen     = new Set();
    const urls     = [];
    const names    = new Map();
    const vidSeen  = new Set();
    const vidItems = [];
    return new Promise(resolve => {
      let lastH = 0, stall = 0;
      const t = setInterval(() => {
        snapshotPinUrls(seen, urls, names);            // grab current DOM before scroll
        snapshotVideoUrls(vidSeen, vidItems);
        window.scrollTo(0, document.body.scrollHeight);
        const h = document.body.scrollHeight;
        setStatus('scroll', urls.length + vidItems.length, 0);
        if (h === lastH) {
          stall++;
          if (stall >= 12) {
            snapshotPinUrls(seen, urls, names);        // final grab
            snapshotVideoUrls(vidSeen, vidItems);
            clearInterval(t);
            window.scrollTo(0, 0);
            resolve({ urls, names, vidItems });
          }
        } else {
          stall = 0;
          lastH = h;
        }
      }, 900);
    });
  }

  // ─── collect + scroll helpers (shared by both download modes) ──────
  async function collectAllPins(setStatus) {
    setStatus('scroll', 0, 0);
    return autoScrollAndCollect(setStatus);
  }

  // Fetch up to `concurrency` URLs in parallel, calling onProgress after each.
  async function fetchParallel(urls, concurrency, onProgress) {
    const results = new Array(urls.length).fill(null);
    let nextIdx = 0, finished = 0;
    async function worker() {
      while (nextIdx < urls.length) {
        const i = nextIdx++;
        try { results[i] = await fetchBinary(urls[i]); } catch (_) {}
        onProgress(++finished, urls.length);
      }
    }
    await Promise.all(Array.from({ length: Math.min(concurrency, urls.length) }, worker));
    return results;
  }

  // ─── Save all board images + videos as named downloads ──────────
  async function downloadBoardFolder(setStatus) {
    const { urls, names, vidItems } = await collectAllPins(setStatus);
    const totalItems = urls.length + vidItems.length;
    if (!totalItems) { alert('[Pinterest Power Menu] No images or videos found on this board.'); return; }

    // Use pin title only. If unavailable, use: "Pin - 12345678".
    function makeFileName(url, ext) {
      let pinName = stripKnownExt(sanitizeFilename(names.get(url) || ''));
      if (!pinName) pinName = makeFallbackPinName();
      if (pinName.length > 120) pinName = pinName.slice(0, 120).trimEnd();
      return `${pinName}${ext}`;
    }

    // ── Download images ───────────────────────────────────────────
    const bufs = await fetchParallel(urls, 5, (done, _) =>
      setStatus('fetch', done, totalItems)
    );

    let saved = 0;
    for (let i = 0; i < bufs.length; i++) {
      const buf = bufs[i];
      if (!buf) continue;
      const ext      = detectFileType(new Uint8Array(buf));
      const fileName = makeFileName(urls[i], ext);
      try {
        const a    = document.createElement('a');
        a.href     = URL.createObjectURL(new Blob([buf]));
        a.download = fileName;
        a.click();
        setTimeout(() => URL.revokeObjectURL(a.href), 200);
        await new Promise(r => setTimeout(r, 300));
        saved++;
      } catch (_) {}
      setStatus('fetch', saved, totalItems);
    }

    // ── Download videos ───────────────────────────────────────────
    for (const vi of vidItems) {
      const fallbackUrls = vi.channel === 'mc'
        ? [
            `https://v1.pinimg.com/videos/mc/720p/${vi.hash}.mp4`,
            `https://v1.pinimg.com/videos/mc/expMp4/${vi.hash}_t4.mp4`,
            `https://v1.pinimg.com/videos/mc/expMp4/${vi.hash}_t3.mp4`,
            `https://v1.pinimg.com/videos/mc/expMp4/${vi.hash}_t2.mp4`,
            `https://v1.pinimg.com/videos/mc/expMp4/${vi.hash}_t1.mp4`,
          ]
        : [`https://v1.pinimg.com/videos/iht/expMp4/${vi.hash}_720w.mp4`];
      const title = stripKnownExt(sanitizeFilename(vi.title || '')) || makeFallbackPinName();
      try {
        await downloadVideoFile(fallbackUrls, title, null);
        saved++;
      } catch (_) {}
      setStatus('fetch', saved, totalItems);
    }

    setStatus('done', saved, totalItems);
  }

  // ─── Board downloader button (lives inside #pe-settings-wrap) ───
  function removeBoardDownloaderUI() {
    // Remove button, menu, and any legacy outer wrapper
    ['pe-bd-btn', 'pe-bd-menu', 'pe-bd-fab'].forEach(id => {
      const el = document.getElementById(id);
      if (el) { if (el._bdCleanup) el._bdCleanup(); el.remove(); }
    });
  }

  function createBoardDownloaderUI() {
    if (document.getElementById('pe-bd-fab')) return;
    if (!get('boardDownloader') || !isBoardPage()) return;
    removeBoardDownloaderUI();

    // Standalone fixed container — independent of #pe-settings-wrap to avoid
    // timing/race issues with the MutationObserver that calls this function.
    const fab = document.createElement('div');
    fab.id = 'pe-bd-fab';

    // Popup menu (appears above the button)
    const menu = document.createElement('div');
    menu.id = 'pe-bd-menu';
    menu.style.display = 'none';
    menu.innerHTML = `
      <div id="pe-bd-status" style="display:none"></div>
      <button class="pe-bd-opt" id="pe-bd-folder">
        <svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
          <path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 19v2h14v-2H5z"/>
        </svg>
        Download All
      </button>
    `;

    // Circular board download button
    const btn = document.createElement('button');
    btn.id = 'pe-bd-btn';
    btn.title = 'Download Board';
    btn.innerHTML = `<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 19v2h14v-2H5z"/></svg>`;

    fab.appendChild(menu);
    fab.appendChild(btn);
    document.body.appendChild(fab);

    const status = menu.querySelector('#pe-bd-status');
    const dirBtn = menu.querySelector('#pe-bd-folder');

    let menuOpen = false;
    function toggleMenu() {
      menuOpen = !menuOpen;
      menu.style.display = menuOpen ? 'block' : 'none';
    }
    btn.addEventListener('click', e => { e.stopPropagation(); toggleMenu(); });

    function onOutsideClick(e) {
      if (menuOpen && !fab.contains(e.target)) { menuOpen = false; menu.style.display = 'none'; }
    }
    document.addEventListener('click', onOutsideClick);
    // Store cleanup on fab so removeBoardDownloaderUI can detach the listener
    fab._bdCleanup = () => document.removeEventListener('click', onOutsideClick);

    function setStatus(phase, a, b) {
      if (phase === 'cancelled') {
        status.style.display = 'none';
        dirBtn.disabled = false;
        return;
      }
      status.style.display = 'block';
      if (phase === 'scroll')      status.textContent = `Scrolling… ${a} items found`;
      else if (phase === 'fetch')  status.textContent = `Saving ${a}/${b} (${b ? Math.round(a/b*100) : 0}%)`;
      else if (phase === 'done') {
        status.textContent = `✓ Done – ${a} files saved`;
        setTimeout(() => {
          status.style.display = 'none';
          dirBtn.disabled = false;
          menuOpen = false; menu.style.display = 'none';
        }, 3000);
      }
    }

    dirBtn.addEventListener('click', async () => {
      dirBtn.disabled = true;
      await downloadBoardFolder(setStatus);
    });
  }


  // ═══════════════════════════════════════════════════════════════════
  //  MODULE: VIDEO DOWNLOADER FAB
  //  Shows a download button in the widget stack on pin closeup pages.
  // ═══════════════════════════════════════════════════════════════════

  // Find the best downloadable video URL from a <video> element.
  // Checks all <source> elements and attributes; prefers direct MP4 over HLS.
  function findPinterestVideoSrc(vid) {
    const candidates = [];
    // Collect all <source> src attrs first (more reliable than currentSrc when HLS.js is active)
    vid.querySelectorAll('source').forEach(s => {
      const u = s.getAttribute('src') || s.getAttribute('data-src') || '';
      if (u) candidates.push(u);
    });
    // Then currentSrc / src attributes
    candidates.push(vid.currentSrc || '', vid.getAttribute('src') || '', vid.getAttribute('data-src') || '');
    // Prefer direct v1.pinimg.com MP4 (non-m3u8)
    for (const u of candidates) {
      if (/v1\.pinimg\.com\/videos/.test(u) && !/\.m3u8/.test(u)) return u;
    }
    // Fall back to any v1.pinimg.com URL (incl. HLS, so we can still extract hash)
    for (const u of candidates) {
      if (/v1\.pinimg\.com\/videos/.test(u)) return u;
    }
    return null;
  }

  // Download a video file with progress feedback.
  // Tries every URL in order; on any error (network, timeout, or non-2xx) moves to the next.
  // Mobile uses responseType:'blob' (streamed to disk) to avoid loading the whole file into RAM.
  function downloadVideoFile(urls, filename, onProgress) {
    return new Promise((resolve, reject) => {
      let idx = 0;
      function tryNext() {
        if (idx >= urls.length) { reject(new Error('all URLs failed')); return; }
        const url = urls[idx++];
        // settled + timer prevent double-calls when abort races with onerror/ontimeout
        let settled = false;
        let timer;
        function finish(fn) {
          if (settled) return;
          settled = true;
          clearTimeout(timer);
          fn();
        }
        const req = GM_xmlhttpRequest({
          method: 'GET', url,
          // blob for mobile — wider support on iOS/Android userscript managers than arraybuffer
          responseType: IS_MOBILE ? 'blob' : 'arraybuffer',
          // Spoof desktop UA so Pinterest CDN doesn't reject the request based on mobile UA
          headers: {
            'Referer':    location.href,
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
            'Accept':     'video/mp4,video/*;q=0.9,*/*;q=0.8',
          },
          onprogress: e => { if (e.lengthComputable && onProgress) onProgress(e.loaded, e.total); },
          onload: r => {
            if (r.status >= 200 && r.status < 300) {
              finish(() => {
                const base = stripKnownExt(sanitizeFilename(filename || '')) || makeFallbackPinName();
                const blob = IS_MOBILE ? r.response : new Blob([r.response], { type: 'video/mp4' });
                const a    = document.createElement('a');
                a.href     = URL.createObjectURL(blob);
                a.download = base + '.mp4';
                document.body.appendChild(a);
                a.click();
                setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 10000);
                resolve();
              });
            } else {
              finish(tryNext);
            }
          },
          onerror:   () => finish(tryNext),
          ontimeout: () => finish(tryNext),
        });
        // Manual 45s deadline — mobile connections sometimes hang indefinitely
        timer = setTimeout(() => finish(() => { try { req.abort(); } catch(_){} tryNext(); }), 45000);
      }
      tryNext();
    });
  }

  function removeVideoDlFab() {
    const el = document.getElementById('pe-vid-dl-fab');
    if (el) el.remove();
  }

  function createVideoDlFab() {
    removeVideoDlFab();
    if (!get('videoDownloader')) return;
    // Only show on pin closeup pages — not on the home feed or boards
    if (!/\/pin\/\d/i.test(location.pathname)) return;

    // Try specific closeup container selectors first
    let vid = document.querySelector(
      '[data-test-id="pin-closeup-image"] video, ' +
      '[data-test-id="duplo-hls-video"] video, ' +
      '[data-test-id="pinrep-video"] video, ' +
      '[data-test-id="closeup-expanded-view"] video, ' +
      '[data-test-id="closeup-image"] video'
    );
    // Fallback: any video on the pin page that resolves to a Pinterest video CDN URL
    if (!vid) {
      for (const v of document.querySelectorAll('video')) {
        const s = findPinterestVideoSrc(v);
        if (s && !/i\.pinimg\.com/.test(s)) { vid = v; break; }
      }
    }
    // Get URL: DOM-based first, then intercepted XHR fallback (desktop HLS.js blob: src)
    let rawSrc = vid ? findPinterestVideoSrc(vid) : null;
    if (!rawSrc && _interceptedVideoUrls.length) rawSrc = _interceptedVideoUrls[0];

    // No URL available yet — watch video elements and retry when src changes or media loads
    if (!rawSrc) {
      const toWatch = vid ? [vid] : [...document.querySelectorAll('video')];
      toWatch.forEach(v => {
        if (v.__peVidDlWatch || /i\.pinimg\.com/.test(getVideoSrc(v))) return;
        v.__peVidDlWatch = true;
        const retry = () => {
          if (document.getElementById('pe-vid-dl-fab') || !/\/pin\/\d/i.test(location.pathname)) return;
          v.__peVidDlWatch = false;
          createVideoDlFab();
        };
        new MutationObserver(retry).observe(v, {
          attributes: true, attributeFilter: ['src'],
          childList: true, subtree: true,
        });
        v.addEventListener('loadedmetadata', retry, { once: true });
        v.addEventListener('canplay',        retry, { once: true });
      });
      return;
    }

    if (/i\.pinimg\.com/.test(rawSrc)) return; // GIF pin — no video download button

    const wrap = document.getElementById('pe-settings-wrap');
    if (!wrap) return;

    // Build fallback URL list. Desktop: highest quality first. Mobile: lowest first (smaller file = less RAM).
    const bestUrl = getHighestQualityVideoUrl(rawSrc);
    // Never include HLS playlist URLs — they're text manifests, not downloadable video files
    const safeRawSrc = rawSrc && !/\.m3u8/i.test(rawSrc) ? rawSrc : null;
    const m = rawSrc.match(/v1\.pinimg\.com\/videos\/(mc|iht)\/(?:expMp4|720p|hls)\/([a-f0-9]{2}\/[a-f0-9]{2}\/[a-f0-9]{2}\/[a-f0-9]{32,})/i);
    const fallbackUrls = m && m[1] === 'mc'
      ? (IS_MOBILE
          ? [
              `https://v1.pinimg.com/videos/mc/expMp4/${m[2]}_t1.mp4`,
              `https://v1.pinimg.com/videos/mc/expMp4/${m[2]}_t2.mp4`,
              `https://v1.pinimg.com/videos/mc/expMp4/${m[2]}_t3.mp4`,
              `https://v1.pinimg.com/videos/mc/expMp4/${m[2]}_t4.mp4`,
              `https://v1.pinimg.com/videos/mc/720p/${m[2]}.mp4`,
              safeRawSrc,
            ]
          : [
              `https://v1.pinimg.com/videos/mc/720p/${m[2]}.mp4`,
              `https://v1.pinimg.com/videos/mc/expMp4/${m[2]}_t4.mp4`,
              `https://v1.pinimg.com/videos/mc/expMp4/${m[2]}_t3.mp4`,
              `https://v1.pinimg.com/videos/mc/expMp4/${m[2]}_t2.mp4`,
              `https://v1.pinimg.com/videos/mc/expMp4/${m[2]}_t1.mp4`,
              safeRawSrc,
            ]
        ).filter((u, i, a) => u && a.indexOf(u) === i)
      : [bestUrl, safeRawSrc].filter((u, i, a) => u && a.indexOf(u) === i);

    const btn = document.createElement('button');
    btn.id    = 'pe-vid-dl-fab';
    btn.title = 'Download Video';
    // Simple downward arrow icon
    btn.innerHTML = `<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M20 12l-1.41-1.41L13 16.17V4h-2v12.17l-5.58-5.59L4 12l8 8z"/></svg>`;
    btn.addEventListener('click', async () => {
      if (btn.disabled) return;
      btn.disabled = true;
      try {
        await downloadVideoFile(fallbackUrls, extractPinTitle(), (loaded, total) => {
          if (total > 0) btn.title = `${Math.round(loaded / total * 100)}%`;
        });
      } catch (_) {}
      btn.disabled = false;
      btn.title = 'Download Video';
    });
    wrap.insertBefore(btn, document.getElementById('pe-settings-btn'));
  }

  function initVideoDownloader() {
    if (!get('videoDownloader')) return;
    createVideoDlFab();
  }

  // When the XHR interceptor captures a video URL, try to create the fab immediately
  // (handles desktop HLS.js where no DOM mutations fire after the URL is fetched)
  _onVideoUrlCapture = function () {
    if (!get('videoDownloader') || !/\/pin\/\d/i.test(location.pathname)) return;
    if (document.getElementById('pe-vid-dl-fab')) return;
    setTimeout(createVideoDlFab, 50);
  };


  // ═══════════════════════════════════════════════════════════════════
  //  SETTINGS PANEL UI  –  circle gear FAB, popup above it
  // ═══════════════════════════════════════════════════════════════════
  const FEATURES = [
    { key: 'originalQuality', label: 'Original Quality',       desc: 'Full-res images instead of thumbnails',                    reload: true  },
    { key: 'downloadFixer',   label: 'Download Fixer',         desc: 'Proper filenames & format detection',                      reload: true  },
    { key: 'gifHover',        label: 'GIF Hover Play',         desc: 'GIFs play on hover, pause on leave',                       reload: false },
    { key: 'gifAutoPlay',     label: 'Auto-Play Visible GIFs', desc: 'Auto-play all GIFs on screen, stop when scrolled away',    reload: false },
    { key: 'boardDownloader', label: 'Board Downloader',       desc: 'Download all images from the current board',              reload: true  },
    { key: 'declutter',       label: 'Declutter',              desc: 'Remove ads, quizzes, sponsored & shopping pins',           reload: false },
    { key: 'removeVideos',    label: 'Remove Videos',          desc: 'Remove all video pins from the feed',                      reload: false },
    { key: 'contextMenu',     label: 'Image Context Menu',     desc: 'Right-click pins to copy, open or save the original',      reload: false },
    { key: 'videoDownloader', label: 'Video Downloader',       desc: 'Download button on video pins — saves at 720p quality',     reload: false },
  ];

  const HIDE_FEATURES = [
    { key: 'hideVisitSite',  label: 'Hide Visit Site',          desc: 'Remove all "Visit site" buttons',                         reload: false },
    { key: 'hideUpdates',    label: 'Hide Updates Bell',        desc: 'Hide the Updates / notifications button',                 reload: false },
    { key: 'hideMessages',   label: 'Hide Messages Button',     desc: 'Hide the Messages / notifications button in the nav',     reload: false },
    { key: 'hideShare',      label: 'Hide Share Button',        desc: 'Hide the Share / Send button on pins',                    reload: false },
    { key: 'hideShopPosts',  label: 'Hide Shop Posts',          desc: 'Collapse pins from shops (Amazon, Etsy, eBay, TeePublic, Redbubble, AliExpress)', reload: false },
    { key: 'hideComments',   label: 'Hide Comments',            desc: 'Hide comment sections and comment input on pins',         reload: false },
  ];

  function createSettingsPanel() {
    const wrap = document.createElement('div');
    wrap.id = 'pe-settings-wrap';
    wrap.innerHTML = `
      <div id="pe-settings-panel" style="display:none">
        <div id="pe-settings-title">Pinterest Power Menu <span id="pe-settings-by">By <a id="pe-settings-author" href="https://github.com/Angel2mp3" target="_blank" rel="noopener">Angel</a></span></div>
        ${FEATURES.map(f => `
          <div class="pe-row">
            <div class="pe-info">
              <span class="pe-name">${f.label}</span>
              <span class="pe-desc">${f.desc}</span>
            </div>
            <label class="pe-switch">
              <input type="checkbox" data-key="${f.key}" data-reload="${f.reload}" ${get(f.key) ? 'checked' : ''}>
              <span class="pe-knob"></span>
            </label>
          </div>`).join('')}
        <div class="pe-group">
          <div class="pe-group-header" id="pe-group-hide-hdr">
            <div class="pe-info">
              <span class="pe-name">Hide UI Elements</span>
              <span class="pe-desc">Hide buttons & interface elements</span>
            </div>
            <svg class="pe-chevron" viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
              <path d="M7 10l5 5 5-5z"/>
            </svg>
          </div>
          <div class="pe-group-body" id="pe-group-hide-body" style="display:none">
            ${HIDE_FEATURES.map(f => `
              <div class="pe-row pe-sub-row">
                <div class="pe-info">
                  <span class="pe-name">${f.label}</span>
                  <span class="pe-desc">${f.desc}</span>
                </div>
                <label class="pe-switch">
                  <input type="checkbox" data-key="${f.key}" data-reload="${f.reload}" ${get(f.key) ? 'checked' : ''}>
                  <span class="pe-knob"></span>
                </label>
              </div>`).join('')}
          </div>
        </div>
        <div id="pe-notice" style="display:none">
          <span>↺ Reload to apply</span>
          <button id="pe-reload-btn">Reload now</button>
        </div>
      </div>
      <button id="pe-settings-btn" title="Pinterest Power Menu Settings">
        <svg viewBox="0 0 24 24" width="17" height="17" fill="currentColor">
          <path d="M12 15.5A3.5 3.5 0 0 1 8.5 12 3.5 3.5 0 0 1 12 8.5a3.5 3.5 0 0 1 3.5 3.5 3.5 3.5 0 0 1-3.5 3.5m7.43-2.92c.04-.36.07-.72.07-1.08s-.03-.73-.07-1.08l2.32-1.82c.21-.16.27-.45.13-.69l-2.2-3.81a.51.51 0 0 0-.63-.22l-2.74 1.1c-.57-.44-1.18-.81-1.85-1.09l-.42-2.91A.51.51 0 0 0 13.5 1h-3c-.27 0-.5.19-.54.46l-.41 2.91c-.67.28-1.28.64-1.85 1.09L4.97 4.37a.51.51 0 0 0-.63.22L2.14 8.4c-.14.24-.08.53.13.69l2.32 1.82C4.55 11.27 4.5 11.63 4.5 12s.04.73.09 1.08l-2.32 1.82c-.21.16-.27.45-.13.69l2.2 3.81c.13.24.42.32.63.22l2.74-1.1c.57.44 1.18.8 1.85 1.09l.41 2.91c.04.27.27.46.54.46h3c.27 0 .5-.19.54-.46l.41-2.91c.67-.28 1.28-.65 1.85-1.09l2.74 1.1a.5.5 0 0 0 .63-.22l2.2-3.81c.14-.24.08-.53-.13-.69z"/>
        </svg>
      </button>
    `;
    document.body.appendChild(wrap);

    const panel  = wrap.querySelector('#pe-settings-panel');
    const btn    = wrap.querySelector('#pe-settings-btn');
    let panelOpen = false;

    function togglePanel() {
      panelOpen = !panelOpen;
      panel.style.display = panelOpen ? 'block' : 'none';
      btn.classList.toggle('pe-settings-open', panelOpen);
    }
    btn.addEventListener('click', e => { e.stopPropagation(); togglePanel(); });
    document.addEventListener('click', e => {
      if (panelOpen && !wrap.contains(e.target)) { panelOpen = false; panel.style.display = 'none'; btn.classList.remove('pe-settings-open'); }
    });

    // Collapsible "Hide UI Elements" group
    const hideHdr  = wrap.querySelector('#pe-group-hide-hdr');
    const hideBody = wrap.querySelector('#pe-group-hide-body');
    hideHdr.addEventListener('click', () => {
      const open = hideBody.style.display !== 'none';
      hideBody.style.display = open ? 'none' : 'block';
      hideHdr.classList.toggle('pe-group-open', !open);
    });

    // Toggle switches
    wrap.querySelectorAll('input[type="checkbox"]').forEach(cb => {
      cb.addEventListener('change', () => {
        const key = cb.dataset.key;
        set(key, cb.checked);
        if (key === 'hideVisitSite') applyVisitSiteToggle();
        if (key === 'gifHover') { pauseActiveGif(); document.querySelectorAll('video').forEach(pauseVidOnAdd); }
        if (key === 'gifAutoPlay') { if (cb.checked) initGifAutoPlay(); else stopGifAutoPlay(); }
        if (key === 'declutter') { if (cb.checked) initDeclutter(); }
        if (key === 'removeVideos') { if (cb.checked) initRemoveVideos(); }
        if (key === 'contextMenu') { if (cb.checked) initImageContextMenu(); }
        if (key === 'hideUpdates' || key === 'hideMessages' || key === 'hideShare') applyNavToggles();
        if (key === 'hideMessages' && cb.checked) initMessagesRemover();
        if (key === 'hideShopPosts') { if (cb.checked) initHideShopPosts(); }
        if (key === 'hideComments') { applyNavToggles(); if (cb.checked) initHideComments(); }
        if (key === 'videoDownloader') { if (cb.checked) createVideoDlFab(); else removeVideoDlFab(); }
        if (cb.dataset.reload === 'true')
          wrap.querySelector('#pe-notice').style.display = 'flex';
      });
    });

    wrap.querySelector('#pe-reload-btn').addEventListener('click', () => location.reload());
  }


  // ═══════════════════════════════════════════════════════════════════
  //  MODULE: IMAGE RIGHT-CLICK CONTEXT MENU
  // ═══════════════════════════════════════════════════════════════════
  // Intercepts right-clicks on (or near) any pinimg.com image and shows
  // a custom menu with options to copy/save the original-quality version.
  // Replaces the native browser menu only when a Pinterest image is
  // under the cursor; other right-clicks fall through normally.

  function initImageContextMenu() {
    // The custom context menu is mouse-only. On mobile the long-press handler
    // would compete with native browser actions (text selection, system menus),
    // so we skip the entire module on touch devices.
    if (IS_MOBILE) return;
    if (!get('contextMenu')) return;

    let _ctxMenu = null;
    let _cleanupCtxMenu = null;

    function removeCtxMenu() {
      if (_cleanupCtxMenu) _cleanupCtxMenu();
      if (_ctxMenu) { _ctxMenu.remove(); _ctxMenu = null; }
    }

    function getMediaInfo(target) {
      let card = target.closest ? target.closest('[data-test-id="pin"], [data-grid-item="true"], [data-test-id="pin-closeup-image"], .PinCard') : null;
      let wrap = target.closest ? target.closest('[data-test-id="pinWrapper"], [data-test-id="pin-closeup-image"]') : null;
      let title = extractPinTitleFromScope(card || wrap);

      if (wrap) {
        // Video
        const vid = wrap.querySelector('video');
        if (vid) {
          const src = vid.src || (vid.querySelector('source') && vid.querySelector('source').src);
          if (src && !/i\.pinimg\.com/.test(src)) return { url: getHighestQualityVideoUrl(src), type: 'video', title };
        }
      }

      // Try finding nearest image
      let img = target;
      for (let i = 0; i < 15 && img && img !== document.body; i++) {
        if (img.tagName === 'IMG' && img.src && /pinimg\.com/i.test(img.src)) {
          break;
        }
        img = img.parentElement;
      }
      
      if (!img || img.tagName !== 'IMG' || !/pinimg\.com/i.test(img.src)) {
        if (wrap) {
           img = wrap.querySelector('img[src*="pinimg.com"]');
        } else if (card) {
           img = card.querySelector('img[src*="pinimg.com"]');
        } else {
           img = null;
        }
      }

      if (!img) return null;

      // Now determine if it's a GIF or Image
      // 1. Is it actively playing a GIF? (hover/auto-play swaps src)
      if (/\.gif(\?|$)/i.test(img.src)) {
        return { url: img.src, type: 'gif', title };
      }
      
      // 2. Does it have a GIF in its original srcset?
      const origSrcset = img.__peAutoOrigSrcset || img.getAttribute('srcset') || '';
      for (const part of origSrcset.split(',')) {
        const url = part.trim().split(/\s+/)[0];
        if (url && /\.gif(\?|$)/i.test(url)) return { url: url, type: 'gif', title };
      }

      // Otherwise, it's a standard image. Return original quality URL.
      return { url: getBestUrl(img), type: 'image', title };
    }

    // Return the best original-quality URL for an img element.
    function getBestUrl(img) {
      const base = img.__peAutoOrigSrc || img.src;
      const m = base.match(OQ_RE);
      return m ? m[1] + '/originals' + m[2] : base;
    }

    async function copyMediaToClipboard(origUrl, type) {
      function fallbackToText() {
        if (typeof GM_setClipboard === 'function') {
          GM_setClipboard(origUrl, 'text');
        } else {
          const ta = document.createElement('textarea');
          ta.value = origUrl;
          ta.style.cssText = 'position:fixed;left:-9999px';
          document.body.appendChild(ta);
          ta.select();
          document.execCommand('copy');
          ta.remove();
        }
      }

      if (type === 'video' || type === 'gif') {
        // We cannot reliably put video or animated gif binaries into the OS clipboard 
        // without causing bugs like Discord pasting "message.txt".
        // Instead, copy the direct URL so it auto-embeds natively.
        fallbackToText();
        return;
      }

      const buf  = await fetchBinary(origUrl);
      const arr  = new Uint8Array(buf);
      const ext  = detectFileType(arr);
      const mime = ext === '.png' ? 'image/png'
                 : ext === '.gif' ? 'image/gif'
                 : ext === '.webp' ? 'image/webp'
                 : 'image/jpeg';

      if (mime === 'image/gif' || mime === 'image/webp') {
        fallbackToText();
        return;
      }

      let blob = new Blob([buf], { type: mime });

      if (mime !== 'image/png') {
        blob = await new Promise(res => {
          const bUrl = URL.createObjectURL(blob);
          const tmp  = new Image();
          tmp.crossOrigin = 'anonymous';
          tmp.onload = () => {
            const cv = document.createElement('canvas');
            cv.width  = tmp.naturalWidth;
            cv.height = tmp.naturalHeight;
            cv.getContext('2d').drawImage(tmp, 0, 0);
            cv.toBlob(b => { URL.revokeObjectURL(bUrl); res(b); }, 'image/png');
          };
          tmp.onerror = () => { URL.revokeObjectURL(bUrl); res(null); };
          tmp.src = bUrl;
        });
      }

      if (blob) {
        try {
          await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
        } catch (_) {
          fallbackToText();
        }
      } else {
        fallbackToText();
      }
    }

    // Long-press state for mobile context menu
    let _lpJustShown = false;
    let _lpTimer     = null;
    let _lpScrolled  = false;
    let _lpStartX = 0, _lpStartY = 0;

    // Extracted so both right-click and long-press can reuse the same menu logic.
    // isTouch = true adds a longer grace period before outside-click dismissal,
    // preventing the finger-lift tap from instantly closing the menu.
    function showCtxMenuAt(x, y, media, isTouch) {
      removeCtxMenu();
      const { url: origUrl, type, title } = media;
      const menuX = Math.min(x, window.innerWidth  - 236);
      const menuY = Math.min(y, window.innerHeight - 200);

      const menu = document.createElement('div');
      menu.id = 'pe-ctx-menu';
      menu.style.cssText = `left:${menuX}px;top:${menuY}px`;

      function addItem(svgD, label, action) {
        const item = document.createElement('button');
        item.className = 'pe-ctx-item';
        item.innerHTML =
          `<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor">${svgD}</svg>` +
          `<span>${label}</span>`;
        item.addEventListener('click', e => { e.stopPropagation(); action(); removeCtxMenu(); });
        menu.appendChild(item);
      }

      // ── Copy media ──────────────────────────────────────────────────
      addItem(
        '<rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>',
        'Copy Original Media',
        async () => {
          try {
            await copyMediaToClipboard(origUrl, type);
          } catch (_) {}
        }
      );

      // ── Copy URL ────────────────────────────────────────────────────
      addItem(
        '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
        'Copy Media URL',
        () => {
          if (typeof GM_setClipboard === 'function') {
            GM_setClipboard(origUrl, 'text');
          } else {
            navigator.clipboard.writeText(origUrl).catch(() => {
              const ta = document.createElement('textarea');
              ta.value = origUrl;
              ta.style.cssText = 'position:fixed;left:-9999px';
              document.body.appendChild(ta);
              ta.select();
              document.execCommand('copy');
              ta.remove();
            });
          }
        }
      );

      // ── Open in new tab ─────────────────────────────────────────────
      addItem(
        '<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>',
        'Open Media in New Tab',
        () => window.open(origUrl, '_blank', 'noopener')
      );

      // ── Save / download ─────────────────────────────────────────────
      addItem(
        '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>',
        'Save Original Media',
        () => downloadSingle(origUrl, title)
      );

      _ctxMenu = menu;
      document.body.appendChild(menu);

      const onClose = ev => {
        if (menu.contains(ev.target)) return;
        removeCtxMenu();
      };

      const onEsc = ev => {
        if (ev.key === 'Escape') removeCtxMenu();
      };

      _cleanupCtxMenu = () => {
        document.removeEventListener('click',       onClose);
        document.removeEventListener('contextmenu', onClose);
        document.removeEventListener('keydown',     onEsc);
        _cleanupCtxMenu = null;
      };

      // On touch, use a longer delay so the finger-lift tap doesn't
      // immediately close the menu before the user can read it.
      setTimeout(() => {
        if (!_cleanupCtxMenu) return;
        document.addEventListener('click',       onClose);
        document.addEventListener('contextmenu', onClose);
        document.addEventListener('keydown',     onEsc);
      }, isTouch ? 300 : 0);
    }

    document.addEventListener('contextmenu', e => {
      if (!get('contextMenu')) { removeCtxMenu(); return; }
      // Suppress native contextmenu on Android when our long-press already fired
      if (_lpJustShown) { e.preventDefault(); return; }
      const media = getMediaInfo(e.target);
      if (!media) { removeCtxMenu(); return; }
      e.preventDefault();
      showCtxMenuAt(e.clientX, e.clientY, media, false);
    }, true);

    // ── Long-press for mobile context menu (iOS + Android fallback) ──
    document.addEventListener('touchstart', e => {
      if (!get('contextMenu')) return;
      const touch = e.touches[0];
      _lpStartX   = touch.clientX;
      _lpStartY   = touch.clientY;
      _lpScrolled = false;
      clearTimeout(_lpTimer);
      _lpTimer = setTimeout(() => {
        _lpTimer = null;
        if (_lpScrolled) return;
        const el = document.elementFromPoint(_lpStartX, _lpStartY);
        if (!el) return;
        const media = getMediaInfo(el);
        if (!media) return;
        // Prevent the Android contextmenu event (fired ~20 ms later) from
        // duplicating the menu we're about to show.
        _lpJustShown = true;
        setTimeout(() => { _lpJustShown = false; }, 400);
        showCtxMenuAt(_lpStartX, _lpStartY, media, true);
        if (navigator.vibrate) navigator.vibrate(30);
      }, 600);
    }, { passive: true });

    document.addEventListener('touchmove', e => {
      if (_lpScrolled) return;
      const touch = e.changedTouches[0];
      if (Math.abs(touch.clientX - _lpStartX) > 10 || Math.abs(touch.clientY - _lpStartY) > 10) {
        _lpScrolled = true;
        clearTimeout(_lpTimer);
        _lpTimer = null;
      }
    }, { passive: true });

    document.addEventListener('touchend', () => {
      clearTimeout(_lpTimer);
      _lpTimer = null;
    }, { passive: true });
  }


  // ═══════════════════════════════════════════════════════════════════
  //  MODULE: MOBILE LAZY-LOAD FIX
  // ═══════════════════════════════════════════════════════════════════
  // Pinterest on mobile aggressively defers image loading via loading="lazy"
  // and data-src attributes. On large feeds or slow devices many images that
  // are already visible on screen never actually load.
  // Uses IntersectionObserver with a generous 600 px rootMargin so images
  // are fetched well before reaching the viewport edge.
  // Also force-copies data-src → src for GIF images that are already
  // visible but whose lazy-loader hasn't fired yet.
  function initMobileLazyFix() {
    if (!IS_MOBILE) return;

    const io = new IntersectionObserver(entries => {
      entries.forEach(entry => {
        if (!entry.isIntersecting) return;
        const el = entry.target;

        if (el.tagName === 'IMG') {
          // Lift native lazy-loading so the browser fetches immediately
          if (el.getAttribute('loading') === 'lazy') el.setAttribute('loading', 'eager');
          // Copy data-src → src if Pinterest's own lazy-loader hasn't fired yet
          const ds = el.getAttribute('data-src');
          if (ds && (!el.src || el.src === location.href)) el.src = ds;
          io.unobserve(el);
          return;
        }

        if (el.tagName === 'VIDEO') {
          // Mobile GIFs are often <video> with lazy data-src values.
          hydrateVideoSource(el);
          el.preload = 'auto';
          el.playsInline = true;
          if (el.readyState === 0) {
            try { el.load(); } catch (_) {}
          }
          // Mark as GIF-video when applicable so GIF modules can manage it.
          if (isGifVideo(el, findGifContainer(el))) el.__peGifVid = true;
          io.unobserve(el);
        }
      });
    }, { rootMargin: '600px 0px', threshold: 0 });

    function observeMedia(root) {
      if (!root || !root.querySelectorAll) return;
      root.querySelectorAll('img[loading="lazy"], img[data-src*="pinimg.com"], video').forEach(el => {
        // Only observe videos that look like Pinterest GIF media.
        if (el.tagName === 'VIDEO') {
          const hasLazySource = !!el.querySelector('source[data-src]');
          const src = getVideoSrc(el);
          if (!hasLazySource && !/pinimg\.com/i.test(src)) return;
        }
        if (el.__peLazyObs) return;
        el.__peLazyObs = true;
        io.observe(el);
      });
    }

    observeMedia(document);

    new MutationObserver(records => {
      records.forEach(r => r.addedNodes.forEach(n => {
        if (!n || n.nodeType !== 1) return;
        if (n.tagName === 'IMG') {
          if (!n.__peLazyObs) { n.__peLazyObs = true; io.observe(n); }
        } else if (n.tagName === 'VIDEO') {
          const hasLazySource = !!n.querySelector('source[data-src]');
          const src = getVideoSrc(n);
          if ((hasLazySource || /pinimg\.com/i.test(src)) && !n.__peLazyObs) {
            n.__peLazyObs = true;
            io.observe(n);
          }
        } else {
          observeMedia(n);
        }
      }));
    }).observe(document.documentElement, { childList: true, subtree: true });
  }


  // ═══════════════════════════════════════════════════════════════════
  //  STYLES
  // ═══════════════════════════════════════════════════════════════════
  function injectStyles() {
    const s = document.createElement('style');
    s.id = 'pe-styles';
    s.textContent = `
      /* ──────── Always hide "Open app" search autocomplete suggestions ──────── */
      [data-test-type="app_upsell_autocomplete"] { display: none !important; }

      /* ──────── Hide Visit Site ──────── */
      body.pe-hide-visit [data-test-id="visit-button"],
      body.pe-hide-visit .domain-link-button,
      body.pe-hide-visit [aria-label="Visit site"],
      body.pe-hide-visit a[rel="nofollow"][href*="://"] {
        display: none !important;
      }

      /* ──────── Hide Updates bell ──────── */
      body.pe-hide-updates [data-test-id="bell-icon"] {
        display: none !important;
      }

      /* ──────── Hide Messages nav button ──────── */
      body.pe-hide-messages div[aria-label="Messages"],
      body.pe-hide-messages [data-test-id="notifications-button"],
      body.pe-hide-messages [data-test-id="nav-bar-speech-ellipsis"],
      body.pe-hide-messages a[href="/notifications/"] {
        display: none !important;
      }

      /* ──────── Hide Share / Send button ──────── */
      body.pe-hide-share [data-test-id="closeup-share-button"],
      body.pe-hide-share div[aria-label="Share"],
      body.pe-hide-share button[aria-label="Send"],
      body.pe-hide-share [data-test-id="sendPinButton"],
      body.pe-hide-share [aria-label="Send"][role="button"],
      body.pe-hide-share [data-test-id="share-button-no-animation"],
      body.pe-hide-share [style*="ANIMATE_SHARE_container"] {
        display: none !important;
      }

      /* ──────── Hide Comments ──────── */
      body.pe-hide-comments #canonical-card,
      body.pe-hide-comments [data-test-id="comment-editor-container"],
      body.pe-hide-comments [data-test-id="editor-with-mentions"],
      body.pe-hide-comments #dweb-comment-editor-container,
      body.pe-hide-comments #mweb-comment-editor-container,
      body.pe-hide-comments [data-test-id="closeup-metadata-details-divider"],
      /* Comment icon button (desktop & mobile) */
      body.pe-hide-comments button[aria-label="Comments"],
      body.pe-hide-comments button[aria-label="comments"],
      /* Mobile comment button wrapper */
      body.pe-hide-comments [data-test-id="comment-button"] {
        display: none !important;
      }

      /* ──────── Remove dark hover overlay on pin cards ──────── */
      /* The overlay is an empty div that siblings [data-test-id="pinrep-image"] */
      [data-test-id="pinrep-image"] ~ div:not([data-test-id]) {
        background: transparent !important;
        background-color: transparent !important;
        background-image: none !important;
        opacity: 0 !important;
        display: none !important;
      }
      /* contentLayer gradient (the hover tint behind buttons) */
      [data-test-id="contentLayer"],
      [data-test-id="contentLayer"]::before,
      [data-test-id="contentLayer"]::after {
        background: transparent !important;
        background-color: transparent !important;
        background-image: none !important;
        box-shadow: none !important;
      }
      /* Any divs inside the image wrapper that could be overlays */
      [data-test-id^="pincard-gif"] > div > [data-test-id="pinrep-image"] ~ * {
        background: transparent !important;
        opacity: 0 !important;
        pointer-events: none !important;
      }
      /* Kill the dark gradient on ALL pin image wrappers (class-agnostic) */
      [data-test-id^="pincard"] > div > div:last-child:not([data-test-id]),
      .PinCard__imageWrapper > div > div:last-child:empty {
        display: none !important;
      }

      /* ──────── Settings circle FAB ──────── */
      #pe-settings-wrap {
        position: fixed;
        bottom: 6px;
        right: 6px;
        z-index: 2147483647;
        display: flex;
        flex-direction: column;
        align-items: flex-end;
        gap: 10px;
        font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
        user-select: none;
      }
      #pe-settings-btn {
        width: 40px; height: 40px;
        border-radius: 50%;
        background: #e60023; color: #fff; border: none;
        cursor: pointer;
        display: flex; align-items: center; justify-content: center;
        box-shadow: 0 4px 18px rgba(230,0,35,.45);
        transition: background .18s, box-shadow .18s, transform .25s;
        flex-shrink: 0;
      }
      #pe-settings-btn:hover { background: #b5001b; box-shadow: 0 6px 24px rgba(230,0,35,.55); transform: scale(1.08); }
      #pe-settings-btn:active { transform: scale(.92); }
      #pe-settings-btn.pe-settings-open { transform: rotate(45deg); }
      #pe-settings-btn.pe-settings-open:hover { transform: rotate(45deg) scale(1.08); }

      #pe-settings-panel {
        background: #fff;
        border-radius: 12px;
        box-shadow: 0 4px 28px rgba(0,0,0,.16), 0 1px 4px rgba(0,0,0,.08);
        border: 1px solid rgba(0,0,0,.07);
        min-width: 230px;
        overflow: hidden;
        animation: pe-bd-pop .15s ease-out;
      }
      #pe-settings-title {
        padding: 8px 12px 7px;
        background: #e60023;
        color: #fff;
        font-weight: 700;
        font-size: 13px;
        letter-spacing: .02em;
        display: flex;
        align-items: baseline;
        gap: 6px;
      }
      #pe-settings-by {
        font-weight: 700;
        font-size: 11px;
        opacity: .85;
        margin-left: auto;
      }
      #pe-settings-author {
        color: #fff;
        text-decoration: underline;
        text-underline-offset: 2px;
      }
      #pe-settings-author:hover { opacity: .75; }

      .pe-row {
        display: flex; align-items: center; justify-content: space-between;
        padding: 7px 12px; gap: 10px;
        transition: background .12s;
        border-top: 1px solid #f2f2f2;
      }
      .pe-row:hover { background: #fafafa; }

      .pe-info { flex: 1; min-width: 0; }
      .pe-name {
        display: block; font-weight: 600; color: #111;
        font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
      }
      .pe-desc {
        display: block; font-size: 10px; color: #767676; margin-top: 1px;
        white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
      }

      /* Toggle switch */
      .pe-switch { position: relative; display: inline-block; width: 36px; height: 20px; flex-shrink: 0; }
      .pe-switch input { opacity: 0; width: 0; height: 0; position: absolute; }
      .pe-knob {
        position: absolute; inset: 0; background: #d1d1d1;
        border-radius: 20px; cursor: pointer;
        transition: background .2s;
      }
      .pe-knob::before {
        content: ''; position: absolute;
        width: 14px; height: 14px; left: 3px; bottom: 3px;
        background: #fff; border-radius: 50%;
        transition: transform .2s;
        box-shadow: 0 1px 3px rgba(0,0,0,.22);
      }
      .pe-switch input:checked ~ .pe-knob { background: #e60023; }
      .pe-switch input:checked ~ .pe-knob::before { transform: translateX(16px); }
      .pe-switch input:focus-visible ~ .pe-knob { outline: 2px solid #e60023; outline-offset: 2px; }

      /* ──────── Collapsible settings group ──────── */
      .pe-group { border-top: 1px solid #f2f2f2; }
      .pe-group-header {
        display: flex; align-items: center; justify-content: space-between;
        padding: 7px 12px; gap: 10px; cursor: pointer; transition: background .12s;
      }
      .pe-group-header:hover { background: #fafafa; }
      .pe-chevron { transition: transform .2s; flex-shrink: 0; color: #767676; }
      .pe-group-open .pe-chevron { transform: rotate(180deg); }
      .pe-group-body { border-top: 1px solid #f2f2f2; }
      .pe-sub-row { padding-left: 28px !important; background: #fafafa; }
      .pe-sub-row:hover { background: #f5f5f5 !important; }

      /* Reload notice */
      #pe-notice {
        display: flex; align-items: center; justify-content: space-between;
        background: #fff9e6; border-top: 1px solid #ffe180;
        padding: 7px 14px; gap: 8px;
        font-size: 12px; color: #7a5800;
      }
      #pe-reload-btn {
        background: #e60023; color: #fff; border: none;
        border-radius: 6px; font-size: 11px; font-weight: 700;
        padding: 3px 10px; cursor: pointer; white-space: nowrap;
        transition: background .15s;
      }
      #pe-reload-btn:hover { background: #b5001b; }

      /* ──────── Board Downloader FAB (standalone, above #pe-settings-wrap) ──────── */
      #pe-bd-fab {
        position: fixed;
        bottom: 56px;
        right: 6px;
        z-index: 2147483647;
        display: flex;
        flex-direction: column;
        align-items: flex-end;
        gap: 10px;
        font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
        user-select: none;
      }
      #pe-bd-btn {
        width: 40px; height: 40px;
        border-radius: 50%;
        background: #e60023; color: #fff; border: none;
        cursor: pointer;
        display: flex; align-items: center; justify-content: center;
        box-shadow: 0 4px 18px rgba(230,0,35,.45);
        transition: background .18s, box-shadow .18s, transform .12s;
        flex-shrink: 0;
        touch-action: manipulation;
      }
      #pe-bd-btn:hover {
        background: #b5001b;
        box-shadow: 0 6px 24px rgba(230,0,35,.55);
        transform: scale(1.08);
      }
      #pe-bd-btn:active { transform: scale(.92); }

      /* ──────── Video Downloader FAB ──────── */
      #pe-vid-dl-fab {
        width: 40px; height: 40px;
        border-radius: 50%;
        background: #e60023; color: #fff; border: none;
        cursor: pointer;
        display: flex; align-items: center; justify-content: center;
        box-shadow: 0 4px 18px rgba(230,0,35,.45);
        transition: background .18s, box-shadow .18s, transform .25s;
        flex-shrink: 0;
        touch-action: manipulation;
      }
      #pe-vid-dl-fab:hover  { background: #b5001b; box-shadow: 0 6px 24px rgba(230,0,35,.55); transform: scale(1.08); }
      #pe-vid-dl-fab:active { transform: scale(.92); }
      #pe-vid-dl-fab:disabled { opacity: .55; cursor: wait; transform: none !important; }

      #pe-bd-menu {
        background: #fff;
        border-radius: 12px;
        box-shadow: 0 4px 24px rgba(0,0,0,.15), 0 1px 4px rgba(0,0,0,.07);
        border: 1px solid rgba(0,0,0,.07);
        overflow: hidden;
        min-width: 192px;
        animation: pe-bd-pop .15s ease-out;
      }
      @keyframes pe-bd-pop {
        from { opacity:0; transform: scale(.9) translateY(6px); }
        to   { opacity:1; transform: scale(1) translateY(0); }
      }
      #pe-bd-status {
        padding: 7px 14px;
        font-size: 11px;
        color: #555;
        background: #f8f8f8;
        border-bottom: 1px solid #eee;
        white-space: nowrap;
      }
      .pe-bd-opt {
        display: flex; align-items: center; gap: 10px;
        padding: 11px 16px;
        font-size: 13px; font-weight: 600; color: #111;
        background: none; border: none; width: 100%;
        cursor: pointer; text-align: left;
        transition: background .12s;
      }
      .pe-bd-opt:hover { background: #f5f5f5; }
      .pe-bd-opt:disabled { color: #aaa; cursor: not-allowed; background: none; }
      .pe-bd-opt + .pe-bd-opt { border-top: 1px solid #f0f0f0; }

      /* ──────── Image right-click context menu ──────── */
      #pe-ctx-menu {
        position: fixed;
        background: #fff;
        border-radius: 10px;
        box-shadow: 0 4px 28px rgba(0,0,0,.18), 0 1px 6px rgba(0,0,0,.1);
        border: 1px solid rgba(0,0,0,.09);
        z-index: 2147483647;
        min-width: 220px;
        overflow: hidden;
        padding: 4px 0;
        animation: pe-bd-pop .12s ease-out;
        font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
        user-select: none;
      }
      .pe-ctx-item {
        display: flex; align-items: center; gap: 10px;
        padding: 8px 14px;
        font-size: 13px; font-weight: 500; color: #111;
        background: none; border: none; width: 100%;
        cursor: pointer; text-align: left;
        transition: background .1s;
      }
      .pe-ctx-item:hover { background: #f5f5f5; }
      .pe-ctx-item + .pe-ctx-item { border-top: 1px solid #f0f0f0; }
      .pe-ctx-item svg { flex-shrink: 0; color: #555; }

      /* ──────── Mobile / Touch support ──────── */
      /* Remove 300ms tap delay on all interactive elements */
      #pe-settings-btn, #pe-bd-btn, #pe-reload-btn,
      .pe-ctx-item, .pe-row, .pe-bd-opt, .pe-group-header, .pe-switch {
        touch-action: manipulation;
      }

      /* Settings panel: scrollable and viewport-safe on small screens */
      #pe-settings-panel {
        max-height: calc(100dvh - 80px);
        overflow-y: auto;
        overscroll-behavior: contain;
        max-width: calc(100vw - 12px);
        -webkit-overflow-scrolling: touch;
      }

      /* Board downloader menu: same treatment */
      #pe-bd-menu {
        max-height: calc(100dvh - 130px);
        overflow-y: auto;
        overscroll-behavior: contain;
        -webkit-overflow-scrolling: touch;
        max-width: calc(100vw - 12px);
      }

      /* ──────── Touch / mobile overrides ──────── */
      @media (pointer: coarse) {
        /* Slightly smaller FABs on touch so they don't obscure pins */
        #pe-settings-btn { width: 32px; height: 32px; }
        #pe-bd-btn        { width: 32px; height: 32px; }
        /* Adjust board fab bottom: 32px (mobile settings btn) + 6px + 10px gap = 48px */
        #pe-bd-fab        { bottom: 48px; }

        /* ── Compact settings panel on mobile ── */
        /* Cap height to ~62% of screen and use a narrower width */
        #pe-settings-panel {
          max-height: 420px; /* px fallback for browsers without dvh support */
          max-height: min(62dvh, 420px);
          min-width: 220px;
          max-width: calc(100vw - 14px);
          border-radius: 12px;
        }
        #pe-vid-dl-fab { width: 32px; height: 32px; }
        /* Smaller title bar */
        #pe-settings-title {
          font-size: 13px;
          padding: 8px 12px 7px;
        }
        #pe-settings-by { font-size: 10px; }

        /* Compact rows — still large enough to tap, but not 48px tall */
        .pe-row {
          padding: 6px 12px;
          min-height: 38px;
          gap: 10px;
        }
        .pe-group-header {
          padding: 6px 12px;
          min-height: 38px;
          gap: 10px;
        }
        .pe-sub-row {
          min-height: 36px;
          padding-left: 20px !important;
        }

        /* Smaller text inside the settings panel */
        .pe-name  { font-size: 12px; }
        .pe-desc  { font-size: 10px; }

        /* Slightly smaller toggle switch */
        .pe-switch { width: 30px; height: 17px; }
        .pe-knob::before { width: 11px; height: 11px; left: 3px; bottom: 3px; }
        .pe-switch input:checked ~ .pe-knob::before { transform: translateX(13px); }

        /* Compact reload notice */
        #pe-notice { padding: 5px 12px; font-size: 11px; }
        #pe-reload-btn { font-size: 10px; padding: 3px 8px; }

        /* Context menu + board downloader keep generous tap targets */
        .pe-ctx-item { padding: 13px 16px; min-height: 48px; }
        .pe-bd-opt   { min-height: 48px; padding: 13px 16px; }
      }

      /* Prevent panels exceeding viewport width on very narrow screens */
      /* Backup compact panel for narrow screens where pointer:coarse may not fire */
      @media (max-width: 600px) {
        #pe-settings-panel {
          max-height: 420px;
          max-height: min(62dvh, 420px);
          min-width: 220px;
          max-width: calc(100vw - 14px);
        }
        #pe-vid-dl-fab { width: 32px; height: 32px; }
      }

      @media (max-width: 320px) {
        #pe-settings-panel { min-width: unset; width: calc(100vw - 12px); }
        #pe-ctx-menu       { min-width: unset; width: calc(100vw - 24px); }
      }

      /* ──────── Mobile performance: reduce GPU over-composition ──────── */
      @media (pointer: coarse) {
        /* Pinterest promotes every pin card to its own GPU compositing layer
           via will-change, which exhausts GPU memory and causes scroll jank.
           Resetting it lets the browser decide when a layer is actually needed. */
        [data-test-id="pinWrapper"] {
          will-change: auto !important;
        }
        /* Async image decoding keeps the main thread free while the user scrolls */
        [data-test-id="pinWrapper"] img {
          decoding: async;
        }
      }
    `;
    (document.head || document.documentElement).appendChild(s);
  }


  // ═══════════════════════════════════════════════════════════════════
  //  INIT – run on DOMContentLoaded (UI) while OQ/modal observers
  //         are already running from document-start.
  // ═══════════════════════════════════════════════════════════════════
  function onReady() {
    injectStyles();

    // Upgrade any images already in DOM
    if (get('originalQuality'))
      document.querySelectorAll('img[src*="pinimg.com"]').forEach(upgradeImg);

    // GIF hover – pause any videos already in DOM, start delegation
    document.querySelectorAll('video').forEach(pauseVidOnAdd);
    initGifHover();

    // Apply hide-visit-site + nav-hide CSS classes
    applyVisitSiteToggle();
    applyNavToggles();
    initVisitSiteHider();
    initMessagesRemover();
    initShareOverride();

    // Declutter
    initDeclutter();

    // Remove videos
    initRemoveVideos();

    // GIF auto-play
    if (get('gifAutoPlay')) initGifAutoPlay();

    // Image right-click context menu
    initImageContextMenu();

    // Download fixer event listener
    initDownloadFixer();

    // Settings panel
    createSettingsPanel();

    // Board downloader button
    createBoardDownloaderUI();

    // Hide shop posts
    initHideShopPosts();

    // Hide comments
    initHideComments();

    // Video downloader FAB
    initVideoDownloader();

    // Mobile: retry video fab after a delay — video src is often set after DOMContentLoaded
    if (IS_MOBILE && /\/pin\/\d/i.test(location.pathname)) {
      [1500, 3500].forEach(ms => setTimeout(() => {
        if (!document.getElementById('pe-vid-dl-fab') && get('videoDownloader'))
          createVideoDlFab();
      }, ms));
    }

    // Scroll preservation (restores position on browser back)
    initScrollPreservation();

    // Mobile: pre-load lazy images and fix GIF loading
    initMobileLazyFix();
  }

  if (document.readyState === 'loading')
    document.addEventListener('DOMContentLoaded', onReady);
  else
    onReady();

  // ═══════════════════════════════════════════════════════════════════
  //  SPA NAVIGATION WATCHER
  //  Pinterest never does a real page reload when you navigate.
  //  Intercept history.pushState / replaceState and popstate so we
  //  can show/hide the board FAB whenever the URL changes.
  // ═══════════════════════════════════════════════════════════════════
  (function () {
    let _lastPath = location.pathname;

    function onNavigate() {
      const newPath = location.pathname;
      if (newPath === _lastPath) return;
      _lastPath = newPath;

      // Clear stale intercepted video URLs from the previous pin so they
      // can't be picked up by createVideoDlFab on the new pin page
      _interceptedVideoUrls.length = 0;

      // Give Pinterest's React a moment to render the new page
      setTimeout(() => {
        removeBoardDownloaderUI();
        if (get('boardDownloader') && isBoardPage()) createBoardDownloaderUI();
        removeVideoDlFab();
        if (get('videoDownloader')) createVideoDlFab();
      }, 600);

      // Further attempts with increasing delays — mobile video src can arrive late
      [1800, 3500].forEach(ms => setTimeout(() => {
        if (!document.getElementById('pe-bd-btn') && get('boardDownloader') && isBoardPage())
          createBoardDownloaderUI();
        if (!document.getElementById('pe-vid-dl-fab') && get('videoDownloader'))
          createVideoDlFab();
      }, ms));
    }

    // Wrap history methods
    const _push    = history.pushState.bind(history);
    const _replace = history.replaceState.bind(history);
    history.pushState    = function (...a) { _push(...a);    onNavigate(); };
    history.replaceState = function (...a) { _replace(...a); onNavigate(); };
    window.addEventListener('popstate', onNavigate);

    // Also watch for the board header / video element appearing in the DOM (handles cases
    // where the URL change fires before React has rendered the new page content)
    new MutationObserver(() => {
      if (!document.getElementById('pe-bd-btn') && get('boardDownloader') && isBoardPage())
        createBoardDownloaderUI();
      if (!document.getElementById('pe-vid-dl-fab') && get('videoDownloader'))
        createVideoDlFab();
    }).observe(document.documentElement, { childList: true, subtree: true });
  })();

})();