JAVDB/JAVBUS/JAVLIBRARY to Jable.tv Linker (Class Refactor)

Add Jable.tv link button to JAVDB, JAVBUS, JAVLIBRARY, AV01 and AVJOY pages, and cross-site navigation buttons, plus subtitle site links. Refactored as JableLinker class for maintainability.

2025-10-02 या दिनांकाला. सर्वात नवीन आवृत्ती पाहा.

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey, Greasemonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल..

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल..

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

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल..

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्क्रिप्ट व्यवस्थापक एक्स्टेंशन इंस्टॉल करावे लागेल.

(माझ्याकडे आधीच युझर स्क्रिप्ट व्यवस्थापक आहे, मला इंस्टॉल करू द्या!)

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

(माझ्याकडे आधीच युझर स्टाईल व्यवस्थापक आहे, मला इंस्टॉल करू द्या!)

// ==UserScript==
// @name        JAVDB/JAVBUS/JAVLIBRARY to Jable.tv Linker (Class Refactor)
// @namespace   https://tampermonkey.net/
// @version     2025.10.04
// @description Add Jable.tv link button to JAVDB, JAVBUS, JAVLIBRARY, AV01 and AVJOY pages, and cross-site navigation buttons, plus subtitle site links. Refactored as JableLinker class for maintainability.
// @author      庄引X@https://x.com/zhuangyin8
// @match       https://javdb.com/*
// @match       https://www.javbus.com/*
// @match       https://www.javlibrary.com/*
// @match       https://jable.tv/*
// @match       https://avjoy.me/video/*
// @match       https://www.av01.tv/*
// @match       https://91md.me/*
// @match       https://missav.ws/*
// @match       https://*.btdig.com/*
// @match       https://sukebei.nyaa.si/*
// @match       https://btdig.com/*
// @match       https://en.btdig.com/*
// @match       https://btsow.pics/*
// @grant       GM_addStyle
// @grant       GM_xmlhttpRequest
// @grant       GM_getValue
// @grant       GM_setValue
// @grant       GM_registerMenuCommand
// @run-at      document-end
// @connect     jable.tv
// @connect     javdb.com
// @connect     javbus.com
// @connect     avjoy.me
// @connect     av01.tv
// @license     MIT
// ==/UserScript==

