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.

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Greasemonkey lub Violentmonkey.

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

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Violentmonkey.

Aby zainstalować ten skrypt, wymagana będzie instalacja rozszerzenia Tampermonkey lub Userscripts.

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

Aby zainstalować ten skrypt, musisz zainstalować rozszerzenie menedżera skryptów użytkownika.

(Mam już menedżera skryptów użytkownika, pozwól mi to zainstalować!)

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.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Musisz zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

(Mam już menedżera stylów użytkownika, pozwól mi to zainstalować!)

// ==UserScript==
// @name         4chan Gallery 
// @namespace    http://tampermonkey.net/
// @version      2026-07-19 (4.6)
// @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/*/*
// @match        https://4archive.org/*/*
// @match        https://4chanarchives.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: rgba(24, 26, 31, 0.96);
            display: flex;
            justify-content: center;
            align-items: center;
            z-index: 9999;
            animation: gcFadeIn 0.25s ease-out;
        }
        .gc-overlay.with-blur {
            background: ${T.overlay};
            backdrop-filter: blur(14px);
            -webkit-backdrop-filter: blur(14px);
        }

        /* ── 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: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
                        border-color 0.3s ease,
                        box-shadow 0.3s ease;
            will-change: transform;
            content-visibility: auto;
            contain-intrinsic-size: auto 200px;
        }
        .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,
        .gc-btn:visited {
            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,
        .gc-btn:hover:visited {
            background: ${T.blue};
            color: ${T.dark};
            border-color: ${T.blue};
            box-shadow: 0 4px 14px ${T.blueShadow};
            transform: translateY(-1px);
        }
        .gc-btn--primary,
        .gc-btn--primary:visited {
            background: linear-gradient(135deg, ${T.blue} 0%, #4fa3e8 100%);
            color: ${T.dark};
            border-color: ${T.blue};
            font-weight: 600;
        }
        .gc-btn--primary:hover,
        .gc-btn--primary:hover:visited {
            box-shadow: 0 6px 18px ${T.blueShadow};
            background: ${T.blue};
            color: ${T.dark};
        }
        .gc-btn--success,
        .gc-btn--success:visited {
            background: linear-gradient(135deg, ${T.green} 0%, #8cc41a 100%);
            color: ${T.dark};
            border: none;
            font-weight: 600;
        }
        .gc-btn--success:hover,
        .gc-btn--success:hover:visited {
            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: rgba(24, 26, 31, 0.96);
            z-index: 9999;
            animation: gcFadeIn 0.2s ease-out;
        }
        .gc-zoom.with-blur {
            background: ${T.overlay};
            backdrop-filter: blur(18px);
            -webkit-backdrop-filter: blur(18px);
        }

        /* ── 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: rgba(24, 26, 31, 0.96);
            display: flex;
            justify-content: center;
            align-items: center;
            z-index: 10001;
            animation: gcFadeIn 0.25s ease-out;
        }
        .gc-settings-overlay.with-blur {
            background: ${T.overlay};
            backdrop-filter: blur(12px);
            -webkit-backdrop-filter: blur(12px);
        }
        .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: rgba(24, 26, 31, 0.96);
            display: flex;
            justify-content: center;
            align-items: center;
            z-index: 10002;
            animation: gcFadeIn 0.2s ease-out;
        }
        .gc-meta-overlay.with-blur {
            background: ${T.overlay};
            backdrop-filter: blur(12px);
            -webkit-backdrop-filter: blur(12px);
        }
        .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: rgba(24, 26, 31, 0.96);
            display: flex;
            justify-content: center;
            align-items: center;
            z-index: 10005;
            animation: gcFadeIn 0.25s ease-out;
        }
        .gc-changelog-overlay.with-blur {
            background: ${T.overlay};
            backdrop-filter: blur(12px);
            -webkit-backdrop-filter: blur(12px);
        }
        .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)",
        },
        Enable_Backdrop_Blur: {
            value: true,
            info: "Enable glassmorphism backdrop blur on overlays (disable for better performance)",
        },
    };

    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,
    };

    const parsedThreadsCache = new Map(); // threadURL -> { postCount, htmlText } or similar

    // 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) {
        const canvas = document.createElement("canvas");
        const w = image.width;
        const h = image.height;
        const ctx = canvas.getContext("2d");
        canvas.width = w;
        canvas.height = h;
        ctx.drawImage(image, 0, 0);

        const imgData = ctx.getImageData(0, 0, w, h);
        const data = imgData.data;

        // Check if there is an alpha channel
        let hasAlpha = false;
        for (let i = 3; i < data.length; i += 4) {
            if (data[i] < 255) {
                hasAlpha = true;
                break;
            }
        }

        let result = null;
        if (hasAlpha) {
            result = decodeStealth(data, w, h, true);
        }
        if (!result) {
            result = decodeStealth(data, w, h, false);
        }
        return result;
    }

    function decodeStealth(data, w, h, isAlphaMode) {
        let byteBuffer = new Uint8Array(1024);
        let byteCount = 0;
        let currentByte = 0;
        let bitCount = 0;
        
        let modeConfirmed = false;
        let compressed = false;
        let targetBits = 0;
        
        const decoder = new TextDecoder();

        outerLoop:
        for (let x = 0; x < w; x++) {
            for (let y = 0; y < h; y++) {
                const idx = (y * w + x) * 4;
                
                let bits = [];
                if (isAlphaMode) {
                    bits.push(data[idx + 3] & 1); // Alpha LSB
                } else {
                    bits.push(data[idx] & 1);     // R LSB
                    bits.push(data[idx + 1] & 1); // G LSB
                    bits.push(data[idx + 2] & 1); // B LSB
                }
                
                for (let b = 0; b < bits.length; b++) {
                    currentByte = (currentByte << 1) | bits[b];
                    bitCount++;
                    
                    if (bitCount === 8) {
                        if (byteCount >= byteBuffer.length) {
                            const newBuffer = new Uint8Array(byteBuffer.length * 2);
                            newBuffer.set(byteBuffer);
                            byteBuffer = newBuffer;
                        }
                        byteBuffer[byteCount++] = currentByte;
                        currentByte = 0;
                        bitCount = 0;
                        
                        // Check signature at 15 bytes (120 bits)
                        if (!modeConfirmed && byteCount === 15) {
                            const sig = decoder.decode(byteBuffer.subarray(0, 15));
                            if (isAlphaMode) {
                                if (sig === "stealth_pnginfo") { modeConfirmed = true; compressed = false; }
                                else if (sig === "stealth_pngcomp") { modeConfirmed = true; compressed = true; }
                                else return null;
                            } else {
                                if (sig === "stealth_rgbinfo") { modeConfirmed = true; compressed = false; }
                                else if (sig === "stealth_rgbcomp") { modeConfirmed = true; compressed = true; }
                                else return null;
                            }
                            byteCount = 0;
                        }
                        
                        // Check length at 4 bytes (32 bits) after signature
                        if (modeConfirmed && targetBits === 0 && byteCount === 4) {
                            targetBits = (byteBuffer[0] << 24) | (byteBuffer[1] << 16) | (byteBuffer[2] << 8) | byteBuffer[3];
                            byteCount = 0;
                        }
                    }
                    
                    if (targetBits > 0 && (byteCount * 8 + bitCount) === targetBits) {
                        break outerLoop;
                    }
                }
            }
        }
        
        if (!modeConfirmed || targetBits === 0) return null;
        
        let finalBytesCount = Math.ceil(targetBits / 8);
        if (bitCount > 0 && byteCount < finalBytesCount) {
            byteBuffer[byteCount] = currentByte << (8 - bitCount);
            byteCount++;
        }
        
        const payloadBytes = byteBuffer.subarray(0, finalBytesCount);
        try {
            return compressed ? pako.inflate(payloadBytes, { to: "string" }) : decoder.decode(payloadBytes);
        } catch (e) {
            console.error("Stealth decode error:", e);
            return null;
        }
    }

    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(/^[a-zA-Z_]+/, "");
                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.replace(/^[a-zA-Z_]+/, "");
                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(/^[a-zA-Z_]+/, "");
                break;
            }
        }

        // Clean post URL and append universal #p{postID} fragment
        const cleanThread = thread.split("#")[0];
        data.postURL = `${cleanThread.replace(/\/$/, "")}/#p${data.postID}`;
        const boardMatch = cleanThread.match(/\/board\/([^/]+)/);
        if (boardMatch) {
            data.board = boardMatch[1];
        } else {
            data.board = cleanThread.match(/\/\/[^/]+\/([^/]+)/)?.[1] || "";
        }

        return data;
    }

    // ═══════════════════════════════════════════════════════════════
    //  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) {
        const sync = () => {
            if (!audio.seeking) {
                audio.currentTime = video.currentTime;
            }
        };

        video.addEventListener("play", () => {
            if (!audio.src) audio.src = audio.dataset.src;
            if (!audio.seeking) {
                audio.currentTime = video.currentTime;
            }
            audio.play().catch(() => {});
        });
        video.addEventListener("pause", () => {
            audio.pause();
        });
        
        video.addEventListener("seeking", sync);
        video.addEventListener("seeked", sync);
        
        // Handle video buffering
        video.addEventListener("waiting", () => {
            audio.pause();
        });
        video.addEventListener("playing", () => {
            if (!audio.seeking) {
                audio.currentTime = video.currentTime;
            }
            if (!video.paused) {
                audio.play().catch(() => {});
            }
        });

        // Ensure audio loops/resumes if ended or paused during video playback
        video.addEventListener("timeupdate", () => {
            if (!video.paused && audio.paused && !audio.seeking) {
                if (Math.abs(video.currentTime - audio.currentTime) > 1.0) {
                    audio.currentTime = video.currentTime;
                }
                audio.play().catch(() => {});
            }
        });
    }

    // ═══════════════════════════════════════════════════════════════
    //  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(item) {
            const { mediaLink, thumbnailUrl, fileName, commentText, soundLink, isVideo, isImage, postURL, board, threadID, postID, isExternal, soundUrl } = item;
            const maxH = cfg("Grid_Cell_Max_Height");
            const cell = h("div", {
                c: ["gc-cell", isExternal ? "gc-cell--external" : ""],
                a: { postURL },
            });
            cell.draggable = true;
            cell._originalIdx = state.mediaCount;

            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, {
                    c: "gc-thumbnail",
                    a: { src: thumbnailUrl, alt: "Video Thumbnail", loading: "lazy", referrerpolicy: "no-referrer" },
                    s: { width: "100%", maxHeight: `${maxH}px`, objectFit: "contain", cursor: "pointer" },
                });

                const video = h("video", {
                    c: "gc-main-video",
                    a: {
                        "data-src": mediaLink,
                        controls: "true",
                        fileName,
                        board,
                        threadID,
                        postID,
                        title: commentText,
                        referrerpolicy: "no-referrer",
                    },
                    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" });
                mediaWrap.append(badge, videoThumb, video);

                // Soundpost audio
                if (soundLink || soundUrl) {
                    const audioSrc = soundUrl || buildAudioSrc(soundLink);
                    const audio = h("audio", { a: { "data-src": audioSrc, encodedSoundPostLink } });
                    mediaWrap.appendChild(audio);
                    syncVideoAudio(video, 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;
                
                const isGif = mediaLink.match(/\.gif$/i) && cfg("Strictly_Load_GIFs_As_Thumbnails_On_Hover");
                if (mediaLink.match(/\.gif$/i) && !cfg("Strictly_Load_GIFs_As_Thumbnails_On_Hover")) imgSrc = mediaLink;

                const image = h("img", {
                    c: ["gc-thumbnail", isGif ? "gc-gif" : ""],
                    a: {
                        src: convertToCatboxWebp(imgSrc),
                        fileName,
                        actualSrc: mediaLink,
                        thumbnailUrl,
                        board,
                        threadID,
                        postID,
                        loading: "lazy",
                        title: commentText,
                        referrerpolicy: "no-referrer",
                    },
                    s: { maxWidth: "100%", maxHeight: `${maxH}px`, objectFit: "contain", cursor: "pointer" },
                });

                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);

                    // 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;
                
                const hash = postURL.includes("#") ? postURL.split("#").pop() : "";
                const postNum = hash ? hash.replace(/^[a-z_]+/, "") : "";
                
                const targetEl = document.getElementById(postNum) || 
                                 document.getElementById("p" + postNum) || 
                                 document.getElementById("p_" + postNum) ||
                                 document.getElementsByName("p" + postNum)[0];
                                 
                if (targetEl) {
                    targetEl.scrollIntoView({ behavior: "smooth", block: "center" });
                    window.history.replaceState(null, null, postURL);
                } else {
                    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({
                    mediaLink: bm.url,
                    thumbnailUrl: bm.thumbnailUrl || bm.url,
                    fileName: bm.url.split("/").pop(),
                    commentText: bm.commentText || "",
                    soundLink: soundLink,
                    isVideo: isVid,
                    isImage: isImg,
                    postURL: bm.postURL,
                    board: bm.board,
                    threadID: bm.threadID,
                    postID: bm.postID,
                    isExternal: bm.isExternal,
                    soundUrl: bm.soundUrl || null
                });

                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;

            // Pause all background grid media to prevent double playback clash
            gridContainer.querySelectorAll("video, audio").forEach((el) => {
                try { el.pause(); } catch(e) {}
            });

            // 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", cfg("Enable_Backdrop_Blur") ? "with-blur" : ""] });

            // 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", cfg("Enable_Backdrop_Blur") ? "with-blur" : ""] });
                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", {
                onClick: () => {
                    const postURL = viewPostLink.href;
                    if (!postURL || postURL === "#" || postURL.startsWith("javascript:")) return;
                    
                    const hash = postURL.includes("#") ? postURL.split("#").pop() : "";
                    const postNum = hash ? hash.replace(/^[a-z_]+/, "") : "";
                    
                    const targetEl = document.getElementById(postNum) || 
                                     document.getElementById("p" + postNum) || 
                                     document.getElementById("p_" + postNum) ||
                                     document.getElementsByName("p" + postNum)[0];
                                     
                    if (targetEl) {
                        targetEl.scrollIntoView({ behavior: "smooth", block: "center" });
                        window.history.replaceState(null, null, postURL);
                    } else {
                        window.location.href = postURL;
                    }
                    closeZoom();
                    const closeBtn = document.querySelector("#closeGallery");
                    if (closeBtn) closeBtn.click();
                }
            });
            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(".gc-main-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";
                    z.setAttribute("referrerpolicy", "no-referrer");
                    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;
                    z.setAttribute("referrerpolicy", "no-referrer");
                    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;

                    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", cfg("Enable_Backdrop_Blur") ? "with-blur" : ""] });
            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", cfg("Enable_Backdrop_Blur") ? "with-blur" : ""] });
            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 finishThreadLoad() {
                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);
                    }
                }
            }

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

                // Caching check
                const cached = parsedThreadsCache.get(thread);
                const isLocal = (thread === state.threadURL);
                
                let isCacheValid = false;
                let currentPostCount = 0;
                if (cached) {
                    if (isLocal) {
                        currentPostCount = getPosts(websiteUrl, document).length;
                        if (cached.postCount === currentPostCount) {
                            isCacheValid = true;
                        }
                    } else {
                        isCacheValid = true;
                    }
                }

                if (isCacheValid) {
                    if (spinner.parentNode) spinner.remove();
                    const frag = document.createDocumentFragment();
                    cached.data.forEach((item) => {
                        if (state.mode === "webm" && !(item.isVideo || (item.isImage && item.soundLink))) return;
                        
                        const cell = buildCell(item);
                        if (cell) {
                            frag.appendChild(cell);
                            state.mediaCount++;
                        }
                    });
                    gridContainer.appendChild(frag);
                    updateMediaCount();
                    finishThreadLoad();
                    return;
                }

                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];
                    }

                    if (spinner.parentNode) spinner.remove();

                    const parsedList = [];
                    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 = [];

                        let isVideo = false, isImage = false, soundLink = 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);
                        }

                        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) {
                                hasEmbeddedLinks = true;
                            }
                        }

                        if (!pd.mediaLink) return;

                        const commentText = pd.comment?.innerText || "";
                        
                        const mainItem = {
                            mediaLink: pd.mediaLink,
                            thumbnailUrl: pd.thumbnailUrl,
                            fileName: pd.fileName,
                            commentText,
                            soundLink,
                            isVideo,
                            isImage,
                            postURL: pd.postURL,
                            board: pd.board,
                            threadID: pd.threadID,
                            postID: pd.postID,
                            isExternal: false,
                            soundUrl: pd.soundUrl || null
                        };
                        parsedList.push(mainItem);

                        if (state.mode === "all" || (isVideo || (isImage && soundLink))) {
                            const cell = buildCell(mainItem);
                            if (cell) {
                                frag.appendChild(cell);
                                state.mediaCount++;
                            }
                        }

                        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);

                                const embedItem = {
                                    mediaLink: url,
                                    thumbnailUrl: url,
                                    fileName: efn,
                                    commentText,
                                    soundLink: esl,
                                    isVideo: ev,
                                    isImage: ei,
                                    postURL: pd.postURL,
                                    board: pd.board,
                                    threadID: pd.threadID,
                                    postID: pd.postID,
                                    isExternal: true,
                                    soundUrl: null
                                };
                                parsedList.push(embedItem);

                                if (state.mode === "all" || (ev || (ei && esl))) {
                                    const eCell = buildCell(embedItem);
                                    if (eCell) {
                                        frag.appendChild(eCell);
                                        state.mediaCount++;
                                    }
                                }
                            });
                        }
                    });

                    // Store in cache
                    parsedThreadsCache.set(thread, {
                        postCount: posts.length,
                        data: parsedList
                    });

                    gridContainer.appendChild(frag);
                    updateMediaCount();
                    finishThreadLoad();
                }).catch((err) => {
                    console.error("Failed to load thread:", thread, err);
                    finishThreadLoad();
                });
            }

            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", cfg("Enable_Backdrop_Blur") ? "with-blur" : ""] });

            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`;
            }

            // Event delegation: Drag & Drop reordering
            gridContainer.addEventListener("dragstart", (e) => {
                const cell = e.target.closest(".gc-cell");
                if (!cell) return;
                e.dataTransfer.setData("text/plain", String([...gridContainer.children].indexOf(cell)));
                e.dataTransfer.dropEffect = "move";
                cell.classList.add("gc-cell--dragging");
                gridContainer._draggedCell = cell;
            });
            gridContainer.addEventListener("dragend", (e) => {
                const cell = e.target.closest(".gc-cell");
                if (cell) cell.classList.remove("gc-cell--dragging");
                gridContainer._draggedCell = null;
            });
            gridContainer.addEventListener("dragover", (e) => {
                e.preventDefault();
                const cell = e.target.closest(".gc-cell");
                if (!cell || cell === gridContainer._draggedCell) return;
                e.dataTransfer.dropEffect = "move";
                cell.classList.add("gc-cell--dragover");
            });
            gridContainer.addEventListener("dragleave", (e) => {
                const cell = e.target.closest(".gc-cell");
                if (cell) cell.classList.remove("gc-cell--dragover");
            });
            gridContainer.addEventListener("drop", (e) => {
                e.preventDefault();
                const cell = e.target.closest(".gc-cell");
                if (cell) 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 && cell) {
                    const dropIdx = children.indexOf(cell);
                    if (draggedIdx < dropIdx) {
                        gridContainer.insertBefore(draggedCell, cell.nextSibling);
                    } else {
                        gridContainer.insertBefore(draggedCell, cell);
                    }
                }
            });

            // Event delegation: Hover play/pause/swap
            gridContainer.addEventListener("mouseover", (e) => {
                const cell = e.target.closest(".gc-cell");
                if (!cell) return;

                // WebM hover auto-play
                const video = cell.querySelector(".gc-main-video");
                const videoThumb = cell.querySelector(".gc-thumbnail");
                const badge = cell.querySelector(".gc-video-badge");
                const audio = cell.querySelector("audio");

                if (video && videoThumb && videoThumb.style.display !== "none") {
                    if (!video.src) video.src = video.dataset.src;
                    videoThumb.style.display = "none";
                    video.style.display = "block";
                    if (badge) badge.style.display = "none";

                    if (state.autoPlayWebms || cfg("Play_Webms_On_Hover")) {
                        video.loop = true;
                        video.play().catch(()=>{});
                    }
                }

                // GIF hover swap
                const image = cell.querySelector("img.gc-gif");
                if (image) {
                    const actualSrc = image.getAttribute("actualSrc");
                    if (actualSrc && image.src !== actualSrc) {
                        image.src = actualSrc;
                    }
                }

                // Audio play for images on hover
                if (audio && !video) {
                    if (!audio.src) audio.src = audio.dataset.src;
                    audio.play().catch(()=>{});
                }
            });

            gridContainer.addEventListener("mouseout", (e) => {
                const cell = e.target.closest(".gc-cell");
                if (!cell) return;
                
                if (e.relatedTarget && cell.contains(e.relatedTarget)) return;

                const video = cell.querySelector(".gc-main-video");
                const videoThumb = cell.querySelector(".gc-thumbnail");
                const badge = cell.querySelector(".gc-video-badge");
                const audio = cell.querySelector("audio");

                if (video) {
                    if (!state.autoPlayWebms) {
                        if (videoThumb && video.style.display !== "none") {
                            video.pause();
                            video.style.display = "none";
                            videoThumb.style.display = "block";
                            if (badge) badge.style.display = "flex";
                        }
                        if (audio) {
                            audio.pause();
                        }
                    }
                } else {
                    // GIF swap back for image cells
                    const image = cell.querySelector("img.gc-gif");
                    if (image) {
                        const thumbnailUrl = image.getAttribute("thumbnailUrl");
                        if (thumbnailUrl && image.src !== thumbnailUrl) {
                            image.src = thumbnailUrl;
                        }
                    }

                    // Audio pause for image soundposts
                    if (audio) {
                        audio.pause();
                    }
                }
            });

            // 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...",
                }
            });
            const debouncedSearch = debounce((query) => {
                filterGrid(query);
            }, 300);
            searchInputEl.addEventListener("input", (e) => {
                debouncedSearch(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.6 loaded successfully!");
})();