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.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==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!");
})();