Bluesky Image/Video Download Button

Adds a download button to Bluesky images and videos.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name        Bluesky Image/Video Download Button
// @namespace   KanashiiWolf
// @match       https://bsky.app/*
// @grant       GM_setValue
// @grant       GM_getValue
// @grant       GM_registerMenuCommand
// @grant       GM_info
// @grant       GM_addStyle
// @grant       GM_addValueChangeListener
// @version     2.5.3
// @author      KanashiiWolf, the-nelsonator, coredumperror
// @description Adds a download button to Bluesky images and videos.
// @icon        data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTEyIiBoZWlnaHQ9IjUxMiIgdmlld0JveD0iMCAwIDUxMiA1MTIiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+DQogIDxkZWZzPg0KICAgIDxsaW5lYXJHcmFkaWVudCBpZD0iZ3JhZDEiIHgxPSIwJSIgeTE9IjAlIiB4Mj0iMCUiIHkyPSIxMDAlIj4NCiAgICAgIDxzdG9wIG9mZnNldD0iMCUiIHN0eWxlPSJzdG9wLWNvbG9yOiMwMDg1ZmY7c3RvcC1vcGFjaXR5OjEiIC8+DQogICAgICA8c3RvcCBvZmZzZXQ9IjEwMCUiIHN0eWxlPSJzdG9wLWNvbG9yOiMxMTg1ZmU7c3RvcC1vcGFjaXR5OjEiIC8+DQogICAgPC9saW5lYXJHcmFkaWVudD4NCiAgICA8ZmlsdGVyIGlkPSJkcm9wU2hhZG93IiBoZWlnaHQ9IjEzMCUiPg0KICAgICAgPGZlR2F1c3NpYW5CbHVyIGluPSJTb3VyY2VBbHBoYSIgc3RkRGV2aWF0aW9uPSIzIi8+DQogICAgICA8ZmVPZmZzZXQgZHg9IjIiIGR5PSI0IiByZXN1bHQ9Im9mZnNldGJsdXIiLz4NCiAgICAgIDxmZUNvbXBvbmVudFRyYW5zZmVyPg0KICAgICAgICA8ZmVGdW5jQSB0eXBlPSJsaW5lYXIiIHNsb3BlPSIwLjMiLz4NCiAgICAgIDwvZmVDb21wb25lbnRUcmFuc2Zlcj4NCiAgICAgIDxmZU1lcmdlPg0KICAgICAgICA8ZmVNZXJnZU5vZGUvPg0KICAgICAgICA8ZmVNZXJnZU5vZGUgaW49IlNvdXJjZUdyYXBoaWMiLz4NCiAgICAgIDwvZmVNZXJnZT4NCiAgICA8L2ZpbHRlcj4NCiAgPC9kZWZzPg0KDQogIDwhLS0gQnV0dGVyZmx5IEJvZHkvV2luZ3MgLS0+DQogIDwhLS0gQSBzdHlsaXplZCBidXR0ZXJmbHkgc2hhcGUgYXBwcm94aW1hdGluZyB0aGUgQmx1ZXNreSBsb2dvIHZpYmUgLS0+DQogIDxwYXRoIGQ9Ik0yNTYsMjE4IGMwLDAgLTQyLC0xMjAgLTEzOCwtMTIwIGMtNTgsMCAtODgsNDAgLTg4LDkwIGMwLDYwIDUwLDkwIDEyOCwxMDAgYy02MCwxMCAtMTA4LDUwIC0xMDgsMTEwIGMwLDUwIDMwLDkwIDk4LDkwIGM2OCwwIDEwOCwtMTAwIDEwOCwtMTAwIHM0MCwxMDAgMTA4LDEwMCBjNjgsMCA5OCwtNDAgOTgsLTkwIGMwLC02MCAtNDgsLTEwMCAtMTA4LC0xMTAgYzc4LC0xMCAxMjgsLTQwIDEyOCwtMTAwIGMwLC01MCAtMzAsLTkwIC04OCwtOTAgYy05NiwwIC0xMzgsMTIwIC0xMzgsMTIwIHoiIGZpbGw9InVybCgjZ3JhZDEpIiAvPg0KDQogIDwhLS0gRG93bmxvYWQgQXJyb3cgLS0+DQogIDwhLS0gQ2VudGVyZWQgYW5kIG92ZXJsYWlkIC0tPg0KICA8cGF0aCBkPSJNMjU2LDQxMCBsLTExMC0xMTAgaDcwIFYxNTAgaDgwIHYxNTAgaDcwIEwyNTYsNDEwIHoiIGZpbGw9IiNmZmZmZmYiIGZpbHRlcj0idXJsKCNkcm9wU2hhZG93KSIvPg0KPC9zdmc+DQo=
// @license     MIT
// @require     https://cdnjs.cloudflare.com/ajax/libs/jszip/3.7.1/jszip.min.js
// ==/UserScript==