class JableLinker {
    constructor() {
        this.SITES = {
            JAVDB: "javdb.com",
            JAVBUS: "javbus.com",
            JAVLIBRARY: "javlibrary.com",
            JABLE: "jable.tv",
            AVJOY: "avjoy.me",
            AV01: "av01.tv",
            MDME: "91md.me",
            MISSAV: "missav.ws",
            SUKEBEI: "sukebei.nyaa.si",
            BTDIG: "btdig.com",
            BTSOW: "btsow.pics"
        };

        this.SELECTORS = {
            // JAV Site Selectors
            JAVDB_CODE: ".value", JAVDB_TITLE: ".title", JAVDB_PREVIEW_IMAGES: ".preview-images .tile-item", JAVBUS_SAMPLE_BOX: ".sample-box", JABLE_TITLE: "h4", JAVBUS_TITLE: ".container h3", JAVLIBRARY_TITLE: "h3.text", AVJOY_TITLE: ".video-title h1", AV01_TITLE: "h1", MISSAV_TITLE: ".mt-4 h1",
        };

        this.STYLES = {
            SUKEBEI: `.container .ad,.hdr-link,tr td:nth-child(1),tr td:nth-child(3),tr td:nth-child(6),tr td:nth-child(7),.text-center{display:none !important;} .table {width: 100%;} .torrent-list>tbody>tr>td { white-space: normal; max-width:90vw;} .navbar-form .input-group {position: fixed; left:0;width: 100vw;}`,
            BTDIG: ``,
            BTSOW: `.search {position: sticky !important;top: 80px !important;} .form-inline .input-group {width: 100%;} .hidden-xs:not(.tags-box,.text-right,.search,.search-container)/*,.data-list:not(.detail) .size,.data-list:not(.detail) .date*/{ display: none !important;} .q-container {max-width: 50vw !important;margin-left: 0 !important;}`,
            JAVBUS: `.row > #waterfall, .container { width: 100vw !important; } .masonry #waterfall,.mb20 { display: grid; grid-template-columns: repeat(3, 1fr); } .movie-box { width: auto !important; height: 100% !important; margin: 0 !important; } #waterfall .masonry-brick { position: relative !important; top: 0px !important; left: 0 !important; } .screencap img {width:auto  !important; }.movie-box .photo-frame, .movie-box img { width: 100% !important; height: auto !important; margin: 0 !important;} iframe,.banner728,.banner300,.bcpic2{display:none}`,
            JAVDB: `.container:not(.is-max-desktop):not(.is-max-widescreen) { max-width: 100vw; } .movie-list .item .video-title { white-space: normal; } .moj-content{display:none}`,
            JAVLIBRARY: `#leftmenu > table:last-child,#topbanner11{display:none}`,
            JABLE: `.video-img-box .title,.title{white-space:normal;max-height:auto}.container {max-width: 100vw !important;} .right-sidebar {display: contents;max-width: 500px;} .right-sidebar .video-img-box {display:inline-block !important;} .right-sidebar>.gutter-20>.col-lg-12 {flex: 0 0 25% !important;} .justify-content-center,.h5,iframe,.asg-interstitial,.root--ujvuu ,.text-sponsor,.pb-3 .row .col-6:nth-child(2),.right-sidebar .row .col-6:nth-child(1){display:none}.right-sidebar .video-img-box .img-box {min-width: 100%; max-width: 100%;}.plyr--video {width: 80%;margin: auto;}`,
            AV01: `.group:has(> div.overflow-hidden){display:none} .gap-8 > div{grid-column:span 3 / span 3} .max-w-7xl{max-width: 90vw;}.space-y-4 > div{width: 33vw;display: -webkit-box;}`,
            MDME: `.detail_left ol li {height: 115px; }.detail_left { writing-mode: tb;}.width1200>div{float:none;width:100vw;} .width1200 {width: 100vw; min-width: 100vw; }.detail_right_div ul,.sugetVideo ul {display: grid;grid-template-columns: repeat(4, 1fr);}`,
            AVJOY: `.related-video .thumb-overlay,.related-video .content-info {width: 100%;}.container {max-width: 100vw;} .content-right{ width: 100%;}.content-left {;max-width: 70%;margin:0 auto;} .content-right{display: grid;grid: auto-flow /repeat(4, 1fr) ;} .ad-content-bot,.ad-content-side{display:none}`,
            MISSAV: `.sm\\:container {max-width: 100vw !important;}.order-first{width:100vw !important;} .video-player-container {width: 100%;} 
            .xl\\:grid-cols-4 {gap: 10px;}
            .-mt-6 {width: 80%;height: 80%;margin: 0 auto !important;}.order-last,.order-last >div {max-width: 100% !important;min-width: 100% !important;} 
            .related-videos,.order-last > div  {display: grid;grid-template-columns: repeat(4, 1fr);gap: 10px;} 
            .ad-banner,.advertisement,.ads,.list-none,.space-y-6{display:none !important;} 
            .content-without-search > .flex, .order-last > div >.flex {flex-direction: column;}
            .ml-6 {margin-left: 0 !important;}.mb-6>div {width:100% !important}
            .truncate {white-space: normal;}`
        };


        const commonButtons = {
            javdb: true, javbus: true, javlibrary: true, jable: true, av01: true, avjoy: true, avsubtitles: true, subtitlecat: true, missav: true, btdig: true, btsow: true, hayav: true
        };

        this.SITE_CONFIG = {
            [this.SITES.JAVDB]: { titleSelector: this.SELECTORS.JAVDB_TITLE, codeSelector: this.SELECTORS.JAVDB_CODE, style: this.STYLES.JAVDB, pathCheck: "/v/", buttons: { ...commonButtons, javdb: false } },
            [this.SITES.JAVBUS]: { titleSelector: this.SELECTORS.JAVBUS_TITLE, style: this.STYLES.JAVBUS, pathCheck: /[A-Za-z]+-\d+/, buttons: { ...commonButtons, javbus: false } },
            [this.SITES.JAVLIBRARY]: { titleSelector: this.SELECTORS.JAVLIBRARY_TITLE, style: this.STYLES.JAVLIBRARY, pathCheck: "/?v=", buttons: { ...commonButtons } },
            [this.SITES.JABLE]: { titleSelector: this.SELECTORS.JABLE_TITLE, style: this.STYLES.JABLE, pathCheck: "/videos/", buttons: { ...commonButtons, jable: false }, isVideoSite: true },
            [this.SITES.AVJOY]: { titleSelector: this.SELECTORS.AVJOY_TITLE, style: this.STYLES.AVJOY, pathCheck: "/video/", buttons: { ...commonButtons, avjoy: false }, isVideoSite: true },
            [this.SITES.AV01]: { titleSelector: this.SELECTORS.AV01_TITLE, style: this.STYLES.AV01, pathCheck: "/video/", buttons: { ...commonButtons, av01: false }, isVideoSite: true },
            [this.SITES.MDME]: { style: this.STYLES.MDME, pathCheck: "/", buttons: { ...commonButtons, javdb: false, javbus: false } },
            [this.SITES.MISSAV]: { titleSelector: this.SELECTORS.MISSAV_TITLE, style: this.STYLES.MISSAV, pathCheck: "/cn/", buttons: { ...commonButtons, missav: false }, isVideoSite: true },
            // Magnet Site Configs
            [this.SITES.SUKEBEI]: { style: this.STYLES.SUKEBEI, isMagnetSite: true, magnetSelectors: { list: "tbody tr", hashLink: "td:nth-child(3) a:last-child", title: "td:nth-child(2) a", size: "td:nth-child(4)", date: "td:nth-child(5)", insertPoint: "td:nth-child(3)" } },
            [this.SITES.BTDIG]: { style: this.STYLES.BTDIG, isMagnetSite: true, magnetSelectors: { list: ".one_result", hashLink: ".torrent_name a", title: ".torrent_name a", size: ".torrent_size", date: ".torrent_age", insertPoint: ".torrent_magnet" } },
            [this.SITES.BTSOW]: { style: this.STYLES.BTSOW, isMagnetSite: true, magnetSelectors: { list: ".data-list .row", hashLink: "a", title: "a", size: ".size", date: ".date", insertPoint: "a" } },
        };
    }

