4chan Gallery

4chan grid-based image gallery with zoom mode support for threads that allows you to browse images, and soundposts (images with sounds, webms with sounds) along with other utility features.

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

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

Advertisement:

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

Advertisement:

// ==UserScript==
// @name         4chan Gallery
// @namespace    http://tampermonkey.net/
// @version      2026-05-28 (4.1)
// @description  4chan grid-based image gallery with zoom mode support for threads that allows you to browse images, and soundposts (images with sounds, webms with sounds) along with other utility features.
// @author       TheDarkEnjoyer
// @match        https://boards.4chan.org/*/thread/*
// @match        https://boards.4chan.org/*/archive
// @match        https://boards.4channel.org/*/thread/*
// @match        https://boards.4channel.org/*/archive
// @match        https://warosu.org/*/*
// @match        https://archived.moe/*/*
// @match        https://archive.palanq.win/*/*
// @match        https://archive.4plebs.org/*/*
// @match        https://desuarchive.org/*/*
// @match        https://thebarchive.com/*/*
// @match        https://archiveofsins.com/*/*
// @icon         data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/exif-reader.min.js
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/pako.min.js
// @grant        GM_download
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_xmlhttpRequest
// @license      GNU GPLv3
// ==/UserScript==

(function () {
    "use strict";

    // ═══════════════════════════════════════════════════════════════
    //  THEME CONSTANTS
    // ═══════════════════════════════════════════════════════════════

    const T = Object.freeze({
        bg:          "#181a1f",
        bgCard:      "#282c34",
        bgPanel:     "#21252b",
        bgSettings:  "#1e2227",
        text:        "#abb2bf",
        textMuted:   "#5c6370",
        border:      "#3e4452",
        blue:        "#61afef",
        green:       "#a6e22e",
        pink:        "#f92672",
        purple:      "#ae81ff",
        gold:        "#e5c07b",
        dark:        "#181a1f",
        overlay:     "rgba(24, 26, 31, 0.88)",
        shadow:      "rgba(0, 0, 0, 0.3)",
        shadowHeavy: "rgba(0, 0, 0, 0.6)",
        blueShadow:  "rgba(97, 175, 239, 0.3)",
        greenShadow: "rgba(166, 226, 46, 0.3)",
        goldShadow:  "rgba(229, 192, 123, 0.4)",
        purpleShadow:"rgba(174, 129, 255, 0.4)",
        font:        "system-ui, -apple-system, 'Segoe UI', sans-serif",
        mono:        "'SF Mono', 'Fira Code', 'Cascadia Code', monospace",
    });

    // ═══════════════════════════════════════════════════════════════
    //  CSS STYLESHEET
    // ═══════════════════════════════════════════════════════════════

    function injectStyles() {
        const css = `
        /* ── Animations ──────────────────────────────────── */
        @keyframes gcFadeIn {
            from { opacity: 0; }
            to   { opacity: 1; }
        }
        @keyframes gcFadeOut {
            from { opacity: 1; }
            to   { opacity: 0; }
        }
        @keyframes gcSpinIn {
            from { opacity: 0; transform: scale(0.92); }
            to   { opacity: 1; transform: scale(1); }
        }
        @keyframes gcSpin {
            to { transform: rotate(360deg); }
        }
        @keyframes gcPulse {
            0%, 100% { opacity: 1; }
            50%      { opacity: 0.5; }
        }

        /* ── Gallery Button (page) ──────────────────────── */
        #openImageGallery {
            position: fixed;
            bottom: 20px;
            right: 20px;
            z-index: 1000;
            background: linear-gradient(135deg, ${T.bgCard} 0%, ${T.bgPanel} 100%);
            color: ${T.text};
            padding: 10px 20px;
            border-radius: 10px;
            border: 1px solid ${T.border};
            cursor: pointer;
            box-shadow: 0 4px 16px ${T.shadow};
            font-family: ${T.font};
            font-weight: 600;
            font-size: 13px;
            letter-spacing: 0.3px;
            transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
        }
        #openImageGallery:hover {
            background: linear-gradient(135deg, ${T.blue} 0%, #4fa3e8 100%) !important;
            color: ${T.dark} !important;
            transform: translateY(-3px) scale(1.05) !important;
            box-shadow: 0 8px 24px ${T.blueShadow} !important;
            border-color: ${T.blue} !important;
        }

        /* ── Gallery Overlay ─────────────────────────────── */
        .gc-overlay {
            position: fixed;
            inset: 0;
            background: ${T.overlay};
            backdrop-filter: blur(14px);
            -webkit-backdrop-filter: blur(14px);
            display: flex;
            justify-content: center;
            align-items: center;
            z-index: 9999;
            animation: gcFadeIn 0.25s ease-out;
        }

        /* ── Grid Container ──────────────────────────────── */
        .gc-grid {
            display: grid;
            grid-auto-rows: max-content;
            gap: 14px;
            padding: 24px;
            background: ${T.bg};
            color: ${T.text};
            max-width: 80%;
            max-height: 80%;
            overflow: auto;
            resize: both;
            border: 1px solid ${T.border};
            border-radius: 14px;
            box-shadow: 0 24px 64px ${T.shadowHeavy};
            scrollbar-width: thin;
            scrollbar-color: ${T.border} ${T.bg};
            align-content: start;
        }
        .gc-grid::-webkit-scrollbar {
            width: 8px;
        }
        .gc-grid::-webkit-scrollbar-track {
            background: ${T.bg};
            border-radius: 4px;
        }
        .gc-grid::-webkit-scrollbar-thumb {
            background: ${T.border};
            border-radius: 4px;
        }
        .gc-grid::-webkit-scrollbar-thumb:hover {
            background: ${T.textMuted};
        }

        /* ── Grid Cell ───────────────────────────────────── */
        .gc-cell {
            border: 1px solid ${T.border};
            border-radius: 10px;
            background: ${T.bgCard};
            position: relative;
            overflow: hidden;
            box-shadow: 0 4px 12px ${T.shadow};
            transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
            will-change: transform;
        }
        .gc-cell:hover {
            transform: scale(1.02);
            border-color: ${T.blue};
            box-shadow: 0 12px 32px ${T.shadowHeavy};
        }
        .gc-cell--external {
            border-color: ${T.purple};
        }
        .gc-cell--external:hover {
            border-color: ${T.purple};
            box-shadow: 0 12px 32px ${T.purpleShadow};
        }
        .gc-cell--dragging {
            opacity: 0.5;
            transform: scale(0.95);
        }
        .gc-cell--dragover {
            border-color: ${T.green} !important;
            box-shadow: 0 0 20px ${T.greenShadow} !important;
        }

        /* ── Cell Media Container ────────────────────────── */
        .gc-media-wrap {
            position: relative;
            display: flex;
            justify-content: center;
            align-items: center;
            background: ${T.bg};
        }
        .gc-media-wrap img,
        .gc-media-wrap video {
            max-width: 100%;
            object-fit: contain;
            cursor: pointer;
            display: block;
        }

        /* ── Video Badge ─────────────────────────────────── */
        .gc-video-badge {
            position: absolute;
            top: 8px;
            left: 8px;
            background: linear-gradient(135deg, ${T.purple} 0%, #9b59e0 100%);
            color: ${T.dark};
            padding: 3px 8px;
            border-radius: 5px;
            font-size: 10px;
            font-weight: 700;
            font-family: ${T.mono};
            pointer-events: none;
            z-index: 2;
            box-shadow: 0 2px 8px ${T.purpleShadow};
            letter-spacing: 0.5px;
            display: flex;
            align-items: center;
            gap: 3px;
        }
        .gc-sound-badge {
            background: linear-gradient(135deg, ${T.gold} 0%, #d4a853 100%);
        }

        /* ── Cell Controls Bar ───────────────────────────── */
        .gc-controls {
            display: flex;
            justify-content: space-between;
            align-items: center;
            padding: 8px 10px;
            background: ${T.bgPanel};
            border-top: 1px solid ${T.border};
            gap: 6px;
        }

        /* ── Buttons ─────────────────────────────────────── */
        .gc-btn {
            background: ${T.bgCard};
            color: ${T.text};
            padding: 6px 12px;
            border-radius: 7px;
            border: 1px solid ${T.border};
            cursor: pointer;
            box-shadow: 0 2px 6px ${T.shadow};
            font-family: ${T.font};
            font-weight: 500;
            font-size: 12px;
            transition: all 0.2s ease;
            white-space: nowrap;
            line-height: 1.4;
            display: inline-flex;
            align-items: center;
            gap: 4px;
            text-decoration: none;
        }
        .gc-btn:hover {
            background: ${T.blue};
            color: ${T.dark};
            border-color: ${T.blue};
            box-shadow: 0 4px 14px ${T.blueShadow};
            transform: translateY(-1px);
        }
        .gc-btn--primary {
            background: linear-gradient(135deg, ${T.blue} 0%, #4fa3e8 100%);
            color: ${T.dark};
            border-color: ${T.blue};
            font-weight: 600;
        }
        .gc-btn--primary:hover {
            box-shadow: 0 6px 18px ${T.blueShadow};
        }
        .gc-btn--success {
            background: linear-gradient(135deg, ${T.green} 0%, #8cc41a 100%);
            color: ${T.dark};
            border: none;
            font-weight: 600;
        }
        .gc-btn--success:hover {
            background: linear-gradient(135deg, #b8e85a 0%, ${T.green} 100%);
            box-shadow: 0 6px 18px ${T.greenShadow};
            color: ${T.dark};
        }
        .gc-btn--large {
            padding: 10px 20px;
            font-size: 13px;
            font-weight: 600;
        }
        .gc-btn--small {
            padding: 4px 8px;
            font-size: 11px;
        }
        .gc-btn--icon {
            padding: 6px 10px;
            font-size: 14px;
            font-weight: bold;
            line-height: 1;
        }
        .gc-btn--bookmark {
            transition: all 0.2s ease;
        }
        .gc-btn--bookmark:hover {
            background: ${T.gold} !important;
            color: ${T.dark} !important;
            border-color: ${T.gold} !important;
            box-shadow: 0 4px 14px ${T.goldShadow} !important;
        }
        .gc-btn--bookmark-active {
            background: ${T.gold};
            color: ${T.dark};
            border-color: ${T.gold};
        }
        .gc-btn--bookmark-active:hover {
            background: #d4a853 !important;
        }

        /* ── Top Bar (mode toggles) ──────────────────────── */
        .gc-topbar {
            position: absolute;
            top: 12px;
            left: 12px;
            display: flex;
            gap: 8px;
            z-index: 10000;
        }
        .gc-topbar-right {
            position: absolute;
            top: 12px;
            right: 12px;
            display: flex;
            gap: 8px;
            z-index: 10000;
        }

        /* ── Zoom Overlay ────────────────────────────────── */
        .gc-zoom {
            position: fixed;
            inset: 0;
            background: ${T.overlay};
            backdrop-filter: blur(18px);
            -webkit-backdrop-filter: blur(18px);
            z-index: 9999;
            animation: gcFadeIn 0.2s ease-out;
        }

        /* ── Zoom Media ──────────────────────────────────── */
        .gc-zoom-media {
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            z-index: 10000;
            height: 80%;
            width: 80%;
            object-fit: contain;
            cursor: pointer;
        }

        /* ── Zoom Arrow Buttons ──────────────────────────── */
        .gc-zoom-arrow {
            position: fixed;
            top: 50%;
            transform: translateY(-50%);
            z-index: 10001;
            background: linear-gradient(135deg, ${T.bgPanel} 0%, ${T.bgCard} 100%);
            color: ${T.text};
            padding: 16px;
            border: 1px solid ${T.border};
            border-radius: 50%;
            cursor: pointer;
            font-size: 16px;
            transition: all 0.2s ease;
            line-height: 1;
            width: 48px;
            height: 48px;
            display: flex;
            align-items: center;
            justify-content: center;
        }
        .gc-zoom-arrow--left  { left: 20px; }
        .gc-zoom-arrow--right { right: 20px; }
        .gc-zoom-arrow:hover {
            background: linear-gradient(135deg, ${T.green} 0%, #8cc41a 100%);
            color: ${T.dark};
            transform: translateY(-50%) scale(1.12);
            box-shadow: 0 6px 18px ${T.greenShadow};
            border-color: ${T.green};
        }

        /* ── Zoom Pill Indicators ────────────────────────── */
        .gc-pill {
            position: fixed;
            top: 15px;
            background: ${T.bgPanel};
            padding: 6px 14px;
            border-radius: 20px;
            border: 1px solid ${T.border};
            box-shadow: 0 4px 12px ${T.shadow};
            z-index: 10000;
            font-family: ${T.font};
            font-weight: 500;
            font-size: 13px;
            color: ${T.text};
        }
        .gc-pill--index {
            left: 15px;
            font-family: ${T.mono};
            font-weight: 700;
            color: ${T.gold};
            border-color: ${T.gold};
            box-shadow: 0 0 12px rgba(229, 192, 123, 0.15);
        }
        .gc-pill--title {
            right: 15px;
        }

        /* ── Zoom Bottom Controls ────────────────────────── */
        .gc-zoom-controls {
            position: fixed;
            bottom: 12px;
            left: 0;
            right: 0;
            z-index: 10000;
            display: flex;
            justify-content: space-around;
            align-items: center;
            padding: 0 20px;
        }
        .gc-zoom-btn-group {
            display: flex;
            gap: 8px;
            align-items: center;
        }

        /* ── Settings Modal ──────────────────────────────── */
        .gc-settings-overlay {
            position: fixed;
            inset: 0;
            background: ${T.overlay};
            backdrop-filter: blur(12px);
            -webkit-backdrop-filter: blur(12px);
            display: flex;
            justify-content: center;
            align-items: center;
            z-index: 10001;
            animation: gcFadeIn 0.25s ease-out;
        }
        .gc-settings-box {
            background: ${T.bgSettings};
            color: ${T.text};
            padding: 32px;
            border-radius: 14px;
            border: 1px solid ${T.border};
            max-width: 700px;
            width: 90%;
            max-height: 80vh;
            overflow-y: auto;
            box-shadow: 0 24px 64px ${T.shadowHeavy};
            animation: gcSpinIn 0.3s ease-out;
        }
        .gc-settings-title {
            text-align: center;
            margin: 0 0 24px;
            color: ${T.pink};
            font-family: ${T.font};
            font-weight: 700;
            font-size: 22px;
            letter-spacing: 0.5px;
        }
        .gc-settings-list {
            list-style: none;
            padding: 0;
            margin: 0;
        }
        .gc-settings-item {
            display: flex;
            align-items: center;
            margin-bottom: 14px;
            padding: 10px 12px;
            border-radius: 8px;
            transition: background 0.2s ease;
        }
        .gc-settings-item:hover {
            background: rgba(255, 255, 255, 0.03);
        }
        .gc-settings-label {
            flex: 1;
            font-size: 13px;
            cursor: help;
        }
        .gc-settings-input {
            padding: 8px 12px;
            border-radius: 7px;
            border: 1px solid ${T.border};
            background: ${T.bg};
            color: ${T.text};
            font-family: ${T.font};
            font-size: 13px;
            transition: all 0.2s ease;
            outline: none;
        }
        .gc-settings-input:focus {
            border-color: ${T.blue};
            box-shadow: 0 0 0 3px rgba(97, 175, 239, 0.2);
        }
        .gc-settings-input[type="checkbox"] {
            width: 18px;
            height: 18px;
            accent-color: ${T.blue};
            cursor: pointer;
        }
        .gc-settings-input[type="number"],
        .gc-settings-input[type="text"] {
            min-width: 80px;
        }
        .gc-settings-actions {
            display: flex;
            gap: 10px;
            margin-top: 20px;
            justify-content: flex-end;
        }

        /* ── Bookmarks Info Card ──────────────────────────── */
        .gc-bm-info {
            padding: 10px 12px;
            background: ${T.bgPanel};
            border-top: 1px solid ${T.border};
            display: flex;
            flex-direction: column;
            gap: 6px;
        }
        .gc-bm-meta {
            display: flex;
            justify-content: space-between;
            font-size: 10px;
            color: ${T.textMuted};
        }
        .gc-bm-comment {
            font-size: 11px;
            color: ${T.text};
            white-space: nowrap;
            overflow: hidden;
            text-overflow: ellipsis;
            max-width: 100%;
            cursor: help;
        }

        /* ── Loading Spinner ─────────────────────────────── */
        .gc-spinner {
            grid-column: 1 / -1;
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: center;
            padding: 48px;
            gap: 14px;
        }
        .gc-spinner-ring {
            width: 36px;
            height: 36px;
            border: 3px solid ${T.border};
            border-top-color: ${T.blue};
            border-radius: 50%;
            animation: gcSpin 0.8s linear infinite;
        }
        .gc-spinner-text {
            color: ${T.textMuted};
            font-size: 13px;
            font-style: italic;
            animation: gcPulse 1.5s ease-in-out infinite;
        }

        /* ── Empty State ─────────────────────────────────── */
        .gc-empty {
            grid-column: 1 / -1;
            text-align: center;
            padding: 48px;
            font-size: 16px;
            color: ${T.textMuted};
            font-style: italic;
        }

        /* ── Scroll-to-last / Close buttons ──────────────── */
        .gc-float-btn {
            position: fixed;
            z-index: 10000;
            transition: all 0.2s ease;
        }
        .gc-float-btn:hover {
            transform: translateY(-2px);
        }

        /* ── Media count indicator ───────────────────────── */
        .gc-media-count {
            position: absolute;
            bottom: 12px;
            left: 50%;
            transform: translateX(-50%);
            z-index: 10000;
            background: ${T.bgPanel};
            color: ${T.textMuted};
            padding: 4px 14px;
            border-radius: 20px;
            border: 1px solid ${T.border};
            font-family: ${T.mono};
            font-size: 11px;
            font-weight: 600;
            pointer-events: none;
            transition: opacity 0.3s ease;
        }

        /* ── Top Bar Center (search) ─────────────────────── */
        .gc-topbar-center {
            position: absolute;
            top: 12px;
            left: 50%;
            transform: translateX(-50%);
            display: flex;
            gap: 8px;
            z-index: 10000;
        }
        .gc-search-input {
            padding: 8px 16px;
            border-radius: 20px;
            border: 1px solid ${T.border};
            background: ${T.bgCard};
            color: ${T.text};
            font-family: ${T.font};
            font-size: 12px;
            width: 200px;
            outline: none;
            transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
            box-shadow: 0 4px 12px ${T.shadow};
        }
        .gc-search-input:focus {
            border-color: ${T.blue};
            box-shadow: 0 0 0 3px ${T.blueShadow};
            width: 300px;
        }

        /* ── Metadata Viewer Panel ───────────────────────── */
        .gc-meta-overlay {
            position: fixed;
            inset: 0;
            background: ${T.overlay};
            backdrop-filter: blur(12px);
            -webkit-backdrop-filter: blur(12px);
            display: flex;
            justify-content: center;
            align-items: center;
            z-index: 10002;
            animation: gcFadeIn 0.2s ease-out;
        }
        .gc-meta-box {
            background: ${T.bgSettings};
            color: ${T.text};
            padding: 28px;
            border-radius: 14px;
            border: 1px solid ${T.border};
            width: 85%;
            max-width: 680px;
            max-height: 80vh;
            overflow-y: auto;
            box-shadow: 0 24px 64px ${T.shadowHeavy};
            display: flex;
            flex-direction: column;
            gap: 16px;
            animation: gcSpinIn 0.25s ease-out;
        }
        .gc-meta-title {
            margin: 0;
            color: ${T.gold};
            font-family: ${T.font};
            font-weight: 700;
            font-size: 20px;
            letter-spacing: 0.5px;
            display: flex;
            align-items: center;
            justify-content: space-between;
        }
        .gc-meta-content {
            font-family: ${T.mono};
            font-size: 12px;
            white-space: pre-wrap;
            background: ${T.bg};
            padding: 16px;
            border-radius: 8px;
            border: 1px solid ${T.border};
            max-height: 48vh;
            overflow-y: auto;
            user-select: text;
            color: ${T.text};
            line-height: 1.5;
        }

        /* ── Changelog Modal ─────────────────────────────── */
        .gc-changelog-overlay {
            position: fixed;
            inset: 0;
            background: ${T.overlay};
            backdrop-filter: blur(12px);
            -webkit-backdrop-filter: blur(12px);
            display: flex;
            justify-content: center;
            align-items: center;
            z-index: 10005;
            animation: gcFadeIn 0.25s ease-out;
        }
        .gc-changelog-box {
            background: ${T.bgSettings};
            color: ${T.text};
            padding: 30px;
            border-radius: 14px;
            border: 1px solid ${T.border};
            max-width: 600px;
            width: 90%;
            max-height: 85vh;
            overflow-y: auto;
            box-shadow: 0 24px 64px ${T.shadowHeavy};
            display: flex;
            flex-direction: column;
            gap: 20px;
            animation: gcSpinIn 0.3s ease-out;
        }
        .gc-changelog-header {
            margin: 0;
            color: ${T.gold};
            font-family: ${T.font};
            font-weight: 700;
            font-size: 22px;
            letter-spacing: 0.5px;
            border-bottom: 1px solid ${T.border};
            padding-bottom: 12px;
        }
        .gc-changelog-list {
            display: flex;
            flex-direction: column;
            gap: 14px;
            padding: 0;
            margin: 0;
            list-style: none;
        }
        .gc-changelog-item {
            display: flex;
            flex-direction: column;
            gap: 4px;
        }
        .gc-changelog-title {
            font-weight: 600;
            font-size: 14px;
            color: ${T.blue};
            display: flex;
            align-items: center;
            gap: 8px;
        }
        .gc-changelog-desc {
            font-size: 12px;
            color: ${T.text};
            padding-left: 24px;
            line-height: 1.5;
        }
        .gc-changelog-btn-wrap {
            margin-top: 10px;
            display: flex;
            justify-content: center;
        }
        `;

        const el = document.createElement("style");
        el.id = "gc-styles";
        el.textContent = css;
        document.head.appendChild(el);
    }

    // ═══════════════════════════════════════════════════════════════
    //  HELPER FUNCTIONS
    // ═══════════════════════════════════════════════════════════════

    /** Batch-set styles on an element */
    function setStyles(el, styles) {
        for (const k in styles) el.style[k] = styles[k];
    }

    /** Create a DOM element with optional styles, attributes, classes, children */
    function h(tag, { s, a, c, ch, txt, html } = {}) {
        const el = document.createElement(tag);
        if (c) {
            const classes = (Array.isArray(c) ? c : [c]).filter(Boolean);
            if (classes.length > 0) {
                el.classList.add(...classes);
            }
        }
        if (a) {
            for (const k in a) {
                if (k === "dataset") {
                    for (const dk in a.dataset) el.dataset[dk] = a.dataset[dk];
                } else {
                    el.setAttribute(k, a[k]);
                }
            }
        }
        if (s) setStyles(el, s);
        if (txt) el.textContent = txt;
        if (html) el.innerHTML = html;
        if (ch) {
            const children = Array.isArray(ch) ? ch : [ch];
            children.forEach(child => { if (child) el.appendChild(child); });
        }
        return el;
    }

    /** Create a button with consistent styling */
    function btn(text, onClick, { classes = [], title: tip, attrs } = {}) {
        const b = h("button", { c: ["gc-btn", ...classes], txt: text, a: attrs });
        if (tip) b.title = tip;
        if (onClick) b.addEventListener("click", onClick);
        return b;
    }

    /** Create an anchor styled as a button */
    function btnLink(text, { classes = [], href, download: dl, title: tip, onClick } = {}) {
        const a = h("a", { c: ["gc-btn", ...classes], txt: text, a: { href: href || "#" } });
        if (dl) a.download = dl;
        if (tip) a.title = tip;
        if (onClick) {
            a.addEventListener("click", (e) => {
                e.preventDefault();
                onClick(e);
            });
        }
        return a;
    }

    /** Debounce helper */
    function debounce(fn, ms) {
        let timer;
        return (...args) => {
            clearTimeout(timer);
            timer = setTimeout(() => fn(...args), ms);
        };
    }

    /** Safe GM_getValue wrapper with fallback */
    function getGMValue(key, defaultVal) {
        if (typeof GM_getValue !== "undefined") {
            try {
                return GM_getValue(key, defaultVal);
            } catch (e) {
                console.error("GM_getValue failed:", e);
            }
        }
        return defaultVal;
    }

    /** Safe GM_setValue wrapper with fallback */
    function setGMValue(key, value) {
        if (typeof GM_setValue !== "undefined") {
            try {
                GM_setValue(key, value);
                return;
            } catch (e) {
                console.error("GM_setValue failed:", e);
            }
        }
    }

    // ═══════════════════════════════════════════════════════════════
    //  SETTINGS
    // ═══════════════════════════════════════════════════════════════

    const defaultSettings = {
        Load_High_Res_Images_By_Default: {
            value: false,
            info: "When opening the gallery, load high quality images by default (no thumbnails)",
        },
        Add_Placeholder_Image_For_Zoom_Mode: {
            value: true,
            info: "Add a placeholder image for zoom mode so even if the thread has no images, you can still open the zoom mode",
        },
        Play_Webms_On_Hover: {
            value: true,
            info: "Autoplay webms on hover, pause on mouse leave",
        },
        Switch_Catbox_To_Pixstash_For_Soundposts: {
            value: false,
            info: "Switch all catbox.moe links to pixstash.moe links for soundposts",
        },
        Show_Arrow_Buttons_In_Zoom_Mode: {
            value: true,
            info: "Show clickable arrow buttons on screen edges in zoom mode",
        },
        Grid_Columns: {
            value: 3,
            info: "Number of columns in the grid view",
        },
        Grid_Cell_Max_Height: {
            value: 200,
            info: "Maximum height of each cell in pixels",
        },
        Embed_External_Links: {
            value: false,
            info: "Embed catbox/pixstash links found in post comments",
        },
        Strictly_Load_GIFs_As_Thumbnails_On_Hover: {
            value: false,
            info: "Only load GIF thumbnails until hovered",
        },
        Open_Close_Gallery_Key: {
            value: "i",
            info: "Key to open/close the gallery",
        },
        Hide_Gallery_Button: {
            value: false,
            info: "Hide the gallery button (You can still open the gallery with the keybind, default is 'i')",
        },
        Force_Load_Catbox_Images_As_Webp: {
            value: false,
            info: "Load Catbox images as WebP format for faster loading (using images.weserv.nl)",
        },
    };

    function loadSettings() {
        let saved = null;
        const gmRaw = getGMValue("gallerySettings", null);
        if (gmRaw) {
            try {
                saved = typeof gmRaw === "string" ? JSON.parse(gmRaw) : gmRaw;
            } catch (e) {
                console.error("Failed to parse GM settings:", e);
            }
        }
        // Migrate from localStorage
        if (!saved) {
            try {
                const localRaw = localStorage.getItem("gallerySettings");
                if (localRaw) {
                    saved = JSON.parse(localRaw);
                    setGMValue("gallerySettings", JSON.stringify(saved));
                    localStorage.removeItem("gallerySettings");
                }
            } catch (e) {
                console.error("Failed to migrate settings:", e);
            }
        }

        if (!saved) {
            saved = JSON.parse(JSON.stringify(defaultSettings));
            setGMValue("gallerySettings", JSON.stringify(saved));
            return saved;
        }

        // Migrate: add missing keys, remove stale keys
        let dirty = false;
        for (const k in defaultSettings) {
            if (!saved.hasOwnProperty(k)) {
                saved[k] = { ...defaultSettings[k] };
                dirty = true;
            }
        }
        for (const k in saved) {
            if (!defaultSettings.hasOwnProperty(k)) {
                delete saved[k];
                dirty = true;
            }
        }
        if (dirty) setGMValue("gallerySettings", JSON.stringify(saved));
        return saved;
    }

    function saveSettings(newSettings) {
        setGMValue("gallerySettings", JSON.stringify(newSettings));
    }

    function loadBookmarks() {
        let bookmarks = {};
        const gmRaw = getGMValue("galleryBookmarks", null);
        if (gmRaw) {
            try {
                bookmarks = typeof gmRaw === "string" ? JSON.parse(gmRaw) : gmRaw;
            } catch (e) {
                console.error("Failed to parse GM bookmarks:", e);
            }
        }
        // Migrate from localStorage
        try {
            const localRaw = localStorage.getItem("galleryBookmarks");
            if (localRaw) {
                const localBookmarks = JSON.parse(localRaw);
                let migrated = false;
                for (const k in localBookmarks) {
                    if (!bookmarks[k]) {
                        bookmarks[k] = localBookmarks[k];
                        migrated = true;
                    }
                }
                if (migrated) {
                    setGMValue("galleryBookmarks", JSON.stringify(bookmarks));
                }
                localStorage.removeItem("galleryBookmarks");
            }
        } catch (e) {
            console.error("Failed to migrate bookmarks:", e);
        }
        return bookmarks;
    }

    // ═══════════════════════════════════════════════════════════════
    //  STATE
    // ═══════════════════════════════════════════════════════════════

    const state = {
        settings: loadSettings(),
        bookmarks: loadBookmarks(),
        threadURL: window.location.href,
        lastScrollPos: 0,
        gallerySize: { w: 0, h: 0 },
        currentView: "gallery",   // "gallery" | "bookmarks"
        mode: "all",              // "all" | "webm"
        autoPlayWebms: false,
        zoomActive: false,
        galleryOpen: false,
        mediaCount: 0,
    };

    // Convenience getters
    function cfg(key) { return state.settings[key]?.value; }

    // ═══════════════════════════════════════════════════════════════
    //  BOOKMARKS
    // ═══════════════════════════════════════════════════════════════

    function isBookmarked(url) {
        if (!url) return false;
        return Object.keys(state.bookmarks).some(k => k === url || state.bookmarks[k].url === url);
    }

    function findBookmarkKey(url) {
        if (!url) return null;
        return Object.keys(state.bookmarks).find(k => k === url || state.bookmarks[k].url === url);
    }

    function toggleBookmark(postData) {
        const key = postData.url;
        const existingKey = findBookmarkKey(key);
        if (existingKey) {
            delete state.bookmarks[existingKey];
        } else {
            const soundLinkStr = postData.soundLink ? (Array.isArray(postData.soundLink) ? postData.soundLink[0] : postData.soundLink) : null;
            state.bookmarks[key] = {
                url: postData.url,
                thumbnailUrl: postData.thumbnailUrl,
                commentText: postData.commentText || "",
                board: postData.board || "",
                threadID: postData.threadID || "",
                postID: postData.postID || "",
                postURL: postData.postURL || window.location.href,
                timestamp: new Date().toISOString(),
                isExternal: postData.isExternal || false,
                soundLink: soundLinkStr,
                soundUrl: postData.soundUrl || null,
            };
            triggerCatboxUpload(key);
        }
        setGMValue("galleryBookmarks", JSON.stringify(state.bookmarks));
        return isBookmarked(key);
    }

    // ═══════════════════════════════════════════════════════════════
    //  DOWNLOAD
    // ═══════════════════════════════════════════════════════════════

    function downloadMedia(url, filename) {
        if (typeof GM_download !== "undefined") {
            try {
                const dl = GM_download({
                    url,
                    name: filename,
                    onerror: (err) => {
                        console.error("GM_download failed, trying fallback:", err);
                        fallbackBlobDownload(url, filename);
                    },
                    ontimeout: () => {
                        console.warn("GM_download timed out, trying fallback");
                        fallbackBlobDownload(url, filename);
                    },
                    saveAs: true,
                });
                setTimeout(() => { if (dl?.abort) dl.abort(); }, 240000);
                return;
            } catch (e) {
                console.error("GM_download exception, trying fallback:", e);
            }
        }
        fallbackBlobDownload(url, filename);
    }

    function fallbackBlobDownload(url, filename) {
        fetch(url, { mode: "cors" })
            .then((r) => { if (!r.ok) throw new Error("Fetch not ok"); return r.blob(); })
            .then((blob) => {
                const blobUrl = URL.createObjectURL(blob);
                const a = h("a", { a: { href: blobUrl, download: filename } });
                document.body.appendChild(a);
                a.click();
                a.remove();
                URL.revokeObjectURL(blobUrl);
            })
            .catch((err) => {
                console.error("Blob download failed, opening new tab:", err);
                window.open(url, "_blank");
            });
    }

    // ═══════════════════════════════════════════════════════════════
    //  UTILITY
    // ═══════════════════════════════════════════════════════════════

    function convertToCatboxWebp(url) {
        if (cfg("Force_Load_Catbox_Images_As_Webp") && url.includes("catbox.moe")) {
            return `https://images.weserv.nl/?url=${encodeURIComponent(url)}&output=webp`;
        }
        return url;
    }

    const FILE_EXT_RE = /\.(webm|mp4|jpg|jpeg|png|gif)$/i;
    const LINK_RE = /https:\/\/(files|litter)\.(catbox|pixstash)\.moe\/[a-z0-9]+\.(jpg|png|gif|webm|mp4)/g;
    const SOUND_RE = /\[sound=(.+?)\]/;

    function isVideoExt(ext) { return ext === "webm" || ext === "mp4"; }
    function isImageExt(ext) { return ext === "jpg" || ext === "jpeg" || ext === "png" || ext === "gif"; }

    // -- Typed array buffers for PNG chunk extraction --
    const uint8 = new Uint8Array(4);
    const int32 = new Int32Array(uint8.buffer);
    const uint32 = new Uint32Array(uint8.buffer);

    /** Asynchronously upload a URL's file contents to Catbox */
    function uploadUrlToCatbox(url) {
        return new Promise((resolve, reject) => {
            if (!url) return reject("No URL provided");
            if (url.includes("catbox.moe") || url.includes("pixstash.moe")) {
                return resolve(url);
            }
            GM_xmlhttpRequest({
                method: "GET",
                url: url,
                responseType: "blob",
                onload: (response) => {
                    if (response.status !== 200 || !response.response) {
                        reject("Failed to fetch media blob, status: " + response.status);
                        return;
                    }
                    const blob = response.response;
                    let filename = url.split("/").pop().split("?")[0] || "file";
                    if (!filename.includes(".")) {
                        const mime = blob.type;
                        let ext = "bin";
                        if (mime.includes("image/png")) ext = "png";
                        else if (mime.includes("image/jpeg")) ext = "jpg";
                        else if (mime.includes("image/gif")) ext = "gif";
                        else if (mime.includes("video/webm")) ext = "webm";
                        else if (mime.includes("video/mp4")) ext = "mp4";
                        else if (mime.includes("audio/mpeg")) ext = "mp3";
                        filename = `file.${ext}`;
                    }

                    const fd = new FormData();
                    fd.append("reqtype", "fileupload");
                    fd.append("fileToUpload", blob, filename);

                    GM_xmlhttpRequest({
                        method: "POST",
                        url: "https://catbox.moe/user/api.php",
                        data: fd,
                        onload: (uploadResponse) => {
                            const resText = uploadResponse.responseText.trim();
                            if (uploadResponse.status === 200 && resText.startsWith("https://files.catbox.moe/")) {
                                resolve(resText);
                            } else {
                                reject("Catbox upload failed: " + resText);
                            }
                        },
                        onerror: (uploadErr) => {
                            reject("Catbox upload network error: " + uploadErr);
                        }
                    });
                },
                onerror: (err) => {
                    reject("Failed to fetch media network error: " + err);
                }
            });
        });
    }

    /** Trigger background Catbox uploads for a bookmarked item */
    function triggerCatboxUpload(key) {
        const bm = state.bookmarks[key];
        if (!bm) return;

        const mediaNeedsUpload = !bm.url.includes("catbox.moe") && !bm.url.includes("pixstash.moe");
        let soundUrl = bm.soundUrl;
        if (!soundUrl && bm.soundLink) {
            soundUrl = buildAudioSrc([null, bm.soundLink]);
        }
        const soundNeedsUpload = soundUrl && !soundUrl.includes("catbox.moe") && !soundUrl.includes("pixstash.moe");

        if (!mediaNeedsUpload && !soundNeedsUpload) return;

        console.log(`[Catbox Archiver] Starting background upload for bookmark: ${bm.url}`);

        const promises = [];

        if (mediaNeedsUpload) {
            promises.push(
                uploadUrlToCatbox(bm.url)
                    .then((catboxMediaUrl) => {
                        if (state.bookmarks[key]) {
                            state.bookmarks[key].url = catboxMediaUrl;
                            state.bookmarks[key].thumbnailUrl = catboxMediaUrl;
                            setGMValue("galleryBookmarks", JSON.stringify(state.bookmarks));
                        }
                        return catboxMediaUrl;
                    })
            );
        } else {
            promises.push(Promise.resolve(bm.url));
        }

        if (soundNeedsUpload) {
            promises.push(
                uploadUrlToCatbox(soundUrl)
                    .then((catboxSoundUrl) => {
                        if (state.bookmarks[key]) {
                            state.bookmarks[key].soundUrl = catboxSoundUrl;
                            setGMValue("galleryBookmarks", JSON.stringify(state.bookmarks));
                        }
                        return catboxSoundUrl;
                    })
            );
        } else if (soundUrl) {
            promises.push(Promise.resolve(soundUrl));
        }

        Promise.all(promises)
            .then(() => {
                console.log(`[Catbox Archiver] Successfully archived bookmark to Catbox: ${key}`);
                document.dispatchEvent(new CustomEvent("gc-bookmarks-updated"));
            })
            .catch((err) => {
                console.error(`[Catbox Archiver] Error archiving bookmark to Catbox:`, err);
            });
    }

    const loadImage = (blob) => {
        return new Promise((resolve, reject) => {
            const image = new Image();
            image.onload = () => {
                URL.revokeObjectURL(image.src);
                resolve(image);
            };
            image.onerror = reject;
            image.src = URL.createObjectURL(blob);
        });
    };

    function imageHasAlpha(context, canvas) {
        const data = context.getImageData(0, 0, canvas.width, canvas.height).data;
        let hasAlphaPixels = false;
        for (let i = 3, n = data.length; i < n; i += 4) {
            if (data[i] < 255) {
                hasAlphaPixels = true;
                break;
            }
        }
        return hasAlphaPixels;
    }

    function readInfoFromImageStealth(image) {
        let geninfo, paramLen;
        let r, g, b, a;

        const canvas = document.createElement("canvas");
        const [width, height] = [image.width, image.height];
        const context = canvas.getContext("2d");
        canvas.width = image.width;
        canvas.height = image.height;
        context.drawImage(image, 0, 0);

        const imageData = context.getImageData(0, 0, width, height);
        const data = imageData.data;

        let hasAlpha = imageHasAlpha(context, canvas);
        let mode = null;
        let compressed = false;
        let binaryData = "";
        let bufferA = "";
        let bufferRGB = "";
        let indexA = 0;
        let indexRGB = 0;
        let sigConfirmed = false;
        let confirmingSignature = true;
        let readingParamLen = false;
        let readingParam = false;
        let readEnd = false;

        for (let x = 0; x < width; x++) {
            for (let y = 0; y < height; y++) {
                let i = (y * width + x) * 4;

                if (hasAlpha) {
                    [r, g, b, a] = data.slice(i, i + 4);
                    bufferA += (a & 1).toString();
                    indexA++;
                } else {
                    [r, g, b] = data.slice(i, i + 3);
                }
                bufferRGB += (r & 1).toString();
                bufferRGB += (g & 1).toString();
                bufferRGB += (b & 1).toString();
                indexRGB += 3;

                if (confirmingSignature) {
                    if (indexA === "stealth_pnginfo".length * 8) {
                        const decodedSig = new TextDecoder().decode(new Uint8Array(bufferA.match(/\d{8}/g).map(b => parseInt(b, 2))));
                        if (decodedSig === "stealth_pnginfo" || decodedSig === "stealth_pngcomp") {
                            confirmingSignature = false;
                            sigConfirmed = true;
                            readingParamLen = true;
                            mode = "alpha";
                            if (decodedSig === "stealth_pngcomp") {
                                compressed = true;
                            }
                            bufferA = "";
                            indexA = 0;
                        } else {
                            readEnd = true;
                            break;
                        }
                    } else if (indexRGB === "stealth_pnginfo".length * 8) {
                        const decodedSig = new TextDecoder().decode(new Uint8Array(bufferRGB.match(/\d{8}/g).map(b => parseInt(b, 2))));
                        if (decodedSig === "stealth_rgbinfo" || decodedSig === "stealth_rgbcomp") {
                            confirmingSignature = false;
                            sigConfirmed = true;
                            readingParamLen = true;
                            mode = "rgb";
                            if (decodedSig === "stealth_rgbcomp") {
                                compressed = true;
                            }
                            bufferRGB = "";
                            indexRGB = 0;
                        }
                    }
                } else if (readingParamLen) {
                    if (mode === "alpha" && indexA === 32) {
                        paramLen = parseInt(bufferA, 2);
                        readingParamLen = false;
                        readingParam = true;
                        bufferA = "";
                        indexA = 0;
                    } else if (mode !== "alpha" && indexRGB === 33) {
                        paramLen = parseInt(bufferRGB.slice(0, -1), 2);
                        readingParamLen = false;
                        readingParam = true;
                        bufferRGB = bufferRGB.slice(-1);
                        indexRGB = 1;
                    }
                } else if (readingParam) {
                    if (mode === "alpha" && indexA === paramLen) {
                        binaryData = bufferA;
                        readEnd = true;
                        break;
                    } else if (mode !== "alpha" && indexRGB >= paramLen) {
                        const diff = paramLen - indexRGB;
                        if (diff < 0) {
                            bufferRGB = bufferRGB.slice(0, diff);
                        }
                        binaryData = bufferRGB;
                        readEnd = true;
                        break;
                    }
                } else {
                    readEnd = true;
                    break;
                }
            }

            if (readEnd) {
                break;
            }
        }

        if (sigConfirmed && binaryData) {
            const byteData = new Uint8Array(binaryData.match(/\d{8}/g).map(b => parseInt(b, 2)));
            let decodedData;
            if (compressed) {
                decodedData = pako.inflate(byteData, { to: "string" });
            } else {
                decodedData = new TextDecoder().decode(byteData);
            }
            geninfo = decodedData;
        }

        return geninfo;
    }

    function extractChunks(data) {
        if (data[0] !== 0x89) throw new Error("Invalid .png file header");
        if (data[1] !== 0x50) throw new Error("Invalid .png file header");
        if (data[2] !== 0x4E) throw new Error("Invalid .png file header");
        if (data[3] !== 0x47) throw new Error("Invalid .png file header");
        if (data[4] !== 0x0D) throw new Error("Invalid .png file header");
        if (data[5] !== 0x0A) throw new Error("Invalid .png file header");
        if (data[6] !== 0x1A) throw new Error("Invalid .png file header");
        if (data[7] !== 0x0A) throw new Error("Invalid .png file header");

        let ended = false;
        let chunks = [];
        let idx = 8;

        while (idx < data.length) {
            uint8[3] = data[idx++];
            uint8[2] = data[idx++];
            uint8[1] = data[idx++];
            uint8[0] = data[idx++];

            let length = uint32[0] + 4;
            let chunk = new Uint8Array(length);
            chunk[0] = data[idx++];
            chunk[1] = data[idx++];
            chunk[2] = data[idx++];
            chunk[3] = data[idx++];

            let name = (
                String.fromCharCode(chunk[0]) +
                String.fromCharCode(chunk[1]) +
                String.fromCharCode(chunk[2]) +
                String.fromCharCode(chunk[3])
            );

            if (!chunks.length && name !== "IHDR") {
                throw new Error("IHDR header missing");
            }

            if (name === "IEND") {
                ended = true;
                chunks.push({
                    name: name,
                    data: new Uint8Array(0)
                });
                break;
            }

            for (let i = 4; i < length; i++) {
                chunk[i] = data[idx++];
            }

            uint8[3] = data[idx++];
            uint8[2] = data[idx++];
            uint8[1] = data[idx++];
            uint8[0] = data[idx++];

            let chunkData = new Uint8Array(chunk.buffer.slice(4));

            chunks.push({
                name: name,
                data: chunkData
            });
        }

        return chunks;
    }

    function textDecode(data, name = "tEXt") {
        if (data.data && data.name) {
            data = data.data;
        }

        let naming = true;
        let keywordBytes = [];
        let textBytes = [];

        for (let i = 0; i < data.length; i++) {
            const code = data[i];
            if (naming) {
                if (code) {
                    keywordBytes.push(code);
                } else {
                    naming = false;
                }
            } else {
                if (code) {
                    textBytes.push(code);
                }
            }
        }

        const decoder = new TextDecoder(name === "tEXt" ? "latin1" : "utf8");
        return {
            keyword: decoder.decode(new Uint8Array(keywordBytes)),
            text: decoder.decode(new Uint8Array(textBytes)),
        };
    }

    function readUint32(uint8array, offset) {
        let byte1, byte2, byte3, byte4;
        byte1 = uint8array[offset++];
        byte2 = uint8array[offset++];
        byte3 = uint8array[offset++];
        byte4 = uint8array[offset];
        return 0 | (byte1 << 24) | (byte2 << 16) | (byte3 << 8) | byte4;
    }

    function readMetadata(buffer) {
        let result = {};
        const chunks = extractChunks(buffer);
        chunks.forEach(chunk => {
            switch (chunk.name) {
                case "tEXt":
                case "iTXt":
                    if (!result.tEXt) {
                        result.tEXt = {};
                    }
                    let textChunk = textDecode(chunk.data, chunk.name);
                    result.tEXt[textChunk.keyword] = textChunk.text;
                    break;
                case "pHYs":
                    result.pHYs = {
                        "x": readUint32(chunk.data, 0),
                        "y": readUint32(chunk.data, 4),
                        "unit": chunk.data[8],
                    };
                    break;
                default:
                    result[chunk.name] = true;
            }
        });
        return result;
    }

    function parseNaiMetadata(metadata) {
        try {
            const parsed = JSON.parse(metadata);
            return JSON.stringify(parsed, null, 2);
        } catch (e) {
            return metadata;
        }
    }

    function formatMetadataOutput(text) {
        try {
            const parsed = JSON.parse(text);
            return JSON.stringify(parsed, null, 2);
        } catch (e) {
            return text;
        }
    }

    function readPromptMetadata(url) {
        return new Promise((resolve, reject) => {
            if (!url) return reject("No URL provided");
            GM_xmlhttpRequest({
                method: "GET",
                url: url,
                responseType: "blob",
                onload: async (response) => {
                    if (response.status !== 200 || !response.response) {
                        reject("Failed to fetch image data, status: " + response.status);
                        return;
                    }
                    const blob = response.response;
                    try {
                        const arrayBuffer = await blob.arrayBuffer();
                        const uint8Array = new Uint8Array(arrayBuffer);

                        try {
                            const exifMeta = ExifReader.load(arrayBuffer);
                            if (exifMeta && exifMeta.UserComment) {
                                const value = exifMeta.UserComment.value;
                                let rawText;
                                if (Array.isArray(value)) {
                                    const cleanBytes = value.slice(8).filter(b => b !== 0);
                                    rawText = new TextDecoder("utf-8").decode(new Uint8Array(cleanBytes));
                                } else if (typeof value === "string") {
                                    rawText = value;
                                }
                                if (rawText && rawText.trim()) {
                                    return resolve(formatMetadataOutput(rawText));
                                }
                            }
                        } catch (e) {}

                        try {
                            const pngMeta = readMetadata(uint8Array);
                            if (pngMeta && pngMeta.tEXt) {
                                let found = null;
                                if (pngMeta.tEXt.Comment && pngMeta.tEXt.Source) {
                                    found = "Version: " + pngMeta.tEXt.Source + "\n\n" + parseNaiMetadata(pngMeta.tEXt.Comment);
                                } else if (pngMeta.tEXt.Dream) {
                                    found = `${pngMeta.tEXt.Dream} ${pngMeta.tEXt['sd-metadata'] || ''}`;
                                } else if (pngMeta.tEXt.parameters) {
                                    found = pngMeta.tEXt.parameters;
                                } else if (pngMeta.tEXt.prompt || pngMeta.tEXt.workflow) {
                                    found = `prompt\n \n${pngMeta.tEXt.prompt}\n \nworkflow\n \n${pngMeta.tEXt.workflow}`;
                                } else if (pngMeta.tEXt.chara) {
                                    try {
                                        let charaDef = atob(pngMeta.tEXt.chara);
                                        let charaDefJson = JSON.parse(charaDef);
                                        if (charaDefJson && ['name', 'description', 'mes_example', 'first_mes'].every(val => Object.keys(charaDefJson).includes(val))) {
                                            found = `Name: ${charaDefJson['name']}\n \nDescription: ${charaDefJson['description']}\n \nMessage example: ${charaDefJson['mes_example']}\n \nFirst message: ${charaDefJson['first_mes']}`;
                                        }
                                    } catch (_) {}
                                }
                                if (found) {
                                    return resolve(found);
                                }
                            }
                        } catch (e) {}

                        try {
                            const image = await loadImage(blob);
                            const stealth = readInfoFromImageStealth(image);
                            if (stealth) {
                                try {
                                    let stealthMetadata = JSON.parse(stealth);
                                    if (stealthMetadata?.Comment) {
                                        return resolve('Version: ' + stealthMetadata?.Source + '\n\n' + parseNaiMetadata(stealthMetadata['Comment']));
                                    } else if (stealthMetadata?.parameters) {
                                        return resolve(stealthMetadata.parameters);
                                    } else if (stealthMetadata?.prompt || stealthMetadata?.workflow) {
                                        return resolve(`prompt\n \n${stealthMetadata?.prompt}\n \nworkflow\n \n${stealthMetadata?.workflow}`);
                                    }
                                } catch (err) {
                                    return resolve(stealth);
                                }
                            }
                        } catch (e) {}

                        reject("No metadata found in this image.");
                    } catch (err) {
                        reject("Error processing image metadata: " + err);
                    }
                },
                onerror: () => {
                    reject("Failed to fetch image: network error");
                }
            });
        });
    }

    // ═══════════════════════════════════════════════════════════════
    //  MULTI-SITE SCRAPING
    // ═══════════════════════════════════════════════════════════════

    function getPosts(websiteUrl, doc) {
        switch (websiteUrl) {
            case "warosu.org":
                return doc.querySelectorAll(".comment, .highlight");
            case "archived.moe":
            case "archive.palanq.win":
            case "archive.4plebs.org":
            case "desuarchive.org":
            case "thebarchive.com":
            case "archiveofsins.com":
                return doc.querySelectorAll(".post, .thread");
            case "boards.4chan.org":
            case "boards.4channel.org":
            default:
                return doc.querySelectorAll(".postContainer");
        }
    }

    function getDocument(thread, currentUrl) {
        if (thread === currentUrl) return Promise.resolve(document);
        return fetch(thread)
            .then((r) => r.text())
            .then((html) => new DOMParser().parseFromString(html, "text/html"));
    }

    function parsePostData(post, websiteUrl, thread) {
        const data = {
            mediaLink: null,
            fileName: null,
            thumbnailUrl: null,
            comment: null,
            board: null,
            threadID: null,
            postID: null,
            postURL: null,
        };

        switch (websiteUrl) {
            case "warosu.org": {
                const thumb = post.querySelector(".thumb");
                data.fileName = post.querySelector(".fileinfo")?.innerText.split(", ")[2];
                data.thumbnailUrl = thumb?.src;
                data.mediaLink = thumb?.parentNode.href;
                data.comment = post.querySelector("blockquote");
                const tidMatch = thread.match(/thread\/(\d+)/);
                if (tidMatch) {
                    data.threadID = tidMatch[1];
                } else {
                    const jsLink = post.querySelector(".js");
                    data.threadID = jsLink?.href.match(/thread\/(\d+)/)?.[1] || "";
                }
                data.postID = post.id.replace("pc", "").replace("p", "");
                break;
            }
            case "archived.moe":
            case "archive.palanq.win":
            case "archive.4plebs.org":
            case "desuarchive.org":
            case "thebarchive.com":
            case "archiveofsins.com":
                data.thumbnailUrl = post.querySelector(".post_image")?.src;
                data.mediaLink = post.querySelector(".thread_image_link")?.href;
                data.fileName = post.querySelector(".post_file_filename")?.title;
                data.comment = post.querySelector(".text");
                data.threadID = post.querySelector(".post_data > a")?.href.match(/thread\/(\d+)/)?.[1] || "";
                data.postID = post.id;
                break;
            case "boards.4chan.org":
            case "boards.4channel.org":
            default: {
                if (post.querySelector(".fileText")) {
                    if (post.querySelector(".download-button")) {
                        // 4chanX compatibility
                        const dlBtn = post.querySelector(".download-button");
                        data.mediaLink = dlBtn.href;
                        data.fileName = dlBtn.download;
                    } else {
                        let fileTextEl, linkEl;
                        if (post.classList.contains("opContainer")) {
                            linkEl = post.querySelector(".fileText a");
                            fileTextEl = linkEl;
                        } else {
                            fileTextEl = post.querySelector(".fileText");
                            linkEl = fileTextEl.querySelector("a");
                        }
                        if (fileTextEl.title) {
                            data.fileName = fileTextEl.title;
                        } else if (linkEl.title) {
                            data.fileName = linkEl.title;
                        } else {
                            data.fileName = linkEl.innerText;
                        }
                        data.mediaLink = linkEl.href;
                    }
                    data.thumbnailUrl = post.querySelector(".fileThumb img")?.src;
                }
                data.comment = post.querySelector(".postMessage");
                data.threadID = thread.match(/thread\/(\d+)/)?.[1] || "";
                data.postID = post.id.replace("pc", "").replace("p", "");
                break;
            }
        }

        // Clean post URL
        let postURL = thread;
        if (thread.includes("#")) {
            postURL = thread.replace(/#p\d+/, "").replace(/#pc\d+/, "");
        }
        data.postURL = postURL;
        data.board = thread.match(/\/\/[^/]+\/([^/]+)/)?.[1] || "";

        return data;
    }

    // ═══════════════════════════════════════════════════════════════
    //  DRAG & DROP (shared setup)
    // ═══════════════════════════════════════════════════════════════

    function setupCellDrag(cell, gridContainer) {
        cell.draggable = true;
        cell.addEventListener("dragstart", (e) => {
            e.dataTransfer.setData("text/plain", String([...gridContainer.children].indexOf(cell)));
            e.dataTransfer.dropEffect = "move";
            cell.classList.add("gc-cell--dragging");
        });
        cell.addEventListener("dragend", () => {
            cell.classList.remove("gc-cell--dragging");
        });
        cell.addEventListener("dragover", (e) => {
            e.preventDefault();
            e.dataTransfer.dropEffect = "move";
            cell.classList.add("gc-cell--dragover");
        });
        cell.addEventListener("dragleave", () => {
            cell.classList.remove("gc-cell--dragover");
        });
        cell.addEventListener("drop", (e) => {
            e.preventDefault();
            cell.classList.remove("gc-cell--dragover");
            const draggedIdx = parseInt(e.dataTransfer.getData("text/plain"), 10);
            const children = [...gridContainer.children];
            const draggedCell = children[draggedIdx];
            if (draggedCell && draggedCell !== cell) {
                const dropIdx = children.indexOf(cell);
                if (draggedIdx < dropIdx) {
                    gridContainer.insertBefore(draggedCell, cell.nextSibling);
                } else {
                    gridContainer.insertBefore(draggedCell, cell);
                }
            }
        });
    }

    // ═══════════════════════════════════════════════════════════════
    //  AUDIO HELPERS
    // ═══════════════════════════════════════════════════════════════

    function buildAudioSrc(soundMatch) {
        let src = decodeURIComponent(
            soundMatch[1].startsWith("http") ? soundMatch[1] : `https://${soundMatch[1]}`
        );
        if (cfg("Switch_Catbox_To_Pixstash_For_Soundposts")) {
            src = src.replace("catbox.moe", "pixstash.moe");
        }
        return src;
    }

    function syncVideoAudio(video, audio) {
        video.onplay = () => {
            if (!audio.src) audio.src = audio.dataset.src;
            audio.play();
        };
        video.onpause = () => audio.pause();
        let lastT = 0;
        video.addEventListener("timeupdate", () => {
            if (Math.abs(video.currentTime - lastT) >= 2) {
                audio.currentTime = video.currentTime;
            }
            lastT = video.currentTime;
        });
    }

    // ═══════════════════════════════════════════════════════════════
    //  BOOKMARK BUTTON FACTORY
    // ═══════════════════════════════════════════════════════════════

    function createBookmarkBtn(url, postData, gridContainer, refreshBookmarks) {
        const isBookmarkedVal = isBookmarked(url);
        const b = btn(isBookmarkedVal ? "★" : "☆", null, {
            classes: ["gc-btn--icon", "gc-btn--bookmark", isBookmarkedVal ? "gc-btn--bookmark-active" : ""],
            title: isBookmarkedVal ? "Remove Bookmark" : "Bookmark Post",
        });

        function updateVisual(active) {
            b.textContent = active ? "★" : "☆";
            b.title = active ? "Remove Bookmark" : "Bookmark Post";
            b.classList.toggle("gc-btn--bookmark-active", active);
        }

        b.addEventListener("click", (e) => {
            e.stopPropagation();
            const nowBM = toggleBookmark(postData);
            updateVisual(nowBM);
            if (state.currentView === "bookmarks" && refreshBookmarks) {
                refreshBookmarks();
            }
        });

        // Expose update method for external sync
        b._updateVisual = updateVisual;
        return b;
    }

    // ═══════════════════════════════════════════════════════════════
    //  MAIN: loadButton (Gallery init)
    // ═══════════════════════════════════════════════════════════════

    function loadButton() {
        const isArchivePage = window.location.pathname.includes("/archive");

        // ── Page button ──
        const pageBtn = h("button", {
            a: { id: "openImageGallery" },
            txt: "Open Image Gallery",
            s: {
                visibility: cfg("Hide_Gallery_Button") ? "hidden" : "visible",
            },
        });

        let gallery, gridContainer;
        let bookmarksBtnEl, settingsBtnEl, mediaCountEl;
        let modeBarEl, searchBarEl, searchInputEl;

        // ────────────────────────────────────────────────
        //  BUILD GRID CELL (unified for regular + external)
        // ────────────────────────────────────────────────
        function buildCell(mediaLink, thumbnailUrl, fileName, commentText, soundLink, isVideo, isImage, postURL, board, threadID, postID, isExternal, soundUrl) {
            const maxH = cfg("Grid_Cell_Max_Height");
            const cell = h("div", {
                c: ["gc-cell", isExternal ? "gc-cell--external" : ""],
                a: { postURL },
            });
            cell._originalIdx = state.mediaCount;
            setupCellDrag(cell, gridContainer);

            const mediaWrap = h("div", { c: "gc-media-wrap" });
            const controlBar = h("div", { c: "gc-controls" });

            let encodedSoundPostLink = null;
            if (soundLink && board && threadID && postID) {
                encodedSoundPostLink = `https://4chan.mahdeensky.top/${board}/thread/${threadID}/${postID}`;
            }

            if (isVideo) {
                // -- Video cell --
                const isCatbox = /catbox\.moe|pixstash\.moe/.test(mediaLink);
                const thumbTag = isCatbox ? "video" : "img";
                const videoThumb = h(thumbTag, {
                    a: { src: thumbnailUrl, alt: "Video Thumbnail", loading: "lazy" },
                    s: { width: "100%", maxHeight: `${maxH}px`, objectFit: "contain", cursor: "pointer" },
                });

                const video = h("video", {
                    a: {
                        "data-src": mediaLink,
                        controls: "true",
                        fileName,
                        board,
                        threadID,
                        postID,
                        title: commentText,
                    },
                    s: { maxWidth: "100%", maxHeight: `${maxH}px`, objectFit: "contain", cursor: "pointer", display: "none" },
                });

                // Video badge
                const badge = h("div", { c: ["gc-video-badge", (soundLink || soundUrl) ? "gc-sound-badge" : ""], html: (soundLink || soundUrl) ? "♫ SOUND" : "▶ VIDEO" });

                // Thumbnail → video swap on hover
                videoThumb.addEventListener("mouseenter", () => {
                    if (!video.src) video.src = video.dataset.src;
                    videoThumb.style.display = "none";
                    video.style.display = "block";
                    badge.style.display = "none";
                });

                // Auto-play logic for non-sound videos
                if (!soundLink && !soundUrl) {
                    if (state.autoPlayWebms) {
                        video.addEventListener("canplaythrough", () => { video.play(); video.loop = true; }, { once: true });
                    } else if (cfg("Play_Webms_On_Hover")) {
                        video.addEventListener("mouseenter", () => {
                            if (!video.src) video.src = video.dataset.src;
                            video.play();
                        });
                        video.addEventListener("mouseleave", () => video.pause());
                    }
                }

                mediaWrap.append(badge, videoThumb, video);

                // Soundpost audio
                if (soundLink || soundUrl) {
                    const audioSrc = soundUrl || buildAudioSrc(soundLink);
                    const audio = h("audio", { a: { "data-src": audioSrc, encodedSoundPostLink } });
                    videoThumb.addEventListener("mouseenter", () => {
                        if (!audio.src) audio.src = audio.dataset.src;
                    });
                    syncVideoAudio(video, audio);
                    mediaWrap.appendChild(audio);

                    // Reset button
                    controlBar.appendChild(btn("Reset", () => { video.currentTime = 0; audio.currentTime = 0; }));
                }

                // Open button
                controlBar.appendChild(btn("Open", () => window.open(mediaLink, "_blank")));

            } else if (isImage) {
                // -- Image cell --
                if (state.mode !== "all" && !soundLink && !soundUrl) return null; // skip non-sound images in webm mode

                let imgSrc = thumbnailUrl;
                if (cfg("Load_High_Res_Images_By_Default")) imgSrc = mediaLink;
                if (mediaLink.match(/\.gif$/i)) {
                    imgSrc = cfg("Strictly_Load_GIFs_As_Thumbnails_On_Hover") ? thumbnailUrl : mediaLink;
                }

                const image = h("img", {
                    a: {
                        src: convertToCatboxWebp(imgSrc),
                        fileName,
                        actualSrc: mediaLink,
                        thumbnailUrl,
                        board,
                        threadID,
                        postID,
                        loading: "lazy",
                        title: commentText,
                    },
                    s: { maxWidth: "100%", maxHeight: `${maxH}px`, objectFit: "contain", cursor: "pointer" },
                });

                // GIF hover swap (fixed: actually swap between thumbnail and full)
                if (cfg("Strictly_Load_GIFs_As_Thumbnails_On_Hover") && mediaLink.match(/\.gif$/i)) {
                    image.addEventListener("mouseenter", () => { image.src = mediaLink; });
                    image.addEventListener("mouseleave", () => { image.src = thumbnailUrl; });
                }

                mediaWrap.appendChild(image);

                // Soundpost audio for images
                if (soundLink || soundUrl) {
                    const audioSrc = soundUrl || buildAudioSrc(soundLink);
                    const audio = h("audio", { a: { "data-src": audioSrc, loop: "true", encodedSoundPostLink } });
                    mediaWrap.appendChild(audio);

                    image.addEventListener("mouseenter", () => {
                        if (!audio.src) audio.src = audio.dataset.src;
                        audio.play();
                    });
                    image.addEventListener("mouseleave", () => audio.pause());

                    // Badge
                    const badge = h("div", { c: ["gc-video-badge", "gc-sound-badge"], html: "♫ SOUND" });
                    mediaWrap.appendChild(badge);

                    controlBar.appendChild(btn("Play/Pause", () => {
                        audio.paused ? audio.play() : audio.pause();
                    }));
                }
            } else {
                return null;
            }

            // View Post button
            controlBar.appendChild(btn("View Post", () => {
                state.gallerySize = { w: gridContainer.offsetWidth, h: gridContainer.offsetHeight };
                state.lastScrollPos = gridContainer.scrollTop;
                window.location.href = postURL;
                gallery.style.display = "none";
            }));

            // Bookmark
            const soundLinkStr = soundLink ? (Array.isArray(soundLink) ? soundLink[1] : soundLink) : null;
            const bmBtn = createBookmarkBtn(mediaLink, {
                url: mediaLink, thumbnailUrl, commentText, board, threadID, postID,
                postURL, isExternal: !!isExternal,
                soundLink: soundLinkStr,
                soundUrl: soundUrl || null,
            }, gridContainer, () => showBookmarksGrid());
            controlBar.appendChild(bmBtn);

            cell.appendChild(mediaWrap);
            cell.appendChild(controlBar);
            return cell;
        }

        // ────────────────────────────────────────────────
        //  BOOKMARKS GRID
        // ────────────────────────────────────────────────
        function showBookmarksGrid() {
            gridContainer.innerHTML = "";
            const list = Object.values(state.bookmarks);
            if (!list.length) {
                gridContainer.appendChild(h("div", { c: "gc-empty", txt: "No bookmarks saved yet." }));
                return;
            }

            list.forEach((bm, idx) => {
                const isVid = /\.(webm|mp4)$/i.test(bm.url);
                const isImg = /\.(jpg|jpeg|png|gif)$/i.test(bm.url);
                const soundLink = bm.soundLink ? [null, bm.soundLink] : null;

                const cell = buildCell(
                    bm.url,
                    bm.thumbnailUrl || bm.url,
                    bm.url.split("/").pop(),
                    bm.commentText || "",
                    soundLink,
                    isVid,
                    isImg,
                    bm.postURL,
                    bm.board,
                    bm.threadID,
                    bm.postID,
                    bm.isExternal,
                    bm.soundUrl
                );

                if (!cell) return;
                cell._originalIdx = idx;

                const info = h("div", { c: "gc-bm-info" });
                const meta = h("div", { c: "gc-bm-meta" });
                meta.appendChild(h("span", { txt: `ID: ${bm.postID || "External"} | Thread: ${bm.threadID || "N/A"}` }));
                const ts = bm.timestamp || "";
                const tsDisplay = ts.includes("T") ? new Date(ts).toLocaleDateString() : ts.split(",")[0];
                const timeSpan = h("span", { txt: tsDisplay });
                timeSpan.title = `Bookmarked at ${ts}`;
                meta.appendChild(timeSpan);
                info.appendChild(meta);

                if (bm.commentText) {
                    const cmnt = h("div", { c: "gc-bm-comment", txt: bm.commentText });
                    cmnt.title = bm.commentText;
                    info.appendChild(cmnt);
                }

                cell.appendChild(info);
                gridContainer.appendChild(cell);
            });

            // Apply current search query if any
            if (searchInputEl && searchInputEl.value) {
                filterGrid(searchInputEl.value);
            }
        }

        function filterGrid(query) {
            const q = query.toLowerCase().trim();
            const cells = [...gridContainer.children].filter(
                cell => cell.classList.contains("gc-cell")
            );

            if (!q) {
                cells.sort((a, b) => (a._originalIdx || 0) - (b._originalIdx || 0));
                cells.forEach(cell => {
                    cell.style.display = "";
                    gridContainer.appendChild(cell);
                });
                updateMediaCount();
                return;
            }

            cells.forEach(cell => {
                const media = cell.querySelector("img, video");
                if (!media) {
                    cell._isMatch = false;
                    return;
                }
                const fileName = (media.getAttribute("fileName") || "").toLowerCase();
                const comment = (media.title || "").toLowerCase();
                const mediaSrc = (media.getAttribute("actualSrc") || media.dataset.src || media.src || "").toLowerCase();
                cell._isMatch = fileName.includes(q) || comment.includes(q) || mediaSrc.includes(q);
            });

            cells.sort((a, b) => {
                if (a._isMatch && !b._isMatch) return -1;
                if (!a._isMatch && b._isMatch) return 1;
                return (a._originalIdx || 0) - (b._originalIdx || 0);
            });

            let matchCount = 0;
            cells.forEach(cell => {
                gridContainer.appendChild(cell);
                if (cell._isMatch) {
                    cell.style.display = "";
                    matchCount++;
                } else {
                    cell.style.display = "none";
                }
            });

            if (mediaCountEl) {
                mediaCountEl.textContent = `${matchCount} of ${cells.length} media`;
            }
        }

        // ────────────────────────────────────────────────
        //  ZOOM MODE
        // ────────────────────────────────────────────────
        function enterZoomMode(cell) {
            if (state.zoomActive) return;
            state.zoomActive = true;

            // Hide top buttons & search bar
            if (settingsBtnEl) settingsBtnEl.style.display = "none";
            if (bookmarksBtnEl) bookmarksBtnEl.style.display = "none";
            if (modeBarEl) modeBarEl.style.display = "none";
            if (searchBarEl) searchBarEl.style.display = "none";

            // Overlay
            const bg = h("div", { c: "gc-zoom" });

            // Arrow buttons
            if (cfg("Show_Arrow_Buttons_In_Zoom_Mode")) {
                const arrowL = h("button", { c: ["gc-zoom-arrow", "gc-zoom-arrow--left"], txt: "◀" });
                const arrowR = h("button", { c: ["gc-zoom-arrow", "gc-zoom-arrow--right"], txt: "▶" });
                arrowL.addEventListener("click", () => navigate("left"));
                arrowR.addEventListener("click", () => navigate("right"));
                bg.append(arrowL, arrowR);
            }

            // Bottom controls
            const bottomCtrl = h("div", { c: "gc-zoom-controls" });
            bg.appendChild(bottomCtrl);

            // Search buttons group
            const searchGrp = h("div", { c: "gc-zoom-btn-group" });
            searchGrp.appendChild(btn("SauceNAO", () => {
                window.open(`https://saucenao.com/search.php?url=${encodeURIComponent(searchGrp.dataset.mediaLink)}`);
            }));
            searchGrp.appendChild(btn("Google Lens", () => {
                window.open(`https://lens.google.com/uploadbyurl?url=${encodeURIComponent(searchGrp.dataset.mediaLink)}`);
            }));
            searchGrp.appendChild(btn("Yandex", () => {
                window.open(`https://yandex.com/images/search?rpt=imageview&url=${encodeURIComponent(searchGrp.dataset.mediaLink)}`);
            }));

            function showMetadataModal(imageUrl) {
                const overlay = h("div", { c: "gc-meta-overlay" });
                const box = h("div", { c: "gc-meta-box" });

                const title = h("h2", { c: "gc-meta-title" });
                title.appendChild(h("span", { txt: "Prompt / Generation Metadata" }));

                const closeBtn = btn("×", () => overlay.remove(), { classes: ["gc-btn--icon"], attrs: { style: "font-size: 20px; line-height: 1; padding: 4px 8px;" } });
                title.appendChild(closeBtn);

                const content = h("div", { c: "gc-meta-content", txt: "Fetching metadata from original image..." });
                box.append(title, content);
                overlay.appendChild(box);

                const actions = h("div", { s: { display: "flex", justifyContent: "flex-end", gap: "10px" } });
                const copyBtn = btn("Copy Metadata", () => {
                    navigator.clipboard.writeText(content.textContent)
                        .then(() => {
                            copyBtn.textContent = "Copied!";
                            setTimeout(() => { copyBtn.textContent = "Copy Metadata"; }, 2000);
                        })
                        .catch(err => console.error("Failed to copy:", err));
                }, { classes: ["gc-btn--success"] });
                copyBtn.style.display = "none";
                actions.appendChild(copyBtn);
                box.appendChild(actions);

                gallery.appendChild(overlay);

                readPromptMetadata(imageUrl)
                    .then((meta) => {
                        content.textContent = meta;
                        copyBtn.style.display = "inline-flex";
                    })
                    .catch((err) => {
                        content.textContent = String(err);
                    });

                const escHandler = (e) => {
                    if (e.key === "Escape") { overlay.remove(); document.removeEventListener("keydown", escHandler); }
                };
                document.addEventListener("keydown", escHandler);
                overlay.addEventListener("click", (e) => {
                    if (e.target === overlay) overlay.remove();
                });
            }

            const metaBtn = btn("Metadata", () => {
                showMetadataModal(searchGrp.dataset.mediaLink);
            });
            searchGrp.appendChild(metaBtn);

            bottomCtrl.appendChild(searchGrp);

            // Download/nav group
            const dlGrp = h("div", { c: "gc-zoom-btn-group" });

            const viewPostLink = btnLink("View Post");
            dlGrp.appendChild(viewPostLink);

            const dlBtn = btnLink("Download", {
                onClick: () => downloadMedia(dlBtn.href, dlBtn.download),
            });
            dlGrp.appendChild(dlBtn);

            // Zoom bookmark button
            const zoomBmBtn = btn("☆", null, { classes: ["gc-btn--icon", "gc-btn--bookmark"], title: "Bookmark Post" });
            zoomBmBtn.addEventListener("click", () => {
                const src = searchGrp.dataset.mediaLink;
                if (!src) return;
                const media = currentCell.querySelector("img, video");
                const nowBM = toggleBookmark({
                    url: src,
                    thumbnailUrl: src,
                    commentText: titlePill.textContent,
                    board: media?.getAttribute("board") || "",
                    threadID: media?.getAttribute("threadID") || "",
                    postID: media?.getAttribute("postID") || "",
                    postURL: currentCell.getAttribute("postURL") || window.location.href,
                    isExternal: currentCell.classList.contains("gc-cell--external"),
                });
                updateZoomBm(nowBM);

                // Sync grid cell bookmark button
                const cells = [...gridContainer.children];
                const matchCell = cells.find((c) => {
                    const m = c.querySelector("img, video");
                    if (!m) return false;
                    const asrc = m.getAttribute("actualSrc") || m.dataset.src || m.src;
                    if (asrc === src) return true;
                    const keyA = findBookmarkKey(asrc);
                    const keyB = findBookmarkKey(src);
                    return keyA && keyB && keyA === keyB;
                });
                if (matchCell) {
                    const starBtn = matchCell.querySelector(".gc-btn--bookmark");
                    if (starBtn?._updateVisual) starBtn._updateVisual(nowBM);
                }
                if (state.currentView === "bookmarks") showBookmarksGrid();
            });
            dlGrp.appendChild(zoomBmBtn);

            const audioDlBtn = btnLink("Download Audio", {
                onClick: () => downloadMedia(audioDlBtn.href, audioDlBtn.download),
            });
            dlGrp.appendChild(audioDlBtn);

            const encodedBtn = btnLink("Download Encoded Soundpost");
            encodedBtn.target = "_blank";
            dlGrp.appendChild(encodedBtn);

            bottomCtrl.appendChild(dlGrp);

            function updateZoomBm(isBookmarked) {
                zoomBmBtn.textContent = isBookmarked ? "★" : "☆";
                zoomBmBtn.title = isBookmarked ? "Remove Bookmark" : "Bookmark Post";
                zoomBmBtn.classList.toggle("gc-btn--bookmark-active", isBookmarked);
            }

            // Pills
            const indexPill = h("div", { c: ["gc-pill", "gc-pill--index"] });
            const titlePill = h("div", { c: ["gc-pill", "gc-pill--title"] });
            bg.append(indexPill, titlePill);

            let currentCell = cell;

            function showZoomedCell(targetCell) {
                // Cleanup old zoomed media
                const oldVid = gallery.querySelector("#gcZoomVideo");
                if (oldVid) {
                    oldVid.querySelector("audio")?.pause();
                    oldVid.pause();
                    oldVid.remove();
                }
                const oldImg = gallery.querySelector("#gcZoomImage");
                if (oldImg) {
                    oldImg.querySelector("audio")?.pause();
                    if (oldImg._cleanupDrag) oldImg._cleanupDrag();
                    oldImg.remove();
                }

                const video = targetCell.querySelector("video");
                const img = targetCell.querySelector("img");

                if (video) {
                    if (metaBtn) metaBtn.style.display = "none";
                    const z = video.cloneNode(true);
                    z.id = "gcZoomVideo";
                    z.removeAttribute("style");
                    z.classList.add("gc-zoom-media");
                    z.src = z.dataset.src || video.src;
                    z.controls = true;
                    z.preload = "auto";
                    gallery.appendChild(z);

                    let audioEl = targetCell.querySelector("audio");
                    if (audioEl) {
                        audioEl = audioEl.cloneNode(true);
                        syncVideoAudio(z, audioEl);
                        z.appendChild(audioEl);
                    }
                } else if (img) {
                    if (metaBtn) metaBtn.style.display = "inline-flex";
                    const z = img.cloneNode(true);
                    z.id = "gcZoomImage";
                    z.removeAttribute("style");
                    z.classList.add("gc-zoom-media");
                    z.src = z.getAttribute("actualSrc") || img.src;
                    gallery.appendChild(z);

                    // Scroll zoom and pan state
                    let zoomScale = 1.0;
                    let panX = 0;
                    let panY = 0;
                    let isDragging = false;
                    let hasDragged = false;
                    let startX = 0;
                    let startY = 0;

                    const updateTransform = () => {
                        z.style.transform = `translate(calc(-50% + ${panX}px), calc(-50% + ${panY}px)) scale(${zoomScale})`;
                    };

                    z.addEventListener("wheel", (e) => {
                        e.preventDefault();
                        const delta = e.deltaY < 0 ? 1.15 : 1 / 1.15;
                        const nextScale = zoomScale * delta;
                        if (nextScale >= 0.5 && nextScale <= 10) {
                            const mx = e.clientX - window.innerWidth / 2;
                            const my = e.clientY - window.innerHeight / 2;
                            panX = mx - (mx - panX) * delta;
                            panY = my - (my - panY) * delta;
                            zoomScale = nextScale;
                            if (zoomScale <= 1.01) {
                                panX = 0;
                                panY = 0;
                                z.style.cursor = "pointer";
                            } else {
                                z.style.cursor = "grab";
                            }
                            updateTransform();
                        }
                    }, { passive: false });

                    const onMouseMove = (e) => {
                        if (!isDragging) return;
                        const newPanX = e.clientX - startX;
                        const newPanY = e.clientY - startY;
                        if (Math.abs(newPanX - panX) > 2 || Math.abs(newPanY - panY) > 2) {
                            hasDragged = true;
                        }
                        panX = newPanX;
                        panY = newPanY;
                        updateTransform();
                    };

                    const onMouseUp = () => {
                        if (isDragging) {
                            isDragging = false;
                            z.style.cursor = zoomScale > 1.0 ? "grab" : "pointer";
                            window.removeEventListener("mousemove", onMouseMove);
                            window.removeEventListener("mouseup", onMouseUp);
                        }
                    };

                    z.addEventListener("mousedown", (e) => {
                        if (zoomScale <= 1.0) return;
                        e.preventDefault();
                        isDragging = true;
                        hasDragged = false;
                        startX = e.clientX - panX;
                        startY = e.clientY - panY;
                        z.style.cursor = "grabbing";
                        window.addEventListener("mousemove", onMouseMove);
                        window.addEventListener("mouseup", onMouseUp);
                    });

                    z.addEventListener("click", (e) => {
                        e.stopPropagation();
                        if (hasDragged) {
                            hasDragged = false;
                            return;
                        }
                        if (zoomScale > 1.0) {
                            zoomScale = 1.0;
                            panX = 0;
                            panY = 0;
                            z.style.cursor = "pointer";
                            updateTransform();
                        } else {
                            closeZoom();
                        }
                    });

                    z._cleanupDrag = () => {
                        window.removeEventListener("mousemove", onMouseMove);
                        window.removeEventListener("mouseup", onMouseUp);
                    };

                    let audioEl = targetCell.querySelector("audio");
                    if (audioEl) {
                        audioEl = audioEl.cloneNode(true);
                        z.appendChild(audioEl);
                        z.addEventListener("mouseenter", () => {
                            if (!audioEl.src) audioEl.src = audioEl.dataset.src;
                            audioEl.play();
                        });
                        z.addEventListener("mouseleave", () => audioEl.pause());
                    }
                }

                // Update controls
                const media = video || img;
                if (media) {
                    const src = media.getAttribute("actualSrc") || media.dataset.src || media.src;
                    searchGrp.dataset.mediaLink = src;

                    const visibleCells = [...targetCell.parentNode.children].filter(
                        c => c.classList.contains("gc-cell") && c.style.display !== "none"
                    );
                    const idx = visibleCells.indexOf(targetCell) + 1;
                    const total = visibleCells.length;
                    indexPill.textContent = `${idx} / ${total}`;

                    const fName = media.getAttribute("fileName") || src.split("/").pop();
                    titlePill.textContent = fName;

                    const bd = media.getAttribute("board");
                    const tid = media.getAttribute("threadID");
                    const pid = media.getAttribute("postID");
                    if (bd && tid && pid) {
                        viewPostLink.href = `https://boards.4chan.org/${bd}/thread/${tid}#p${pid}`;
                    } else {
                        viewPostLink.href = targetCell.getAttribute("postURL") || window.location.href;
                    }
                    viewPostLink.style.display = "inline-flex";

                    dlBtn.href = src;
                    dlBtn.download = fName;

                    const cellAudio = targetCell.querySelector("audio");
                    if (cellAudio) {
                        const aSrc = cellAudio.dataset.src || cellAudio.src;
                        audioDlBtn.href = aSrc;
                        audioDlBtn.download = aSrc.split("/").pop();
                        audioDlBtn.style.display = "inline-flex";

                        const encLink = cellAudio.getAttribute("encodedSoundPostLink");
                        if (encLink) {
                            encodedBtn.href = encLink;
                            encodedBtn.style.display = "inline-flex";
                        } else {
                            encodedBtn.style.display = "none";
                        }
                    } else {
                        audioDlBtn.style.display = "none";
                        encodedBtn.style.display = "none";
                    }

                    updateZoomBm(isBookmarked(src));
                }
            }

            function navigate(dir) {
                const children = [...gridContainer.children].filter(
                    cell => cell.classList.contains("gc-cell") && cell.style.display !== "none"
                );
                if (!children.length) return;

                let idx = children.indexOf(currentCell);
                if (dir === "left") {
                    idx = idx <= 0 ? children.length - 1 : idx - 1;
                } else {
                    idx = idx >= children.length - 1 ? 0 : idx + 1;
                }
                currentCell = children[idx];
                showZoomedCell(currentCell);
            }

            function closeZoom() {
                const zv = gallery.querySelector("#gcZoomVideo");
                if (zv) { zv.pause(); zv.querySelector("audio")?.pause(); zv.remove(); }
                const zi = gallery.querySelector("#gcZoomImage");
                if (zi) {
                    zi.querySelector("audio")?.pause();
                    if (zi._cleanupDrag) zi._cleanupDrag();
                    zi.remove();
                }
                bg.remove();
                document.removeEventListener("keydown", zoomKeyHandler);
                state.zoomActive = false;

                if (settingsBtnEl) settingsBtnEl.style.display = "";
                if (bookmarksBtnEl) bookmarksBtnEl.style.display = "";
                if (state.currentView !== "bookmarks") {
                    if (modeBarEl) modeBarEl.style.display = "";
                }
                if (searchBarEl) searchBarEl.style.display = "";
            }

            function zoomKeyHandler(e) {
                if (e.key === "ArrowLeft") navigate("left");
                else if (e.key === "ArrowRight") navigate("right");
                else if (e.key === "Escape") closeZoom();
            }

            document.addEventListener("keydown", zoomKeyHandler);

            bg.addEventListener("click", (e) => {
                if (e.target === bg) closeZoom();
            });

            gallery.appendChild(bg);
            showZoomedCell(currentCell);
        }

        // ────────────────────────────────────────────────
        //  SETTINGS MODAL
        // ────────────────────────────────────────────────
        function openSettings() {
            const overlay = h("div", { c: "gc-settings-overlay" });
            const box = h("div", { c: "gc-settings-box" });
            box.appendChild(h("h2", { c: "gc-settings-title", txt: "Settings" }));

            const list = h("ul", { c: "gc-settings-list", a: { id: "gcSettingsList" } });

            for (const key in state.settings) {
                if (!(key in defaultSettings)) continue;
                const item = h("li", { c: "gc-settings-item" });
                const label = h("label", { c: "gc-settings-label", txt: key.replace(/_/g, " ") });
                label.title = state.settings[key].info;
                item.appendChild(label);

                const valType = typeof defaultSettings[key].value;
                const input = h("input", { c: "gc-settings-input" });
                input.dataset.settingKey = key;

                if (valType === "boolean") {
                    input.type = "checkbox";
                    input.checked = state.settings[key].value;
                } else if (valType === "number") {
                    input.type = "number";
                    input.value = state.settings[key].value;
                } else {
                    input.type = "text";
                    input.value = state.settings[key].value;
                }
                item.appendChild(input);
                list.appendChild(item);
            }

            const actions = h("div", { c: "gc-settings-actions" });
            actions.appendChild(btn("Save", () => {
                const newSettings = {};
                for (const k in defaultSettings) newSettings[k] = { ...defaultSettings[k] };

                list.querySelectorAll("input").forEach((inp) => {
                    const k = inp.dataset.settingKey;
                    if (k && k in defaultSettings) {
                        const t = typeof defaultSettings[k].value;
                        if (t === "boolean") {
                            newSettings[k].value = inp.checked;
                        } else if (t === "number") {
                            newSettings[k].value = parseInt(inp.value, 10) || defaultSettings[k].value;
                        } else {
                            newSettings[k].value = inp.value;
                        }
                    }
                });

                saveSettings(newSettings);
                state.settings = newSettings;
                overlay.remove();

                // Update grid in-place
                if (gridContainer) {
                    gridContainer.style.gridTemplateColumns = `repeat(${cfg("Grid_Columns")}, 1fr)`;
                    // Reload to apply cell height and other changes
                    gridContainer.innerHTML = "";
                    loadPosts();
                }
            }, { classes: ["gc-btn--success", "gc-btn--large"] }));

            actions.appendChild(btn("Close", () => overlay.remove(), { classes: ["gc-btn--large"] }));

            box.append(list, actions);
            overlay.appendChild(box);

            // Close on Escape
            const escHandler = (e) => {
                if (e.key === "Escape") { overlay.remove(); document.removeEventListener("keydown", escHandler); }
            };
            document.addEventListener("keydown", escHandler);

            // Close on backdrop click
            overlay.addEventListener("click", (e) => {
                if (e.target === overlay) overlay.remove();
            });

            gallery.appendChild(overlay);
        }

        // ────────────────────────────────────────────────
        //  CHANGELOG POPUP (v4.0 Highlights)
        // ────────────────────────────────────────────────
        function checkChangelog() {
            if (getGMValue("hasSeenChangelog_4_0", false)) return;

            const overlay = h("div", { c: "gc-changelog-overlay" });
            const box = h("div", { c: "gc-changelog-box" });
            box.appendChild(h("h2", { c: "gc-changelog-header", txt: "What's New in Version 4.0!" }));

            const list = h("ul", { c: "gc-changelog-list" });

            const features = [
                { title: "★ Bookmarking & Catbox Archival", desc: "Star any media in the cell controls to save it persistently. Favorites are automatically archived/backed up to Catbox to prevent link rot." },
                { title: "📁 Dedicated Bookmarks Grid", desc: "Toggle between the gallery and your personal bookmarks grid containing full metadata (original thread post link, date, comment, and search)." },
                { title: "🔍 Real-time Search & Filter", desc: "A new search bar in the header lets you instantly filter the media grid by filenames, comments, or URLs." },
                { title: "🤖 AI Image Metadata Viewer", desc: "Examine embedded AI generation parameters (prompts, seeds, CFG, models) from PNG images (supports Stable Diffusion, NovelAI, ComfyUI)." },
                { title: "🔎 Smooth Pan & Zoom Controls", desc: "Use your mouse scroll wheel to zoom in/out on images and click-drag to pan around in zoom mode." },
                { title: "⚙️ Cross-Site Settings", desc: "Settings are now saved securely and persist across all board sections and archive sites using GM_getValue." },
                { title: "⌨️ Keyboard Navigation Enhancements", desc: "Close the gallery or zoom view instantly with the Escape key." }
            ];

            features.forEach(f => {
                const item = h("li", { c: "gc-changelog-item", ch: [
                    h("span", { c: "gc-changelog-title", txt: f.title }),
                    h("span", { c: "gc-changelog-desc", txt: f.desc })
                ]});
                list.appendChild(item);
            });

            box.appendChild(list);

            const dismiss = () => {
                setGMValue("hasSeenChangelog_4_0", true);
                overlay.remove();
            };

            const btnWrap = h("div", { c: "gc-changelog-btn-wrap" });
            btnWrap.appendChild(btn("Got it!", dismiss, { classes: ["gc-btn--success", "gc-btn--large"] }));
            box.appendChild(btnWrap);

            overlay.appendChild(box);

            // Close on Escape
            const escHandler = (e) => {
                if (e.key === "Escape") {
                    dismiss();
                    document.removeEventListener("keydown", escHandler);
                }
            };
            document.addEventListener("keydown", escHandler);

            // Close on backdrop click
            overlay.addEventListener("click", (e) => {
                if (e.target === overlay) dismiss();
            });

            gallery.appendChild(overlay);
        }

        // ────────────────────────────────────────────────
        //  LOAD POSTS
        // ────────────────────────────────────────────────
        function loadPosts() {
            const addFakeImage = cfg("Add_Placeholder_Image_For_Zoom_Mode");
            state.mediaCount = 0;

            // Show spinner
            const spinner = h("div", { c: "gc-spinner", ch: [
                h("div", { c: "gc-spinner-ring" }),
                h("div", { c: "gc-spinner-text", txt: "Loading media..." }),
            ]});
            gridContainer.appendChild(spinner);

            const checkedThreads = isArchivePage
                ? Array.from(document.querySelectorAll(".flashListing input[type='checkbox']:checked"))
                    .map((cb) => cb.parentNode.parentNode.querySelector("a").href)
                : [state.threadURL];

            if (!checkedThreads.length) {
                spinner.remove();
                gridContainer.appendChild(h("div", { c: "gc-empty", txt: "No threads selected." }));
                return;
            }

            let pendingThreads = checkedThreads.length;

            function loadFromThread(thread, useFakeImage) {
                const websiteUrl = thread.replace(/(^\w+:|^)\/\//, "").split("/")[0];

                getDocument(thread, state.threadURL).then((doc) => {
                    let posts = [...getPosts(websiteUrl, doc)];

                    posts.forEach((p) => {
                        p.setAttribute("thread", thread);
                        p.setAttribute("websiteUrl", websiteUrl);
                    });

                    if (useFakeImage) {
                        const placeholderURL = "https://files.catbox.moe/ecl8vh.png";
                        const fake = document.createElement("div");
                        fake.innerHTML = `
                            <div class="postContainer" id="1231232">
                                <div class="fileText">
                                    <a href="${placeholderURL}" download="${placeholderURL}">OpenZoomMode[sound=https://files.catbox.moe/brugtt.mp3].jpg</a>
                                </div>
                                <div class="fileThumb">
                                    <img src="${placeholderURL}" alt="Thumbnail">
                                </div>
                                <div class="postMessage">Just a placeholder image for zoom mode</div>
                            </div>`;
                        fake.setAttribute("thread", "https://boards.4chan.org/b/thread/123456789");
                        fake.setAttribute("websiteUrl", "boards.4chan.org");
                        posts = [fake, ...posts];
                    }

                    // Remove spinner once first thread starts rendering
                    if (spinner.parentNode) spinner.remove();

                    const frag = document.createDocumentFragment();

                    posts.forEach((post) => {
                        const wUrl = post.getAttribute("websiteUrl");
                        const thr = post.getAttribute("thread");
                        const pd = parsePostData(post, wUrl, thr);

                        let hasEmbeddedLinks = false;
                        let embeddedMatches = [];

                        // Determine media type
                        let isVideo = false, isImage = false, soundLink = null, encodedSoundPostLink = null;

                        if (pd.mediaLink) {
                            const ext = pd.mediaLink.match(FILE_EXT_RE)?.[1]?.toLowerCase();
                            isVideo = ext ? isVideoExt(ext) : false;
                            isImage = ext ? isImageExt(ext) : false;
                            if (pd.fileName) soundLink = pd.fileName.match(SOUND_RE);
                        }

                        // Check embedded external links
                        if (cfg("Embed_External_Links") && pd.comment) {
                            embeddedMatches = [...pd.comment.innerText.matchAll(LINK_RE)].map((m) => m[0]);
                            if (embeddedMatches.length > 0 && !pd.mediaLink) {
                                pd.mediaLink = embeddedMatches.shift();
                                pd.fileName = pd.mediaLink.split("/").pop();
                                pd.thumbnailUrl = pd.mediaLink;
                                const ext = pd.mediaLink.match(FILE_EXT_RE)?.[1]?.toLowerCase();
                                isVideo = ext ? isVideoExt(ext) : false;
                                isImage = ext ? isImageExt(ext) : false;
                                if (pd.fileName) soundLink = pd.fileName.match(SOUND_RE);
                            } else if (embeddedMatches.length > 0 && pd.mediaLink) {
                                // Has main media + extra embedded links
                                hasEmbeddedLinks = true;
                            }
                        }

                        if (!pd.mediaLink) return;

                        // Mode filter
                        if (state.mode === "webm" && !(isVideo || (isImage && soundLink))) return;

                        if (soundLink) {
                            encodedSoundPostLink = `https://4chan.mahdeensky.top/${pd.board}/thread/${pd.threadID}/${pd.postID}`;
                        }

                        const postURL = pd.postURL + "#" + post.id;
                        const commentText = pd.comment?.innerText || "";

                        const cell = buildCell(
                            pd.mediaLink, pd.thumbnailUrl, pd.fileName, commentText,
                            soundLink, isVideo, isImage, postURL,
                            pd.board, pd.threadID, pd.postID, false
                        );
                        if (cell) {
                            frag.appendChild(cell);
                            state.mediaCount++;
                        }

                        // Embedded extra links
                        if (hasEmbeddedLinks) {
                            embeddedMatches.forEach((url) => {
                                const ext = url.match(FILE_EXT_RE)?.[1]?.toLowerCase();
                                const ev = ext ? isVideoExt(ext) : false;
                                const ei = ext ? isImageExt(ext) : false;
                                const efn = url.split("/").pop();
                                const esl = efn.match(SOUND_RE);

                                if (state.mode === "webm" && !(ev || (ei && esl))) return;

                                const eCell = buildCell(
                                    url, url, efn, commentText, esl, ev, ei,
                                    postURL, pd.board, pd.threadID, pd.postID, true
                                );
                                if (eCell) {
                                    frag.appendChild(eCell);
                                    state.mediaCount++;
                                }
                            });
                        }
                    });

                    gridContainer.appendChild(frag);
                    updateMediaCount();

                    pendingThreads--;
                    if (pendingThreads <= 0) {
                        if (spinner.parentNode) spinner.remove();
                        if (state.mediaCount === 0) {
                            gridContainer.appendChild(h("div", { c: "gc-empty", txt: "No media found." }));
                        } else if (searchInputEl && searchInputEl.value) {
                            filterGrid(searchInputEl.value);
                        }
                    }
                }).catch((err) => {
                    console.error("Failed to load thread:", thread, err);
                    pendingThreads--;
                    if (pendingThreads <= 0) {
                        if (spinner.parentNode) spinner.remove();
                        if (state.mediaCount === 0) {
                            gridContainer.appendChild(h("div", { c: "gc-empty", txt: "No media found." }));
                        } else if (searchInputEl && searchInputEl.value) {
                            filterGrid(searchInputEl.value);
                        }
                    }
                });
            }

            loadFromThread(checkedThreads[0], addFakeImage);
            checkedThreads.slice(1).forEach((t) => loadFromThread(t, false));
        }

        function updateMediaCount() {
            if (mediaCountEl) {
                mediaCountEl.textContent = `${state.mediaCount} media`;
            }
        }

        // ────────────────────────────────────────────────
        //  OPEN GALLERY
        // ────────────────────────────────────────────────
        function openGallery() {
            // If already exists, just show
            const existing = document.getElementById("imageGallery");
            if (existing) {
                existing.style.display = "flex";
                state.galleryOpen = true;
                return;
            }

            state.galleryOpen = true;
            state.currentView = "gallery";

            document.addEventListener("gc-bookmarks-updated", () => {
                if (state.currentView === "bookmarks" && !state.zoomActive) {
                    showBookmarksGrid();
                }
            });

            gallery = h("div", { a: { id: "imageGallery" }, c: "gc-overlay" });

            gridContainer = h("div", {
                c: "gc-grid",
                s: { gridTemplateColumns: `repeat(${cfg("Grid_Columns")}, 1fr)` },
            });

            // Restore size
            if (state.gallerySize.w > 0 && state.gallerySize.h > 0) {
                gridContainer.style.width = `${state.gallerySize.w}px`;
                gridContainer.style.height = `${state.gallerySize.h}px`;
            }

            // Grid dragover/drop delegation
            gridContainer.addEventListener("dragover", (e) => e.preventDefault());
            gridContainer.addEventListener("drop", (e) => {
                e.preventDefault();
                const draggedIdx = parseInt(e.dataTransfer.getData("text/plain"), 10);
                const target = e.target.closest(".gc-cell");
                if (!target) return;
                const children = [...gridContainer.children];
                const dropIdx = children.indexOf(target);
                if (draggedIdx >= 0 && dropIdx >= 0) {
                    const dragged = children[draggedIdx];
                    if (draggedIdx < dropIdx) {
                        gridContainer.insertBefore(dragged, children[dropIdx].nextSibling);
                    } else {
                        gridContainer.insertBefore(dragged, children[dropIdx]);
                    }
                }
            });

            // Click delegation → zoom mode
            gridContainer.addEventListener("click", (e) => {
                if (e.target.closest("button") || e.target.closest("a")) return;
                const cell = e.target.closest(".gc-cell");
                if (cell) enterZoomMode(cell);
            });

            // ── Top bar: mode toggles ──
            modeBarEl = h("div", { c: "gc-topbar" });

            const toggleModeBtn = btn(`Mode: All`, () => {
                state.mode = state.mode === "all" ? "webm" : "all";
                toggleModeBtn.textContent = state.mode === "all" ? "Mode: All" : "Mode: Webm & Sound";
                gridContainer.innerHTML = "";
                loadPosts();
            }, { classes: ["gc-btn--large"] });
            modeBarEl.appendChild(toggleModeBtn);

            const autoPlayBtn = btn("Auto Play Webms", () => {
                state.autoPlayWebms = !state.autoPlayWebms;
                autoPlayBtn.textContent = state.autoPlayWebms ? "Stop Auto Play" : "Auto Play Webms";
                autoPlayBtn.classList.toggle("gc-btn--primary", state.autoPlayWebms);
                gridContainer.innerHTML = "";
                loadPosts();
            }, { classes: ["gc-btn--large"] });
            modeBarEl.appendChild(autoPlayBtn);

            gallery.appendChild(modeBarEl);

            // ── Top bar center: search bar ──
            searchBarEl = h("div", { c: "gc-topbar-center" });
            searchInputEl = h("input", {
                c: "gc-search-input",
                a: {
                    type: "text",
                    placeholder: "Search comments, filenames, URLs...",
                }
            });
            searchInputEl.addEventListener("input", (e) => {
                filterGrid(e.target.value);
            });
            searchInputEl.addEventListener("keydown", (e) => {
                if (e.key === "Escape") {
                    searchInputEl.value = "";
                    filterGrid("");
                    searchInputEl.blur();
                    e.stopPropagation();
                }
            });
            searchBarEl.appendChild(searchInputEl);
            gallery.appendChild(searchBarEl);

            // ── Top bar right: settings + bookmarks ──
            const topRight = h("div", { c: "gc-topbar-right" });

            bookmarksBtnEl = btn("Bookmarks", () => {
                if (searchInputEl) {
                    searchInputEl.value = "";
                }
                if (state.currentView === "gallery") {
                    state.currentView = "bookmarks";
                    bookmarksBtnEl.textContent = "Show Gallery";
                    bookmarksBtnEl.classList.add("gc-btn--primary");
                    modeBarEl.style.display = "none";
                    showBookmarksGrid();
                } else {
                    state.currentView = "gallery";
                    bookmarksBtnEl.textContent = "Bookmarks";
                    bookmarksBtnEl.classList.remove("gc-btn--primary");
                    modeBarEl.style.display = "flex";
                    gridContainer.innerHTML = "";
                    loadPosts();
                }
            }, { classes: ["gc-btn--large"] });
            topRight.appendChild(bookmarksBtnEl);

            settingsBtnEl = btn("⚙ Settings", openSettings, { classes: ["gc-btn--large"] });
            topRight.appendChild(settingsBtnEl);

            gallery.appendChild(topRight);

            // ── Grid ──
            gallery.appendChild(gridContainer);

            // ── Close button ──
            const closeBtn = btn("Close", () => {
                state.gallerySize = { w: gridContainer.offsetWidth, h: gridContainer.offsetHeight };
                state.galleryOpen = false;
                if (searchInputEl) {
                    searchInputEl.value = "";
                }
                gallery.style.display = "none";
            }, {
                classes: ["gc-btn--large", "gc-float-btn"],
                attrs: { id: "closeGallery" },
            });
            setStyles(closeBtn, { position: "fixed", bottom: "12px", right: "12px", zIndex: "10000" });
            gallery.appendChild(closeBtn);

            // ── Scroll to last ──
            let isScrollToTop = false;
            const scrollBtn = btn("⬇ Scroll to Last", () => {
                if (isScrollToTop) {
                    gridContainer.scrollTo({ top: 0, behavior: "smooth" });
                } else {
                    const last = gridContainer.lastElementChild;
                    if (last) last.scrollIntoView({ behavior: "smooth" });
                }
            }, { classes: ["gc-btn--large", "gc-float-btn"] });
            setStyles(scrollBtn, { position: "fixed", bottom: "12px", left: "12px", zIndex: "10000" });
            gallery.appendChild(scrollBtn);

            const updateScrollBtn = () => {
                const isAtBottom = gridContainer.scrollHeight - gridContainer.scrollTop - gridContainer.clientHeight < 40;
                if (isAtBottom && gridContainer.scrollTop > 100) {
                    scrollBtn.textContent = "⬆ Scroll to Top";
                    isScrollToTop = true;
                } else {
                    scrollBtn.textContent = "⬇ Scroll to Last";
                    isScrollToTop = false;
                }
            };
            gridContainer.addEventListener("scroll", updateScrollBtn);
            updateScrollBtn();

            // ── Media count ──
            mediaCountEl = h("div", { c: "gc-media-count", txt: "0 media" });
            gallery.appendChild(mediaCountEl);

            document.body.appendChild(gallery);

            // Load posts
            loadPosts();

            // Scroll persistence
            const saveScroll = debounce(() => { state.lastScrollPos = gridContainer.scrollTop; }, 100);
            gridContainer.addEventListener("scroll", saveScroll);

            // Restore scroll
            const baseURL = state.threadURL.replace(/#.*$/, "");
            if (window.location.href.includes(baseURL)) {
                requestAnimationFrame(() => {
                    setTimeout(() => {
                        if (state.gallerySize.w > 0 && state.gallerySize.h > 0) {
                            gridContainer.style.width = `${state.gallerySize.w}px`;
                            gridContainer.style.height = `${state.gallerySize.h}px`;
                        }
                        gridContainer.scrollTop = state.lastScrollPos;
                    }, 120);
                });
            } else {
                state.threadURL = window.location.href;
                state.lastScrollPos = 0;
                state.gallerySize = { w: 0, h: 0 };
            }

            // Click outside grid → close
            gallery.addEventListener("click", (e) => {
                if (e.target === gallery) closeBtn.click();
            });

            // Show changelog popup if v4.0 opened for the first time
            checkChangelog();
        }

        pageBtn.addEventListener("click", openGallery);
        document.body.appendChild(pageBtn);

        // ── Archive page checkboxes ──
        if (isArchivePage) {
            const thead = document.querySelector(".flashListing thead tr");
            if (thead) {
                const td = h("td", { c: "postblock", txt: "Selected" });
                thead.insertBefore(td, thead.firstChild);
            }

            document.querySelectorAll(".flashListing tbody tr").forEach((row) => {
                const cb = h("input", { a: { type: "checkbox" } });
                const td = h("td", { ch: cb });
                row.insertBefore(td, row.firstChild);
            });
        }
    }

    // ═══════════════════════════════════════════════════════════════
    //  GLOBAL KEYBINDS
    // ═══════════════════════════════════════════════════════════════

    document.addEventListener("keydown", (e) => {
        // Skip if typing in an input
        if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA" || e.target.isContentEditable) return;

        const key = cfg("Open_Close_Gallery_Key");

        if (e.key === "Escape" && state.galleryOpen && !state.zoomActive) {
            // Close gallery with Escape
            const closeBtn = document.querySelector("#closeGallery");
            if (closeBtn) closeBtn.click();
            return;
        }

        if (e.key === key) {
            // Don't toggle gallery while zoom is active
            if (state.zoomActive) return;

            const g = document.querySelector("#imageGallery");
            if (!g) {
                document.querySelector("#openImageGallery")?.click();
            } else if (g.style.display === "none") {
                document.querySelector("#openImageGallery")?.click();
            } else {
                document.querySelector("#closeGallery")?.click();
            }
        }
    });

    // ═══════════════════════════════════════════════════════════════
    //  INIT
    // ═══════════════════════════════════════════════════════════════

    injectStyles();
    loadButton();
    console.log("4chan Gallery v4.1 loaded successfully!");
})();