(function () {
  "use strict";

  // ========================================================================
  // 1. CONFIGURATION
  // ========================================================================

  const CONFIG = {
    // Template for naming downloaded files
    defaultTemplate: "@<%username>-bsky-<%post_id>-<%img_num>",

    // Regex to extract the post ID from a standard Bluesky URL
    postUrlRegex: /\/profile\/[^\/]+\/post\/[A-Za-z0-9]+/,

    // DOM Selectors used to find specific elements on the page
    selectors: {
      images: 'img[src^="https://cdn.bsky.app/img/feed_thumbnail"]', // Target feed images
      videos: 'video[poster^="https://video.bsky.app/watch"]', // Target feed videos
      settings: '[href="/settings/account"]', // Target settings menu for injection
      bookmark: '[data-testid="postBookmarkBtn"]', // Target bookmark button to place "Download All" next to

      // Containers that hold a post link (Feed, Thread, Search results)
      // Feed items are div[role="link"] with data-testid="feedItem-by-*" — covered by the role selector.
      // Thread items use data-testid without role="link", so they need a separate selector.
      postItem:
        '[data-testid^="postThreadItem-by-"], div[role="link"]',

      // Container for quoted posts (requires specific handling)
      quotePost: '[aria-label^="Post by"]',
    },
    // SVG paths for UI icons
    iconPath:
      "M925.248 356.928l-258.176-258.176a64 64 0 0 0-45.248-18.752H144a64 64 0 0 0-64 64v736a64 64 0 0 0 64 64h736a64 64 0 0 0 64-64V402.176a64 64 0 0 0-18.752-45.248zM288 144h192V256H288V144z m448 736H288V736h448v144z m144 0H800V704a32 32 0 0 0-32-32H256a32 32 0 0 0-32 32v176H144v-736H224V288a32 32 0 0 0 32 32h256a32 32 0 0 0 32-32V144h77.824l258.176 258.176V880z",
    checkPath: "M433 817L133 517l90-90 210 210L821 249l90 90z", // Simple checkmark for success state

    // MIME type to file extension map
    mimeToExt: {
      "image/jpeg": "jpg",
      "image/png": "png",
      "image/gif": "gif",
      "image/webp": "webp",
      "image/svg+xml": "svg",
      "video/mp4": "mp4",
      "video/webm": "webm",
      "video/ogg": "ogv",
    },
  };

  // Pre-computed combined selector for images and videos
  const MEDIA_SELECTOR = `${CONFIG.selectors.images}, ${CONFIG.selectors.videos}`;

  // Shared regex for sanitizing filenames (used in multiple functions)
  const SANITIZE_REGEX = /[/\\?%*:|"<>]/g;

  // Pre-computed SVG icon markup (avoids string interpolation per button)
  const SVG_ICON = `<svg viewBox="0 0 1024 1024"><path d="${CONFIG.iconPath}"></path></svg>`;
  const SVG_CHECK = `<svg viewBox="0 0 1024 1024"><path d="${CONFIG.checkPath}"></path></svg>`;
  const SVG_FAIL = '<svg viewBox="0 0 1024 1024"><path d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm165.4 618.2l-66-.3L512 563.4l-99.3 118.4-66.1.3c-4.4 0-8-3.5-8-8 0-1.9.7-3.7 1.9-5.2l130.1-155L340.5 359a8.32 8.32 0 0 1-1.9-5.2c0-4.4 3.6-8 8-8l66.1.3L512 464.6l99.3-118.4 66-.3c4.4 0 8 3.5 8 8 0 1.9-.7 3.7-1.9 5.2L553.5 514l130 155c1.2 1.5 1.9 3.3 1.9 5.2 0 4.4-3.6 8-8 8z"></path></svg>';

  // In-memory tracking for processed elements (avoids DOM attribute reads/writes)
  const processedMedia = new WeakSet();
  const processedBookmarks = new WeakSet();

  // Track whether settings UI has been injected (re-checks DOM in case of SPA navigation)
  let settingsInjected = false;
  let settingsButtonRef = null; // Cached DOM reference for fast contains() check

  // In-memory cache for API responses (avoids redundant network requests per session)
  const apiCache = new Map();

  // Download HUD notifier — shows active/completed/failed counts
  const notifier = (() => {
    let el, activeEl, doneEl, failEl, hideTimer;
    let active = 0, done = 0, failed = 0;

    const SVG_SPINNER = '<svg viewBox="0 0 24 24"><path d="M12 2v4m0 12v4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83M2 12h4m12 0h4M4.93 19.07l2.83-2.83m8.48-8.48l2.83-2.83" stroke="currentColor" fill="none" stroke-width="2" stroke-linecap="round"/></svg>';
    const SVG_OK = '<svg viewBox="0 0 24 24"><path d="M5 13l4 4L19 7" stroke="currentColor" fill="none" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
    const SVG_X = '<svg viewBox="0 0 24 24"><path d="M18 6L6 18M6 6l12 12" stroke="currentColor" fill="none" stroke-width="2.5" stroke-linecap="round"/></svg>';

    function ensure() {
      if (el) return;
      el = document.createElement("div");
      el.className = "bsky-dl-notifier";
      el.innerHTML = `<span class="bsky-n-active">${SVG_SPINNER}<b>0</b></span>` +
        "<span class=\"bsky-n-sep\">|</span>" +
        `<span class="bsky-n-done">${SVG_OK}<b>0</b></span>`;
      activeEl = el.children[0].querySelector("b");
      doneEl = el.children[2].querySelector("b");
      document.body.appendChild(el);
    }

    function render() {
      ensure();
      activeEl.textContent = active;
      doneEl.textContent = done;
      if (failEl) failEl.textContent = failed;
      if (failed > 0 && !failEl) {
        const sep = document.createElement("span");
        sep.className = "bsky-n-sep";
        sep.textContent = "|";
        const span = document.createElement("span");
        span.className = "bsky-n-fail";
        span.innerHTML = `${SVG_X}<b>${failed}</b>`;
        span.title = "Click to dismiss";
        span.onclick = () => { failed = 0; failEl = null; span.remove(); sep.remove(); render(); };
        el.appendChild(sep);
        el.appendChild(span);
        failEl = span.querySelector("b");
      }

      const isActive = active > 0 || done > 0 || failed > 0;
      el.classList.toggle("visible", isActive);

      clearTimeout(hideTimer);
      if (active === 0 && failed === 0 && done > 0) {
        hideTimer = setTimeout(() => { done = 0; render(); }, 3000);
      }
    }

    return {
      start() { active++; render(); },
      finish() { active = Math.max(0, active - 1); done++; render(); },
      fail() { active = Math.max(0, active - 1); failed++; render(); },
    };
  })();

  const WHATS_NEW = [
    "Performance: Optimized DOM selectors and reduced redundant queries during page mutations.",
  ];

  // ========================================================================
  // 2. STATE & STYLES
  // ========================================================================

  // Retrieve user preferences and history from Tampermonkey storage
  let filenameTemplate = GM_getValue("filename", CONFIG.defaultTemplate);
  let downloadHistory = GM_getValue("dl_history", {});

  // Inject Custom CSS for the buttons and settings UI
  const css = `
        /* Single Image Button - Top Left overlay */
        .bsky-dl-btn {
            cursor: pointer; z-index: 999; display: flex; align-items: center; justify-content: center;
            position: absolute; left: 5px; top: 5px;
            background: rgba(0, 0, 0, 0.5); color: white;
            height: 30px; width: 30px; border-radius: 50%;
            transition: background 0.2s, color 0.2s;
        }
        .bsky-dl-btn:hover { background: rgba(0, 0, 0, 0.8); }
        .bsky-dl-btn svg { width: 16px; height: 16px; fill: currentColor; }

        /* Download All Button - Placed in the post footer actions */
        .bsky-dl-all-btn {
            display: flex; align-items: center; justify-content: center;
            cursor: pointer; padding: 5px; border-radius: 999px;
            transition: background 0.2s, color 0.2s;
            color: rgb(111, 131, 159); /* Matches native Bluesky action icon color */
            margin-right: -4px;
        }
        .bsky-dl-all-btn:hover { background-color: rgba(0, 0, 0, 0.05); }
        .bsky-dl-all-btn svg { width: 18px; height: 18px; fill: currentColor; }

        /* Success State (Green Checkmark) */
        .bsky-dl-btn.downloaded, .bsky-dl-all-btn.downloaded { color: #4caf50; }
        .bsky-dl-btn.downloaded { background: rgba(0, 0, 0, 0.7); }
        .bsky-dl-btn.downloaded svg, .bsky-dl-all-btn.downloaded svg { width: 20px; height: 20px; }

        /* Loading State (Spinning) */
        .bsky-dl-btn.loading, .bsky-dl-all-btn.loading { color: #208bfe; pointer-events: none; }
        .bsky-dl-btn.loading { background: rgba(0, 0, 0, 0.7); }
        .bsky-dl-btn.loading svg, .bsky-dl-all-btn.loading svg { animation: bsky-dl-spin 0.8s linear infinite; }
        @keyframes bsky-dl-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }

        /* Failed State (Red) */
        .bsky-dl-btn.failed, .bsky-dl-all-btn.failed { color: #f44336; }
        .bsky-dl-btn.failed { background: rgba(0, 0, 0, 0.7); }

        /* Download HUD Notifier */
        .bsky-dl-notifier {
            display: none; position: fixed; left: 20px; bottom: 20px; z-index: 9999;
            background: rgba(0, 0, 0, 0.85); color: #fff; border-radius: 10px;
            padding: 8px 14px; font-size: 13px; font-weight: 600;
            backdrop-filter: blur(10px); box-shadow: 0 2px 12px rgba(0,0,0,0.3);
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
        }
        .bsky-dl-notifier.visible { display: flex; align-items: center; gap: 10px; animation: bsky-dl-slideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1); }
        .bsky-dl-notifier span { display: flex; align-items: center; gap: 4px; }
        .bsky-dl-notifier span svg { width: 14px; height: 14px; fill: currentColor; }
        .bsky-dl-notifier .bsky-n-active { color: #208bfe; }
        .bsky-dl-notifier .bsky-n-active svg { animation: bsky-dl-spin 0.8s linear infinite; }
        .bsky-dl-notifier .bsky-n-done { color: #4caf50; }
        .bsky-dl-notifier .bsky-n-fail { color: #f44336; cursor: pointer; }
        .bsky-dl-notifier .bsky-n-sep { opacity: 0.3; }
        @media (prefers-color-scheme: light) {
            .bsky-dl-notifier { background: rgba(255, 255, 255, 0.95); color: #000; border: 1px solid #ddd; }
        }

        /* Settings UI - Config button */
        .bsky-dl-settings-btn {
            display: flex; align-items: center; justify-content: center;
            margin-top: 10px; border: 2px solid; cursor: pointer; padding: 5px; font-weight: bold;
            transition: all 0.2s;
            border-radius: 4px;
        }
        .bsky-dl-settings-btn:hover { opacity: 0.8; }

        /* Animations */
        @keyframes bsky-dl-fadeIn { from { opacity: 0; } to { opacity: 1; } }
        @keyframes bsky-dl-modalIn {
            from { opacity: 0; transform: scale(0.96) translateY(8px); }
            to { opacity: 1; transform: scale(1) translateY(0); }
        }
        @keyframes bsky-dl-slideUp { from { transform: translateY(100%); opacity: 0; } to { transform: translateY(0); opacity: 1; } }

        /* MODAL STYLES */
        .bsky-dl-overlay {
            position: fixed; left: 0; top: 0; width: 100%; height: 100%;
            background-color: rgba(0,0,0,0.5); z-index: 2147483647;
            backdrop-filter: blur(4px);
            display: flex; justify-content: center; align-items: center;
            animation: bsky-dl-fadeIn 0.15s ease-out;
        }
        .bsky-dl-modal {
            background: #fff; color: #000;
            border-radius: 8px; padding: 24px;
            width: 400px; max-width: 90vw;
            box-shadow: 0 10px 25px rgba(0,0,0,0.2);
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
            display: flex; flex-direction: column;
            transition: width 0.3s, max-width 0.3s;
            animation: bsky-dl-modalIn 0.2s cubic-bezier(0.16, 1, 0.3, 1);
        }
        @media (prefers-color-scheme: dark) {
            .bsky-dl-modal { background: #161e27; color: #fff; }
        }
        .bsky-dl-modal.expanded { width: 900px; max-width: 95vw; }

        .bsky-dl-modal h3 {
            margin: 0 0 20px 0; text-align: center;
            font-size: 20px; font-weight: 700;
        }

        /* Settings Form Elements */
        .bsky-dl-option-group {
            border: 1px solid rgba(128,128,128,0.2);
            border-radius: 4px;
            padding: 12px; margin-bottom: 12px;
            background: rgba(128,128,128,0.05);
        }
        .bsky-dl-label {
            display: block; margin-bottom: 8px;
            font-size: 14px; font-weight: 600;
            color: inherit;
        }

        .bsky-dl-textarea {
            width: 100%; min-height: 80px;
            background: rgba(255,255,255,0.1); color: inherit;
            border: 1px solid rgba(128,128,128,0.3);
            border-radius: 4px; padding: 8px;
            font-family: monospace; font-size: 12px;
            margin-top: 8px; box-sizing: border-box;
        }
        .bsky-dl-textarea:focus { outline: none; border-color: #208bfe; box-shadow: 0 0 0 2px rgba(32, 139, 254, 0.2); }

        .bsky-dl-select {
            margin-left: 8px; padding: 4px 8px;
            border-radius: 4px; border: 1px solid rgba(128, 128, 128, 0.3);
            background: var(--background, #fff); color: var(--text, #000);
            cursor: pointer; font-size: 13px;
        }
        .bsky-dl-select option {
            background: var(--background, #fff); color: var(--text, #000);
        }

        .bsky-dl-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
        .bsky-dl-tag {
            background-color: transparent;
            color: #208bfe;
            border: 1px solid #208bfe;
            padding: 4px 8px; border-radius: 4px;
            font-size: 11px; font-weight: 700; cursor: pointer;
            transition: 0.2s; font-family: monospace;
        }
        .bsky-dl-tag:hover { background-color: #208bfe; color: white; }

        .bsky-dl-btn-primary {
            background: #208bfe; color: white;
            border: none; border-radius: 4px;
            padding: 12px 24px; font-size: 15px; font-weight: 700;
            cursor: pointer; transition: 0.2s;
            width: 100%; text-align: center;
            margin-top: 10px;
        }
        .bsky-dl-btn-primary:hover { opacity: 0.9; }

        /* History Layout */
        .bsky-dl-layout { display: flex; gap: 0; height: 400px; transition: gap 0.3s; }
        .bsky-dl-modal.expanded .bsky-dl-layout { gap: 16px; }

        .bsky-dl-list {
            flex: 1; overflow-y: auto;
            border: 1px solid rgba(128,128,128,0.2);
            border-radius: 4px; padding: 4px;
        }

        .bsky-dl-row {
            display: flex; justify-content: space-between; align-items: center;
            padding: 8px; border-bottom: 1px solid rgba(128,128,128,0.1);
            transition: background 0.2s;
        }
        .bsky-dl-row:hover:not(.active) { background: rgba(128,128,128,0.06); }
        .bsky-dl-row.active { background: rgba(32, 139, 254, 0.1); border-left: 3px solid #208bfe; }
        .bsky-dl-row:last-child { border-bottom: none; }

        .bsky-dl-link {
            color: inherit; text-decoration: none; font-size: 13px;
            font-family: monospace; cursor: pointer;
        }
        .bsky-dl-link:hover { text-decoration: underline; color: #0085ff; }

        .bsky-dl-row-actions { display: flex; gap: 5px; align-items: center; }
        .bsky-dl-btn-sm {
            background: transparent; color: #e11d48;
            border: 1px solid #e11d48; border-radius: 4px;
            font-size: 10px; padding: 2px 6px; cursor: pointer;
            text-decoration: none;
        }
        .bsky-dl-btn-sm:hover { background: #e11d48; color: white; }
        .bsky-dl-btn-sm.open { color: #208bfe; border-color: #208bfe; }
        .bsky-dl-btn-sm.open:hover { background: #208bfe; color: white; }

        /* Preview Pane */
        .bsky-dl-preview {
            flex: 0; width: 0; overflow: hidden;
            border: 0; opacity: 0; transition: all 0.3s;
            display: flex; justify-content: center; align-items: flex-start;
            background: rgba(128,128,128,0.05); border-radius: 4px;
            position: relative;
        }
        .bsky-dl-modal.expanded .bsky-dl-preview {
            flex: 1.5; width: auto; opacity: 1;
            border: 1px solid rgba(128,128,128,0.2);
            padding: 10px; overflow-y: auto;
        }
        .bsky-dl-close-preview {
            position: absolute; top: 5px; right: 10px;
            font-size: 24px; cursor: pointer; opacity: 0.6; z-index: 10;
        }
        .bsky-dl-close-preview:hover { opacity: 1; color: #e11d48; }

        /* Preview Card */
        .bsky-card { width: 100%; height: 100%; overflow-y: auto; }
        .bsky-card-header { display: flex; align-items: center; margin-bottom: 10px; }
        .bsky-card-avatar { width: 40px; height: 40px; border-radius: 50%; margin-right: 10px; object-fit: cover; }
        .bsky-card-user { display: flex; flex-direction: column; }
        .bsky-card-name { font-weight: bold; font-size: 15px; }
        .bsky-card-handle { font-size: 13px; opacity: 0.7; }
        .bsky-card-text { font-size: 15px; margin-bottom: 10px; white-space: pre-wrap; line-height: 1.4; }

        .bsky-card-media {
            display: grid; gap: 4px; margin-bottom: 10px; border-radius: 8px; overflow: hidden;
            width: 100%;
        }
        .bsky-media-1 { grid-template-columns: 1fr; }
        .bsky-media-2, .bsky-media-3, .bsky-media-4 { grid-template-columns: 1fr 1fr; }

        .bsky-card-img {
            width: 100%; height: auto; max-height: 300px;
            object-fit: contain; background: #000;
            display: block; margin: 0 auto;
        }
        .bsky-card-date { font-size: 12px; opacity: 0.6; }
        .bsky-card-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 10px; }

        .bsky-dl-modal-footer { margin-top: 15px; display: flex; justify-content: space-between; align-items: center; gap: 10px; }
        .bsky-dl-footer-group { display: flex; gap: 10px; }
        .bsky-dl-modal-close {
            background: transparent; color: inherit; border: 1px solid currentColor; padding: 8px 16px;
            border-radius: 4px; cursor: pointer; font-weight: bold; opacity: 0.7;
        }
        .bsky-dl-modal-close:hover { opacity: 1; }
        .bsky-dl-modal-close.danger { color: #e11d48; border-color: #e11d48; }
        .bsky-dl-modal-close.danger:hover { background: #e11d48; color: white; }

        /* Settings Preview Box */
        .bsky-dl-preview-box {
            margin-bottom: 10px; padding: 8px;
            background: rgba(32, 139, 254, 0.08);
            border: 1px solid rgba(32, 139, 254, 0.25);
            border-radius: 4px;
            font-size: 12px; font-family: monospace;
            word-break: break-all;
        }
        .bsky-dl-preview-box .bsky-dl-preview-label { font-weight: bold; margin-bottom: 4px; opacity: 0.8; }

        /* Secondary button variant */
        .bsky-dl-btn-primary.secondary { background: transparent; color: #208bfe; border: 2px solid #208bfe; }
        .bsky-dl-btn-primary.secondary:hover { background: rgba(32, 139, 254, 0.1); }

        /* Cancel/text button variant */
        .bsky-dl-btn-text {
            background: transparent; color: inherit; border: none;
            padding: 8px; cursor: pointer; font-size: 13px; opacity: 0.7;
        }
        .bsky-dl-btn-text:hover { opacity: 1; text-decoration: underline; }

        /* Video Play Overlay */
        .bsky-card-play {
            position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
            background: rgba(0,0,0,0.6); border-radius: 50%;
            width: 40px; height: 40px;
            display: flex; align-items: center; justify-content: center;
            color: white; pointer-events: none;
        }

        /* What's New Modal */
        .bsky-wn-modal {
            position: fixed; top: 20px; right: 20px; width: 300px;
            background: #fff; color: #000;
            border-radius: 8px; padding: 16px;
            box-shadow: 0 10px 30px rgba(0,0,0,0.3);
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
            z-index: 2147483647; border: 1px solid rgba(128,128,128,0.2);
            animation: slideIn 0.5s cubic-bezier(0.16, 1, 0.3, 1);
        }
        @media (prefers-color-scheme: dark) {
            .bsky-wn-modal { background: #161e27; color: #fff; border-color: #2e4052; }
        }
        @keyframes slideIn { from { transform: translateX(120%); } to { transform: translateX(0); } }

        .bsky-wn-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
        .bsky-wn-title { font-weight: 700; font-size: 16px; display: flex; align-items: center; gap: 6px; }
        .bsky-wn-tag { background: #208bfe; color: white; font-size: 10px; padding: 2px 6px; border-radius: 4px; }

        .bsky-wn-close { cursor: pointer; font-size: 20px; opacity: 0.5; line-height: 1; }
        .bsky-wn-close:hover { opacity: 1; }

        .bsky-wn-list { list-style: none; padding: 0; margin: 0; font-size: 13px; line-height: 1.5; opacity: 0.9; }
        .bsky-wn-list li { margin-bottom: 8px; display: flex; gap: 8px; }
        .bsky-wn-list li:before { content: "•"; color: #208bfe; font-weight: bold; }
    `;

  // Add styles to the document
  if (typeof GM_addStyle !== "undefined") {
    GM_addStyle(css);
  } else {
    const style = document.createElement("style");
    style.textContent = css;
    document.head.appendChild(style);
  }

  // Register Menu Commands
  GM_registerMenuCommand("Settings", openSettings);
  GM_registerMenuCommand("View History", viewHistory);
  GM_registerMenuCommand("Export History (JSON)", exportHistory);

  // Sync history across tabs when another tab writes
  GM_addValueChangeListener("dl_history", (_key, _old, newVal, remote) => {
    if (!remote) return;
    downloadHistory = newVal;

    // Refresh individual media button states
    document.querySelectorAll(".bsky-dl-btn").forEach(btn => {
      if (btn.classList.contains("loading")) return;
      const id = btn.dataset.historyId;
      if (!id) return;
      if (newVal[id]) {
        markButtonAsDownloaded(btn);
      } else {
        revertButton(btn);
      }
    });

    // Refresh "Download All" button states
    document.querySelectorAll(".bsky-dl-all-btn").forEach(btn => {
      if (btn.classList.contains("loading")) return;
      const post = btn.closest(CONFIG.selectors.postItem);
      if (post) updatePostButtonState(post, btn);
    });
  });

  // ========================================================================
  // 3. OBSERVER LOGIC
  // ========================================================================

  // Helper to process a single media item (image or video)
  const processMediaItem = (item, isVideo) => {
    if (!processedMedia.has(item)) {
      processedMedia.add(item);
      const post = item.closest(CONFIG.selectors.postItem);
      injectDownloadButton(item, isVideo, post);

      // Try to inject "Download All" if we found media but missed the bookmark button
      if (post) {
        const bookmark = post.querySelector(CONFIG.selectors.bookmark);
        if (bookmark) injectDownloadAllButton(bookmark);
      }
    }
  };

  // Scan a specific node (and its children) for actionable elements
  const scanNode = (node) => {
    // A. SETTINGS: Check if the Settings header was loaded to inject our config UI
    // Re-check DOM presence in case SPA navigation destroyed and recreated the page
    // Uses cached element reference + contains() instead of querySelector (O(1) vs O(n))
    if (settingsInjected && settingsButtonRef && !document.body.contains(settingsButtonRef)) {
      settingsInjected = false;
      settingsButtonRef = null;
    }
    if (!settingsInjected) {
      const settingsHeader = node.querySelector(CONFIG.selectors.settings);
      if (settingsHeader) injectSettingsUI(settingsHeader);
    }

    // B. DOWNLOAD ALL: Check for Bookmark buttons to inject "Download All" next to them
    if (node.matches(CONFIG.selectors.bookmark)) {
      injectDownloadAllButton(node);
    } else {
      node
        .querySelectorAll(CONFIG.selectors.bookmark)
        .forEach((btn) => injectDownloadAllButton(btn));
    }

    // C. MEDIA (images + videos in a single query)
    if (node.matches(MEDIA_SELECTOR)) {
      processMediaItem(node, node.tagName === "VIDEO");
    } else {
      node
        .querySelectorAll(MEDIA_SELECTOR)
        .forEach((el) => processMediaItem(el, el.tagName === "VIDEO"));
    }
  };

  // Batch observer callback with requestAnimationFrame to reduce layout thrashing
  let pendingNodes = [];
  let rafScheduled = false;

  const processPendingNodes = () => {
    const nodes = pendingNodes;
    pendingNodes = [];
    rafScheduled = false;
    for (const node of nodes) {
      scanNode(node);
    }
  };

  const handleMutations = (mutationList) => {
    for (const mutation of mutationList) {
      for (const node of mutation.addedNodes) {
        if (node instanceof HTMLElement) {
          pendingNodes.push(node);
        }
      }
    }
    if (pendingNodes.length > 0 && !rafScheduled) {
      rafScheduled = true;
      requestAnimationFrame(processPendingNodes);
    }
  };

  // Initialize the observer on the entire document body
  new MutationObserver(handleMutations).observe(document.body, { childList: true, subtree: true });

  // ========================================================================
  // 4. UI INJECTION & EVENT HANDLING
  // ========================================================================

  // Settings Modal
  function openSettings() {
    const { overlay, removeOverlay } = createModalOverlay();

    const modal = createElement("div", { class: "bsky-dl-modal" });
    const title = createElement("h3", {}, {}, "Download Settings");

    // Option Groups using helper
    const historyGroup = createCheckboxOption("Remember download history", "save_history", true);
    const packagingGroup = createCheckboxOption("Package multiple files into a ZIP", "enable_packaging", false);

    // Option Group: Image Format
    const formatGroup = createElement("div", { class: "bsky-dl-option-group" });
    const formatLabel = createElement(
      "label",
      { class: "bsky-dl-label" },
      { display: "flex", alignItems: "center" },
    );
    formatLabel.appendChild(document.createTextNode("Image format: "));
    const formatSelect = createElement("select", { class: "bsky-dl-select" });
    const formats = [
      { value: "original", label: "Original (as uploaded)" },
      { value: "jpeg", label: "JPEG" },
      { value: "png", label: "PNG" },
    ];
    formats.forEach(f => {
      const opt = createElement("option", { value: f.value }, {}, f.label);
      formatSelect.appendChild(opt);
    });
    formatSelect.value = GM_getValue("image_format", "original");
    formatSelect.onchange = () => GM_setValue("image_format", formatSelect.value);
    formatLabel.appendChild(formatSelect);
    formatGroup.appendChild(formatLabel);

    // Option Group: Filename
    const fileGroup = createElement("div", { class: "bsky-dl-option-group" });

    // Preview Box
    const previewContainer = createElement(
      "div",
      { class: "bsky-dl-preview-box" },
    );
    const previewLabel = createElement(
      "div",
      { class: "bsky-dl-preview-label" },
      {},
      "Preview:",
    );
    const previewText = createElement("div", {}, {}, "");
    previewContainer.appendChild(previewLabel);
    previewContainer.appendChild(previewText);

    const label = createElement(
      "label",
      { class: "bsky-dl-label" },
      {},
      "File Name Pattern",
    );

    const textarea = createElement("textarea", {
      class: "bsky-dl-textarea",
      spellcheck: "false",
    });
    textarea.value = GM_getValue("filename", CONFIG.defaultTemplate);

    // Mock Data for Preview (keys match template tag names)
    const mockData = {
      uname: "oh8",
      username: "oh8.bsky.social",
      handle: "oh8",
      display_name: "Oh Eight",
      post_id: "3krmccyl4722w",
      post_time: 1715347800000,
      timestamp: Date.now(),
      img_num: 0,
      title: "A cute cat",
      width: 1920,
      height: 1080,
      original_ext: "png",
    };

    const updatePreview = () => {
      previewText.textContent = replaceTemplate(textarea.value, mockData) + ".jpg";
    };

    textarea.addEventListener("input", updatePreview);

    // Tags Helper
    const tagsContainer = createElement("div", { class: "bsky-dl-tags" });
    const tags = [
      { tag: "<%uname>", desc: "Short username (e.g. oh8)" },
      { tag: "<%username>", desc: "Full username (e.g. oh8.bsky.social)" },
      { tag: "<%handle>", desc: "Short for .bsky.social, full for custom domains" },
      { tag: "<%display_name>", desc: "Display name (e.g. Oh Eight)" },
      { tag: "<%post_id>", desc: "Post ID (e.g. 3krmccyl4722w)" },
      { tag: "<%post_time>", desc: "Post Timestamp (e.g. 1715347800000)" },
      { tag: "<%timestamp>", desc: "Download Timestamp (e.g. 1550557810891)" },
      { tag: "<%img_num>", desc: "Image Number (e.g. 0, 1, 2)" },
      { tag: "<%title>", desc: "Alt Text (from image description)" },
      { tag: "<%width>", desc: "Original width in pixels (e.g. 1920)" },
      { tag: "<%height>", desc: "Original height in pixels (e.g. 1080)" },
      { tag: "<%original_ext>", desc: "Original file format (e.g. png, jpg)" },
    ];

    tags.forEach((t) => {
      const tagEl = createElement(
        "span",
        { class: "bsky-dl-tag", title: t.desc },
        {},
        t.tag,
      );
      tagEl.onclick = () => {
        const start = textarea.selectionStart;
        const end = textarea.selectionEnd;
        textarea.value =
          textarea.value.substring(0, start) +
          t.tag +
          textarea.value.substring(end);
        textarea.selectionStart = textarea.selectionEnd = start + t.tag.length;
        textarea.focus();
        updatePreview();
      };
      tagsContainer.appendChild(tagEl);
    });

    fileGroup.appendChild(previewContainer);
    fileGroup.appendChild(label);
    fileGroup.appendChild(textarea);
    fileGroup.appendChild(tagsContainer);

    // Initialize Preview
    updatePreview();

    const saveBtn = createElement(
      "button",
      { class: "bsky-dl-btn-primary" },
      {},
      "Save",
    );
    saveBtn.onclick = () => {
      GM_setValue("filename", textarea.value);
      filenameTemplate = textarea.value;
      removeOverlay();
    };

    const footer = createElement("div", { class: "bsky-dl-modal-footer" }, { justifyContent: "flex-end" });
    const closeBtn = createElement(
      "button",
      { class: "bsky-dl-modal-close" },
      {},
      "Cancel",
    );
    closeBtn.onclick = () => removeOverlay();

    footer.appendChild(closeBtn);

    modal.appendChild(title);
    modal.appendChild(historyGroup);
    modal.appendChild(packagingGroup);
    modal.appendChild(formatGroup);
    modal.appendChild(fileGroup);
    modal.appendChild(saveBtn);
    modal.appendChild(footer);
    overlay.appendChild(modal);
    document.body.appendChild(overlay);
  }

  // Injects the "Download All" button into the post footer
  function injectDownloadAllButton(bookmarkBtn) {
    if (processedBookmarks.has(bookmarkBtn)) return;

    const postContainer = bookmarkBtn.closest(CONFIG.selectors.postItem);
    if (!postContainer) return;

    // Fast path: skip full querySelectorAll + filtering when no media exists
    if (!postContainer.querySelector(MEDIA_SELECTOR)) return;

    const mainMedia = getValidMedia(postContainer);
    const quotedMedia = getQuotedMedia(postContainer);
    if (mainMedia.length === 0 && quotedMedia.length === 0) return;

    const container = bookmarkBtn.parentNode;
    if (!container) return;

    processedBookmarks.add(bookmarkBtn);

    // Create the button element
    const downloadAllBtn = document.createElement("div");
    downloadAllBtn.className = "bsky-dl-all-btn";
    downloadAllBtn.title = "Download All Media";
    downloadAllBtn.innerHTML = SVG_ICON;

    // Insert before the bookmark button
    container.insertBefore(downloadAllBtn, bookmarkBtn);

    // Initial state check (turn green if already downloaded)
    if (mainMedia.length > 0) {
      updatePostButtonState(postContainer, downloadAllBtn, mainMedia);
    }

    // Click Handler for Download All
    downloadAllBtn.addEventListener("click", async (e) => {
      e.preventDefault();
      e.stopPropagation();

      // Re-query at click time (React may reuse DOM nodes)
      const currentMain = getValidMedia(postContainer);
      const currentQuoted = getQuotedMedia(postContainer);

      let items;
      let postInfo = null;

      if (currentMain.length > 0 && currentQuoted.length > 0) {
        // Both original and quoted post have media — ask the user
        const mainHandle = parsePostPath(currentMain[0]).handle;
        const quoteContainer = currentQuoted[0].closest(CONFIG.selectors.quotePost);
        const ariaLabel = quoteContainer?.getAttribute("aria-label") ?? "";
        const quotedHandle = ariaLabel.replace(/^Post by\s+/i, "") || "quoted post";

        const choice = await selectPostDialog(mainHandle, quotedHandle);
        if (!choice) return;

        if (choice === "quoted") {
          const resolved = await resolveQuotedPostInfo(quoteContainer);
          if (!resolved) return;
          items = currentQuoted;
          postInfo = resolved;
        } else {
          items = currentMain;
        }
      } else if (currentQuoted.length > 0) {
        // Only quoted post has media
        const quoteContainer = currentQuoted[0].closest(CONFIG.selectors.quotePost);
        const resolved = await resolveQuotedPostInfo(quoteContainer);
        if (!resolved) return;
        items = currentQuoted;
        postInfo = resolved;
      } else {
        items = currentMain;
      }

      if (items.length === 0) return;

      markButtonAsLoading(downloadAllBtn);

      const enablePackaging = GM_getValue("enable_packaging", false);

      if (enablePackaging && items.length > 1) {
        await downloadAllZip(items, postContainer, downloadAllBtn, postInfo);
      } else {
        const anyFailed = await downloadAllSequential(items, postContainer, postInfo);
        downloadAllBtn.classList.remove("loading");
        if (anyFailed) {
          markButtonAsFailed(downloadAllBtn);
        }
      }
    });
  }

  // Injects individual download buttons onto images/videos
  // Elements are pre-validated by the MEDIA_SELECTOR (feed_thumbnail images or video.bsky.app videos)
  function injectDownloadButton(element, isVideo = false, postItem = null) {
    // Button attaches to grandparent (the image/video wrapper)
    const downloadBtnParent = element.parentElement?.parentElement;
    if (!downloadBtnParent) return;

    // Avoid injecting on very small thumbnails (likely avatars or quote previews)
    if (downloadBtnParent.parentElement?.style.maxWidth === "100px") return;

    // Avoid injecting on external link embed previews (e.g. URL card thumbnails)
    const externalAnchor = element.closest('a[href^="http"]');
    if (externalAnchor) return;

    // Calculate a unique ID for history tracking
    const postContainer =
      postItem ?? element.closest(CONFIG.selectors.quotePost);
    const validMedia = postContainer ? getValidMedia(postContainer) : [];
    const index = validMedia.indexOf(element);
    const safeIndex =
      index >= 0 ? index : isVideo ? 0 : getImageNumberDOM(element);

    const uniqueId = getBlobId(element, safeIndex);
    const isDownloaded = uniqueId && !!downloadHistory[uniqueId];

    // Create the button
    const downloadBtn = document.createElement("div");
    downloadBtn.className = isDownloaded
      ? "bsky-dl-btn downloaded"
      : "bsky-dl-btn";

    downloadBtn.innerHTML = isDownloaded ? SVG_CHECK : SVG_ICON;
    if (uniqueId) downloadBtn.dataset.historyId = uniqueId;

    // Ensure parent is relative so absolute positioning works
    downloadBtnParent.style.position = "relative";
    downloadBtnParent.appendChild(downloadBtn);

    // Async history check for quoted post media (getBlobId fails since
    // quoted posts no longer have <a> tags in the DOM)
    if (!uniqueId) {
      const quoteContainer = element.closest(CONFIG.selectors.quotePost);
      if (quoteContainer) {
        resolveQuotedPostInfo(quoteContainer).then(info => {
          if (info) {
            const resolvedId = `${info.postId}_${safeIndex}`;
            downloadBtn.dataset.historyId = resolvedId;
            if (downloadHistory[resolvedId]) {
              markButtonAsDownloaded(downloadBtn);
            }
          }
        }).catch(() => {});
      }
    }

    // Click Handler for Single Download
    // Re-derive values at click time to handle React DOM node reuse
    downloadBtn.addEventListener("click", async (e) => {
      e.stopPropagation();
      e.preventDefault();

      markButtonAsLoading(downloadBtn);
      notifier.start();

      const currentUrl = getMediaUrl(element, isVideo);
      const currentContainer =
        element.closest(CONFIG.selectors.postItem) ??
        element.closest(CONFIG.selectors.quotePost);
      const currentMedia = currentContainer ? getValidMedia(currentContainer) : [];
      const currentIndex = currentMedia.indexOf(element);
      const currentSafeIndex =
        currentIndex >= 0 ? currentIndex : isVideo ? 0 : getImageNumberDOM(element);

      const data = await prepareDownloadData(element, isVideo, currentSafeIndex);
      if (!data) {
        markButtonAsFailed(downloadBtn);
        notifier.fail();
        return;
      }
      data.btnElement = downloadBtn;

      const blob = await fetchBlueskyBlob(currentUrl, data.isVideo, data.blobInfo);
      if (blob) {
        sendFile(data, blob);
        notifier.finish();
      } else {
        markButtonAsFailed(downloadBtn);
        notifier.fail();
      }
    });

    // Prevent dragging the button
    downloadBtn.addEventListener("mousedown", (e) => e.preventDefault());
  }

  // ========================================================================
  // 5. DATA PREPARATION & LOGIC
  // ========================================================================

  // Parses the post URL from DOM to extract user info and post ID
  function parsePostPath(element) {
    try {
      const postPath = getPostLink(element).split("/");
      const username = postPath[2] ?? "unknown";
      const uname = username.split(".")[0] ?? "unknown";
      const handle = username.endsWith(".bsky.social") ? uname : username;
      const postId = postPath[4] ?? "00000";
      return { username, uname, handle, postId };
    } catch (err) {
      console.error("BSKY-DL: Error parsing URL", err);
      return { username: "unknown", uname: "unknown", handle: "unknown", postId: "00000" };
    }
  }

  // Extracts alt text from the nearest aria-label ancestor
  function getAltText(element) {
    try {
      const ariaElem = element.closest("[aria-label]");
      if (ariaElem) {
        return ariaElem.getAttribute("aria-label").replace(SANITIZE_REGEX, "-");
      }
    } catch {}
    return "";
  }

  // Fetches the post thread data from the API (cached per session)
  async function fetchPostThread(username, postId) {
    const cacheKey = `${username}/${postId}`;
    if (apiCache.has(cacheKey)) return apiCache.get(cacheKey);

    try {
      const response = await fetch(buildPostThreadUrl(username, postId));
      if (response.ok) {
        const data = await response.json();
        apiCache.set(cacheKey, data);
        return data;
      }
    } catch (err) {
      console.warn("BSKY-DL: API fetch failed, using fallbacks.", err);
    }
    return null;
  }

  // Extracts per-item metadata from already-fetched API data
  function extractMediaMetadata(apiData, isVideo, imageNumber) {
    if (!apiData) return { postTime: 0, altText: "", displayName: "", width: 0, height: 0 };

    const post = apiData.thread.post;
    const record = post.record;
    const postTime = Date.parse(record.createdAt);
    const displayName = (post.author?.displayName ?? "").replace(SANITIZE_REGEX, "-");

    let embed = record.embed;
    if (embed?.["$type"] === "app.bsky.embed.recordWithMedia") {
      embed = embed.media;
    }

    let altText = "";
    let width = 0;
    let height = 0;

    if (isVideo) {
      const ratio = embed?.aspectRatio;
      if (ratio) { width = ratio.width; height = ratio.height; }
    } else if (embed?.images) {
      const img = embed.images[imageNumber];
      const apiAlt = img?.alt;
      if (apiAlt?.trim()) {
        altText = apiAlt.replace(SANITIZE_REGEX, "-");
      }
      const ratio = img?.aspectRatio;
      if (ratio) { width = ratio.width; height = ratio.height; }
    }

    return { postTime, altText, displayName, width, height };
  }

  // Extracts blob DID, CID, and mimeType from the API response for a specific media item.
  // More reliable than parsing CDN/poster URLs, which may change format or encode DIDs.
  // Uses post.embed (view) for video CID, post.record.embed for image CIDs and mimeType.
  function extractBlobInfo(apiData, isVideo, imageNumber) {
    if (!apiData?.thread?.post) return null;
    const post = apiData.thread.post;
    const did = post.author?.did;
    if (!did) return null;

    // Record embed holds blob refs and mimeType for both images and videos
    let recordEmbed = post.record?.embed;
    if (recordEmbed?.["$type"] === "app.bsky.embed.recordWithMedia") {
      recordEmbed = recordEmbed.media;
    }

    // Video: CID from view embed (most direct), mimeType from record embed
    if (isVideo) {
      let viewEmbed = post.embed;
      if (viewEmbed?.["$type"] === "app.bsky.embed.recordWithMedia#view") {
        viewEmbed = viewEmbed.media;
      }
      if (viewEmbed?.["$type"] === "app.bsky.embed.video#view" && viewEmbed.cid) {
        const mimeType = recordEmbed?.video?.mimeType ?? "video/mp4";
        return { did, cid: viewEmbed.cid, mimeType, playlist: viewEmbed.playlist ?? null };
      }
      return null;
    }

    // Image: CID and mimeType from record embed blob ref
    if (recordEmbed?.images) {
      const imageBlob = recordEmbed.images[imageNumber]?.image;
      const cid = imageBlob?.ref?.["$link"];
      if (cid) return { did, cid, mimeType: imageBlob.mimeType ?? "image/jpeg" };
    }

    return null;
  }

  // Extracts the quoted/embedded record from a parent post's API response
  function extractQuotedRecord(apiData) {
    if (!apiData?.thread?.post?.embed) return null;
    const embed = apiData.thread.post.embed;

    // Direct quote: app.bsky.embed.record#view
    if (embed["$type"] === "app.bsky.embed.record#view" && embed.record) {
      return embed.record;
    }

    // Quote with media: app.bsky.embed.recordWithMedia#view
    if (embed["$type"] === "app.bsky.embed.recordWithMedia#view" && embed.record?.record) {
      return embed.record.record;
    }

    return null;
  }

  // Resolves quoted post info via the parent post's API data.
  // Returns { username, uname, handle, postId } or null if resolution fails.
  // Also pre-caches the quoted post's API data from the parent response,
  // so the subsequent fetchPostThread call avoids a second network request.
  async function resolveQuotedPostInfo(quoteContainer) {
    // Find the parent post container (go up past the quote)
    const parentPost = quoteContainer.parentElement?.closest(CONFIG.selectors.postItem);
    if (!parentPost) return null;

    // Find a link in the parent post that is NOT inside the quoted post
    const parentLinks = parentPost.querySelectorAll('a[href*="/post/"]');
    let parentHref = null;
    for (const link of parentLinks) {
      if (!quoteContainer.contains(link)) {
        const match = link.getAttribute("href").match(CONFIG.postUrlRegex);
        if (match) { parentHref = match[0]; break; }
      }
    }
    if (!parentHref) return null;

    const parentPath = parentHref.split("/");
    const parentUsername = parentPath[2];
    const parentPostId = parentPath[4];
    if (!parentUsername || !parentPostId) return null;

    // Fetch parent post API data and extract the quoted record
    const parentApiData = await fetchPostThread(parentUsername, parentPostId);
    const quotedRecord = extractQuotedRecord(parentApiData);
    if (!quotedRecord?.uri) return null;

    // Parse AT URI: at://did:plc:xxx/app.bsky.feed.post/rkey
    const uriParts = quotedRecord.uri.split("/");
    const postId = uriParts[4];
    const username = quotedRecord.author?.handle ?? uriParts[2];
    const uname = username.split(".")[0] ?? "unknown";
    const handle = username.endsWith(".bsky.social") ? uname : username;

    // Pre-cache the quoted post's data from the parent response.
    // The parent's quoted record contains author, record embed (value),
    // and view embed (embeds[0]) — enough for extractBlobInfo/extractMediaMetadata.
    const cacheKey = `${username}/${postId}`;
    if (!apiCache.has(cacheKey) && quotedRecord.author && quotedRecord.value) {
      apiCache.set(cacheKey, {
        thread: {
          post: {
            author: quotedRecord.author,
            embed: quotedRecord.embeds?.[0] ?? null,
            record: quotedRecord.value,
          },
        },
      });
    }

    return { username, uname, handle, postId };
  }

  // Coordinator: assembles download data from sub-helpers
  async function prepareDownloadData(element, isVideo, index) {
    let username, uname, handle, postId;

    // Check if element is inside a quoted post embed
    const quoteContainer = element.closest(CONFIG.selectors.quotePost);
    const isInsideQuote = quoteContainer &&
      quoteContainer.parentElement?.closest(CONFIG.selectors.postItem);

    if (isInsideQuote) {
      const resolved = await resolveQuotedPostInfo(quoteContainer);
      if (resolved) ({ username, uname, handle, postId } = resolved);
    }

    // Fallback to DOM-based resolution
    if (!postId) {
      ({ username, uname, handle, postId } = parsePostPath(element));
    }

    const imageNumber = index !== undefined ? index : isVideo ? 0 : getImageNumberDOM(element);
    const domTitle = getAltText(element);
    const apiData = await fetchPostThread(username, postId);
    const { postTime, altText, displayName, width, height } =
      extractMediaMetadata(apiData, isVideo, imageNumber);

    return {
      uname,
      username,
      handle,
      displayName,
      postId,
      postTime: postTime || Date.now(),
      imageNumber,
      isVideo,
      width,
      height,
      title: altText || domTitle || "Image",
      uniqueId: `${postId}_${imageNumber}`,
      blobInfo: extractBlobInfo(apiData, isVideo, imageNumber),
      btnElement: null,
      postContainer: null,
    };
  }

  // Helper to find all valid media items in a container
  // Filters out items belonging to Quoted Posts inside the current post
  function getValidMedia(postContainer) {
    if (!postContainer) return [];
    const candidates = postContainer.querySelectorAll(MEDIA_SELECTOR);
    const result = [];

    for (let i = 0; i < candidates.length; i++) {
      const el = candidates[i];
      // Filter: Ensure media belongs to THIS post, not a nested quoted post
      const quoteParent = el.closest(CONFIG.selectors.quotePost);
      if (
        quoteParent &&
        quoteParent !== postContainer &&
        postContainer.contains(quoteParent)
      ) {
        continue;
      }
      // Filter: Ignore tiny thumbnails
      const wrapper = el.parentElement?.parentElement?.parentElement;
      if (wrapper && wrapper.style.maxWidth === "100px") {
        continue;
      }
      // Filter: Ignore external link embed previews
      if (el.closest('a[href^="http"]')) {
        continue;
      }
      result.push(el);
    }

    return result;
  }

  // Finds media items that belong to a quoted post inside the container
  function getQuotedMedia(postContainer) {
    if (!postContainer) return [];
    const candidates = postContainer.querySelectorAll(MEDIA_SELECTOR);
    const result = [];

    for (let i = 0; i < candidates.length; i++) {
      const el = candidates[i];
      const quoteParent = el.closest(CONFIG.selectors.quotePost);
      if (
        !quoteParent ||
        quoteParent === postContainer ||
        !postContainer.contains(quoteParent)
      ) {
        continue;
      }
      const wrapper = el.parentElement?.parentElement?.parentElement;
      if (wrapper && wrapper.style.maxWidth === "100px") continue;
      if (el.closest('a[href^="http"]')) continue;
      result.push(el);
    }

    return result;
  }

  // Updates the visual state of the "Download All" button
  function updatePostButtonState(postContainer, specificBtn = null, precomputedMedia = null) {
    if (!postContainer) return;

    const btn = specificBtn || postContainer.querySelector(".bsky-dl-all-btn");
    if (!btn) return;

    const mediaItems = precomputedMedia || getValidMedia(postContainer);
    if (mediaItems.length === 0) return;

    // Extract postId once instead of calling getPostLink per media item
    let postId;
    try {
      const link = getPostLink(mediaItems[0]);
      const pathParts = link.split("/");
      postId = pathParts.length >= 5 ? pathParts[4] : null;
    } catch {
      return;
    }
    if (!postId) return;

    // Check if every item in this post is in our history
    const allDownloaded = mediaItems.every((_, i) => downloadHistory[`${postId}_${i}`]);
    if (allDownloaded) {
      markButtonAsDownloaded(btn);
    } else {
      revertButton(btn);
    }
  }

  // ========================================================================
  // 6. NETWORK & DOWNLOAD
  // ========================================================================

  // Downloads a video by fetching HLS playlist segments from video.bsky.app.
  // Used as fallback when getBlob fails (CORS redirect to user's PDS).
  async function fetchVideoViaHLS(playlistUrl) {
    // Fetch master playlist
    const masterResp = await fetch(playlistUrl);
    if (!masterResp.ok) return null;
    const masterText = await masterResp.text();

    // Parse: find the highest bandwidth stream
    const lines = masterText.split("\n");
    let bestUrl = null;
    let bestBw = 0;
    for (let i = 0; i < lines.length; i++) {
      if (lines[i].startsWith("#EXT-X-STREAM-INF:")) {
        const bwMatch = lines[i].match(/BANDWIDTH=(\d+)/);
        const bw = bwMatch ? parseInt(bwMatch[1]) : 0;
        if (bw > bestBw && i + 1 < lines.length) {
          bestBw = bw;
          bestUrl = lines[i + 1].trim();
        }
      }
    }
    if (!bestUrl) return null;

    // Resolve relative URL for variant playlist
    const baseUrl = playlistUrl.substring(0, playlistUrl.lastIndexOf("/") + 1);
    const variantUrl = bestUrl.startsWith("http") ? bestUrl : baseUrl + bestUrl;

    // Fetch variant playlist
    const variantResp = await fetch(variantUrl);
    if (!variantResp.ok) return null;
    const variantText = await variantResp.text();

    // Parse: collect all .ts segment URLs
    const segLines = variantText.split("\n");
    const segments = [];
    const variantBase = variantUrl.substring(0, variantUrl.lastIndexOf("/") + 1);
    for (const line of segLines) {
      const trimmed = line.trim();
      if (trimmed && !trimmed.startsWith("#")) {
        segments.push(trimmed.startsWith("http") ? trimmed : variantBase + trimmed);
      }
    }
    if (segments.length === 0) return null;

    // Download all segments in parallel
    const parts = await Promise.all(segments.map(async (segUrl) => {
      const resp = await fetch(segUrl);
      return resp.ok ? resp.arrayBuffer() : null;
    }));
    if (parts.some(p => p === null)) return null;

    // Concatenate into a single video blob
    return new Blob(parts, { type: "video/mp4" });
  }

  // Fetch the raw blob from Bluesky's CDN
  // blobInfo: optional { did, cid, playlist } from API (preferred over URL parsing)
  async function fetchBlueskyBlob(url, isVideo, blobInfo = null) {
    let did, cid;

    if (blobInfo) {
      // Use API-sourced DID/CID (more reliable than URL parsing)
      did = blobInfo.did;
      cid = blobInfo.cid;
    } else {
      // Parse DID/CID from the CDN URL
      // Video poster URLs may have percent-encoded colons (did%3Aplc%3A)
      const urlArray = url.split("/");
      const rawDid = isVideo ? urlArray[4] : urlArray[6];
      const rawCid = isVideo ? urlArray[5] : urlArray[7]?.split("@")[0];
      did = rawDid ? decodeURIComponent(rawDid) : "";
      cid = rawCid ? decodeURIComponent(rawCid) : "";
    }

    // For images: check if user has a format preference
    const imageFormat = !isVideo ? GM_getValue("image_format", "original") : null;

    try {
      if (!did || !cid) throw new Error("Could not parse DID/CID");

      // If a specific image format is requested, use the CDN with format suffix
      if (imageFormat && imageFormat !== "original") {
        const cdnUrl = `https://cdn.bsky.app/img/feed_fullsize/plain/${did}/${cid}@${imageFormat}`;
        const cdnResponse = await fetch(cdnUrl);
        if (cdnResponse.ok) return await cdnResponse.blob();
        console.warn("BSKY-DL: CDN format fetch failed, falling back to original.");
      }

      // Fetch via Bluesky Sync API (original quality)
      // Note: bsky.social may redirect to the user's PDS which can fail due to CORS
      const response = await fetch(
        `https://bsky.social/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`,
      );
      if (!response.ok) throw new Error(`API Error: ${response.status}`);
      return await response.blob();
    } catch (err) {
      console.warn(
        "BSKY-DL: High-quality fetch failed, falling back.",
        err,
      );

      try {
        if (isVideo) {
          // Video fallback: download via HLS segments from video.bsky.app
          // (getBlob fails for videos due to CORS redirect to user's PDS)
          if (blobInfo?.playlist) {
            const hlsBlob = await fetchVideoViaHLS(blobInfo.playlist);
            if (hlsBlob) return hlsBlob;
          }
          return null;
        }

        // Image fallback: CDN fullsize URL (better than DOM's feed_thumbnail)
        const fallbackUrl = (did && cid)
          ? `https://cdn.bsky.app/img/feed_fullsize/plain/${did}/${cid}`
          : url;
        const fallbackResponse = await fetch(fallbackUrl);
        if (!fallbackResponse.ok)
          throw new Error(`CDN Error: ${fallbackResponse.status}`);
        return await fallbackResponse.blob();
      } catch (fatalErr) {
        console.error("BSKY-DL: All fetch attempts failed.", fatalErr);
        return null;
      }
    }
  }

  // Builds a media data object for a single item in a batch download
  function buildMediaItemData(base, item, i, apiData) {
    const isVideo = item.tagName === "VIDEO";
    const domTitle = getAltText(item);
    const { postTime, altText, displayName, width, height } =
      extractMediaMetadata(apiData, isVideo, i);
    return {
      ...base,
      displayName: displayName || base.displayName,
      postTime: postTime || Date.now(),
      imageNumber: i,
      isVideo,
      width,
      height,
      title: altText || domTitle || "Image",
      uniqueId: `${base.postId}_${i}`,
      blobInfo: extractBlobInfo(apiData, isVideo, i),
      btnElement: null,
      postContainer: null,
    };
  }

  // Downloads all media items as a ZIP package
  // postInfo: optional { username, uname, handle, postId } override (for quoted posts)
  async function downloadAllZip(mediaItems, postContainer, downloadAllBtn, postInfo = null) {
    if (typeof JSZip === "undefined") {
      alert("BSKY-DL: JSZip library not loaded. Cannot create ZIP.");
      return;
    }

    console.log("BSKY-DL: Starting ZIP packaging for", mediaItems.length, "items");
    notifier.start();

    const zip = new JSZip();
    let zipName = "bluesky_media";

    // Fetch post data once for all items in this post
    const { username, uname, handle, postId } = postInfo || parsePostPath(mediaItems[0]);
    const apiData = await fetchPostThread(username, postId);

    const displayName = (apiData?.thread?.post?.author?.displayName ?? "").replace(SANITIZE_REGEX, "-");
    const base = { uname, username, handle, displayName, postId };

    const promises = mediaItems.map(async (item, i) => {
      try {
        const data = buildMediaItemData(base, item, i, apiData);

        if (i === 0) {
          const zipInfo = { ...data, imageNumber: mediaItems.length };
          zipName = convertFilename(zipInfo);
        }

        const dlUrl = getMediaUrl(item, data.isVideo);
        const blob = await fetchBlueskyBlob(dlUrl, data.isVideo, data.blobInfo);

        if (blob) {
          const ext = getExtensionFromBlob(blob);
          const filename = `${convertFilename(data)}.${ext}`;
          zip.file(filename, blob);

          if (!data.btnElement) {
            data.btnElement = item.parentElement?.parentElement?.querySelector(".bsky-dl-btn");
          }
          updateHistory(data);
          if (data.btnElement) markButtonAsDownloaded(data.btnElement);
        }
      } catch (err) {
        console.error("BSKY-DL: Error processing item", i, err);
      }
    });

    await Promise.all(promises);

    if (Object.keys(zip.files).length > 0) {
      try {
        const content = await zip.generateAsync({ type: "blob" });
        const blobUrl = URL.createObjectURL(content);
        fallbackDownload(blobUrl, `${zipName}.zip`);
        updatePostButtonState(postContainer, downloadAllBtn, mediaItems);
        notifier.finish();
      } catch (err) {
        console.error("BSKY-DL: ZIP Generation failed", err);
        notifier.fail();
      }
    } else {
      markButtonAsFailed(downloadAllBtn);
      notifier.fail();
    }

    downloadAllBtn.classList.remove("loading");
  }

  // Downloads all media items sequentially (one at a time)
  // postInfo: optional { username, uname, handle, postId } override (for quoted posts)
  async function downloadAllSequential(mediaItems, postContainer, postInfo = null) {
    if (mediaItems.length === 0) return;

    // Fetch post data once for all items in this post
    const { username, uname, handle, postId } = postInfo || parsePostPath(mediaItems[0]);
    const apiData = await fetchPostThread(username, postId);
    const displayName = (apiData?.thread?.post?.author?.displayName ?? "").replace(SANITIZE_REGEX, "-");
    const base = { uname, username, handle, displayName, postId };

    let anyFailed = false;
    const itemDataList = mediaItems.map((item, i) => {
      const data = buildMediaItemData(base, item, i, apiData);
      data.btnElement = item.parentElement?.parentElement?.querySelector(".bsky-dl-btn");
      data.postContainer = postContainer;
      markButtonAsLoading(data.btnElement);
      notifier.start();
      return data;
    });

    for (let i = 0; i < itemDataList.length; i++) {
      const data = itemDataList[i];
      const item = mediaItems[i];

      const dlUrl = getMediaUrl(item, data.isVideo);
      const blob = await fetchBlueskyBlob(dlUrl, data.isVideo, data.blobInfo);
      if (blob) {
        sendFile(data, blob);
        notifier.finish();
      } else {
        anyFailed = true;
        markButtonAsFailed(data.btnElement);
        notifier.fail();
      }
    }
    return anyFailed;
  }

  // Triggers the browser download behavior
  function sendFile(data, blob) {
    // Construct filename and trigger download
    const filename = convertFilename(data) + `.${getExtensionFromBlob(blob)}`;
    fallbackDownload(URL.createObjectURL(blob), filename);

    // Update History and UI
    updateHistory(data);
    if (data.btnElement) {
      markButtonAsDownloaded(data.btnElement);
    }

    // Update "Download All" status
    const post = data.btnElement
      ? data.btnElement.closest(CONFIG.selectors.postItem)
      : data.postContainer;
    if (post) {
      updatePostButtonState(post);
    }
  }

  // Persist download history
  function updateHistory(data) {
    if (!GM_getValue("save_history", true)) return;

    const uniqueId = data.uniqueId;
    if (!uniqueId) return;

    // Store rich data for the history viewer
    downloadHistory[uniqueId] = {
      username: data.username,
      handle: data.handle,
      postId: data.postId,
      timestamp: Date.now(),
    };
    GM_setValue("dl_history", downloadHistory);
  }

  // Visual helper to turn button green
  function markButtonAsDownloaded(btn) {
    if (!btn) return;
    btn.classList.remove("loading", "failed");
    if (btn.classList.contains("downloaded")) return;

    btn.classList.add("downloaded");
    btn.innerHTML = SVG_CHECK;
  }

  // Visual helper to reset button to default download state
  function revertButton(btn) {
    if (!btn) return;
    btn.classList.remove("downloaded", "loading");
    btn.innerHTML = SVG_ICON;
  }

  // Show loading spinner on button
  function markButtonAsLoading(btn) {
    if (!btn) return;
    btn.classList.add("loading");
  }

  // Show red error state, then revert to previous state after a delay
  function markButtonAsFailed(btn) {
    if (!btn) return;
    const wasDownloaded = btn.classList.contains("downloaded");
    btn.classList.remove("loading", "downloaded");
    btn.classList.add("failed");
    btn.innerHTML = SVG_FAIL;
    setTimeout(() => {
      if (btn.classList.contains("failed")) {
        btn.classList.remove("failed");
        if (wasDownloaded) {
          btn.classList.add("downloaded");
          btn.innerHTML = SVG_CHECK;
        } else {
          btn.innerHTML = SVG_ICON;
        }
      }
    }, 3000);
  }

  // Reverts downloaded buttons for a specific post after history deletion
  function revertButtonsForPost(postId) {
    document.querySelectorAll(".bsky-dl-btn.downloaded, .bsky-dl-btn.loading, .bsky-dl-all-btn.downloaded, .bsky-dl-all-btn.loading").forEach((btn) => {
      try {
        const container = btn.closest(CONFIG.selectors.postItem) ||
          btn.closest(CONFIG.selectors.quotePost);
        if (!container) return;

        const link = getPostLink(container);
        const parts = link.split("/");
        if (parts[4] === postId) {
          revertButton(btn);
        }
      } catch {}
    });
  }

  // ========================================================================
  // 7. UTILITY HELPERS
  // ========================================================================

  // Creates a modal overlay with escape-to-close and click-outside-to-close
  function createModalOverlay() {
    const overlay = createElement("div", { class: "bsky-dl-overlay" });
    const onEscape = (e) => {
      if (e.key === "Escape") removeOverlay();
    };
    const removeOverlay = () => {
      overlay.remove();
      document.removeEventListener("keydown", onEscape);
    };
    overlay.addEventListener("click", (e) => {
      if (e.target === overlay) removeOverlay();
    });
    document.addEventListener("keydown", onEscape);
    return { overlay, removeOverlay };
  }

  // Promise-based dialog for choosing between original and quoted post media
  function selectPostDialog(originalLabel, quotedLabel) {
    return new Promise((resolve) => {
      const overlay = createElement("div", { class: "bsky-dl-overlay" });

      const close = (value) => {
        resolve(value);
        overlay.remove();
        document.removeEventListener("keydown", onEscape);
      };
      const onEscape = (e) => {
        if (e.key === "Escape") close(null);
      };
      document.addEventListener("keydown", onEscape);
      overlay.addEventListener("click", (e) => {
        if (e.target === overlay) close(null);
      });

      const modal = createElement("div", { class: "bsky-dl-modal" });

      const title = createElement("h3", {}, {}, "Which post to download?");

      const container = createElement("div", {}, {
        display: "flex", flexDirection: "column", gap: "12px",
      });

      const originalBtn = createElement(
        "button", { class: "bsky-dl-btn-primary" }, {},
        `Original post (by ${originalLabel})`,
      );
      originalBtn.onclick = () => close("original");

      const quotedBtn = createElement(
        "button", { class: "bsky-dl-btn-primary secondary" }, {},
        `Quoted post (by ${quotedLabel})`,
      );
      quotedBtn.onclick = () => close("quoted");

      const cancelBtn = createElement(
        "button", { class: "bsky-dl-btn-text" }, {},
        "Cancel",
      );
      cancelBtn.onclick = () => close(null);

      container.append(originalBtn, quotedBtn, cancelBtn);
      modal.append(title, container);
      overlay.appendChild(modal);
      document.body.appendChild(overlay);
    });
  }

  // Single-pass template tag replacement
  function replaceTemplate(template, data) {
    return template.replace(/<%(\w+)>/g, (match, key) => data[key] ?? match);
  }

  // Constructs Bluesky post thread API URL
  function buildPostThreadUrl(username, postId) {
    return `https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?uri=at://${username}/app.bsky.feed.post/${postId}&depth=0&parentHeight=0`;
  }

  // Creates a checkbox option group for settings modals
  function createCheckboxOption(label, key, defaultVal) {
    const group = createElement("div", { class: "bsky-dl-option-group" });
    const labelEl = createElement(
      "label",
      { class: "bsky-dl-label" },
      { display: "flex", alignItems: "center", cursor: "pointer" },
    );
    const input = createElement(
      "input",
      { type: "checkbox" },
      { marginRight: "8px" },
    );
    input.checked = GM_getValue(key, defaultVal);
    input.onchange = () => GM_setValue(key, input.checked);
    labelEl.appendChild(input);
    labelEl.appendChild(document.createTextNode(label));
    group.appendChild(labelEl);
    return group;
  }

  // Extracts the appropriate media URL from an element
  function getMediaUrl(element, isVideo) {
    return isVideo ? element.poster : element.src ?? element.poster;
  }

  // Helper to create elements with attributes and styles
  function createElement(tag, attributes = {}, styles = {}, text = "") {
    const el = document.createElement(tag);
    for (const key in attributes) el.setAttribute(key, attributes[key]);
    Object.assign(el.style, styles);
    if (text) el.textContent = text;
    return el;
  }

  // Clear all history
  function clearHistory() {
    if (
      !confirm(
        "Are you sure you want to clear all download history? This cannot be undone.",
      )
    )
      return;

    downloadHistory = {};
    GM_setValue("dl_history", {});

    // Reset UI buttons on the page
    document.querySelectorAll(".bsky-dl-btn.downloaded, .bsky-dl-all-btn.downloaded").forEach((btn) => {
      revertButton(btn);
    });

    // Update Modal if open
    const list = document.querySelector(".bsky-dl-list");
    if (list) {
      list.innerHTML =
        '<div style="padding:10px; text-align:center; opacity:0.7">No history found.</div>';
    }

    // Clear Preview
    const preview = document.querySelector(".bsky-dl-preview");
    if (preview) {
      preview.innerHTML = "";
      const placeholder = createElement(
        "div",
        {},
        { opacity: "0.6" },
        "Select an item to view details.",
      );
      preview.appendChild(placeholder);
    }
  }

  // Export history as JSON
  function exportHistory() {
    const entries = Object.entries(downloadHistory);

    if (entries.length === 0) {
      alert("No history to export.");
      return;
    }

    const exportData = entries.map(([id, entry]) => {
      if (entry === true) {
        return { id, legacy: true };
      }
      return {
        id,
        ...entry,
        url: `https://bsky.app/profile/${entry.username}/post/${entry.postId}`,
      };
    });

    const blob = new Blob([JSON.stringify(exportData, null, 4)], {
      type: "application/json",
    });
    const link = document.createElement("a");
    link.href = URL.createObjectURL(blob);
    link.download = `bsky_download_history_${Date.now()}.json`;
    link.click();
    setTimeout(() => URL.revokeObjectURL(link.href), 10000);
  }

  // Injects the Settings UI into the Bluesky settings page
  function injectSettingsUI(node) {
    settingsInjected = true;
    const container = node.parentNode;

    const settingsBtn = settingsButtonRef = createElement(
      "div",
      { class: "bsky-dl-settings-btn" },
      {},
      `DL Settings v${GM_info.script.version}`,
    );

    settingsBtn.addEventListener("click", (e) => {
      e.preventDefault();
      openSettings();
    });

    const viewHistoryBtn = createElement(
      "div",
      { class: "bsky-dl-settings-btn" },
      { color: "#208bfe", borderColor: "#208bfe", marginTop: "10px" },
      "View Download History",
    );

    viewHistoryBtn.addEventListener("click", (e) => {
      e.preventDefault();
      viewHistory();
    });

    container.insertBefore(settingsBtn, node);
    container.insertBefore(viewHistoryBtn, node);
  }

  // View History Modal
  function viewHistory() {
    const { overlay, removeOverlay } = createModalOverlay();

    const modal = createElement("div", { class: "bsky-dl-modal" });
    const title = createElement(
      "h3",
      {},
      { textAlign: "center", margin: "0 0 15px 0" },
      "Download History",
    );

    const layout = createElement("div", { class: "bsky-dl-layout" });
    const list = createElement("div", { class: "bsky-dl-list" });
    const preview = createElement("div", { class: "bsky-dl-preview" });
    const previewPlaceholder = createElement(
      "div",
      {},
      { opacity: "0.6" },
      "Select an item to view details.",
    );
    preview.appendChild(previewPlaceholder);

    const groupedHistory = {};
    for (const key in downloadHistory) {
      const entry = downloadHistory[key];
      // Handle legacy boolean entries
      if (entry === true) continue;

      if (!groupedHistory[entry.postId]) {
        groupedHistory[entry.postId] = {
          ...entry,
          // Keep track of all keys associated with this post for deletion
          keys: [],
        };
      }
      groupedHistory[entry.postId].keys.push(key);
      // Update to the most recent entry's metadata
      if (entry.timestamp > groupedHistory[entry.postId].timestamp) {
        groupedHistory[entry.postId].timestamp = entry.timestamp;
        groupedHistory[entry.postId].username = entry.username;
        groupedHistory[entry.postId].handle = entry.handle;
      }
    }

    const sortedPostIds = Object.keys(groupedHistory).sort((a, b) => {
      return groupedHistory[b].timestamp - groupedHistory[a].timestamp;
    });

    if (sortedPostIds.length === 0) {
      list.innerHTML =
        '<div style="padding:10px; text-align:center; opacity:0.7">No history found.</div>';
    } else {
      sortedPostIds.forEach((postId) => {
        const entry = groupedHistory[postId];
        const row = createHistoryRow(
          entry,
          postId,
          modal,
          list,
          preview,
          previewPlaceholder,
        );
        list.appendChild(row);
      });
    }

    const footer = createElement("div", { class: "bsky-dl-modal-footer" });

    const leftGroup = createElement("div", { class: "bsky-dl-footer-group" });

    const clearBtn = createElement(
      "button",
      { class: "bsky-dl-modal-close danger" },
      {},
      "Clear History",
    );
    clearBtn.onclick = () => clearHistory();

    const exportBtn = createElement(
      "button",
      { class: "bsky-dl-modal-close" },
      {},
      "Export JSON",
    );
    exportBtn.onclick = () => exportHistory();

    const closeBtn = createElement(
      "button",
      { class: "bsky-dl-modal-close" },
      {},
      "Close",
    );
    closeBtn.onclick = () => removeOverlay();

    leftGroup.appendChild(clearBtn);
    leftGroup.appendChild(exportBtn);
    footer.appendChild(leftGroup);
    footer.appendChild(closeBtn);

    layout.appendChild(list);
    layout.appendChild(preview);
    modal.appendChild(title);
    modal.appendChild(layout);
    modal.appendChild(footer);
    overlay.appendChild(modal);

    document.body.appendChild(overlay);
  }

  function createHistoryRow(
    entry,
    postId,
    modal,
    list,
    preview,
    previewPlaceholder,
  ) {
    const row = createElement("div", { class: "bsky-dl-row" });
    const idLink = createElement("div", { class: "bsky-dl-link" }, {}, postId);

    idLink.addEventListener("click", async () => {
      modal.classList.add("expanded");
      preview.innerHTML = "";

      list
        .querySelectorAll(".bsky-dl-row")
        .forEach((r) => r.classList.remove("active"));
      row.classList.add("active");

      const closePrev = createElement(
        "div",
        { class: "bsky-dl-close-preview" },
        {},
        "×",
      );
      closePrev.onclick = (e) => {
        e.stopPropagation();
        modal.classList.remove("expanded");
        list
          .querySelectorAll(".bsky-dl-row")
          .forEach((r) => r.classList.remove("active"));
        preview.innerHTML = "";
        preview.appendChild(previewPlaceholder);
      };
      preview.appendChild(closePrev);

      const loading = createElement(
        "div",
        {},
        { opacity: "0.6" },
        "Loading preview...",
      );
      preview.appendChild(loading);

      try {
        const json = await fetchPostThread(entry.username, entry.postId);
        if (!json) throw new Error("Failed to fetch post");

        const post = json.thread.post;
        const record = post.record;
        const author = post.author;

        loading.remove();

        const card = createPreviewCard(author, record, post);
        preview.appendChild(card);
      } catch (e) {
        console.error(e);
        loading.textContent = "Error loading preview. Post may be deleted.";
      }
    });

    const actions = createElement(
      "div",
      { class: "bsky-dl-row-actions" },
    );

    const openBtn = createElement(
      "a",
      {
        href: `https://bsky.app/profile/${entry.username}/post/${entry.postId}`,
        target: "_blank",
        class: "bsky-dl-btn-sm open",
      },
      {},
      "OPEN ↗",
    );
    actions.appendChild(openBtn);

    const delBtn = createElement(
      "div",
      { class: "bsky-dl-btn-sm" },
      {},
      "DELETE",
    );
    delBtn.onclick = () => {
      if (confirm(`Remove post ${postId} and all its media from history?`)) {
        entry.keys.forEach((k) => delete downloadHistory[k]);
        GM_setValue("dl_history", downloadHistory);
        revertButtonsForPost(postId);
        row.remove();
      }
    };

    actions.appendChild(delBtn);

    row.appendChild(idLink);
    row.appendChild(actions);
    return row;
  }

  function createPreviewCard(author, record, post) {
    const card = createElement("div", { class: "bsky-card" });

    const header = createElement("div", { class: "bsky-card-header" });
    const avatar = createElement("img", {
      class: "bsky-card-avatar",
      src: author.avatar || "",
    });
    const user = createElement("div", { class: "bsky-card-user" });
    const name = createElement(
      "div",
      { class: "bsky-card-name" },
      {},
      author.displayName || author.handle,
    );
    const handle = createElement(
      "div",
      { class: "bsky-card-handle" },
      {},
      `@${author.handle}`,
    );

    user.appendChild(name);
    user.appendChild(handle);
    header.appendChild(avatar);
    header.appendChild(user);

    const text = createElement(
      "div",
      { class: "bsky-card-text" },
      {},
      record.text || "",
    );

    const mediaContainer = createElement("div", { class: "bsky-card-media" });

    let images = [];
    let videoThumbnail = null;

    // Helper to extract media from embed
    const extractMedia = (embed) => {
      if (!embed) return;

      if (embed.images) {
        images = embed.images;
      } else if (embed.thumbnail) {
        videoThumbnail = embed.thumbnail; // External media often has a thumbnail
      }
      if (embed.media) {
        extractMedia(embed.media);
      }
    };

    if (post.embed) {
      extractMedia(post.embed);

      if (
        post.embed["$type"] === "app.bsky.embed.video#view" &&
        post.embed.thumbnail
      ) {
        videoThumbnail = post.embed.thumbnail;
      }
    }

    if (images.length > 0) {
      mediaContainer.classList.add(`bsky-media-${Math.min(images.length, 4)}`);
      images.forEach((img) => {
        const imgEl = createElement("img", {
          class: "bsky-card-img",
          src: img.fullsize || img.thumb,
        });
        mediaContainer.appendChild(imgEl);
      });
    } else if (videoThumbnail) {
      mediaContainer.classList.add("bsky-media-1");
      const imgEl = createElement("img", {
        class: "bsky-card-img",
        src: videoThumbnail,
      });

      const playOverlay = createElement(
        "div",
        { class: "bsky-card-play" },
        {},
        "▶",
      );

      const wrapper = createElement("div", {}, { position: "relative" });
      wrapper.appendChild(imgEl);
      wrapper.appendChild(playOverlay);
      mediaContainer.appendChild(wrapper);
    }

    card.appendChild(header);
    card.appendChild(text);
    if (mediaContainer.hasChildNodes()) card.appendChild(mediaContainer);

    const footerRow = createElement(
      "div",
      { class: "bsky-card-footer" },
      {},
    );
    const date = createElement(
      "div",
      { class: "bsky-card-date" },
      {},
      new Date(record.createdAt).toLocaleString(),
    );

    footerRow.appendChild(date);
    card.appendChild(footerRow);

    return card;
  }
  // Generates the Unique ID for history checking
  function getBlobId(element, index) {
    try {
      const link = getPostLink(element);
      const pathParts = link.split("/");
      // Expecting format: /profile/{user}/post/{id}
      if (pathParts.length >= 5) {
        const postId = pathParts[4];
        return `${postId}_${index}`;
      }
    } catch {
      return null;
    }
    return null;
  }

  // Gets the index of an image within its post (filters out quote post media)
  function getImageNumberDOM(image) {
    try {
      const ancestor =
        image.closest(CONFIG.selectors.postItem) ?? document.body;
      const validMedia = getValidMedia(ancestor);
      const index = validMedia.indexOf(image);
      return index >= 0 ? index : 0;
    } catch {
      return 0;
    }
  }

  // Robust function to find the Link associated with the Post
  // This is critical for determining the Post ID
  function getPostLink(element) {
    // STRATEGY 1: Traverse UP to find a parent anchor with a post URL
    let currentEl = element;
    while (currentEl) {
      // Stop if we hit a thread item wrapper to avoid grabbing wrong parent link
      if (currentEl.dataset?.testid?.startsWith("postThreadItem")) {
        break;
      }
      // Check if current element is an anchor with a post href
      if (currentEl.tagName === "A") {
        const href = currentEl.getAttribute("href");
        const match = href && href.match(CONFIG.postUrlRegex);
        if (match) return match[0];
      }
      // Check direct child anchors by iterating children (avoids selector engine per level)
      for (const child of currentEl.children) {
        if (child.tagName !== "A") continue;
        const href = child.getAttribute("href");
        if (href && href.includes("/post/")) {
          const match = href.match(CONFIG.postUrlRegex);
          if (match) return match[0];
        }
      }
      currentEl = currentEl.parentElement;
    }

    // STRATEGY 2: Fallback - Search DOWN into the container
    // Useful for Expanded Views where the media isn't wrapped in the link anchor
    const container =
      element.closest(CONFIG.selectors.postItem) ||
      element.closest(CONFIG.selectors.quotePost);
    if (container) {
      // Iterate all potential links to avoid grabbing one from a NESTED quote
      const links = container.querySelectorAll('a[href*="/post/"]');
      for (const link of links) {
        const linkQuoteParent = link.closest(CONFIG.selectors.quotePost);

        // Skip links belonging to nested quotes (child of current container)
        if (
          linkQuoteParent &&
          linkQuoteParent !== container &&
          container.contains(linkQuoteParent)
        ) {
          continue;
        }
        const match = link.getAttribute("href").match(CONFIG.postUrlRegex);
        if (match) return match[0];
      }
    }

    return window.location.pathname;
  }

  // Helper to map MIME types to file extensions
  function getExtensionFromBlob(blob) {
    if (CONFIG.mimeToExt[blob.type]) return CONFIG.mimeToExt[blob.type];
    if (blob.type && blob.type.startsWith("video/")) return "mp4";
    return "jpg";
  }

  // Fallback download method using anchor tag
  function fallbackDownload(blobUrl, filename) {
    const link = document.createElement("a");
    link.href = blobUrl;
    link.download = filename;
    link.style.display = "none";
    document.body.appendChild(link);
    link.click();
    link.remove();
    setTimeout(() => URL.revokeObjectURL(blobUrl), 10000);
  }

  // Replaces placeholders in the filename template and sanitizes
  function convertFilename(data) {
    const ext = data.blobInfo?.mimeType ? (CONFIG.mimeToExt[data.blobInfo.mimeType] ?? "") : "";
    return replaceTemplate(filenameTemplate, {
      uname: data.uname,
      username: data.username,
      handle: data.handle,
      display_name: data.displayName ?? "",
      post_id: data.postId,
      post_time: data.postTime,
      timestamp: Date.now(),
      img_num: data.imageNumber,
      title: data.title,
      width: data.width ?? 0,
      height: data.height ?? 0,
      original_ext: ext,
    }).replace(SANITIZE_REGEX, "-");
  }

  // ========================================================================
  // 8. WHAT'S NEW MODAL
  // ========================================================================

  function showWhatsNew() {
    const modal = createElement("div", { class: "bsky-wn-modal" });

    // Header
    const header = createElement("div", { class: "bsky-wn-header" });
    const title = createElement(
      "div",
      { class: "bsky-wn-title" },
      {},
      "What's New",
    );
    const versionTag = createElement(
      "span",
      { class: "bsky-wn-tag" },
      {},
      `v${GM_info.script.version}`,
    );
    title.appendChild(versionTag);

    const closeBtn = createElement("div", { class: "bsky-wn-close" }, {}, "×");
    closeBtn.onclick = () => {
      GM_setValue("last_version", GM_info.script.version);
      modal.style.transform = "translateX(120%)";
      setTimeout(() => modal.remove(), 500);
    };

    header.appendChild(title);
    header.appendChild(closeBtn);

    const list = createElement("ul", { class: "bsky-wn-list" });
    WHATS_NEW.forEach((item) => {
      const li = createElement("li", {}, {}, item);
      list.appendChild(li);
    });

    modal.appendChild(header);
    modal.appendChild(list);
    document.body.appendChild(modal);
  }

  function checkVersion() {
    const lastVersion = GM_getValue("last_version", "0.0.0");
    if (lastVersion !== GM_info.script.version) {
      // Delay slightly to ensure page is interactive
      setTimeout(showWhatsNew, 1500);
    }
  }

  checkVersion();
})();