    // ==================== 2. UTILITY FUNCTIONS ====================
    utils = {
        isIncludes: (domain) => window.location.hostname.includes(domain),
        isValidPath: (pathCheck) => typeof pathCheck === "string" ? window.location.href.includes(pathCheck) : window.location.href.match(pathCheck),
        waitForElement: (selector, timeoutMs = 3000) => new Promise((resolve, reject) => {
            const existing = document.querySelector(selector);
            if (existing) return resolve(existing);
            const observer = new MutationObserver(() => {
                const el = document.querySelector(selector);
                if (el) { observer.disconnect(); resolve(el); }
            });
            observer.observe(document.documentElement, { childList: true, subtree: true });
            if (timeoutMs > 0) setTimeout(() => { observer.disconnect(); reject(new Error(`waitForElement timeout: ${selector}`)); }, timeoutMs);
        }),
        moveElementToNextSiblingFirstChild: (selector) => {
            const element = document.querySelector(selector);

            if (!element) {
                console.error("The provided element is invalid.");
                return;
            }
            const nextSibling = element.nextElementSibling;

            if (!nextSibling) {
                console.warn("The element has no next sibling to move to.");
                return;
            }

            nextSibling.insertBefore(element, nextSibling.firstElementChild);
        },
        getCodeFromCurrentSite: (domain, selector) => {
            if (!this.utils.isIncludes(domain)) return null;
            const titleElement = document.querySelector(selector);
            const title = titleElement ? titleElement.textContent.trim() : "";
            let codeMatch = title.match(/[A-Za-z]+-\d+/) || document.title.match(/[A-Za-z]+-\d+/);
            if (codeMatch && codeMatch[0]) return codeMatch[0];
            const lastSegment = decodeURIComponent(window.location.pathname.split("/").filter(Boolean).pop() || "");
            codeMatch = lastSegment.match(/[A-Za-z]+-\d+/);
            return codeMatch ? codeMatch[0] : null;
        },
        gmFetch: (url, options = {}) => new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: "GET",
                url,
                ...options,
                onload: (response) => {
                    if (response.status >= 200 && response.status < 300) {
                        resolve(response);
                    } else {
                        reject(new Error(`Request failed with status ${response.status}`));
                    }
                },
                onerror: (error) => reject(new Error("Network error during request", { cause: error })),
            });
        }),
        NewElement: (tagName = "div", config = {}) => {
            const element = document.createElement(tagName);
            if (config.textContent) element.textContent = config.textContent;
            if (config.className) element.className = config.className;
            if (config.id) element.id = config.id;
            if (config.attributes) Object.entries(config.attributes).forEach(([key, value]) => element.setAttribute(key, value));
            if (config.events) Object.entries(config.events).forEach(([event, handler]) => element.addEventListener(event, handler));
            if (config.style) Object.assign(element.style, config.style);
            return element;
        },

    };

    // ==================== 3. DOM & UI MANIPULATION ====================
    dom = {
        injectStyleOnce: (style, idKey) => {
            if (!style || document.getElementById(idKey)) return;
            const styleElement = this.utils.NewElement("style", { id: idKey, textContent: style });
            document.head.appendChild(styleElement);
        },

        linkButtonDefs: {
            javbus: { text: "JAVBUS", color: "#3498db", url: (code) => `https://www.javbus.com/${code.replace("DSVR", "3DSVR")}` },
            javlibrary: { text: "Javlibrary", color: "#6027ae", url: (code) => `https://www.javlibrary.com/cn/vl_searchbyid.php?keyword=${code}` },
            jable: { text: "Jable", color: "#27ae60", url: (code) => `https://jable.tv/videos/${code}/` },
            av01: { text: "AV01", color: "#9b59b6", url: (code) => `https://www.av01.tv/cn/search?q=${code}` },
            avjoy: { text: "AVJOY", color: "#f39c12", url: (code) => `https://avjoy.me/search/videos/${code}` },
            avsubtitles: { text: "AVSubtitles", color: "#e91e63", url: (code) => `https://www.avsubtitles.com/search_results.php?search=${code}` },
            subtitlecat: { text: "SubtitleCat", color: "#ff9800", url: (code) => `https://www.subtitlecat.com/index.php?search=${code}` },
            missav: { text: "MISSAV", color: "#3f51b5", url: (code) => `https://missav.ws/cn/search/${code}` },
            btdig: { text: "BTDIG", color: "#795548", url: (code) => `https://btdig.com/search?q=${code}` },
            btsow: { text: "BTSOW", color: "#607d8b", url: (code) => `https://btsow.pics/#/search/${code}` },
            hayav: { text: "HAYAV", color: "#07d8b6", url: (code) => `https://hayav.com/search/${code}` }
        },

        addLinkButtons: async (code, selector, buttonsConfig) => {
            const titleElement = await this.utils.waitForElement(selector).catch(() => document.querySelector(selector));
            if (!code || !titleElement) return;

            const buttonContainer = this.utils.NewElement("div", {
                id: "cross-site-button-container",
                style: { display: "inline-block", marginLeft: "10px" }
            });

            // JAVDB special handling (async search)
            if (buttonsConfig?.javdb) {
                try {
                    const response = await this.utils.gmFetch(`https://javdb.com/search?q=${encodeURIComponent(code)}`);
                    const doc = new DOMParser().parseFromString(response.responseText, "text/html");
                    const videoLink = doc.querySelector('a[href*="/v/"]');
                    const url = videoLink ? `https://javdb.com${videoLink.getAttribute("href")}` : `https://javdb.com/search?q=${encodeURIComponent(code)}`;
                    const btn = this.dom.createSingleButton("JAVDB", videoLink ? "#e74c3c" : "#e67e22", url);
                    buttonContainer.appendChild(btn);
                } catch (error) {
                    console.error("JAVDB search failed:", error);
                    const btn = this.dom.createSingleButton("搜JAVDB", "#e67e22", `https://javdb.com/search?q=${encodeURIComponent(code)}`);
                    buttonContainer.appendChild(btn);
                }
            }

            // Other sites
            Object.entries(this.dom.linkButtonDefs).forEach(([key, def]) => {
                if (buttonsConfig?.[key]) {
                    const btn = this.dom.createSingleButton(def.text, def.color, def.url(code));
                    buttonContainer.appendChild(btn);
                }
            });

            titleElement.parentNode.insertBefore(buttonContainer, titleElement.nextSibling);
        },

        createSingleButton: (text, bgColor, url) => {
            return this.utils.NewElement("a", {
                textContent: text,
                attributes: { href: url, target: "_blank" },
                style: { marginRight: "8px", padding: "4px 8px", color: "#fff", background: bgColor, border: "none", borderRadius: "3px", cursor: "pointer", textDecoration: "none", fontSize: "14px" }
            });
        },

        enhanceMagnetLinks: (config) => {
            const processNode = (node) => {
                if (!node || node.__tm_magnet_enhanced__) return;

                const selectors = config.magnetSelectors;
                const hashLinkEl = node.querySelector(selectors.hashLink);
                if (!hashLinkEl) return;

                const hashMatch = hashLinkEl.href.match(/[0-9a-fA-F]{40}/);
                if (!hashMatch) return;
                const hash = hashMatch[0].toLowerCase();

                const title = node.querySelector(selectors.title)?.innerText || `magnet-${hash.substring(0, 8)}`;
                const size = node.querySelector(selectors.size)?.innerText || "";
                const date = node.querySelector(selectors.date)?.innerText || "";
                const magnetUrl = `magnet:?xt=urn:btih:${hash}&dn=${encodeURIComponent(title)} ${size} ${date}`;

                const insertPoint = node.querySelector(selectors.insertPoint);
                if (!insertPoint) return;

                const parent = insertPoint.parentElement;

                // Copy button
                const copyBtn = this.utils.NewElement("a", {
                    textContent: "📋",
                    attributes: { href: "#", title: "Copy Magnet Link" },
                    style: { marginLeft: "8px", cursor: "pointer", textDecoration: "none" },
                    events: {
                        click: async (e) => {
                            e.preventDefault();
                            e.stopPropagation();
                            await navigator.clipboard.writeText(decodeURIComponent(magnetUrl));
                            e.currentTarget.textContent = "✅";
                            setTimeout(() => { e.currentTarget.textContent = "📋"; }, 2000);
                        }
                    }
                });

                // Preview button
                const previewBtn = this.utils.NewElement("a", {
                    textContent: "👁️",
                    attributes: { href: "#", title: "Preview Torrent Content" },
                    style: { marginLeft: "6px", cursor: "pointer", textDecoration: "none" },
                    events: {
                        click: (e) => {
                            e.preventDefault();
                            e.stopPropagation();
                            this.drawer.open(`https://magnet.pics/m/${hash}`, title);
                        }
                    }
                });

                parent.append(copyBtn, previewBtn);
                node.__tm_magnet_enhanced__ = true;
            };

            const run = () => document.querySelectorAll(config.magnetSelectors.list).forEach(processNode);
            run(); // Initial run

            // Observe for dynamically added nodes
            const observer = new MutationObserver((mutations) => {
                mutations.forEach(m => {
                    if (m.addedNodes.length) run();
                });
            });
            observer.observe(document.body, { childList: true, subtree: true });
        },
        replacePreviewImagesFromJAVBUS: async function () {
            try {
                const code = document.querySelector(this.SELECTORS.JAVDB_CODE)?.textContent.trim();
                if (!code) {
                    console.log("JableLinker: JAVDB code not found for image replacement.");
                    return;
                }

                const javbusUrl = `https://www.javbus.com/${code}`;
                const userCookie = await GM_getValue("javbus_cookie", "");
                const headers = {};
                if (userCookie) {
                    headers["Cookie"] = userCookie;
                }

                const response = await this.utils.gmFetch(javbusUrl, { headers });
                const doc = new DOMParser().parseFromString(response.responseText, "text/html");
                const anchorElements = Array.from(doc.querySelectorAll(this.SELECTORS.JAVBUS_SAMPLE_BOX));
                const javbusHrefs = anchorElements.map(a => a.getAttribute("href")).filter(Boolean);

                if (javbusHrefs.length > 0) {
                    if ((javbusHrefs[0] || "").includes("javdb")) {
                        console.log("JableLinker: JAVBUS links back to JAVDB, skipping image replacement.");
                        return;
                    }

                    const previewImagesContainer = document.querySelector(".preview-images");
                    if (previewImagesContainer) {
                        Array.from(document.querySelectorAll(this.SELECTORS.JAVDB_PREVIEW_IMAGES)).forEach(item => item.remove());

                        javbusHrefs.forEach(href => {
                            const a = this.utils.NewElement("a", {
                                className: "tile-item",
                                attributes: {
                                    href: href,
                                    "data-fancybox": "gallery",
                                    "data-caption": ""
                                }
                            });
                            const img = this.utils.NewElement("img", {
                                attributes: {
                                    src: href,
                                    alt: "",
                                    loading: "lazy"
                                }
                            });
                            a.appendChild(img);
                            previewImagesContainer.appendChild(a);
                        });
                        console.log(`JableLinker: Replaced preview images with ${javbusHrefs.length} items from JAVBUS for code: ${code}`);
                    }
                } else {
                    console.log(`JableLinker: No preview images found on JAVBUS for code: ${code}`);
                }
            } catch (error) {
                console.error("JableLinker: Error during replacePreviewImagesFromJAVBUS:", error);
            }
        }
    };

    // ==================== 4. DRAWER CLASS FOR PREVIEWS ====================
    drawer = (() => {
        let drawerInstance = null;

        const create = (options) => {
            const { direction = 'right', width = '40vw', title = 'Preview' } = options;

            if (drawerInstance) {
                try { document.body.removeChild(drawerInstance.element); } catch (e) { }
            }

            const element = this.utils.NewElement('div', {
                style: {
                    position: 'fixed', top: '0', [direction]: '0', width, height: '100vh', background: 'white',
                    boxShadow: '0 0 10px rgba(0,0,0,0.1)', transform: `translateX(${direction === 'left' ? '-100%' : '100%'})`,
                    transition: 'transform 0.3s ease', zIndex: '2147483647'
                }
            });

            const header = this.utils.NewElement('div', {
                style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '16px', borderBottom: '1px solid #eee' }
            });
            const titleEl = this.utils.NewElement('h5', { textContent: title, style: { margin: '0' } });
            const closeBtn = this.utils.NewElement('button', {
                textContent: '×',
                style: { background: 'none', border: 'none', fontSize: '24px', cursor: 'pointer', color: '#666' }
            });
            const content = this.utils.NewElement('div', {
                style: { padding: '0', height: 'calc(100vh - 60px)', overflowY: 'auto' }
            });

            closeBtn.onclick = () => close();

            header.append(titleEl, closeBtn);
            element.append(header, content);
            document.body.appendChild(element);

            drawerInstance = { element, content };
            return drawerInstance;
        };

        const open = (url, title, width, direction) => {
            const instance = create({ title, width, direction });
            instance.content.innerHTML = `<iframe width='100%' height='100%' src='${url}' style='border:none;'></iframe>`;
            requestAnimationFrame(() => instance.element.style.transform = 'translateX(0)');
        };

        const close = () => {
            if (!drawerInstance) return;
            const { element } = drawerInstance;
            const direction = element.style.left === '0px' ? 'left' : 'right';
            element.style.transform = `translateX(${direction === 'left' ? '-100%' : '100%'})`;
            setTimeout(() => {
                if (drawerInstance && drawerInstance.element === element) {
                    drawerInstance.content.innerHTML = '';
                }
            }, 300);
        };

        return { open };
    })();

    // ==================== NEW SUBTITLES MODULE ====================
    subtitles = {
        _inited: false,
        _state: {
            vttText: null,
            subtitleLabel: null,
            objectUrlsByVideo: new WeakMap(),
            hasLoadedSubtitle: false
        },
        _isLikelyVtt: (text) => {
            const firstLine = (text || "").split(/\r?\n/)[0]?.trim();
            return firstLine === "WEBVTT" || (firstLine || "").startsWith("WEBVTT");
        },
        _convertSrtToVtt: (srtText) => {
            let text = (srtText || "").replace(/\r\n?/g, "\n");
            text = text.replace(/^\uFEFF/, "");
            text = text.replace(/(\d\d:\d\d:\d\d),(\d\d\d)/g, "$1.$2");
            text = text.replace(/\s+-->\s+/g, " --> ");
            text = text.replace(/\n\d+\n(?=\d\d:\d\d:\d\d\.\d{3}\s+-->)/g, "\n");
            text = text.replace(/\n{3,}/g, "\n\n");
            return "WEBVTT\n\n" + text.trim() + "\n";
        },
        _convertAssToVtt: (assText) => {
            let text = (assText || "").replace(/\r\n?/g, "\n");
            text = text.replace(/^\uFEFF/, "");
            const eventsMatch = text.match(/\[Events\][\s\S]*?(?=\[|$)/);
            if (!eventsMatch) return "WEBVTT\n\n";
            const eventsSection = eventsMatch[0];
            const dialogueLines = eventsSection.match(/^Dialogue:.*$/gm) || [];
            let vttContent = "WEBVTT\n\n";
            dialogueLines.forEach((line) => {
                const parts = line.split(",");
                if (parts.length < 10) return;
                const startTime = parts[1];
                const endTime = parts[2];
                const textContent = parts.slice(9).join(",");
                const formatTime = (timeStr) => {
                    const timeMatch = timeStr.match(/(\d+):(\d{2}):(\d{2})\.(\d{2})/);
                    if (!timeMatch) return timeStr;
                    const [, h, m, s, cs] = timeMatch;
                    return `${h.padStart(2, "0")}:${m.padStart(2, "0")}:${s.padStart(2, "0")}.${(parseInt(cs) * 10).toString().padStart(3, "0")}`;
                };
                let cleanText = textContent.replace(/\{[^}]*\}/g, "").replace(/\\N/g, "\n").replace(/\\n/g, "\n").replace(/\\h/g, " ").trim();
                if (cleanText) vttContent += `${formatTime(startTime)} --> ${formatTime(endTime)}\n${cleanText}\n\n`;
            });
            return vttContent.trim() + "\n";
        },
        _isLikelyAss: (text) => {
            return (text || "").split(/\r?\n/)[0]?.trim() === "[Script Info]" || text.includes("[Events]") || text.includes("Dialogue:");
        },
        _toVtt: (text) => {
            if (this.subtitles._isLikelyVtt(text)) return text;
            if (this.subtitles._isLikelyAss(text)) return this.subtitles._convertAssToVtt(text);
            return this.subtitles._convertSrtToVtt(text);
        },
        _revokeExistingObjectUrl: (video) => {
            try {
                const url = this.subtitles._state.objectUrlsByVideo.get(video);
                if (url) {
                    URL.revokeObjectURL(url);
                    this.subtitles._state.objectUrlsByVideo.delete(video);
                }
            } catch (_) { }
        },
        _removeExistingLocalTracks: (video) => {
            video.querySelectorAll('track[data-local-subtitle="1"]').forEach((t) => t.remove());
        },
        _attachSubtitleToVideo: (video) => {
            if (!this.subtitles._state.vttText) return;
            this.subtitles._revokeExistingObjectUrl(video);
            this.subtitles._removeExistingLocalTracks(video);
            const blob = new Blob([this.subtitles._state.vttText], { type: "text/vtt" });
            const url = URL.createObjectURL(blob);
            this.subtitles._state.objectUrlsByVideo.set(video, url);
            const track = this.utils.NewElement("track", {
                attributes: { kind: "subtitles", label: this.subtitles._state.subtitleLabel || "Local Subtitle", srclang: "en", src: url, default: true, "data-local-subtitle": "1" },
                events: {
                    load: () => {
                        if (track.track) track.track.mode = "showing";
                    }
                }
            });
            video.appendChild(track);
            try {
                for (const tt of video.textTracks) {
                    tt.mode = (tt.label === track.label || tt.language === "en") ? "showing" : "disabled";
                }
            } catch (e) { }
        },
        _attachToAllVideos: () => {
            const videoSelectors = ["video", 'iframe[src*="player"]', 'iframe[src*="video"]', ".video-player video", ".player video", "#player video", ".jwplayer video", ".video-js video", ".plyr video"];
            let allVideos = [];
            videoSelectors.forEach((selector) => {
                allVideos = allVideos.concat(Array.from(document.querySelectorAll(selector)));
            });
            const uniqueVideos = [...new Set(allVideos)];
            if (uniqueVideos.length === 0) return;
            let maxWidthVideo = uniqueVideos.reduce((max, vid) => (vid.getBoundingClientRect().width > max.getBoundingClientRect().width ? vid : max), uniqueVideos[0]);
            if (maxWidthVideo) this.subtitles._attachSubtitleToVideo(maxWidthVideo);
        },
        _ensureUi: () => {
            if (document.getElementById("tm-add-local-subtitle-btn")) return;
            const container = this.utils.NewElement("div", {
                id: "tm-add-local-subtitle-container",
                style: { position: "fixed", zIndex: "2147483647", bottom: "16px", left: "16px", display: "flex", gap: "8px", alignItems: "center" }
            });
            const input = this.utils.NewElement("input", { attributes: { type: "file", accept: ".vtt,.srt,.ass,text/vtt,text/plain,.sub" }, style: { display: "none" } });
            const label = this.utils.NewElement("span", { id: "tm-add-local-subtitle-label", style: { fontSize: "12px", color: "#fff", textShadow: "0 1px 2px rgba(0,0,0,0.6)" } });
            const button = this.utils.NewElement("button", {
                id: "tm-add-local-subtitle-btn", textContent: "本地字幕", attributes: { title: "为页面视频加载本地字幕 (.srt/.vtt/.ass)" },
                style: { padding: "6px 10px", fontSize: "12px", color: "#fff", background: "rgba(0,0,0,0.6)", border: "1px solid rgba(255,255,255,0.2)", borderRadius: "6px", cursor: "pointer", backdropFilter: "saturate(150%) blur(6px)", userSelect: "none" },
                events: { click: () => input.click() }
            });
            const retryButton = this.utils.NewElement("button", {
                id: "tm-retry-subtitle-btn", textContent: "重新附加", attributes: { title: "重新尝试附加字幕到视频" },
                style: { padding: "6px 10px", fontSize: "12px", color: "#fff", background: "rgba(255,165,0,0.8)", border: "1px solid rgba(255,255,255,0.2)", borderRadius: "6px", cursor: "pointer", backdropFilter: "saturate(150%) blur(6px)", userSelect: "none" },
                events: { click: () => this.subtitles._attachToAllVideos() }
            });
            input.addEventListener("change", async () => {
                const file = input.files?.[0];
                if (!file) return;
                this.subtitles._state.subtitleLabel = file.name;
                try {
                    const text = await file.text();
                    this.subtitles._state.vttText = this.subtitles._toVtt(text);
                    this.subtitles._state.hasLoadedSubtitle = true;
                    label.textContent = file.name;
                    this.subtitles._attachToAllVideos();
                    setTimeout(() => this.subtitles._attachToAllVideos(), 3000);
                } catch (err) { alert("读取字幕文件失败,请重试。"); }
                finally { input.value = ""; }
            });
            container.append(button, retryButton, label, input);
            document.documentElement.appendChild(container);
        },
        _ensureCueStyle: () => {
            if (document.getElementById("tm-local-subtitle-cue-style")) return;
            const style = this.utils.NewElement("style", { id: "tm-local-subtitle-cue-style", textContent: `\nvideo::cue {\n  -webkit-text-stroke: 2px #000000;\n  text-shadow: 1px 0 0 #000000, -1px 0 0 #000000, 0 1px 0 #000000, 0 -1px 0 #000000, 1px 1px 0 #000000, -1px -1px 0 #000000, 1px -1px 0 #000000, -1px 1px 0 #000000;\n}` });
            document.head.appendChild(style);
        },
        _observeNewVideos: () => {
            const observer = new MutationObserver((mutations) => {
                if (!this.subtitles._state.hasLoadedSubtitle) return;
                for (const m of mutations) {
                    if (m.type === "childList") {
                        m.addedNodes.forEach((node) => {
                            if (node?.nodeType === 1 && (node.nodeName.toLowerCase() === "video" || node.querySelector?.("video"))) {
                                this.subtitles._attachToAllVideos();
                            }
                        });
                    }
                }
            });
            observer.observe(document.documentElement || document.body, { childList: true, subtree: true });
        },
        init: () => {
            if (this.subtitles._inited) return;
            this.subtitles._inited = true;
            this.subtitles._ensureUi();
            this.subtitles._ensureCueStyle();
            this.subtitles._observeNewVideos();
        }
    };

    // ==================== 5. SITE HANDLERS ====================
    handlers = {
        handleGenericSite: (siteKey, hooks = {}) => {
            const config = this.SITE_CONFIG[siteKey];
            if (!config) return;

            if (config.style) this.dom.injectStyleOnce(config.style, siteKey.replace(/\./g, "-"));

            const isDetailPage = this.utils.isValidPath(config.pathCheck);

            if (siteKey === this.SITES.JAVBUS) {
                this.utils.moveElementToNextSiblingFirstChild(".masonry-brick:has(.avatar-box)");

                [...document.querySelectorAll('.photo-frame img')].forEach((img) => {
                    if (img.src.includes('/pics/thumb/')) {
                        img.src = img.src.replace('/pics/thumb/', '/pics/cover/').replace('.jpg', '_b.jpg');
                    }
                });
            } else if (siteKey === this.SITES.JAVDB) {
                if (isDetailPage) this.dom.replacePreviewImagesFromJAVBUS.call(this);
            }

            if (config.isMagnetSite) {
                this.dom.enhanceMagnetLinks(config);
                return;
            }

            if (config.isVideoSite && isDetailPage) {
                this.subtitles.init();
            }

            if (isDetailPage) {
                const code = this.utils.getCodeFromCurrentSite(siteKey, config.titleSelector);
                if (code) {
                    this.dom.addLinkButtons(code, config.titleSelector, config.buttons);
                }
            } else {
                if (typeof hooks.onListPage === "function") hooks.onListPage(config);
            }
        },
        setupCookieMenu: () => {
            GM_registerMenuCommand("设置JAVBUS Cookie", async () => {
                const current = await GM_getValue("javbus_cookie", "");
                const input = prompt("请输入JAVBUS的Cookie:", current || "");
                if (input !== null) await GM_setValue("javbus_cookie", input.trim());
            });
            GM_registerMenuCommand("设置JAVDB Cookie", async () => {
                const current = await GM_getValue("javdb_cookie", "");
                const input = prompt("请输入JAVDB的Cookie:", current || "");
                if (input !== null) await GM_setValue("javdb_cookie", input.trim());
            });
        }
    };

    // ==================== 6. INITIALIZATION ====================
    init() {
        this.handlers.setupCookieMenu();
        const currentSiteKey = Object.keys(this.SITE_CONFIG).find(site => this.utils.isIncludes(site));

        if (currentSiteKey) {
            this.handlers.handleGenericSite(currentSiteKey);
        } else {
            console.log("JableLinker: No handler for current site.");
        }
    }
}

// Launch the script
(function () {
    'use strict';
    new JableLinker().init();
})();