NavCarousel 🎞️

One-click access toolbar!

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @version      1.15.6
// @name         NavCarousel 🎞️
// @description  One-click access toolbar!
// @icon         https://fitgirl-repacks.site/favicon.ico
// @match        *://fitgirl-repacks.site/*
// @match        *://fitgirl-repacks.site/feed/*
// @grant        GM_setClipboard
// @grant        GM_addStyle
// @grant        GM_openInTab
// @grant        GM_xmlhttpRequest
// @grant        GM_registerMenuCommand
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_notification
// @connect      127.0.0.1
// @connect      localhost
// @run-at       document-idle
// @license      MIT
// @namespace    name1or2-1510385-jd4dsc
// @author       name1or2
// ==/UserScript==

(function() {
    'use strict';

    const CONFIG = {
        CAROUSEL_MAX_WIDTH: 1100,
        CAROUSEL_SCROLL_SPEED: 30,
        CAROUSEL_MIN_BUTTONS: 4,
        NOTIFICATION_DURATION: 2000,
        BUTTON_FLASH_DURATION: 1500,
        OBSERVER_DEBOUNCE: 500,
        CACHE_MAX_ENTRIES: 100,
        CACHE_KEY_PREFIX: 'fitgirl_cache_',
        DEFAULT_SETTINGS: {
            carousel_speed: 30,
            use_native_notifications: true,
            buttons_visible: {
                steam: true, steam_search: true, steamgriddb: true,
                pcgw: true, fuckingfast: true, jdownloader: true
            }
        }
    };

    GM_addStyle(`
        article .fitgirl-button-bar-wrapper{max-width:${CONFIG.CAROUSEL_MAX_WIDTH}px;overflow:hidden;margin:10px 0;position:relative}
        article .fitgirl-button-bar{display:inline-flex;gap:8px;padding:8px;background-color:#181a1b;border:1px solid #3e4446;border-radius:10px;flex-wrap:nowrap;white-space:nowrap;will-change:transform}
        article .fitgirl-button{display:flex;align-items:center;justify-content:center;gap:6px;padding:6px 12px;margin:0 4px;font-size:12px;font-weight:500;color:white;border-radius:3px;transition:all .2s ease;cursor:pointer;border:none;height:36px;flex-shrink:0}
        article .fitgirl-button.unavailable{filter:grayscale(100%);opacity:.4;cursor:not-allowed}
        article .fitgirl-button.fuckingfast{background-color:#2b2a33}
        article .fitgirl-button.fuckingfast:hover:not(.success):not(.error){background-color:#52525e}
        article .fitgirl-button.success{background-color:#339966}
        article .fitgirl-button.error{background-color:#ff3333}
        article .fitgirl-button:active{transform:scale(.95)}
        article .fitgirl-icon{width:24px;height:24px;flex-shrink:0}
        article .fitgirl-button.fitgirl-steam-button{background-color:rgba(103,193,245,.2);color:#67c1f5;font-family:"Motiva Sans",Arial,Helvetica,sans-serif;font-size:13px;padding:0 15px;border-radius:3px;box-shadow:1px 1px 0 #0000001a;height:36px;line-height:30px;text-shadow:none}
        article .fitgirl-button.fitgirl-steam-button:hover{background-color:#417a9b;color:#67c1f5;text-decoration:none}
        article .fitgirl-button.fitgirl-steam-button:active{background-color:#2b485f;color:#67c1f5;box-shadow:inset 0 2px 4px rgba(0,0,0,.3);transform:none}
        article .fitgirl-button.fitgirl-steam-search-button{background:#1a9fff;width:34px;height:34px;padding:0;border-radius:2px;display:flex;align-items:center;justify-content:center;border:none;color:#fff;cursor:pointer;transition:background .2s ease-out,box-shadow .2s ease-out,transform .2s ease-out;flex-shrink:0}
        article .fitgirl-button.fitgirl-steam-search-button svg{width:18px;height:18px;transition:transform .2s ease-out}
        article .fitgirl-button.fitgirl-steam-search-button:hover{background:#45acff;box-shadow:2px 2px 3px rgba(0,0,0,.2)}
        article .fitgirl-button.fitgirl-steam-search-button:hover svg{transform:scale(1.2)}
        article .fitgirl-button.fitgirl-steam-search-button:active{transform:translateY(1px)}
        article .fitgirl-button.fitgirl-steamgriddb-button{background-color:#32414C;color:#5FB4F0;font-family:"Open Sans",sans-serif;font-size:11px;font-weight:600;text-transform:uppercase;text-decoration:underline;padding:6px 12px}
        article .fitgirl-button.fitgirl-steamgriddb-button:hover:not(.success):not(.error){background-color:#263238;color:#8ECAF4}
        article .fitgirl-button.fitgirl-steamgriddb-button img{height:20px;width:auto}
        article .fitgirl-button.fitgirl-pcgw-button{background-color:#262A2B;padding:6px 12px}
        article .fitgirl-button.fitgirl-pcgw-button:hover:not(.success):not(.error){background-color:#141516}
        article .fitgirl-button.fitgirl-pcgw-button img{height:20px;width:auto}
        article .fitgirl-button.fitgirl-jd-button{background-color:#0a6ab6;color:#ffffff;font-family:sans-serif;font-size:13px;font-weight:600}
        article .fitgirl-button.fitgirl-jd-button:hover:not(.success):not(.error){background-color:#124700}
        .fitgirl-settings-overlay{position:fixed;top:0;left:0;width:100%;height:100%;background-color:rgba(0,0,0,.7);z-index:10000;display:flex;align-items:center;justify-content:center}
        .fitgirl-settings-modal{background-color:#1e1e1e;border:1px solid #3e4446;border-radius:8px;padding:20px;max-width:500px;width:90%;color:white}
        .fitgirl-settings-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;padding-bottom:10px;border-bottom:1px solid #3e4446}
        .fitgirl-settings-header h2{margin:0;font-size:18px}
        .fitgirl-settings-close{background:none;border:none;color:#999;font-size:24px;cursor:pointer;padding:0;width:30px;height:30px;line-height:30px}
        .fitgirl-settings-close:hover{color:white}
        .fitgirl-settings-section{margin-bottom:20px}
        .fitgirl-settings-section h3{margin:0 0 10px;font-size:14px;color:#999}
        .fitgirl-settings-control{display:flex;align-items:center;justify-content:space-between;padding:8px 0}
        .fitgirl-settings-control label{font-size:13px}
        .fitgirl-settings-slider{width:150px;height:4px;background:#3e4446;border-radius:2px;outline:none;-webkit-appearance:none}
        .fitgirl-settings-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:16px;height:16px;background:#67c1f5;cursor:pointer;border-radius:50%}
        .fitgirl-settings-slider::-moz-range-thumb{width:16px;height:16px;background:#67c1f5;cursor:pointer;border-radius:50%;border:none}
        .fitgirl-settings-checkbox{width:18px;height:18px;cursor:pointer}
        .fitgirl-settings-value{font-size:13px;color:#67c1f5;min-width:40px;text-align:right}
        .fitgirl-settings-buttons{display:flex;gap:10px;justify-content:flex-end;margin-top:20px;padding-top:20px;border-top:1px solid #3e4446}
        .fitgirl-settings-button{padding:8px 16px;border:none;border-radius:4px;cursor:pointer;font-size:13px;font-weight:500}
        .fitgirl-settings-button-primary{background-color:#0a6ab6;color:white}
        .fitgirl-settings-button-primary:hover{background-color:#124700}
        .fitgirl-settings-button-secondary{background-color:#3e4446;color:white}
        .fitgirl-settings-button-secondary:hover{background-color:#52525e}
    `);

    // ----- CACHE -----
    function getCacheKey(postId) { return CONFIG.CACHE_KEY_PREFIX + postId; }
    function getCachedData(postId) {
        const data = localStorage.getItem(getCacheKey(postId));
        return data ? JSON.parse(data) : null;
    }
    function setCachedData(postId, data) {
        const key = getCacheKey(postId);
        const keys = Object.keys(localStorage).filter(k => k.startsWith(CONFIG.CACHE_KEY_PREFIX));
        if (keys.length >= CONFIG.CACHE_MAX_ENTRIES) {
            localStorage.removeItem(keys.sort()[0]);
        }
        localStorage.setItem(key, JSON.stringify(data));
    }

    // ----- USER SETTINGS -----
    let userSettings = null;
    function loadUserSettings() {
        const stored = GM_getValue('user_settings', null);
        if (stored) {
            const parsed = JSON.parse(stored);
            userSettings = {
                carousel_speed: parsed.carousel_speed ?? CONFIG.DEFAULT_SETTINGS.carousel_speed,
                use_native_notifications: parsed.use_native_notifications ?? CONFIG.DEFAULT_SETTINGS.use_native_notifications,
                buttons_visible: {
                    steam: parsed.buttons_visible?.steam ?? CONFIG.DEFAULT_SETTINGS.buttons_visible.steam,
                    steam_search: parsed.buttons_visible?.steam_search ?? CONFIG.DEFAULT_SETTINGS.buttons_visible.steam_search,
                    steamgriddb: parsed.buttons_visible?.steamgriddb ?? CONFIG.DEFAULT_SETTINGS.buttons_visible.steamgriddb,
                    pcgw: parsed.buttons_visible?.pcgw ?? CONFIG.DEFAULT_SETTINGS.buttons_visible.pcgw,
                    fuckingfast: parsed.buttons_visible?.fuckingfast ?? CONFIG.DEFAULT_SETTINGS.buttons_visible.fuckingfast,
                    jdownloader: parsed.buttons_visible?.jdownloader ?? CONFIG.DEFAULT_SETTINGS.buttons_visible.jdownloader
                }
            };
            saveUserSettings(userSettings);
        } else {
            userSettings = JSON.parse(JSON.stringify(CONFIG.DEFAULT_SETTINGS));
        }
        return userSettings;
    }
    function saveUserSettings(settings) {
        userSettings = settings;
        GM_setValue('user_settings', JSON.stringify(settings));
    }

    function openSettingsModal() {
        const overlay = document.createElement('div');
        overlay.className = 'fitgirl-settings-overlay';
        overlay.innerHTML = `
            <div class="fitgirl-settings-modal">
                <div class="fitgirl-settings-header">
                    <h2>FitGirl Enhanced Settings</h2>
                    <button class="fitgirl-settings-close">&times;</button>
                </div>
                <div class="fitgirl-settings-section">
                    <h3>CAROUSEL</h3>
                    <div class="fitgirl-settings-control">
                        <label>Scroll Speed</label>
                        <div style="display:flex;align-items:center;gap:10px;">
                            <input type="range" class="fitgirl-settings-slider" id="carousel-speed" min="10" max="100" value="${userSettings.carousel_speed}">
                            <span class="fitgirl-settings-value" id="carousel-speed-value">${userSettings.carousel_speed} px/s</span>
                        </div>
                    </div>
                </div>
                <div class="fitgirl-settings-section">
                    <h3>NOTIFICATIONS</h3>
                    <div class="fitgirl-settings-control">
                        <label>Use Native Browser Notifications</label>
                        <input type="checkbox" class="fitgirl-settings-checkbox" id="native-notifications" ${userSettings.use_native_notifications ? 'checked' : ''}>
                    </div>
                </div>
                <div class="fitgirl-settings-section">
                    <h3>BUTTON VISIBILITY</h3>
                    <div class="fitgirl-settings-control"><label>Steam Store Button</label><input type="checkbox" class="fitgirl-settings-checkbox" id="btn-steam" ${userSettings.buttons_visible.steam ? 'checked' : ''}></div>
                    <div class="fitgirl-settings-control"><label>Steam Search Button</label><input type="checkbox" class="fitgirl-settings-checkbox" id="btn-steam-search" ${userSettings.buttons_visible.steam_search ? 'checked' : ''}></div>
                    <div class="fitgirl-settings-control"><label>SteamGridDB Button</label><input type="checkbox" class="fitgirl-settings-checkbox" id="btn-steamgriddb" ${userSettings.buttons_visible.steamgriddb ? 'checked' : ''}></div>
                    <div class="fitgirl-settings-control"><label>PCGamingWiki Button</label><input type="checkbox" class="fitgirl-settings-checkbox" id="btn-pcgw" ${userSettings.buttons_visible.pcgw ? 'checked' : ''}></div>
                    <div class="fitgirl-settings-control"><label>FuckingFast Button</label><input type="checkbox" class="fitgirl-settings-checkbox" id="btn-fuckingfast" ${userSettings.buttons_visible.fuckingfast ? 'checked' : ''}></div>
                    <div class="fitgirl-settings-control"><label>JDownloader Button</label><input type="checkbox" class="fitgirl-settings-checkbox" id="btn-jdownloader" ${userSettings.buttons_visible.jdownloader ? 'checked' : ''}></div>
                </div>
                <div class="fitgirl-settings-buttons">
                    <button class="fitgirl-settings-button fitgirl-settings-button-secondary" id="settings-cancel">Cancel</button>
                    <button class="fitgirl-settings-button fitgirl-settings-button-primary" id="settings-save">Save</button>
                </div>
            </div>
        `;
        document.body.appendChild(overlay);
        const speedSlider = overlay.querySelector('#carousel-speed');
        const speedValue = overlay.querySelector('#carousel-speed-value');
        speedSlider.addEventListener('input', () => { speedValue.textContent = `${speedSlider.value} px/s`; });
        const closeModal = () => { document.body.removeChild(overlay); };
        overlay.querySelector('.fitgirl-settings-close').addEventListener('click', closeModal);
        overlay.querySelector('#settings-cancel').addEventListener('click', closeModal);
        overlay.addEventListener('click', e => { if (e.target === overlay) closeModal(); });
        overlay.querySelector('#settings-save').addEventListener('click', () => {
            const newSettings = {
                carousel_speed: parseInt(speedSlider.value),
                use_native_notifications: overlay.querySelector('#native-notifications').checked,
                buttons_visible: {
                    steam: overlay.querySelector('#btn-steam').checked,
                    steam_search: overlay.querySelector('#btn-steam-search').checked,
                    steamgriddb: overlay.querySelector('#btn-steamgriddb').checked,
                    pcgw: overlay.querySelector('#btn-pcgw').checked,
                    fuckingfast: overlay.querySelector('#btn-fuckingfast').checked,
                    jdownloader: overlay.querySelector('#btn-jdownloader').checked
                }
            };
            saveUserSettings(newSettings);
            showNotification('Settings saved! Refresh the page to apply changes.', '#339966');
            closeModal();
        });
    }

    // ----- DATA EXTRACTION -----
    function extractSteamId(postElement) {
        // video sources first
        for (const source of postElement.querySelectorAll('video source[src*="steam"]')) {
            const m = source.src.match(/\/apps\/(\d+)\//);
            if (m) { console.log('FitGirl Enhanced: Found Steam ID', m[1], 'from video source'); return m[1]; }
        }
        for (const img of postElement.querySelectorAll('img[src*="steam"]')) {
            const m = img.src.match(/\/apps\/(\d+)\//);
            if (m) { console.log('FitGirl Enhanced: Found Steam ID', m[1], 'from img source'); return m[1]; }
        }
        const patterns = [
            /steam\/apps\/(\d+)\//,
            /store_trailers\/(\d+)\//,
            /store_item_assets\/steam\/apps\/(\d+)\//,
            /steamstatic\.com\/.*?\/apps\/(\d+)\//,
            /steampowered\.com\/app\/(\d+)\//
        ];
        for (const pat of patterns) {
            const m = postElement.innerHTML.match(pat);
            if (m) { console.log('FitGirl Enhanced: Found Steam ID', m[1], 'with pattern', pat); return m[1]; }
        }
        console.log('FitGirl Enhanced: No Steam ID found for post', postElement.id);
        return null;
    }
    function extractGameName(postElement) {
        const strong = postElement.querySelector('.entry-content h3 strong');
        if (!strong) return null;
        const full = strong.textContent;
        const gray = strong.querySelector('span[style*="128, 128, 128"]');
        return gray ? full.replace(gray.textContent, '').trim() : full.trim();
    }
    function getPostData(postElement) {
        const id = postElement.id;
        let data = getCachedData(id);
        if (!data) {
            data = { steam_id: extractSteamId(postElement), game_name: extractGameName(postElement) };
            setCachedData(id, data);
        }
        return data;
    }

    // ----- GAME POST DETECTION (refactored) -----
    function hasRepackCategory(post) {
        const catLink = post.querySelector('.cat-links a');
        if (catLink && (catLink.textContent.includes('Lossless Repack') || catLink.href.includes('/lossless-repack/') || catLink.textContent.includes('Repack'))) return true;
        const entryMeta = post.querySelectorAll('.entry-meta a[rel="category tag"]');
        return Array.from(entryMeta).some(link => link.textContent.includes('Lossless Repack') || link.href.includes('/lossless-repack/') || link.textContent.includes('Repack'));
    }
    function hasDownloadSection(post) {
        const h3 = post.querySelector('.entry-content h3');
        return h3 && h3.textContent.includes('Download Mirrors');
    }
    function isGamePost(post) {
        return hasRepackCategory(post) || hasDownloadSection(post);
    }

    // ----- BUTTON HELPERS -----
    const buttonListenerMap = new WeakMap();

    function addButton(barElement, options) {
        const btn = document.createElement('button');
        btn.className = `fitgirl-button ${options.class}`;
        if (options.unavailable) {
            btn.classList.add('unavailable');
            btn.title = options.unavailable_reason || 'Not available for this post';
        } else if (options.tooltip) {
            btn.title = options.tooltip;
        }
        if (options.icon) {
            const img = document.createElement('img');
            img.src = options.icon;
            img.className = 'fitgirl-icon';
            img.alt = '';
            btn.appendChild(img);
        }
        if (options.text) {
            const span = document.createElement('span');
            span.textContent = options.text;
            btn.appendChild(span);
        }
        const handler = async () => {
            if (options.unavailable) {
                showNotification(options.unavailable_reason || 'Not available', '#ff3333');
                return;
            }
            const origClass = btn.className;
            try {
                await options.action();
                btn.className = `fitgirl-button ${options.class} success`;
                setTimeout(() => { btn.className = origClass; }, CONFIG.BUTTON_FLASH_DURATION);
            } catch (err) {
                console.error(err);
                btn.className = `fitgirl-button ${options.class} error`;
                showNotification(err.message || 'Operation failed', '#ff3333');
                setTimeout(() => { btn.className = origClass; }, CONFIG.BUTTON_FLASH_DURATION);
            }
        };
        btn.addEventListener('click', handler);
        buttonListenerMap.set(btn, { click: handler });
        barElement.appendChild(btn);
        return btn;
    }

    function addSteamButton(bar, data) {
        const unavailable = !data.steam_id;
        const url = data.steam_id ? `https://store.steampowered.com/app/${data.steam_id}/` : null;
        return addButton(bar, {
            text: 'Steam', icon: 'https://store.steampowered.com/favicon.ico',
            class: 'fitgirl-steam-button', tooltip: 'Open on Steam Store',
            unavailable, unavailable_reason: 'Steam ID not found for this post',
            action: async () => GM_openInTab(url, { active: true })
        });
    }
    function addSteamSearchButton(bar, data) {
        const unavailable = !data.game_name;
        const url = data.game_name ? `https://store.steampowered.com/search?term=${encodeURIComponent(data.game_name)}` : null;
        const btn = addButton(bar, {
            class: 'fitgirl-steam-search-button', tooltip: 'Search Steam Store by title',
            unavailable, unavailable_reason: 'Game name not found for this post',
            action: async () => GM_openInTab(url, { active: true })
        });
        btn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18" fill="none" aria-label="Search"><path fill="currentColor" d="M13.8296 12.0786C14.8347 10.6321 15.2623 8.86133 15.0284 7.11496C14.7945 5.36859 13.9159 3.77313 12.5656 2.64269C11.2153 1.51224 9.49114 0.928708 7.73254 1.00696C5.97394 1.08522 4.30831 1.8196 3.06357 3.06552C1.81882 4.31144 1.08514 5.97864 1.00696 7.7389.928776 9.49916 1.51176 11.2249 2.64114 12.5765C3.77052 13.9281 5.36446 14.8075 7.10919 15.0417 8.85391 15.2758 10.623 14.8477 12.0682 13.8417L15.2185 17 15.3997 16.8187 16.8188 15.3982 17 15.2168 13.8296 12.0786ZM8.04222 12.5824C7.14643 12.5824 6.27075 12.3165 5.52593 11.8183 4.7811 11.3202 4.20058 10.6122 3.85777 9.78376 3.51497 8.95538 3.42528 8.04384 3.60004 7.16443 3.7748 6.28502 4.20616 5.47723 4.83958 4.84321 5.47301 4.20919 6.28004 3.77742 7.15862 3.60249 8.0372 3.42757 8.94787 3.51734 9.77548 3.86047 10.6031 4.2036 11.3104 4.78467 11.8081 5.5302 12.3058 6.27573 12.5714 7.15223 12.5714 8.04887 12.5714 9.25123 12.0943 10.4043 11.2449 11.2545 10.3955 12.1047 9.24344 12.5824 8.04222 12.5824V12.5824Z"></path></svg>`;
        return btn;
    }
    function addSteamgriddbButton(bar, data) {
        const unavailable = !data.game_name;
        const url = data.game_name ? `https://www.steamgriddb.com/search/grids?term=${encodeURIComponent(data.game_name)}` : null;
        return addButton(bar, {
            text: 'SteamGridDB', icon: 'https://www.steamgriddb.com/static/img/logo-512.png',
            class: 'fitgirl-steamgriddb-button', tooltip: 'Search SteamGridDB for artwork',
            unavailable, unavailable_reason: 'Game name not found for this post',
            action: async () => GM_openInTab(url, { active: true })
        });
    }
    function addPcgwButton(bar, data) {
        const unavailable = !data.steam_id;
        const url = data.steam_id ? `https://pcgamingwiki.com/api/appid.php?appid=${data.steam_id}` : null;
        return addButton(bar, {
            icon: 'https://pcgamingwiki.ams3.digitaloceanspaces.com/6/61/PCGamingWiki_wide_white.svg',
            class: 'fitgirl-pcgw-button', tooltip: 'Open on PCGamingWiki',
            unavailable, unavailable_reason: 'Steam ID not found for this post',
            action: async () => GM_openInTab(url, { active: true })
        });
    }
    function getFuckingfastLinks(post) {
        return Array.from(post.querySelectorAll('a')).map(a => a.href).filter(h => h.startsWith('https://fuckingfast.co/'));
    }
    async function copyFuckingfastLinks(post) {
        const links = getFuckingfastLinks(post);
        if (!links.length) throw new Error('No FuckingFast links found');
        await GM_setClipboard(links.join('\n'));
        showNotification(`${links.length} FuckingFast links copied!`, '#339966');
    }
    function addFuckingfastButton(bar, post) {
        return addButton(bar, {
            text: 'FUCKINGFAST', icon: 'https://fuckingfast.co/static/favicon.ico',
            class: 'fuckingfast', action: () => copyFuckingfastLinks(post)
        });
    }
    function sendToJdownloader(post) {
        return new Promise((resolve, reject) => {
            const links = getFuckingfastLinks(post);
            if (!links.length) { reject(new Error('No FuckingFast links found')); return; }
            const params = new URLSearchParams();
            params.append('urls', links.join('\r\n'));
            params.append('source', window.location.href);
            GM_xmlhttpRequest({
                method: 'POST', url: 'http://127.0.0.1:9666/flash/add', data: params.toString(),
                headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
                onload: r => {
                    if (r.status >= 200 && r.status < 300) {
                        showNotification(`${links.length} links sent to JDownloader!`, '#339966');
                        resolve();
                    } else reject(new Error(`JDownloader returned error ${r.status}`));
                },
                onerror: () => reject(new Error('Could not connect to JDownloader. Is it running?')),
                ontimeout: () => reject(new Error('JDownloader connection timed out'))
            });
        });
    }
    function addJdownloaderButton(bar, post) {
        return addButton(bar, {
            text: "Click'n'Load", icon: 'http://jdownloader.org/lib/tpl/arctic/images/favicon.ico',
            class: 'fitgirl-jd-button', tooltip: 'Send to JDownloader',
            action: () => sendToJdownloader(post)
        });
    }

    function addAllButtons(bar, postData, postElem) {
        const btns = [];
        if (userSettings.buttons_visible.steam) btns.push({ type: 'steam', element: addSteamButton(bar, postData) });
        if (userSettings.buttons_visible.steam_search) btns.push({ type: 'steam_search', element: addSteamSearchButton(bar, postData) });
        if (userSettings.buttons_visible.steamgriddb) btns.push({ type: 'steamgriddb', element: addSteamgriddbButton(bar, postData) });
        if (userSettings.buttons_visible.pcgw) btns.push({ type: 'pcgw', element: addPcgwButton(bar, postData) });
        if (userSettings.buttons_visible.fuckingfast) btns.push({ type: 'fuckingfast', element: addFuckingfastButton(bar, postElem) });
        if (userSettings.buttons_visible.jdownloader) btns.push({ type: 'jdownloader', element: addJdownloaderButton(bar, postElem) });
        return btns;
    }

    // ----- CAROUSEL (with cleanup) -----
    const activeAnimationIds = new Set();

    function initJsCarousel(wrapper, buttonBar) {
        const totalWidth = buttonBar.scrollWidth;
        const thirdWidth = totalWidth / 3;
        const speedPerFrame = userSettings.carousel_speed / 60;
        let currentPos = -0.01;
        let isPaused = false;

        function wrapPosition(pos) {
            if (pos <= -thirdWidth) pos += thirdWidth;
            else if (pos >= 0) pos -= thirdWidth;
            return pos;
        }

        wrapper.addEventListener('mouseenter', () => { isPaused = true; });
        wrapper.addEventListener('mouseleave', () => { isPaused = false; });

        let supportsPassive = false;
        try {
            const opts = Object.defineProperty({}, 'passive', { get: function() { supportsPassive = true; } });
            window.addEventListener('test', null, opts);
            window.removeEventListener('test', null, opts);
        } catch (e) {}

        wrapper.addEventListener('wheel', e => {
            e.preventDefault();
            currentPos -= e.deltaY;
            currentPos = wrapPosition(currentPos);
            buttonBar.style.transform = `translateX(${currentPos}px)`;
        }, supportsPassive ? { passive: false } : false);

        function animate() {
            if (!isPaused) currentPos -= speedPerFrame;
            currentPos = wrapPosition(currentPos);
            buttonBar.style.transform = `translateX(${currentPos}px)`;
            const id = requestAnimationFrame(animate);
            buttonBar.animationId = id;
            activeAnimationIds.add(id);
        }
        animate();

        // cleanup when buttonBar removed from DOM
        const remObserver = new MutationObserver(() => {
            if (!document.body.contains(buttonBar)) {
                if (buttonBar.animationId) {
                    cancelAnimationFrame(buttonBar.animationId);
                    activeAnimationIds.delete(buttonBar.animationId);
                }
                remObserver.disconnect();
            }
        });
        remObserver.observe(buttonBar, { childList: true, subtree: false });
    }

    function cloneButtonsForCarousel(buttonBar, buttonsData) {
        buttonsData.forEach(btnData => {
            const cloned = btnData.element.cloneNode(true);
            const origHandler = buttonListenerMap.get(btnData.element);
            if (origHandler) {
                cloned.addEventListener('click', origHandler.click);
                buttonListenerMap.set(cloned, { click: origHandler.click });
            }
            buttonBar.appendChild(cloned);
        });
    }

    function setupCarousel(wrapper, buttonBar, buttonsData) {
        setTimeout(() => {
            if (buttonsData.length < CONFIG.CAROUSEL_MIN_BUTTONS) return;
            cloneButtonsForCarousel(buttonBar, buttonsData);
            cloneButtonsForCarousel(buttonBar, buttonsData);
            initJsCarousel(wrapper, buttonBar);
        }, 0);
    }

    // ----- MAIN BAR CREATION -----
    function createButtonBar() {
        const posts = document.querySelectorAll('article[id^="post-"]');
        for (const post of posts) {
            if (post.querySelector('.fitgirl-button-bar-wrapper') || !isGamePost(post)) continue;
            const title = post.querySelector('h1.entry-title');
            if (!title) continue;
            const postData = getPostData(post);
            const wrapper = document.createElement('div');
            wrapper.className = 'fitgirl-button-bar-wrapper';
            const bar = document.createElement('div');
            bar.className = 'fitgirl-button-bar';
            wrapper.appendChild(bar);
            title.insertAdjacentElement('afterend', wrapper);
            const btnsData = addAllButtons(bar, postData, post);
            setupCarousel(wrapper, bar, btnsData);
        }
    }

    // ----- NOTIFICATIONS -----
    function showNotification(message, color) {
        if (userSettings.use_native_notifications && typeof GM_notification !== 'undefined') {
            GM_notification({ text: message, title: 'FitGirl Enhanced', silent: false, timeout: CONFIG.NOTIFICATION_DURATION });
        } else {
            const div = document.createElement('div');
            div.textContent = message;
            div.style.cssText = `position:fixed;top:20px;right:20px;background-color:${color};color:white;padding:10px 15px;border-radius:4px;z-index:10000`;
            document.body.appendChild(div);
            setTimeout(() => { if (document.body.contains(div)) div.remove(); }, CONFIG.NOTIFICATION_DURATION);
        }
    }

    // ----- INIT & OBSERVER -----
    let observer = null;
    let observerIdleTimer = null;
    const disconnectObserver = () => {
        if (observer) {
            observer.disconnect();
            observer = null;
        }
    };
    const resetObserverTimer = () => {
        if (observerIdleTimer) clearTimeout(observerIdleTimer);
        observerIdleTimer = setTimeout(disconnectObserver, 10000);
    };

    try { loadUserSettings(); } catch(e) { console.error(e); userSettings = JSON.parse(JSON.stringify(CONFIG.DEFAULT_SETTINGS)); }
    try { GM_registerMenuCommand('Settings', openSettingsModal, { title: 'Configure FitGirl Enhanced settings' }); } catch(e) { console.error(e); }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', createButtonBar);
    else createButtonBar();

    observer = new MutationObserver(debounce(() => {
        createButtonBar();
        resetObserverTimer();
    }, CONFIG.OBSERVER_DEBOUNCE));
    observer.observe(document.body, { childList: true, subtree: true });
    resetObserverTimer();

    window.addEventListener('beforeunload', () => {
        if (observer) observer.disconnect();
        for (const id of activeAnimationIds) cancelAnimationFrame(id);
        activeAnimationIds.clear();
        if (observerIdleTimer) clearTimeout(observerIdleTimer);
    });

    function debounce(fn, wait) {
        let t;
        return function(...a) {
            clearTimeout(t);
            t = setTimeout(() => fn(...a), wait);
        };
    }
})();