Kinopoisk Free Mirror: Customizable v5.1

Зеркала, настройка кнопок, отсутствие мерцания и лагов.

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Kinopoisk Free Mirror: Customizable v5.1
// @namespace    kp-free-mirror-direct
// @version      5.1.0
// @description  Зеркала, настройка кнопок, отсутствие мерцания и лагов.
// @author       Evreu1pro
// @match        https://www.kinopoisk.ru/film/*
// @match        https://www.kinopoisk.ru/series/*
// @match        *://tapeop.dev/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=kinopoisk.ru
// @grant        GM_openInTab
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @run-at       document-body
// ==/UserScript==

(function () {
    'use strict';

    // --- КОНФИГУРАЦИЯ ---
    const CONFIG = {
        telegramUrl: 'https://t.me/gostibissi',
        tapeOpUrl: 'https://tapeop.dev/',
        storageKey: 'kp_mirror_v5_data_local'
    };

    const DEFAULT_SOURCES = [
        { id: 'kp_film', name: 'Kinopoisk.Film', color: '#00b3ff', template: 'https://www.kinopoisk.film/{type}/{id}/', enabled: true },
        { id: 'reyohoho', name: 'ReYoHoHo', color: '#4CAF50', template: 'https://reyohoho.github.io/reyohoho/#/movie/{id}', enabled: true },
        { id: 'kp_gg', name: 'GG', color: '#9C27B0', template: 'https://www.kinopoisk.gg/{type}/{id}-{translit}', enabled: true },
        { id: 'freefk', name: 'FreeFKSee', color: '#FF9800', template: 'https://alpha.freefksee.ru/watch/id_{id}', enabled: true },
        { id: 'kp_one', name: 'Kinopoisk.One', color: '#FF5722', template: 'https://www.kinopoisk.one/{type}/{id}/', enabled: true },
        { id: 'rutube', name: 'Rutube', color: '#DE002B', template: 'https://rutube.ru/search/?query={title}', enabled: true },
        { id: 'vk', name: 'VK Video', color: '#0077FF', template: 'https://vkvideo.ru/?q={title}', enabled: true },
        { id: 'tapeop', name: 'Tape Operator', color: '#6C5CE7', template: 'special:tapeop', enabled: true }
    ];

    // --- CSS СТИЛИ ---
    const STYLES_CSS = `
        /* Основной контейнер - на всю ширину */
        .kp-mirror-wrapper {
            display: flex;
            flex-wrap: wrap;
            gap: 8px;
            margin-top: 15px;
            margin-bottom: 15px;
            align-items: center;
            font-family: 'Graphik Kinopoisk LC', sans-serif;
            justify-content: center;
            width: 100%;
            clear: both;
            padding: 0 16px; /* Отступы как у основного контента */
            box-sizing: border-box;
        }

        @media (max-width: 768px) {
            .kp-mirror-wrapper {
                gap: 6px;
            }
            .kp-mirror-btn {
                flex-grow: 1;
                min-width: 90px;
                padding: 12px 4px !important;
                font-size: 13px !important;
            }
        }

        .kp-mirror-btn { display: inline-flex; align-items: center; justify-content: center; padding: 8px 14px; border: none; border-radius: 8px; color: #fff; font-size: 13px; font-weight: 600; cursor: pointer; text-decoration: none; transition: transform 0.2s ease, filter 0.2s ease; box-shadow: 0 2px 5px rgba(0,0,0,0.2); opacity: 0.95; position: relative; user-select: none; -webkit-tap-highlight-color: transparent; }
        .kp-mirror-btn:hover { opacity: 1; transform: translateY(-1px); box-shadow: 0 4px 8px rgba(0,0,0,0.3); filter: brightness(1.1); }

        .kp-telegram-btn { background: #24A1DE; padding: 8px 14px; gap: 6px; }
        .kp-telegram-icon { width: 16px; height: 16px; fill: white; }

        .kp-settings-btn { background: #333; width: 36px; height: 36px; padding: 0; border-radius: 50%; flex-grow: 0 !important; min-width: 36px !important; }
        .kp-settings-icon { width: 20px; height: 20px; fill: #ccc; transition: transform 0.5s ease; }
        .kp-settings-btn:hover .kp-settings-icon { fill: #fff; transform: rotate(90deg); }

        .kp-hotkey-hint { font-size: 9px; position: absolute; top: -4px; right: -4px; background: #333; color: #fff; padding: 1px 4px; border-radius: 4px; opacity: 0; transition: opacity 0.2s; pointer-events: none; }
        .kp-mirror-wrapper:hover .kp-hotkey-hint { opacity: 1; }

        /* Модальное окно */
        .kp-modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); z-index: 9999; display: flex; justify-content: center; align-items: center; opacity: 0; pointer-events: none; transition: opacity 0.3s; backdrop-filter: blur(3px); }
        .kp-modal-overlay.active { opacity: 1; pointer-events: auto; }
        .kp-modal { background: #1f1f1f; color: #fff; padding: 20px; border-radius: 12px; width: 90%; max-width: 500px; box-shadow: 0 20px 50px rgba(0,0,0,0.5); z-index: 10000; max-height: 90vh; overflow-y: auto; display: flex; flex-direction: column; gap: 15px; }
        .kp-modal h3 { margin: 0; font-size: 18px; border-bottom: 1px solid #333; padding-bottom: 10px; display: flex; justify-content: space-between; align-items: center; }
        .kp-source-list { display: flex; flex-direction: column; gap: 5px; max-height: 300px; overflow-y: auto; padding-right: 5px; }
        .kp-source-item { display: flex; align-items: center; justify-content: space-between; background: #2a2a2a; padding: 8px 12px; border-radius: 6px; user-select: none; }
        .kp-source-item.disabled { opacity: 0.6; }
        .kp-source-left { display: flex; align-items: center; gap: 10px; flex-grow: 1; }
        .kp-checkbox { accent-color: #f60; cursor: pointer; width: 16px; height: 16px; }
        .kp-source-name { font-weight: 600; }
        .kp-source-actions { display: flex; gap: 5px; }
        .kp-action-btn { background: #444; border: none; color: #fff; width: 24px; height: 24px; border-radius: 4px; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 14px; transition: background 0.2s; }
        .kp-action-btn:hover { background: #666; }
        .kp-action-btn.delete { background: #8B0000; }
        .kp-add-form { background: #252525; padding: 10px; border-radius: 6px; display: flex; flex-direction: column; gap: 8px; border: 1px solid #333; }
        .kp-form-row { display: flex; gap: 8px; }
        .kp-input { background: #111; border: 1px solid #444; color: #fff; padding: 6px 10px; border-radius: 4px; font-size: 13px; flex-grow: 1; }
        .kp-input-color { width: 50px; padding: 0; border: none; height: 30px; cursor: pointer; }
        .kp-modal-footer { border-top: 1px solid #333; padding-top: 15px; display: flex; justify-content: space-between; align-items: center; }
        .kp-btn-primary { background: #f60; color: #fff; border: none; padding: 8px 20px; border-radius: 6px; cursor: pointer; font-weight: bold; }
        .kp-btn-close { background: transparent; color: #aaa; border: none; cursor: pointer; text-decoration: underline; }
        .kp-hint { font-size: 11px; color: #888; margin-top: 4px; }
    `;

    function injectStyles(css) {
        const head = document.head || document.getElementsByTagName('head')[0];
        const style = document.createElement('style');
        style.appendChild(document.createTextNode(css));
        head.appendChild(style);
    }
    injectStyles(STYLES_CSS);

    // --- SAFE API ---
    const SafeAPI = {
        save: (key, value) => { try { localStorage.setItem(key, JSON.stringify(value)); } catch (e) { console.error(e); } },
        load: (key) => { try { const val = localStorage.getItem(key); return val ? JSON.parse(val) : null; } catch (e) { return null; } },
        openTab: (url) => {
            if (typeof GM_openInTab === 'function') GM_openInTab(url, { active: true });
            else if (typeof GM !== 'undefined' && GM.openInTab) GM.openInTab(url, { active: true });
            else window.open(url, '_blank');
        }
    };

    const Utils = {
        transliterate: (text) => {
            if (!text) return '';
            const rus = "абвгдеёжзийклмнопрстуфхцчшщъыьэюяАБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ";
            const eng = "abvgdeezzijklmnoprstufhzcss_y_euaABVGDEEZZIJKLMNOPRSTUFHZCSS_Y_EUA";
            let res = '';
            for (let i = 0; i < text.length; i++) { const idx = rus.indexOf(text[i]); res += idx !== -1 ? eng[idx] : text[i]; }
            return res.replace(/\s+/g, '-').toLowerCase();
        },
        getPageType: () => window.location.href.includes('/series/') ? 'series' : 'film',
        getId: () => {
            const parts = window.location.pathname.split('/');
            const idx = parts.findIndex(p => p === 'film' || p === 'series');
            return (idx !== -1 && parts[idx + 1]) ? parts[idx + 1] : null;
        },
        getTitle: () => {
            let t = document.querySelector('meta[property="og:title"]')?.content;
            if (t) return t.replace(/^Кинопоиск\.\s*/i, '').replace(/\s*\(\d{4}\)/, '').trim();
            t = document.querySelector('h1')?.textContent;
            return t ? t.replace(/\s*\(\d{4}\)/, '').trim() : null;
        }
    };

    class KinopoiskEnhancer {
        constructor() {
            this.data = { forceNewTab: true, sources: [...DEFAULT_SOURCES] };
            this.renderTimeout = null;
            this.init();
        }

        init() {
            this.loadData();
            this.setupHotkeys();
            this.debouncedRender();
            this.startObserver();
        }

        loadData() {
            const stored = SafeAPI.load(CONFIG.storageKey);
            if (stored) {
                this.data.forceNewTab = stored.forceNewTab ?? true;
                if (stored.sources && Array.isArray(stored.sources)) this.data.sources = stored.sources;
            }
        }

        saveData() { SafeAPI.save(CONFIG.storageKey, this.data); }

        buildUrl(template, kpId, title) {
            if (template === 'special:tapeop') return 'special:tapeop';
            let url = template;
            const type = Utils.getPageType();
            const translit = Utils.transliterate(title);
            url = url.replace(/{id}/g, kpId || '').replace(/{type}/g, type).replace(/{title}/g, encodeURIComponent(title || '')).replace(/{translit}/g, translit);
            return url;
        }

        setupHotkeys() {
            document.addEventListener('keydown', (e) => {
                if (e.altKey && e.key >= '1' && e.key <= '9') {
                    const index = parseInt(e.key) - 1;
                    const activeSources = this.data.sources.filter(s => s.enabled);
                    if (activeSources[index]) {
                        const kpId = Utils.getId();
                        const title = Utils.getTitle();
                        this.openSource(activeSources[index], kpId, title);
                    }
                }
            });
        }

        static initTapeOpReceiver() {
            const data = SafeAPI.load('movie-data');
            if (data) {
                localStorage.removeItem('movie-data');
                const script = document.createElement('script');
                script.innerHTML = `globalThis.init(JSON.parse('${JSON.stringify(data)}'), '5.1');`;
                document.body.appendChild(script);
            }
        }

        openSource(source, kpId, title) {
            if (source.template === 'special:tapeop') {
                SafeAPI.save('movie-data', { kinopoisk: kpId, title });
                SafeAPI.openTab(CONFIG.tapeOpUrl);
                return;
            }
            const url = this.buildUrl(source.template, kpId, title);
            if (this.data.forceNewTab) SafeAPI.openTab(url);
            else window.location.href = url;
        }

        createSettingsModal() {
            if (document.getElementById('kp-settings-modal')) return;
            const overlay = document.createElement('div');
            overlay.id = 'kp-settings-modal';
            overlay.className = 'kp-modal-overlay';
            const renderSourcesList = () => {
                const container = overlay.querySelector('.kp-source-list');
                if (!container) return;
                container.innerHTML = '';
                this.data.sources.forEach((src, index) => {
                    const item = document.createElement('div');
                    item.className = `kp-source-item ${src.enabled ? '' : 'disabled'}`;
                    item.innerHTML = `
                        <div class="kp-source-left">
                            <input type="checkbox" class="kp-checkbox" ${src.enabled ? 'checked' : ''}>
                            <span class="kp-source-name" style="color: ${src.color}">${src.name}</span>
                        </div>
                        <div class="kp-source-actions">
                            <button class="kp-action-btn move-up" title="Вверх">⬆️</button>
                            <button class="kp-action-btn move-down" title="Вниз">⬇️</button>
                            ${!DEFAULT_SOURCES.find(d => d.id === src.id) ? '<button class="kp-action-btn delete" title="Удалить">✖</button>' : ''}
                        </div>
                    `;
                    item.querySelector('.kp-checkbox').onchange = (e) => { src.enabled = e.target.checked; item.className = `kp-source-item ${src.enabled ? '' : 'disabled'}`; };
                    item.querySelector('.move-up').onclick = () => { if (index > 0) { [this.data.sources[index], this.data.sources[index - 1]] = [this.data.sources[index - 1], this.data.sources[index]]; renderSourcesList(); } };
                    item.querySelector('.move-down').onclick = () => { if (index < this.data.sources.length - 1) { [this.data.sources[index], this.data.sources[index + 1]] = [this.data.sources[index + 1], this.data.sources[index]]; renderSourcesList(); } };
                    const delBtn = item.querySelector('.delete');
                    if (delBtn) delBtn.onclick = () => { if (confirm(`Удалить кнопку "${src.name}"?`)) { this.data.sources.splice(index, 1); renderSourcesList(); } };
                    container.appendChild(item);
                });
            };
            overlay.innerHTML = `
                <div class="kp-modal">
                    <h3>Настройка кнопок <button class="kp-btn-close">Закрыть</button></h3>
                    <div style="display:flex; align-items:center; gap:10px; padding-bottom:10px;">
                        <input type="checkbox" id="st-newtab" class="kp-checkbox" ${this.data.forceNewTab ? 'checked' : ''}>
                        <label for="st-newtab" style="cursor:pointer">Всегда открывать в новой вкладке</label>
                    </div>
                    <div class="kp-source-list"></div>
                    <div class="kp-add-form">
                        <span style="font-weight:bold; font-size:13px;">Добавить свою кнопку:</span>
                        <div class="kp-form-row">
                            <input type="text" id="new-name" class="kp-input" placeholder="Название">
                            <input type="color" id="new-color" class="kp-input-color" value="#ffffff">
                        </div>
                        <input type="text" id="new-url" class="kp-input" placeholder="Ссылка: https://site.com/watch/{id}">
                        <button class="kp-btn-primary" id="btn-add-source" style="margin-top:5px; font-size:12px; padding:5px;">Добавить</button>
                    </div>
                    <div class="kp-modal-footer"><button class="kp-btn-primary kp-save-btn" style="width:100%">Сохранить настройки</button></div>
                </div>
            `;
            setTimeout(renderSourcesList, 0);
            overlay.onclick = (e) => { if(e.target === overlay) overlay.classList.remove('active'); };
            overlay.querySelector('.kp-btn-close').onclick = () => overlay.classList.remove('active');
            overlay.querySelector('.kp-save-btn').onclick = () => {
                this.data.forceNewTab = overlay.querySelector('#st-newtab').checked;
                this.saveData();
                this.render(true);
                overlay.classList.remove('active');
            };
            overlay.querySelector('#btn-add-source').onclick = () => {
                const name = overlay.querySelector('#new-name').value.trim();
                const url = overlay.querySelector('#new-url').value.trim();
                const color = overlay.querySelector('#new-color').value;
                if (!name || !url) return alert('Заполните поля!');
                this.data.sources.push({ id: 'custom_' + Date.now(), name: name, color: color, template: url, enabled: true });
                overlay.querySelector('#new-name').value = ''; overlay.querySelector('#new-url').value = '';
                renderSourcesList();
            };
            document.body.appendChild(overlay);
        }

        // --- ПОИСК ОРИЕНТИРА НА ОСНОВЕ HTML ПОЛЬЗОВАТЕЛЯ ---
        findAnchor() {
            // 1. Самый точный поиск по классу из HTML пользователя
            const buttonBar = document.querySelector('.style_buttonBar__Su2eL') ||
                              document.querySelector('.styles_root__giEsN'); // Внутренний контейнер

            if (buttonBar) return buttonBar;

            // 2. Поиск кнопки "Еще" (она уникальна для этого меню)
            const moreButton = document.querySelector('button.styles_more__URDt5');
            if (moreButton) {
                // Поднимаемся до общего контейнера
                const parentRow = moreButton.closest('.styles_root__giEsN') ||
                                  moreButton.closest('.style_buttonBar__Su2eL');
                if (parentRow) return parentRow;
            }

            // 3. Фоллбэк на десктопные контейнеры
            const desktopContainer = document.querySelector('[class*="styles_buttonsContainer__"]');
            if (desktopContainer) return desktopContainer;

            return null;
        }

        render(force = false) {
            const kpId = Utils.getId();
            const title = Utils.getTitle();
            if (!kpId) return;

            const globalExisting = document.querySelector('.kp-mirror-wrapper');
            if (globalExisting && !force) return;
            if (globalExisting && force) globalExisting.remove();

            const anchor = this.findAnchor();
            if (!anchor) return; // Если не нашли, куда вставлять, не рисуем

            const wrapper = document.createElement('div');
            wrapper.className = 'kp-mirror-wrapper';

            let hotkeyCounter = 1;
            this.data.sources.forEach(src => {
                if (!src.enabled) return;
                const btn = document.createElement('button');
                btn.className = 'kp-mirror-btn';
                btn.textContent = src.name;
                btn.style.background = src.color;
                if (window.innerWidth > 1024 && hotkeyCounter <= 9) {
                    const hint = document.createElement('span');
                    hint.className = 'kp-hotkey-hint';
                    hint.textContent = `Alt+${hotkeyCounter}`;
                    btn.appendChild(hint);
                    hotkeyCounter++;
                }
                btn.onclick = (e) => { e.preventDefault(); this.openSource(src, kpId, title); };
                wrapper.appendChild(btn);
            });

            const tgBtn = document.createElement('a');
            tgBtn.className = 'kp-mirror-btn kp-telegram-btn';
            tgBtn.href = CONFIG.telegramUrl;
            tgBtn.target = '_blank';
            tgBtn.innerHTML = `<svg class="kp-telegram-icon" viewBox="0 0 24 24"><path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm5.562 8.161c-.18 1.897-.962 6.502-1.361 8.627-.168.9-.5 1.201-.82 1.23-.697.064-1.226-.461-1.901-.903-1.056-.692-1.653-1.123-2.678-1.799-1.185-.781-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.139-5.062 3.345-.479.329-.913.489-1.302.481-.428-.008-1.252-.241-1.865-.44-.751-.244-1.349-.374-1.297-.789.027-.216.324-.437.893-.663 3.498-1.524 5.831-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.477-1.635.099-.002.321.023.465.141.119.098.152.228.166.33.016.113.02.325.016.365z"/></svg>Скрипты`;
            wrapper.appendChild(tgBtn);

            const settingsBtn = document.createElement('button');
            settingsBtn.className = 'kp-mirror-btn kp-settings-btn';
            settingsBtn.innerHTML = `<svg class="kp-settings-icon" viewBox="0 0 24 24"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.488.488 0 0 0-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 0 0-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58a.49.49 0 0 0-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>`;
            settingsBtn.onclick = (e) => {
                e.preventDefault();
                this.createSettingsModal();
                setTimeout(() => document.getElementById('kp-settings-modal').classList.add('active'), 10);
            };
            wrapper.appendChild(settingsBtn);

            // ВСТАВКА: Если у нас ButtonBar, вставляем ПОСЛЕ него
            anchor.after(wrapper);
        }

        debouncedRender() {
            if (this.renderTimeout) clearTimeout(this.renderTimeout);
            this.renderTimeout = setTimeout(() => {
                this.cleanGarbage();
                this.render();
            }, 300);
        }

        cleanGarbage() {
            const selectors = ['.kinopoisk-watch-online-button', 'a[href*="/plus/"]', '.styles_subscriptionOffer__eau7V'];
            selectors.forEach(sel => document.querySelectorAll(sel).forEach(el => { el.style.display = 'none'; }));
            document.querySelectorAll('button').forEach(btn => {
                if (btn.textContent && (btn.textContent.includes('Попробовать Плюс') || btn.textContent.includes('Билеты'))) btn.style.display = 'none';
            });
        }

        startObserver() {
            const observer = new MutationObserver((mutations) => {
                const isOwnMutation = Array.from(mutations).every(m => m.target.closest && m.target.closest('.kp-mirror-wrapper'));
                if (isOwnMutation) return;
                let shouldRender = false;
                for (let m of mutations) if (m.addedNodes.length) shouldRender = true;
                if (shouldRender) this.debouncedRender();
            });
            observer.observe(document.body, { childList: true, subtree: true });
        }
    }

    if (window.location.hostname.includes('tapeop.dev')) {
        window.addEventListener('load', () => setTimeout(KinopoiskEnhancer.initTapeOpReceiver, 500));
    } else {
        const app = new KinopoiskEnhancer();
        const start = () => { app.debouncedRender(); app.startObserver(); };
        if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
        else start();
    }

})();