Javdb 增强脚本

增强 Javdb 浏览体验:热力图高亮、热度排序、隐藏低分、列表页管理“已看/想看”、点击抓取并预览大图。兼容自动翻页脚本。

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey, Greasemonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name Javdb 增强脚本
// @name:zh Javdb 增强脚本
// @name:en Javdb Enhanced Script
// @namespace http://tampermonkey.net/
// @version 2.5.1
// @icon https://javdb.com/favicon-32x32.png
// @description 增强 Javdb 浏览体验:热力图高亮、热度排序、隐藏低分、列表页管理“已看/想看”、点击抓取并预览大图。兼容自动翻页脚本。
// @description:zh 增强 Javdb 浏览体验:热力图高亮、热度排序、隐藏低分、列表页管理“已看/想看”、点击抓取并预览大图。兼容自动翻页脚本。
// @description:en Enhances Javdb: heatmap highlighting, sort by heat, hide low score, list-page status (Watched/Want), click-to-fetch preview image. Compatible with auto-paging scripts.
// @author 黄页大嫖客 (Modified by Gemini), Refined by Assistant
// @match https://javdb*.com/*
// @grant GM_addStyle
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @connect javdb*.com
// @connect javstore.net
// @license GPLv3
// @homeURL https://sleazyfork.org/zh-CN/scripts/539525
// @supportURL https://sleazyfork.org/zh-CN/scripts/539525/feedback
// ==/UserScript==

(function() {
    'use strict';

    const getLang = () => {
        const pageLang = document.body?.dataset?.lang;
        if (pageLang === 'zh' || pageLang === 'en') return pageLang;
        return (navigator.language || navigator.userLanguage || 'zh').toLowerCase().startsWith('zh') ? 'zh' : 'en';
    };
    const lang = getLang();

    const I18N = {
        zh: {
            settings: '设置',
            highlightFeature: '启用高亮功能',
            sortFeature: '启用排序功能',
            hideFeature: '启用隐藏低分影片',
            hideFeatureDesc: '(隐藏排序分低于 C_MIN 的影片)',
            statusFeature: '启用状态标记功能',
            previewFeature: '启用预览图显示功能',
            previewPrivacyNote: '(将向第三方 javstore.net 发送番号以抓取预览图)',
            previewConsentTitle: '第三方请求提示',
            previewConsentBody: '启用“预览图”后,脚本会将影片番号发送至第三方网站(javstore.net)以抓取预览。是否同意?',
            previewConsentAgree: '同意并继续',
            previewConsentCancel: '取消',
            delegationFeature: '启用事件委托(性能模式)',
            hideOnMouseLeave: '移开鼠标隐藏状态图标',
            heatmapMin: '热力图最低分 (C)',
            heatmapMax: '热力图最高分 (C_MAX)',
            heatmapCurveFactor: '热力图曲线因子 (<1 拉伸低分, >1 拉伸高分)',
            ratedByThreshold: '基准人数 (m)',
            reset: '重置热力图',
            resetDone: '已重置为默认值',
            sort: '排序',
            unmarked: '暂未标记',
            want: '想看',
            watched: '已看',
            modify: '修改',
            delete: '删除',
            confirmDelete: '确认删除标记?',
            confirm: '确认',
            cancel: '取消',
            preview: '查看预览图',
            noPreview: '无可用预览图',
            loadingPreview: '正在获取预览图...',
            errorNeedLogin: '需要登录后才能标记状态',
            errorNetworkBusy: '网络繁忙,请稍后重试',
            errorCsrf: '无法获取安全令牌,请刷新页面后重试',
            previewConsentRequired: '需同意第三方请求后才能抓取预览',
            previewClickToFetch: '点击以抓取预览',
            errorPreview: '获取失败 (可重试)',
            tooltipHeatmapMin: '热力图的起始分 (0%)。\n低分和少评分的影片会趋向此值。\n也是隐藏低分的阈值。',
            tooltipHeatmapMax: '热力图的最高分 (100%)。\n达到此值的影片会显示为最热的红色。\n仅影响颜色,不影响排序。',
            tooltipRatedBy: '系统的“自信度”,代表“虚拟评分人数”。\n值越高,系统越“保守”,需要更多真实评分才能摆脱基准分。',
            tooltipHeatmapCurve: '调整颜色渐变曲线。\n<1: 拉开中低分(蓝/绿)的差异。\n>1: 拉开高分(黄/红)的差异。\n=1: 线性过渡。',
        },
        en: {
            settings: 'Settings',
            highlightFeature: 'Enable Highlight Feature',
            sortFeature: 'Enable Sort Feature',
            hideFeature: 'Enable Hide Low Score Videos',
            hideFeatureDesc: '(Hide items with sort score below C_MIN)',
            statusFeature: 'Enable Status Tag Feature',
            previewFeature: 'Enable Preview Image Feature',
            previewPrivacyNote: '(Sends the code to third-party javstore.net to fetch previews)',
            previewConsentTitle: 'Third-party Request Notice',
            previewConsentBody: 'When enabled, the script sends the video code to a third-party site (javstore.net) to fetch previews. Do you consent?',
            previewConsentAgree: 'Agree and continue',
            previewConsentCancel: 'Cancel',
            delegationFeature: 'Enable event delegation (Performance mode)',
            hideOnMouseLeave: 'Hide status icons on mouse leave',
            heatmapMin: 'Heatmap Min Score (C)',
            heatmapMax: 'Heatmap Max Score (C_MAX)',
            heatmapCurveFactor: 'Heatmap Curve (<1 stretches low, >1 stretches high)',
            ratedByThreshold: 'Rated-By Threshold (m)',
            reset: 'Reset Heatmap',
            resetDone: 'Reset to defaults',
            sort: 'Sort',
            unmarked: 'Unmarked',
            want: 'Want',
            watched: 'Watched',
            modify: 'Modify',
            delete: 'Delete',
            confirmDelete: 'Confirm Delete?',
            confirm: 'Confirm',
            cancel: 'Cancel',
            preview: 'View Preview Image',
            noPreview: 'No Preview Available',
            loadingPreview: 'Loading preview...',
            errorNeedLogin: 'Login required to set status',
            errorNetworkBusy: 'Network busy, please try again later',
            errorCsrf: 'Failed to get security token, please refresh the page',
            previewConsentRequired: 'Consent required before fetching preview',
            previewClickToFetch: 'Click to fetch preview',
            errorPreview: 'Fetch failed (Retryable)',
            tooltipHeatmapMin: "The starting score (0%) for the heatmap.\nLow-score and low-rated items are pulled towards this value.\nAlso the threshold for hiding items.",
            tooltipHeatmapMax: "The 'perfect score' (100%) for the heatmap.\nItems at this score appear bright red.\nOnly affects color, not sorting.",
            tooltipRatedBy: "System 'confidence', a 'virtual rating count'.\nHigher value = more 'conservative' (needs more real ratings to trust the score).",
            tooltipHeatmapCurve: "Adjusts the color curve.\n<1: Emphasizes differences in the low-mid range (blue/green).\n>1: Emphasizes differences in the high range (yellow/red).\n=1: Linear.",
        }
    };
    const T = (key) => I18N[lang]?.[key] ?? I18N['zh'][key];

    // --- Config & Globals ---
    const SETTINGS_KEY = 'JavdbEnhanced_Settings';
    const DEFAULT_NAVBAR_HEIGHT = 58;
    const PREVIEW_MODAL_MIN_SCALE = 0.2;
    const PREVIEW_MODAL_MAX_SCALE = 8;
    const PREVIEW_ICON_INACTIVE_OPACITY = 0.55;
    const MAX_CACHE_SIZE = 500;
    const ITEM_SELECTOR = '.movie-list .item, .is-user-page .column.is-one-quarter';

    const scoreRegexes = [
        /([\d.]+)\s*\/\s*5[^\d]*?(\d+)/,
        /([\d.]+)[^\d]+(\d+)/,
        /评分[::]\s*([\d.]+)[^\d]+(\d+)/
    ];

    const DEFAULT_SETTINGS = {
        highlight: true,
        sort: true,
        hideLowScore: false,
        status: true,
        enablePreview: true,
        hideOnMouseLeave: true,
        heatmapMin: 3.75,
        heatmapMax: 4.75,
        heatmapCurveFactor: 0.5,
        ratedByThreshold: 200,
        fetchDelay: 300,
        useEventDelegation: false,
        previewThirdPartyConsent: false
    };

    let settings = { ...DEFAULT_SETTINGS };

    let authenticityToken = null;

    const statusCache = new Map();
    const previewCache = new Map();
    const STATUS_CONCURRENCY = 3;
    const PREVIEW_CONCURRENCY = 2;
    const statusLimiter = createLimiter(STATUS_CONCURRENCY);
    const previewLimiter = createLimiter(PREVIEW_CONCURRENCY);

    let delegationInitialized = false;
    const hoverTimers = new WeakMap();

    // --- ICONS ---
    const ICONS = {
        plus: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>`,
        eye: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5C21.27 7.61 17 4.5 12 4.5zm0 10c-2.48 0-4.5-2.02-4.5-4.5S9.52 5.5 12 5.5s4.5 2.02 4.5 4.5-2.02 4.5-4.5 4.5zm0-7C10.62 7.5 9.5 8.62 9.5 10s1.12 2.5 2.5 2.5 2.5-1.12 2.5-2.5S13.38 7.5 12 7.5z"/></svg>`,
        heart: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/></svg>`,
        preview: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg>`,
        brokenImage: `<svg viewBox="0 0 24 24"><path fill="currentColor" d="M21 5v6.59l-2.29-2.3c-.39-.39-1.03-.39-1.42 0L14 12.59L10.71 9.3a.996.996 0 0 0-1.41 0L6 12.59L3 9.58V5c0-1.1.9-2 2-2h14c1.1 0 2 .9 2 2m-3 6.42l3 3.01V19c0 1.1-.9 2-2 2H5c-1.1 0-2-.9-2-2v-6.58l2.29 2.29c.39.39 1.02.39 1.41 0l3.3-3.3l3.29 3.29c.39.39 1.02.39 1.41 0z"/></svg>`,
        loading: `<svg viewBox="0 0 24 24"><path fill="currentColor" d="M18 15v4c0 .55-.45 1-1 1H5c-.55 0-1-.45-1-1V7c0-.55.45-1 1-1h3.02c.55 0 1-.45 1-1s-.45-1-1-1H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2v-5c0-.55-.45-1-1-1s-1 .45-1 1m-2.5 3H6.52c-.42 0-.65-.48-.39-.81l1.74-2.23a.5.5 0 0 1 .78-.01l1.56 1.88l2.35-3.02c.2-.26.6-.26.79.01l2.55 3.39c.25.32.01.79-.4.79m3.8-9.11c.48-.77.75-1.67.69-2.66c-.13-2.15-1.84-3.97-3.97-4.2A4.5 4.5 0 0 0 11 6.5c0 2.49 2.01 4.5 4.49 4.5c.88 0 1.7-.26 2.39-.7l2.41 2.41c.39.39 1.03.39 1.42 0s.39-1.03 0-1.42zM15.5 9a2.5 2.5 0 0 1 0-5a2.5 2.5 0 0 1 0 5"/></svg>`
    };

    // --- UI Templates ---
    const STATUS_BUTTONS_TEMPLATE = `
        <div class="cover-status-buttons">
            <div class="cover-ui-wrapper">
                <div class="csb-outer-container">
                    <div class="csb-status-container">
                        <div class="state state-unmarked">
                            <div class="status-main-icon" title="${T('unmarked')}">${ICONS.plus}</div>
                            <div class="action-buttons-wrapper">
                                <div class="action-buttons">
                                    <button class="button btn-set-wanted">${T('want')}</button>
                                    <button class="button btn-set-watched js-set-watched">${T('watched')}</button>
                                </div>
                            </div>
                        </div>
                        <div class="state state-watched">
                            <div class="status-main-icon" title="${T('watched')}">
                                ${ICONS.eye}
                                <div class="star-arc-container">${'<span>★</span>'.repeat(5)}</div>
                            </div>
                            <div class="action-buttons-wrapper">
                                <div class="action-buttons">
                                    <button class="button btn-modify">${T('modify')}</button>
                                    <button class="button btn-delete js-delete">${T('delete')}</button>
                                </div>
                            </div>
                        </div>
                        <div class="state state-wanted">
                            <div class="status-main-icon" title="${T('want')}">${ICONS.heart}</div>
                            <div class="action-buttons-wrapper">
                                <div class="action-buttons">
                                    <button class="button btn-set-watched js-set-watched">${T('watched')}</button>
                                    <button class="button btn-delete js-delete">${T('delete')}</button>
                                </div>
                            </div>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    `;
    const PREVIEW_ICON_TEMPLATE = `
        <button type="button" class="csb-preview-icon-container" title="${T('previewClickToFetch')}" aria-label="${T('preview')}">
            ${ICONS.preview}
        </button>
    `;

    // --- Styles ---
    GM_addStyle(`
        @keyframes jdbe-flip {
            from { transform: rotateY(0deg); }
            to { transform: rotateY(360deg); }
        }
        @keyframes jdbe-shake {
            10%, 90% { transform: translate3d(-1px, 0, 0); }
            20%, 80% { transform: translate3d(2px, 0, 0); }
            30%, 50%, 70% { transform: translate3d(-3px, 0, 0); }
            40%, 60% { transform: translate3d(3px, 0, 0); }
        }
        :root {
            --color-unmarked: #f5f5f5;
            --color-unmarked-icon: #333;
            --color-watched: #3273dc;
            --color-watched-hover: #4a89e8;
            --color-wanted: #e83e8c;
            --color-wanted-hover: #f06fab;
            --color-delete: #ff3860;
            --color-delete-hover: #ff5c7c;
            --color-modify: #ffc107;
            --color-modify-hover: #ffce3a;
            --color-star: #ccc;
            --color-star-filled: #ffdd44;
            --color-preview-has: #48c78e;
            --color-preview-no: #f5f5f5;
            --color-preview-no-icon: #666;
            --color-preview-failed: #dbdbdb;
            --color-preview-failed-icon: #7a7a7a;
            --preview-icon-inactive-opacity: ${PREVIEW_ICON_INACTIVE_OPACITY};
        }
        .item .cover {
            position: relative;
            overflow: visible;
        }
        .item .cover img.cover-img-blurred {
            filter: blur(0.25rem) brightness(0.8);
            transition: filter 0.3s ease;
        }
        .cover-status-buttons {
            position: absolute;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            z-index: 6;
            pointer-events: none;
        }
        .cover-ui-wrapper {
            position: relative;
            width: 100%;
            height: 100%;
        }
        .csb-outer-container {
            position: absolute;
            top: 0.5rem;
            left: 0.5rem;
            opacity: 0;
            visibility: hidden;
            transition: opacity 0.25s ease, visibility 0.25s ease;
        }
        .item:hover .csb-outer-container,
        .item.status-ui-persist .csb-outer-container {
            opacity: 1;
            visibility: visible;
        }
        .csb-status-container {
            position: relative;
        }
        .item.api-error .csb-status-container {
            animation: jdbe-shake 0.5s ease-in-out;
        }
        .item[data-api-busy="true"] .csb-status-container {
            pointer-events: none;
            opacity: 0.5;
            cursor: wait;
        }
        .status-main-icon {
            position: relative;
            width: 2.25rem;
            height: 2.25rem;
            border-radius: 50%;
            display: flex;
            justify-content: center;
            align-items: center;
            cursor: pointer;
            box-shadow: 0 2px 8px rgba(0,0,0,0.3);
            transition: all 0.3s ease-in-out;
            flex-shrink: 0;
            z-index: 10;
        }
        .status-main-icon svg {
            width: 1.25rem;
            height: 1.25rem;
        }
        .csb-status-container.show-unmarked .status-main-icon {
            background-color: var(--color-unmarked);
            color: var(--color-unmarked-icon);
        }
        .csb-status-container.show-watched .status-main-icon {
            background-color: var(--color-watched);
            color: white;
        }
        .csb-status-container.show-wanted .status-main-icon {
            background-color: var(--color-wanted);
            color: white;
        }
        .action-buttons-wrapper {
            position: absolute;
            left: 0.5rem;
            top: 50%;
            transform: translateY(-50%);
            display: flex;
            align-items: center;
            max-width: 0;
            opacity: 0;
            visibility: hidden;
            transition: max-width 0.4s ease, opacity 0.3s ease 0.1s;
            overflow: hidden;
            pointer-events: none;
        }
        .action-buttons {
            display: flex;
            align-items: center;
            background-color: rgba(30,30,30,0.8);
            backdrop-filter: blur(2px);
            padding: 0.4rem 0.6rem 0.4rem 2rem;
            border-radius: 2rem;
            white-space: nowrap;
        }
        .csb-status-container:hover .action-buttons-wrapper {
            opacity: 1;
            visibility: visible;
            max-width: 300px;
            pointer-events: auto;
        }
        .csb-status-container:hover .status-main-icon {
            box-shadow: 0 4px 12px rgba(0,0,0,0.4);
        }
        .action-buttons .button {
            padding: 0.25rem 0.625rem;
            font-size: 0.8rem;
            border: none;
            border-radius: 0.25rem;
            color: white;
            cursor: pointer;
            margin: 0 0.25rem;
            transition: all 0.2s ease;
        }
        .action-buttons .button:hover {
            transform: translateY(-1px);
        }
        .action-buttons .button:active {
            transform: translateY(0);
            filter: brightness(0.9);
        }
        .btn-set-wanted { background-color: var(--color-wanted); }
        .btn-set-watched { background-color: var(--color-watched); }
        .btn-modify { background-color: var(--color-modify); }
        .btn-delete { background-color: var(--color-delete); }
        .btn-set-wanted:hover { background-color: var(--color-wanted-hover); }
        .btn-set-watched:hover { background-color: var(--color-watched-hover); }
        .btn-modify:hover { background-color: var(--color-modify-hover); }
        .btn-delete:hover { background-color: var(--color-delete-hover); }
        .star-arc-container {
            position: absolute;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            pointer-events: none;
            z-index: 11;
        }
        .star-arc-container span {
            position: absolute;
            top: 50%;
            left: 50%;
            font-size: 0.7rem;
            color: var(--color-star);
            text-shadow: 0 0 2px black;
            transform-origin: center;
        }
        .star-arc-container span.is-filled {
            color: var(--color-star-filled);
        }
        .star-arc-container span:nth-child(1) { transform: translate(-50%, -50%) rotate(-60deg) translateY(-12px) rotate(60deg); }
        .star-arc-container span:nth-child(2) { transform: translate(-50%, -50%) rotate(-30deg) translateY(-12px) rotate(30deg); }
        .star-arc-container span:nth-child(3) { transform: translate(-50%, -50%) rotate(0deg) translateY(-12px) rotate(0deg); }
        .star-arc-container span:nth-child(4) { transform: translate(-50%, -50%) rotate(30deg) translateY(-12px) rotate(-30deg); }
        .star-arc-container span:nth-child(5) { transform: translate(-50%, -50%) rotate(60deg) translateY(-12px) rotate(-60deg); }

        .csb-preview-icon-container {
            position: absolute;
            top: 0.5rem;
            right: 0.5rem;
            z-index: 7;
            flex-shrink: 0;
            width: 2.25rem;
            height: 2.25rem;
            border-radius: 50%;
            display: flex;
            justify-content: center;
            align-items: center;
            color: var(--color-preview-no-icon);
            background-color: var(--color-preview-no);
            box-shadow: 0 2px 8px rgba(0,0,0,0.3);
            transition: opacity 0.15s ease-in-out;
            opacity: var(--preview-icon-inactive-opacity);
            outline: none;
            cursor: pointer;
            border: none;
            padding: 0;
            margin: 0;
            font-family: inherit;
            font-size: inherit;
        }
        .csb-preview-icon-container svg {
            width: 1.25rem;
            height: 1.25rem;
        }
        .csb-preview-icon-container:hover,
        .csb-preview-icon-container:focus-visible,
        .csb-preview-icon-container:active {
            opacity: 1;
        }
        .csb-preview-icon-container.is-loading svg {
            animation: jdbe-flip 1.5s ease-in-out infinite;
        }
        .csb-preview-icon-container.has-preview {
            background-color: var(--color-preview-has);
            color: white;
        }
        .csb-preview-icon-container.fetch-failed {
            background-color: var(--color-preview-failed);
            color: var(--color-preview-failed-icon);
            cursor: not-allowed;
        }
        /* [NEW] Style for temporary, retryable errors */
        .csb-preview-icon-container.fetch-error {
            background-color: var(--color-delete); /* Use 'delete' red color */
            color: white;
            cursor: pointer; /* Allow retry */
        }

        .state {
            display: none;
        }
        .csb-status-container.show-unmarked .state-unmarked,
        .csb-status-container.show-watched .state-watched,
        .csb-status-container.show-wanted .state-wanted {
            display: contents;
        }

        .cover-modal-base {
            display: flex;
            justify-content: center;
            align-items: center;
            position: absolute;
            z-index: 10;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            background: transparent;
            flex-direction: column;
            gap: 0.625rem;
        }

        #preview-modal {
            position: fixed;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            background-color: rgba(0,0,0,0.8);
            display: flex;
            justify-content: center;
            align-items: center;
            z-index: 9999;
            cursor: zoom-out;
            overflow: auto;
        }
        #preview-modal-content {
            cursor: grab;
            text-align: center;
        }
        #preview-modal-content.is-panning {
            cursor: grabbing;
        }
        #preview-modal-content img {
            max-width: 95vw;
            max-height: 95vh;
            object-fit: contain;
            box-shadow: 0 8px 30px rgba(0,0,0,0.5);
            border-radius: 4px;
            transition: transform 0.2s ease-out;
            transform-origin: center center;
        }

        #sort-by-heat-btn-container {
            position: fixed;
            bottom: 1.7rem;
            right: 0;
            z-index: 9998;
        }
        #sort-by-heat-btn-container .button {
            width: 2.45rem;
            height: 1.7rem;
            font-size: 0.8rem;
            background-color: #fa6699;
            color: white;
            border: none;
            padding: 0;
            display: flex;
            align-items: center;
            justify-content: center;
            cursor: pointer;
        }

        .jdbe-modal {
            position: fixed;
            z-index: 10000;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            background: rgba(0,0,0,0.5);
            display: flex;
            justify-content: center;
            align-items: center;
        }
        .jdbe-modal-content {
            background: #fff;
            padding: 1.25rem;
            border-radius: 0.5rem;
            box-shadow: 0 0.3125rem 1rem rgba(0,0,0,0.3);
            position: relative;
            max-width: 480px;
        }
        .jdbe-modal-close {
            position: absolute;
            top: 0.5rem;
            right: 0.5rem;
            font-size: 1.5rem;
            border: none;
            background: transparent;
            cursor: pointer;
        }
        .jdbe-modal-body .jdbe-setting-row {
            display: flex;
            align-items: center;
            margin: 8px 0;
            gap: 8px;
        }
        .jdbe-modal-body label {
            flex: 1;
            display: flex;
            align-items: center;
            user-select: none;
        }
        .jdbe-modal-body input[type="checkbox"] {
            margin-right: 8px;
        }
        .jdbe-modal-body input[type="number"] {
            width: 70px;
            padding: 4px;
            border: 1px solid #ccc;
            border-radius: 4px;
        }
        .jdbe-modal-body .jdbe-setting-row.is-indented {
            padding-left: 20px;
        }
        .jdbe-modal-body .jdbe-setting-desc {
            font-size: 11px;
            color: #666;
            margin-left: 26px;
            margin-top: -5px;
        }

        .jdbe-help-icon {
            display: inline-flex;
            justify-content: center;
            align-items: center;
            width: 14px;
            height: 14px;
            border-radius: 50%;
            background-color: #aaa;
            color: white;
            font-size: 10px;
            font-weight: bold;
            margin-left: 6px;
            cursor: help;
            user-select: none;
            font-style: normal;
        }

        .rating-modal-container {
            z-index: 15;
        }
        .rating-stars {
            display: flex;
            flex-direction: row-reverse;
        }
        .rating-stars input[type="radio"] {
            display: none;
        }
        .rating-stars label {
            font-size: 1.5rem;
            color: var(--color-star);
            cursor: pointer;
            text-shadow: 0 0 3px rgba(0,0,0,0.5);
        }
        .rating-stars label:hover, .rating-stars label:hover ~ label, .rating-stars input[type="radio"]:checked ~ label {
            color: var(--color-star-filled);
        }
        .rating-modal-container .btn-cancel-rating {
            background-color: #f5f5f5;
            padding: 0.25rem 0.5rem;
            font-size: 0.7rem;
            border-radius: 0.25rem;
            border: none;
            cursor: pointer;
            margin-top: 0.3125rem;
        }

        .confirm-delete-modal p {
            color: white;
            font-weight: bold;
            text-shadow: 0 0 3px rgba(0,0,0,0.5);
        }
        .confirm-delete-modal .buttons {
            display: flex;
            gap: 0.625rem;
        }
        .confirm-delete-modal .is-danger {
            background-color: #ff3860;
            color: white;
        }

        /* [MODIFIED] Added 'div.box' for highlight disabling */
        body.jdbe-highlight-disabled .item a.box,
        body.jdbe-highlight-disabled .item div.box {
            background-color: transparent !important;
        }
        body.jdbe-status-disabled .cover-status-buttons {
            display: none !important;
        }
        body.jdbe-preview-disabled .csb-preview-icon-container {
            display: none !important;
        }
        body.jdbe-hide-low-score-enabled .item[data-jdbe-hide="true"] {
            display: none !important;
        }

        .jdbe-toast {
            position: fixed;
            left: 50%;
            transform: translateX(-50%);
            bottom: 2rem;
            background: rgba(0,0,0,0.85);
            color: #fff;
            padding: 0.5rem 0.75rem;
            border-radius: 0.5rem;
            z-index: 10001;
            font-size: 0.85rem;
            box-shadow: 0 4px 12px rgba(0,0,0,0.35);
        }
        .jdbe-privacy-note {
            display:inline-block;
            margin-left: 8px;
            color:#999;
            font-size: 12px;
        }
    `);

    // --- Helpers ---
    function setPreviewCache(key, value) {
        previewCache.set(key, value);
        limitCacheSize(previewCache, MAX_CACHE_SIZE);
    }

    function limitCacheSize(cache, maxSize) {
        if (cache.size > maxSize) {
            const oldestKey = cache.keys().next().value;
            cache.delete(oldestKey);
        }
    }

    function toast(message, timeout = 2000) {
        const div = document.createElement('div');
        div.className = 'jdbe-toast';
        div.textContent = message;
        document.body.appendChild(div);
        setTimeout(() => { div.remove(); }, timeout);
    }

    function createLimiter(max) {
        let active = 0;
        const queue = [];
        const runNext = () => {
            if (active >= max || queue.length === 0) return;
            active++;
            const job = queue.shift();
            Promise.resolve().then(job.fn).then(job.resolve).catch(job.reject).finally(() => {
                active--; runNext();
            });
        };
        return {
            run(fn) {
                return new Promise((resolve, reject) => {
                    queue.push({ fn, resolve, reject });
                    runNext();
                });
            }
        };
    }

    function normalizeCode(raw) {
        if (!raw) return '';
        let code = raw.trim().toUpperCase();
        code = code.replace(/\s+/g, '-');
        code = code.replace(/^FC2[-\s]PPV[-\s_](\d+)$/i, (_, num) => `FC2-PPV-${num}`);
        return code;
    }

    function setModalLock(item, locked) {
        const coverImg = item.querySelector('.cover img');
        if (locked) {
            item.dataset.modalOpen = 'true';
            if (coverImg) coverImg.classList.add('cover-img-blurred');
        } else {
            delete item.dataset.modalOpen;
            if (coverImg) coverImg.classList.remove('cover-img-blurred');
        }
    }

    function showStatusUI(item) {
        if (item.dataset.modalOpen === 'true') return;
        const overlay = item.querySelector('.cover-status-buttons');
        if (overlay) overlay.style.pointerEvents = 'auto';
        if (!settings.hideOnMouseLeave) item.classList.add('status-ui-persist');
    }

    function hideStatusUI(item) {
        if (item.dataset.modalOpen === 'true') return;
        const overlay = item.querySelector('.cover-status-buttons');
        if (overlay) overlay.style.pointerEvents = 'none';
        item.classList.remove('status-ui-persist');
    }

    function createCoverModal(item, className, innerHTML) {
        const cover = item.querySelector('.cover');
        if (!cover || cover.querySelector(`.${className}`)) return null;
        setModalLock(item, true);
        const modal = document.createElement('div');
        modal.className = `${className} cover-modal-base`;
        modal.innerHTML = innerHTML;
        cover.appendChild(modal);
        const closeModal = () => {
            setModalLock(item, false);
            if (!item.matches(':hover') && settings.hideOnMouseLeave) {
                hideStatusUI(item);
            } else {
                showStatusUI(item);
            }
            if (cover.contains(modal)) cover.removeChild(modal);
        };
        return { modal, closeModal };
    }

    function presentPreviewConsentModal(triggerElement) {
        return new Promise((resolve) => {
            const m = document.createElement('div');
            m.className = 'jdbe-modal';
            m.setAttribute('aria-modal', 'true');
            m.innerHTML = `
                <div class="jdbe-modal-content">
                    <button class="jdbe-modal-close" aria-label="close">&times;</button>
                    <h3 style="margin-top:0;">${T('previewConsentTitle')}</h3>
                    <p>${T('previewConsentBody')}</p>
                    <p style="color:#666;font-size:12px;margin:8px 0 16px;">Domains: javstore.net</p>
                    <div style="display:flex;gap:8px;justify-content:flex-end;">
                        <button class="button btn-cancel">${T('previewConsentCancel')}</button>
                        <button class="button btn-agree" style="background:#3273dc;color:#fff;border:none;padding:6px 10px;border-radius:4px;">${T('previewConsentAgree')}</button>
                    </div>
                </div>
            `;
            document.body.appendChild(m);
            const agreeButton = m.querySelector('.btn-agree');
            agreeButton.focus();

            function close(val) {
                try { m.remove(); } catch (e) {}
                triggerElement?.focus();
                resolve(!!val);
            }

            m.addEventListener('click', (e) => { if (e.target === m) close(false); });
            m.querySelector('.jdbe-modal-close').addEventListener('click', () => close(false));
            m.querySelector('.btn-cancel').addEventListener('click', () => close(false));
            agreeButton.addEventListener('click', () => close(true));
        });
    }

    function appendUIOnce(parent, childSelector, template) {
        if (!parent || parent.querySelector(childSelector)) return parent ? parent.querySelector(childSelector) : null;
        const wrapper = document.createElement('div');
        wrapper.innerHTML = template;
        const element = wrapper.firstElementChild;
        if (element) {
            parent.appendChild(element);
        }
        return element;
    }

    // --- Core Logic ---
    function loadSettings() {
        const saved = GM_getValue(SETTINGS_KEY, {});
        settings = { ...DEFAULT_SETTINGS, ...saved };
    }

    function saveSettings() {
        GM_setValue(SETTINGS_KEY, settings);
    }

    function openSettingsModal() {
        const existing = document.getElementById('jdbe-settings-modal');
        if (existing) {
            existing.style.display = 'flex';
            existing.querySelector('.jdbe-modal-close').focus();
            return;
        }

        const modal = document.createElement('div');
        modal.id = 'jdbe-settings-modal';
        modal.className = 'jdbe-modal';
        modal.setAttribute('aria-modal', 'true');
        modal.innerHTML = `
            <div class="jdbe-modal-content">
                <button class="jdbe-modal-close">&times;</button>
                <h2>${T('settings')}</h2>
                <div class="jdbe-modal-body"></div>
            </div>
        `;
        const body = modal.querySelector('.jdbe-modal-body');
        document.body.appendChild(modal);
        const closeButton = modal.querySelector('.jdbe-modal-close');
        closeButton.focus();

        const closeModal = () => modal.style.display = 'none';
        closeButton.addEventListener('click', closeModal);
        modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); });

        const controls = [
            { id: 'setting-highlight', key: 'highlight', label: T('highlightFeature'), type: 'checkbox' },
            { id: 'setting-sort', key: 'sort', label: T('sortFeature'), dependsOn: 'highlight', type: 'checkbox' },
            { id: 'setting-hide-low-score', key: 'hideLowScore', label: T('hideFeature'), dependsOn: 'highlight', type: 'checkbox', desc: T('hideFeatureDesc') },
            { id: 'setting-heatmap-min', key: 'heatmapMin', label: T('heatmapMin'), dependsOn: 'highlight', type: 'number', step: 0.05, indented: true, tooltipKey: 'tooltipHeatmapMin' },
            { id: 'setting-heatmap-max', key: 'heatmapMax', label: T('heatmapMax'), dependsOn: 'highlight', type: 'number', step: 0.05, indented: true, tooltipKey: 'tooltipHeatmapMax' },
            { id: 'setting-heatmap-curve', key: 'heatmapCurveFactor', label: T('heatmapCurveFactor'), dependsOn: 'highlight', type: 'number', step: 0.1, indented: true, tooltipKey: 'tooltipHeatmapCurve' },
            { id: 'setting-ratedby', key: 'ratedByThreshold', label: T('ratedByThreshold'), dependsOn: 'highlight', type: 'number', step: 10, indented: true, tooltipKey: 'tooltipRatedBy' },
            { id: 'setting-status', key: 'status', label: T('statusFeature'), type: 'checkbox' },
            { id: 'setting-hover-status', key: 'hideOnMouseLeave', label: T('hideOnMouseLeave'), dependsOn: 'status', type: 'checkbox', indented: true },
            { id: 'setting-preview', key: 'enablePreview', label: T('previewFeature'), type: 'checkbox' },
            { id: 'setting-delegation', key: 'useEventDelegation', label: T('delegationFeature'), type: 'checkbox' }
        ];

        controls.forEach(c => {
            const wrapper = document.createElement('div');
            wrapper.className = 'jdbe-setting-row';
            if (c.indented) wrapper.classList.add('is-indented');

            const label = document.createElement('label');
            label.htmlFor = c.id;

            const input = document.createElement('input');
            input.type = c.type;
            input.id = c.id;
            if (c.readonly) input.disabled = true;

            if (c.type === 'checkbox') {
                label.appendChild(input);
                label.appendChild(document.createTextNode(` ${c.label}`));
            } else if (c.type === 'number') {
                label.appendChild(document.createTextNode(`${c.label}`));
                input.step = c.step || 'any';
                input.className = 'jdbe-input-number';
            }

            if (c.tooltipKey) {
                const helpIcon = document.createElement('i');
                helpIcon.className = 'jdbe-help-icon';
                helpIcon.textContent = '?';
                helpIcon.title = T(c.tooltipKey);
                label.appendChild(helpIcon);
            }

            wrapper.appendChild(label);
            if (c.type === 'number') wrapper.appendChild(input);

            if (c.id === 'setting-preview') {
                const note = document.createElement('span');
                note.className = 'jdbe-privacy-note';
                note.textContent = T('previewPrivacyNote');
                wrapper.appendChild(note);
            }
            body.appendChild(wrapper);

            if (c.desc) {
                const desc = document.createElement('div');
                desc.className = 'jdbe-setting-desc';
                desc.textContent = c.desc;
                wrapper.appendChild(desc);
            }
        });

        const resetWrapper = document.createElement('div');
        resetWrapper.style.margin = '10px 0 5px 0';
        resetWrapper.style.paddingLeft = '188px';

        const resetButton = document.createElement('button');
        resetButton.id = 'jdbe-reset-heatmap';
        resetButton.type = 'button';
        resetButton.className = 'button is-small';
        resetButton.textContent = T('reset');

        resetWrapper.appendChild(resetButton);
        body.appendChild(resetWrapper);

        resetButton.addEventListener('click', () => {
            settings.heatmapMin = DEFAULT_SETTINGS.heatmapMin;
            settings.heatmapMax = DEFAULT_SETTINGS.heatmapMax;
            settings.heatmapCurveFactor = DEFAULT_SETTINGS.heatmapCurveFactor;
            settings.ratedByThreshold = DEFAULT_SETTINGS.ratedByThreshold;

            saveSettings();

            updateUI();

            document.querySelectorAll(ITEM_SELECTOR).forEach(item => {
                item.dataset.highlightProcessed = 'false';
                processItem(item);
            });

            toast(T('resetDone'), 1500);
        });


        const updateUI = () => {
            controls.forEach(c => {
                const input = document.getElementById(c.id);
                if (c.type === 'checkbox') {
                    input.checked = !!settings[c.key];
                } else if (c.type === 'number') {
                    input.value = settings[c.key];
                }
                const row = input.closest('.jdbe-setting-row');
                if (c.dependsOn) {
                    row.style.display = settings[c.dependsOn] ? 'flex' : 'none';
                    input.disabled = c.readonly ? true : !settings[c.dependsOn];
                }
            });
        };

        modal.addEventListener('change', async (e) => {
            const control = controls.find(x => x.id === e.target.id);
            if (!control) return;

            if (control.id === 'setting-preview' && e.target.checked && !settings.previewThirdPartyConsent) {
                const agreed = await presentPreviewConsentModal();
                if (agreed) {
                    settings.previewThirdPartyConsent = true;
                } else {
                    e.target.checked = false;
                    settings.enablePreview = false;
                    saveSettings();
                    updateUIVisibility();
                    updateUI();
                    return;
                }
            }

            if (control.type === 'checkbox') {
                settings[control.key] = e.target.checked;
            } else if (control.type === 'number') {
                settings[control.key] = parseFloat(e.target.value) || 0;
            }

            if (control.id === 'setting-highlight' && !settings.highlight) {
                settings.sort = false;
                settings.hideLowScore = false;
            }
            if (control.id === 'setting-delegation') {
                if (settings.useEventDelegation && !delegationInitialized) {
                    initDelegatedEvents();
                }
            }

            saveSettings();
            updateUIVisibility();
            updateUI();

            if (control.key.startsWith('heatmap') || control.key.startsWith('ratedBy') || control.key === 'hideLowScore') {
                document.querySelectorAll(ITEM_SELECTOR).forEach(item => {
                    item.dataset.highlightProcessed = 'false';
                    processItem(item);
                });
            }
        });

        updateUI();
    }

    function updateUIVisibility() {
        document.body.classList.toggle('jdbe-highlight-disabled', !settings.highlight);
        document.body.classList.toggle('jdbe-status-disabled', !settings.status);
        document.body.classList.toggle('jdbe-preview-disabled', !settings.enablePreview);
        document.body.classList.toggle('jdbe-hide-low-score-enabled', settings.hideLowScore);

        const sortButton = document.getElementById('sort-by-heat-btn-container');
        if (sortButton) {
            const shouldShow = settings.sort && settings.highlight && document.querySelector(ITEM_SELECTOR);
            sortButton.style.display = shouldShow ? 'block' : 'none';
        }
    }

    function getCsrfToken() {
        const meta = document.querySelector('meta[name="csrf-token"]');
        authenticityToken = meta ? meta.content : null;
    }

    function isLoggedIn() {
        return !!document.querySelector('a[href="/logout"]');
    }

    function gmRequest(options) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                onload: (res) => {
                    if (res.status >= 200 && res.status < 300) {
                        resolve(res);
                    } else {
                        reject(res);
                    }
                },
                onerror: reject,
                onabort: reject,
                ontimeout: reject,
                ...options
            });
        });
    }

    async function ensureCsrfToken(item) {
        if (authenticityToken) return authenticityToken;
        getCsrfToken();
        if (authenticityToken) return authenticityToken;
        try {
            const anchor = item.querySelector('a.box') || item.querySelector('.box a');
            if (!anchor) return null;
            const res = await statusLimiter.run(() => gmRequest({ method: "GET", url: anchor.href }));
            if (res.status >= 200 && res.status < 300) {
                const doc = new DOMParser().parseFromString(res.responseText, "text/html");
                const meta = doc.querySelector('meta[name="csrf-token"]');
                authenticityToken = meta ? meta.content : null;
            }
        } catch (e) {}
        return authenticityToken;
    }

    async function updateReviewStatus(action, videoId, item, rating) {
        if (item.dataset.apiBusy === 'true') return;
        if (!isLoggedIn()) {
            toast(T('errorNeedLogin'));
            return;
        }
        const token = await ensureCsrfToken(item);
        if (!token) {
            item.classList.add('api-error');
            toast(T('errorCsrf'));
            setTimeout(() => item.classList.remove('api-error'), 500);
            return;
        }
        item.dataset.apiBusy = 'true';

        let urlPath = '';
        let body = '';
        const reviewId = item.dataset.reviewId;

        switch (action) {
            case 'watched': {
                if (rating == null) {
                    delete item.dataset.apiBusy;
                    return;
                }
                urlPath = reviewId ? `/v/${videoId}/reviews/${reviewId}` : `/v/${videoId}/reviews`;
                const baseBody = `authenticity_token=${encodeURIComponent(token)}&video_review[status]=watched&video_review[score]=${rating}&video_review[content]=`;
                body = reviewId ? `_method=patch&${baseBody}` : baseBody;
                break;
            }
            case 'wanted':
                urlPath = `/v/${videoId}/reviews/want_to_watch`;
                body = `authenticity_token=${encodeURIComponent(token)}`;
                break;
            case 'delete':
                if (!reviewId) {
                    delete item.dataset.apiBusy;
                    return;
                }
                urlPath = `/v/${videoId}/reviews/${reviewId}`;
                body = `_method=delete&authenticity_token=${encodeURIComponent(token)}`;
                break;
            default:
                delete item.dataset.apiBusy;
                return;
        }

        try {
            await statusLimiter.run(() => gmRequest({
                method: 'POST',
                url: window.location.origin + urlPath,
                headers: { "Content-Type": "application/x-www-form-urlencoded" },
                data: body
            }));
            statusCache.delete(videoId);
            await fetchItemData(item, true);
            if (settings.status) showStatusUI(item);
        } catch (error) {
            console.error('JavDB Enhanced: Status update failed.', error);

            if (error.status === 422) {
                toast(T('errorCsrf'));
                authenticityToken = null;
            } else {
                toast(T('errorNetworkBusy'));
            }
            item.classList.add('api-error');
            setTimeout(() => item.classList.remove('api-error'), 500);
        } finally {
            delete item.dataset.apiBusy;
        }
    }

    async function fetchPreviewImage(code) {
        if (!settings.previewThirdPartyConsent) return { status: 'consent-required' };
        const normalized = normalizeCode(code);
        if (!normalized) return { status: 'no-data' };
        if (previewCache.has(normalized)) return previewCache.get(normalized);

        let resDoc;
        try {
            const res = await previewLimiter.run(() => gmRequest({ method: "GET", url: `https://javstore.net/search/${encodeURIComponent(normalized)}.html` }));
            resDoc = new DOMParser().parseFromString(res.responseText, "text/html");
        } catch (e) {
            console.error(`JavDB Enhanced: search failed for ${normalized}`, e);
            return { status: 'error' };
        }

        let searchResults = resDoc.querySelectorAll("#content_news li > a, div.item > a");
        if (!searchResults.length) {
            const result = { status: 'no-data' };
            setPreviewCache(normalized, result);
            return result;
        }

        const regex = new RegExp(normalized.replace(/[-_ ]/g, '[-_ ]?'), 'i');
        const foundLink = Array.from(searchResults).find(a => {
            const str = (a.title || a.textContent || '').toUpperCase();
            const imgEl = a.querySelector("img");
            return regex.test(str) && imgEl && /^https?:\/\//i.test(imgEl.src);
        });

        if (!foundLink) {
            const result = { status: 'no-data' };
            setPreviewCache(normalized, result);
            return result;
        }

        try {
            const res = await previewLimiter.run(() => gmRequest({ method: "GET", url: foundLink.href }));
            resDoc = new DOMParser().parseFromString(res.responseText, "text/html");
        } catch (e) {
            console.error(`JavDB Enhanced: detail fetch failed ${foundLink.href}`, e);
            return { status: 'error' };
        }

        const imgLink = resDoc.querySelector(".news > a, .screencap > a");
        const potentialUrl = imgLink ? imgLink.href : '';

        const safeUrlRegex = /^https?:\/\/[^\s/$.?#].[^\s]*\.(jpg|jpeg|png|webp)$/i;
        if (potentialUrl && safeUrlRegex.test(potentialUrl)) {
            const result = { status: 'success', img: potentialUrl };
            setPreviewCache(normalized, result);
            return result;
        }

        const result = { status: 'no-data' };
        setPreviewCache(normalized, result);
        return result;
    }


    function showPreviewModal(imageUrl, triggerElement) {
        if (document.getElementById('preview-modal')) return;

        const modal = document.createElement('div');
        modal.id = 'preview-modal';
        modal.setAttribute('aria-modal', 'true');

        const content = document.createElement('div');
        content.id = 'preview-modal-content';

        const img = document.createElement('img');
        img.src = imageUrl;
        content.appendChild(img);
        modal.appendChild(content);

        document.body.appendChild(modal);
        document.body.style.overflow = 'hidden';

        let scale = 1, panX = 0, panY = 0;
        let isPanning = false, startX = 0, startY = 0;

        const updateTransform = () => {
            content.style.transform = `translate(${panX}px, ${panY}px) scale(${scale})`;
        };

        const onWheel = (e) => {
            e.preventDefault();
            const delta = e.deltaY > 0 ? -0.1 : 0.1;
            const newScale = scale + delta * scale;
            scale = Math.min(Math.max(PREVIEW_MODAL_MIN_SCALE, newScale), PREVIEW_MODAL_MAX_SCALE);
            updateTransform();
        };

        const onMouseDown = (e) => {
            e.preventDefault();
            isPanning = true;
            startX = e.clientX - panX;
            startY = e.clientY - panY;
            content.classList.add('is-panning');
        };

        const onMouseMove = (e) => {
            if (!isPanning) return;
            e.preventDefault();
            panX = e.clientX - startX;
            panY = e.clientY - startY;
            updateTransform();
        };

        const onMouseUp = () => {
            if (isPanning) {
                isPanning = false;
                content.classList.remove('is-panning');
            }
        };

        const closeModal = (e) => {
            if (e && e.target !== modal) return;
            try { document.body.removeChild(modal); } catch (err) {}
            document.body.style.overflow = '';
            window.removeEventListener('keydown', closeOnEsc);
            window.removeEventListener('mousemove', onMouseMove);
            window.removeEventListener('mouseup', onMouseUp);
            triggerElement?.focus();
        };

        const closeOnEsc = (e) => {
            if (e.key === 'Escape') closeModal({ target: modal });
        };

        modal.addEventListener('wheel', onWheel, { passive: false });
        content.addEventListener('mousedown', onMouseDown);
        window.addEventListener('mousemove', onMouseMove);
        window.addEventListener('mouseup', onMouseUp);
        modal.addEventListener('click', closeModal);
        window.addEventListener('keydown', closeOnEsc);
    }

    function parseStatusFromDoc(doc) {
        let status = 'unmarked';
        let reviewId = '';
        let rating = 0;

        const watchedTag = doc.querySelector('.review-title .tag.is-success.is-light');
        const wantedTag = doc.querySelector('.review-title .tag.is-info.is-light');
        const deleteLink = doc.querySelector('a[data-method="delete"][href*="/reviews/"]');

        if (watchedTag) {
            status = 'watched';
            const checkedInput = doc.querySelector('.rating-star .control input:checked');
            if (checkedInput) rating = parseInt(checkedInput.value, 10) || 0;
        } else if (wantedTag) {
            status = 'wanted';
        }

        if (deleteLink) {
            const m = deleteLink.href.match(/\/reviews\/(\d+)/);
            if (m) reviewId = m[1];
        }

        return { status, reviewId, rating };
    }

    function applyStatusToItem(item, parsed) {
        const statusContainer = item.querySelector('.csb-status-container');
        if (!statusContainer || !parsed) return;

        const { status, reviewId, rating } = parsed;
        statusContainer.className = 'csb-status-container';
        item.dataset.reviewId = reviewId || '';

        const ratingDisplay = statusContainer.querySelector('.star-arc-container');
        if (ratingDisplay) {
            ratingDisplay.querySelectorAll('span').forEach((star, index) => {
                star.classList.toggle('is-filled', status === 'watched' && index < (rating || 0));
            });
        }
        statusContainer.classList.add(`show-${status || 'unmarked'}`);
    }

    async function fetchItemData(item, forceUpdate = false) {
        if ((item.dataset.statusChecked && !forceUpdate) || item.dataset.statusFetching) return;
        item.dataset.statusFetching = 'true';

        const anchor = item.querySelector('a.box') || item.querySelector('.box a');
        if (!anchor) {
            delete item.dataset.statusFetching;
            return;
        }

        let videoId = anchor.href.split('/').pop();

        if (settings.status && isLoggedIn()) {
            if (!statusCache.has(videoId) || forceUpdate) {
                try {
                    const res = await statusLimiter.run(() => gmRequest({ method: "GET", url: anchor.href }));
                    if (res.status >= 200 && res.status < 300) {
                        const doc = new DOMParser().parseFromString(res.responseText, "text/html");
                        const parsed = parseStatusFromDoc(doc);
                        statusCache.set(videoId, parsed);
                        limitCacheSize(statusCache, MAX_CACHE_SIZE);
                        applyStatusToItem(item, parsed);
                    }
                } catch (error) {
                    console.error('JavDB Enhanced: fetch item failed', error);
                }
            } else {
                applyStatusToItem(item, statusCache.get(videoId));
            }
        }

        item.dataset.statusChecked = 'true';
        delete item.dataset.statusFetching;
    }

    function showRatingModal(item, videoId) {
        const html = `
            <div class="rating-stars">
                ${[5, 4, 3, 2, 1].map(i => `<input type="radio" id="star${i}-${videoId}" name="rating-${videoId}" value="${i}"><label for="star${i}-${videoId}">★</label>`).join('')}
            </div>
            <button class="btn-cancel-rating">${T('cancel')}</button>
        `;
        const result = createCoverModal(item, 'rating-modal-container', html);
        if (!result) return;
        const { modal, closeModal } = result;
        modal.addEventListener('click', (e) => {
            e.stopPropagation();
        });
        modal.querySelectorAll('input[type="radio"]').forEach(radio => {
            radio.addEventListener('change', (e) => {
                updateReviewStatus('watched', videoId, item, e.target.value);
                closeModal();
            });
        });
        modal.querySelector('.btn-cancel-rating').addEventListener('click', (e) => {
            e.preventDefault();
            e.stopPropagation();
            closeModal();
        });
    }

    function showDeleteConfirmation(item, videoId) {
        const html = `
            <p>${T('confirmDelete')}</p>
            <div class="buttons">
                <button class="button is-danger btn-confirm-delete">${T('confirm')}</button>
                <button class="button is-light btn-cancel-delete">${T('cancel')}</button>
            </div>
        `;
        const result = createCoverModal(item, 'confirm-delete-modal', html);
        if (!result) return;
        const { modal, closeModal } = result;
        modal.querySelector('.btn-confirm-delete').addEventListener('click', (e) => {
            e.preventDefault();
            e.stopPropagation();
            updateReviewStatus('delete', videoId, item);
            closeModal();
        });
        modal.querySelector('.btn-cancel-delete').addEventListener('click', (e) => {
            e.preventDefault();
            e.stopPropagation();
            closeModal();
        });
    }

    function initHoverBehavior(item) {
        if (item.dataset.hoverInit) return;
        item.dataset.hoverInit = 'true';

        let hoverTimer;
        const startFetch = async () => {
            if (hoverTimer) clearTimeout(hoverTimer);
            hoverTimer = setTimeout(async () => {
                showStatusUI(item);
                if (!item.dataset.statusChecked) {
                    await fetchItemData(item, false);
                }
            }, settings.fetchDelay);
        };

        item.addEventListener('mouseenter', startFetch);
        item.addEventListener('mouseleave', () => {
            if (hoverTimer) clearTimeout(hoverTimer);
            if (settings.hideOnMouseLeave) hideStatusUI(item);
        });
    }

    async function handlePreviewTrigger(item, previewIcon) {
        if (!settings.enablePreview || !previewIcon || previewIcon.classList.contains('fetch-failed')) return;

        previewIcon.classList.remove('fetch-error');

        if (item.dataset.previewUrl && previewIcon.classList.contains('has-preview')) {
            showPreviewModal(item.dataset.previewUrl, previewIcon);
            return;
        }

        if (!settings.previewThirdPartyConsent) {
            const agreed = await presentPreviewConsentModal(previewIcon);
            if (agreed) {
                settings.previewThirdPartyConsent = true;
                saveSettings();
            } else {
                return;
            }
        }

        const titleElement = item.querySelector('.video-title strong');
        if (!titleElement) return;

        previewIcon.classList.remove('has-preview');
        previewIcon.classList.add('is-loading');
        previewIcon.innerHTML = ICONS.loading;
        previewIcon.title = T('loadingPreview');

        const rawCode = titleElement.textContent.trim();
        const previewData = await fetchPreviewImage(rawCode);
        previewIcon.classList.remove('is-loading');

        if (previewData.status === 'success') {
            item.dataset.previewUrl = previewData.img;
            previewIcon.classList.add('has-preview');
            previewIcon.innerHTML = ICONS.preview;
            previewIcon.title = T('preview');
            showPreviewModal(previewData.img, previewIcon);
        } else if (previewData.status === 'no-data') {
            previewIcon.classList.add('fetch-failed');
            previewIcon.innerHTML = ICONS.brokenImage;
            previewIcon.title = T('noPreview');
        } else {
            previewIcon.classList.add('fetch-error');
            previewIcon.innerHTML = ICONS.brokenImage;
            previewIcon.title = T('errorPreview');
        }
    }

    function initDelegatedEvents() {
        if (delegationInitialized) return;
        delegationInitialized = true;

        const body = document.body;

        body.addEventListener('mouseover', e => {
            const item = e.target.closest?.(ITEM_SELECTOR);
            if (!item || (e.relatedTarget && item.contains(e.relatedTarget))) return;

            const t = setTimeout(async () => {
                showStatusUI(item);
                if (!item.dataset.statusChecked) {
                    await fetchItemData(item, false);
                }
            }, settings.fetchDelay);
            clearTimeout(hoverTimers.get(item));
            hoverTimers.set(item, t);
        });

        body.addEventListener('mouseout', e => {
            const item = e.target.closest?.(ITEM_SELECTOR);
            if (!item || (e.relatedTarget && item.contains(e.relatedTarget))) return;

            clearTimeout(hoverTimers.get(item));
            hoverTimers.delete(item);
            if (settings.hideOnMouseLeave) hideStatusUI(item);
        });

        body.addEventListener('click', e => {
            const target = e.target;
            const btnWanted = target.closest('.btn-set-wanted');
            const btnWatched = target.closest('.js-set-watched, .btn-modify');
            const btnDelete = target.closest('.js-delete');
            const previewIcon = target.closest('.csb-preview-icon-container');

            if (!btnWanted && !btnWatched && !btnDelete && !previewIcon) return;

            const item = target.closest(ITEM_SELECTOR);
            if (!item) return;

            e.preventDefault();
            e.stopPropagation();

            const videoId = (item.querySelector('a.box') || item.querySelector('.box a'))?.href.split('/').pop();

            if (previewIcon) {
                handlePreviewTrigger(item, previewIcon);
            } else if (videoId) {
                if (btnWanted) updateReviewStatus('wanted', videoId, item);
                else if (btnWatched) showRatingModal(item, videoId);
                else if (btnDelete) showDeleteConfirmation(item, videoId);
            }
        }, true);

        body.addEventListener('keydown', e => {
            const previewIcon = e.target.closest?.('.csb-preview-icon-container');
            if (!previewIcon || (e.key !== 'Enter' && e.key !== ' ')) return;

            e.preventDefault();
            e.stopPropagation();

            const item = previewIcon.closest(ITEM_SELECTOR);
            if (!item) return;
            handlePreviewTrigger(item, previewIcon);
        }, true);
    }

    function processItem(item) {
        const anchorElement = item.querySelector('a.box') || item.querySelector('.box a');
        const elementToColor = item.querySelector('a.box') || item.querySelector('div.box');

        const scoreElement = item.querySelector('.score .value');
        let weightedRating = 0;

        if (settings.highlight && item.dataset.highlightProcessed !== 'true' && scoreElement) {
            item.dataset.highlightProcessed = 'true';

            const C_MIN = settings.heatmapMin;
            const m = settings.ratedByThreshold;
            const C_MAX = settings.heatmapMax;
            const curveFactor = settings.heatmapCurveFactor || 1.0;

            delete item.dataset.heatColor;

            for (const re of scoreRegexes) {
                const m_text = (scoreElement.textContent || '').trim().match(re);
                if (m_text) {
                    const parsedScore = { score: parseFloat(m_text[1]), ratedBy: parseInt(m_text[2], 10) };

                    weightedRating = (parsedScore.ratedBy * parsedScore.score + m * C_MIN) / (parsedScore.ratedBy + m);

                    if (parsedScore.score >= C_MIN) {

                        const valueToColor = weightedRating;

                        let normalizedHeat = (valueToColor - C_MIN) / (C_MAX - C_MIN);
                        normalizedHeat = Math.max(0, Math.min(1, normalizedHeat));
                        if (curveFactor !== 1.0) {
                            normalizedHeat = Math.pow(normalizedHeat, curveFactor);
                        }
                        item.dataset.heatColor = getHeatmapColor(normalizedHeat);
                    }
                    break;
                }
            }
            item.dataset.heat = String(weightedRating);

            if (elementToColor) {
                elementToColor.style.backgroundColor = (settings.highlight && item.dataset.heatColor) ? item.dataset.heatColor : '';
            }
        } else if (elementToColor && !item.dataset.highlightProcessed) {
            item.dataset.heat = '0';
        }

        if (settings.highlight) {
            const score = parseFloat(item.dataset.heat || '0');
            const c_min = settings.heatmapMin;

            if (score > 0 && score < c_min) {
                item.dataset.jdbeHide = 'true';
            } else {
                item.dataset.jdbeHide = 'false';
            }
        }

        const coverElement = item.querySelector('.cover');
        if (coverElement && anchorElement && !item.dataset.statusProcessed) {
            item.dataset.statusProcessed = 'true';

            const overlayRoot = appendUIOnce(coverElement, '.cover-status-buttons', STATUS_BUTTONS_TEMPLATE);
            if (overlayRoot && !settings.useEventDelegation) {
                const videoId = anchorElement.href.split('/').pop();
                overlayRoot.querySelector('.btn-set-wanted')?.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); updateReviewStatus('wanted', videoId, item); });
                overlayRoot.querySelectorAll('.js-set-watched, .btn-modify').forEach(btn => btn.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); showRatingModal(item, videoId); }));
                overlayRoot.querySelectorAll('.js-delete').forEach(btn => btn.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); showDeleteConfirmation(item, videoId); }));
            }

            const previewIcon = appendUIOnce(coverElement, '.csb-preview-icon-container', PREVIEW_ICON_TEMPLATE);
            if (previewIcon && !settings.useEventDelegation) {
                const trigger = (e) => {
                    e.preventDefault(); e.stopPropagation();
                    handlePreviewTrigger(item, previewIcon);
                };
                previewIcon.addEventListener('click', trigger);
                previewIcon.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') trigger(e); });
            }

            if (!settings.useEventDelegation) {
                 initHoverBehavior(item);
            }
        }
    }

    function getHeatmapColor(heat) {
        const h = Math.min(Math.max(heat, 0), 1);

        const r = h > 0.5 ? Math.round(255 * ((h - 0.5) * 2)) : 0;
        const g = h < 0.5 ? Math.round(255 * (h * 2)) : Math.round(255 * (1 - (h - 0.5) * 2));
        const b = h < 0.5 ? Math.round(255 * (1 - (h * 2))) : 0;

        return `rgba(${r},${g},${b},0.5)`;
    }

    function createIndependentSortButton() {
        if (document.getElementById('sort-by-heat-btn-container')) return;
        const hasItems = document.querySelector(ITEM_SELECTOR);
        if (!hasItems) return;

        const cont = document.createElement('div');
        cont.id = 'sort-by-heat-btn-container';
        cont.innerHTML = `<a class="button">${T('sort')}</a>`;
        cont.addEventListener('click', e => { e.preventDefault(); sortItemsByHeat(); });
        document.body.appendChild(cont);
        updateUIVisibility();
    }

    function sortItemsByHeat() {
        const containers = Array.from(document.querySelectorAll('.movie-list, .is-user-page .columns.is-multiline'));
        if (containers.length === 0) return;

        const allItems = Array.from(document.querySelectorAll(ITEM_SELECTOR));

        allItems.sort((a, b) => (parseFloat(b.dataset.heat || '0') - parseFloat(a.dataset.heat || '0')));

        const primaryContainer = containers[0];
        allItems.forEach(item => primaryContainer.appendChild(item));

        const navBar = document.querySelector('.navbar.is-fixed-top');
        const offset = navBar ? navBar.offsetHeight : DEFAULT_NAVBAR_HEIGHT;
        window.scrollTo({ top: primaryContainer.getBoundingClientRect().top + window.scrollY - offset, behavior: 'smooth' });
    }

    function main() {
        loadSettings();
        GM_registerMenuCommand(T('settings'), openSettingsModal);
        getCsrfToken();
        updateUIVisibility();

        const obs = new MutationObserver(muts => {
            const itemsToProcess = new Set();
            for (const mut of muts) {
                if (mut.addedNodes && mut.addedNodes.length) {
                    for (const node of mut.addedNodes) {
                        if (node.nodeType !== 1) continue;
                        if (node.matches?.(ITEM_SELECTOR)) {
                            itemsToProcess.add(node);
                        }
                        if (node.querySelectorAll) {
                            node.querySelectorAll(ITEM_SELECTOR).forEach(it => itemsToProcess.add(it));
                        }
                    }
                }

                if (mut.type === 'childList' && mut.target && mut.target.closest) {
                    const potentialItem = mut.target.closest(ITEM_SELECTOR);
                    if (potentialItem && !itemsToProcess.has(potentialItem)) {
                        itemsToProcess.add(potentialItem);
                    }
                }
            }

            if (itemsToProcess.size > 0) {
                itemsToProcess.forEach(item => {
                    if (item.querySelector('.score .value') || item.querySelector('.meta')) {
                        item.dataset.highlightProcessed = 'false';
                    }
                    processItem(item);
                });
                createIndependentSortButton();
            }
        });

        obs.observe(document.body, { childList: true, subtree: true });

        document.querySelectorAll(ITEM_SELECTOR).forEach(processItem);
        createIndependentSortButton();

        if (settings.useEventDelegation) {
            initDelegatedEvents();
        }
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', main);
    } else {
        main();
    }
})();