I Hate Waiting

Ускоряет загрузку страниц: на видеохостингах — приоритет главному видео, на остальных — приоритет видимому контенту.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         I Hate Waiting
// @name:en      I Hate Waiting
// @namespace    https://tampermonkey.net/
// @version      1.1.9.1
// @license      MIT
// @description  Ускоряет загрузку страниц: на видеохостингах — приоритет главному видео, на остальных — приоритет видимому контенту.
// @description:en Speeds up page loading: on video-hosting sites, priority is given to the main video; on other sites, priority is given to visible content.
// @author       Qwen + Claude + Grok + twicks other programmers
// @match        *://*/*
// @grant        none
// @run-at       document-start
// @compatible   firefox 132+ Violentmonkey
// @compatible   firefox 132+ Tampermonkey
// @compatible   firefox 132+ GreaseMonkey
// @compatible   chrome  101+ Violentmonkey
// @compatible   chrome  101+ Tampermonkey
// @compatible   chrome  101+ ScriptCat
// @compatible   safari 18.0+ Stay
// @compatible   edge 101+ Tampermonkey
// @compatible   opera 87+ Tampermonkey
// ==/UserScript==

(function () {
    'use strict';

    /* ── ЗАЩИТА ОТ ФРЕЙМОВ ──────────────────────────────── */
    // Скрипт работает только на основной странице.
    // Плюсы: нет дублирования логов, не трогаем DOM внутри iframe-плееров,
    // меньше MutationObserver-ов в памяти. Видео ищем через DOM основной
    // страницы — boostMainVideo не теряет доступ к iframe-плеерам.
    if (window.top !== window) return;

    /* ── ОТЛАДКА ────────────────────────────────────────── */
    // true  — все сообщения видны в консоли F12 (режим разработки)
    // false — лог отключён полностью (режим релиза, нет затрат на вывод)
    const DEBUG = false;
    const log = (...args) => { if (DEBUG) console.log(...args); };

    /* ── НАСТРОЙКИ ──────────────────────────────────────── */
    // true  — при скрытии вкладки ставить видео на паузу, при возврате — возобновлять.
    // Не влияет на картинку-в-картинке (PiP): если видео в PiP — оно продолжает играть.
    // Полезно на мобиле (АКБ) и Desktop (CPU/GPU в фоне). Отключить если сайт сам
    // управляет паузой (на YouTube Desktop) или поведение кажется лишним.
	// работает только на Youtube (там где плеер в главном окне, а не в защищенном iframe)
    const PAUSE_ON_HIDDEN = true;

    // Флаг защиты от двойного запуска onLoadHandler —
    // при readyState=interactive вызываем сразу, но событие load всё равно придёт
    let _initDone = false;

    // _t0 — момент запуска скрипта (мс от навигации). Вне DEBUG-блока: нужен всегда
    // для показа «load − скрипт_запустился» на кнопке независимо от DEBUG-флага.
    const _t0 = performance.now();

    let _btn = null; // ссылка на кнопку — для кратковременного показа времени загрузки

    /* ── ЗАМЕР ВРЕМЕНИ ЗАГРУЗКИ ────────────────────────────────── */
    // _showDeltaOnBtn — показывает «▲ X мс» на кнопке ON/OFF после загрузки страницы:
    //   X = load − скрипт_запустился (= loadEventEnd − _t0)
    //   Это время от старта нашего скрипта до полной загрузки страницы.
    //   При ON браузер работает с нашими оптимизациями, при OFF — без них.
    //   Разница X(OFF) − X(ON) = реальный выигрыш от скрипта.
    //   Кнопка показывает значение 3 секунды, затем возвращает ON/OFF.
    // _printTiming — вывод всех метрик в консоль (только при DEBUG=true):
    //   • DOMContentLoaded — страница видна (HTML разобран, синхронные скрипты выполнены)
    //   • load            — всё загружено (картинки, шрифты, iframe)
    //   • load − DCL      — время догрузки ресурсов после первого рендера
    //   • load − старт    — КЛЮЧЕВАЯ метрика для сравнения ON vs OFF
    //   • скрипт запустился — для справки, когда Tampermonkey внедрил скрипт
    // Всё запускается строго после load-события — до него loadEventEnd = 0.
    // Работает при ON и при OFF одинаково (вызов ниже, до early-return SITE_KEY).

    function _showDeltaOnBtn() {
        if (!_btn) return;
        const nav = performance.getEntriesByType('navigation')[0];
        if (!nav || nav.loadEventEnd <= 0) return;
        const delta = Math.round(nav.loadEventEnd - _t0);
        const saved = _btn.textContent;
        _btn.textContent = `▲ ${delta} мс`;
        _btn.title = `load − старт скрипта: ${delta} мс`;
        setTimeout(() => { if (_btn) { _btn.textContent = saved; _btn.removeAttribute('title'); } }, 3000);
        if (DEBUG) {
            const mark = nav.loadEventEnd - _t0 > 0
                ? (localStorage.getItem('ihw:off:' + location.hostname) === '1' ? '[OFF]' : '[ON] ')
                : '[ ? ]';
            const dcl  = nav.domContentLoadedEventEnd.toFixed(0);
            const load = nav.loadEventEnd.toFixed(0);
            const dDCL = Math.round(nav.loadEventEnd - nav.domContentLoadedEventEnd);
            console.group(`[IHW] ${mark} Время загрузки — ${location.hostname}`);
            console.log(`  скрипт запустился : ${_t0.toFixed(0)} мс от навигации`);
            console.log(`  DOMContentLoaded  : ${dcl} мс`);
            console.log(`  load              : ${load} мс`);
            console.log(`  load − DCL        : ${dDCL} мс  ← догрузка ресурсов после рендера`);
            console.log(`  load − старт (▲)  : ${delta} мс  ← сравнивай ON vs OFF`);
            console.groupEnd();
        }
    }

    // Планируем показ всегда после load (до load — loadEventEnd = 0)
    if (document.readyState === 'complete') {
        setTimeout(_showDeltaOnBtn, 0);
    } else {
        window.addEventListener('load', () => setTimeout(_showDeltaOnBtn, 0), { once: true });
    }

    /* ── ИСКЛЮЧЕНИЯ САЙТОВ ──────────────────────────────── */
    const SITE_KEY = 'ihw:off:' + location.hostname;
    if (localStorage.getItem(SITE_KEY) === '1') {
        _renderBtn(true);
        return;
    }

    /* ── ОПРЕДЕЛЕНИЕ УСТРОЙСТВА ─────────────────────────── */
    // Mode: "Mobile" или "Desktop"
    const isMobile = /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent)
        || (navigator.maxTouchPoints > 1 && window.innerWidth < 1024);
    const MODE = isMobile ? 'Mobile' : 'Desktop';

    /* ── ОПРЕДЕЛЕНИЕ ТИПА СТРАНИЦЫ ──────────────────────── */
    // Page: "Video Content" — популярные видеохостинги
    // Page: "Mixed Content" — все остальные сайты
    const VIDEO_HOSTS = [
        'youtube.com', 'youtu.be',
        'vimeo.com', 'rutube.ru', 'twitch.tv', 'dailymotion.com',
        'ok.ru', 'vk.com', 'vkvideo.ru', 'video.mail.ru', 'mail.ru',
        'bilibili.com', 'tiktok.com', 'odysee.com',
        'dzen.ru', 'yandex.ru', 'ya.ru', 'smotret.tv', 'platform.rambler.ru'
    ];
    const isVideoHost = VIDEO_HOSTS.some(h => location.hostname.endsWith(h));
    const PAGE = isVideoHost ? 'Video Content' : 'Mixed Content';

    log(`[IHW] Mode:${MODE} | Page:${PAGE}`);

    /* ── ТРЕКЕРЫ И ИСКЛЮЧЕНИЯ ───────────────────────────── */

    // Домены трекеров — блокируем sendBeacon, удаляем из DOM, исключаем из dns-prefetch
    const TRACKERS = [
        'google-analytics.com', 'googletagmanager.com', 'doubleclick.net',
        'facebook.com', 'connect.facebook.net', 'clarity.ms',
        'hotjar.com', 'mc.yandex.ru', 'top-fwz1.mail.ru',
        // 'stat.livejournal.net',          // счётчик LiveJournal
        'redirect.appmetrica.yandex.com', // аналитика Яндекс AppMetrica
        'counter.yadro.ru'               // счётчик Liveinternet/Yadro
    ];

    // Домены-исключения — НЕ удаляем их элементы из DOM даже если совпадают с трекером.
    // Добавлять сюда сервисы, которые ломаются при агрессивной фильтрации DOM
    // (капчи, антибот-проверки, платёжные виджеты и т.п.)
    const TRACKER_EXCEPTIONS = [
        'cloudflare.com',            // Cloudflare Challenge / Turnstile капча
        'challenges.cloudflare.com', // прямой домен капчи Cloudflare
        // 'recaptcha.net',          // Google reCAPTCHA — раскомментировать если сломается
        // 'hcaptcha.com',           // hCaptcha — раскомментировать если сломается
    ];

    // Проверка: является ли URL трекером
    const isTracker = url => {
        try {
            const h = new URL(url, location.origin).hostname;
            return TRACKERS.some(t => h.endsWith(t));
        } catch { return false; }
    };

    // Проверка: находится ли URL в списке исключений (не трогаем даже если трекер)
    const isException = url => {
        try {
            const h = new URL(url, location.origin).hostname;
            return TRACKER_EXCEPTIONS.some(e => h.endsWith(e));
        } catch { return false; }
    };

    // Блокируем sendBeacon трекеров — исключения не в TRACKERS, отдельно не нужны
    const _beacon = navigator.sendBeacon.bind(navigator);
    navigator.sendBeacon = (url, data) => isTracker(url) ? false : _beacon(url, data);

    // font-display:swap — текст виден сразу системным шрифтом, веб-шрифт подгружается потом
    const _fs = document.createElement('style');
    _fs.textContent = '@font-face{font-display:swap}';
    (document.head || document.documentElement).appendChild(_fs);

    // ---------- CSS: принудительная видимость и опционально отключение плавного скролла ----------

    /* ── СПИСОК САЙТОВ-ЧАТОВ ────────────────────────────── */
    // Используется тремя блоками: scroll-behavior, content-visibility, lazy-iframe.
    // БАГ v1.1.5: content-visibility:auto на main>div и lazy-iframe ломают
    // динамический скролл к полю ввода (поле "улетает" вверх после отправки).
    // FIX v1.1.6: для этих сайтов пропускаем runRenderOpts() и iframe-lazy целиком.
    const noScrollBehaviorSites = [
        'chat.deepseek.com',
        'chatgpt.com',
        'grok.com',
        'qwen.ai',
        'claude.ai',
        'claude.site',
        'kagi.com',       // другие чаты с динамическим скроллом
        'perplexity.ai',
    ];

    const currentHost = window.location.hostname;
    // shouldSkipScroll === true означает: это AI-чат с динамическим скроллом.
    // На таких сайтах НЕ отключаем smooth scroll, НЕ применяем content-visibility,
    // НЕ делаем lazy-iframe — всё это ломает поле ввода.
    const shouldSkipScroll = noScrollBehaviorSites.some(site => currentHost.includes(site));

    // Базовый стиль: принудительная видимость (идея из PureRender)
    // scroll-behavior:auto — отменяет smooth scroll сайта, скролл реагирует 1-в-1.
    // visibility/opacity — некоторые сайты прячут body до загрузки рекламы/попапов,
    // это принудительно раскрывает страницу сразу.
    const baseCss = 'html,body{visibility:visible!important;opacity:1!important}';
    const _css = document.createElement('style');
    _css.textContent = baseCss;
    (document.head || document.documentElement).appendChild(_css);

    // Дополнительный стиль: отключение плавного скролла (если сайт не в исключениях)
    if (!shouldSkipScroll) {
        const scrollCss = 'html,body{scroll-behavior:auto!important}';
        const _scrollCss = document.createElement('style');
        _scrollCss.textContent = scrollCss;
        (document.head || document.documentElement).appendChild(_scrollCss);
    }

    /* ── НАБЛЮДАТЕЛЬ ЗА DOM ─────────────────────────────── */
    const seen = new WeakSet();

    const processNode = node => {
        if (!(node instanceof HTMLElement) || seen.has(node)) return;
        seen.add(node);

        const tag = node.tagName;
        const src = node.src || node.href || '';

        // Трекеры — удаляем при вставке в DOM, но только если не в списке исключений
        if (src && isTracker(src) && !isException(src)) { node.remove(); return; }

        // prefetch-ссылки — удаляем (не тратим ресурсы впрок),
        // кроме исключений (Cloudflare Challenge использует prefetch для своей проверки).
        // На видеохостингах: сохраняем same-origin prefetch — это SPA-навигация
        // (YouTube, VK и др. префетчат JSON следующей вкладки; без них вкладки
        // «Videos», «Streams» и переходы между страницами остаются пустыми).
        if (tag === 'LINK' && node.rel === 'prefetch' && !isException(src)) {
            if (isVideoHost) {
                try { if (new URL(src, location.origin).hostname.endsWith(location.hostname)) return; } catch {}
            }
            node.remove(); return;
        }

        // Внешние шрифты — откладываем на idle, не блокируем рендер текста
        if (tag === 'LINK' && /fonts\.g(oogle|static)apis\.com/.test(src)) {
            node.media = 'print';
            setTimeout(() => { if (node.parentNode) node.media = 'all'; }, 2000); // setTimeout гарантирует задержку 2с (requestIdleCallback игнорирует числовой аргумент)
            return;
        }

        // Изображения — ленивая загрузка + async decoding
        if (tag === 'IMG') {
            if (!node.loading) node.loading = 'lazy';
            node.decoding = 'async';
        }
    };

    const mo = new MutationObserver(muts => {
        for (const m of muts)
            for (const n of m.addedNodes) processNode(n);
    });
    mo.observe(document.documentElement, { childList: true, subtree: true });

    /* ── ОБЩИЕ ОПТИМИЗАЦИИ РЕНДЕРА (Desktop + Mobile) ───── */
    // Запускаем на idle ПОСЛЕ поиска видео — иначе content-visibility:auto
    // обнуляет offsetWidth плеера и tryBoost его не находит.
    // FIX v1.1.6: на сайтах-чатах (shouldSkipScroll) не применяем content-visibility
    // и не убираем srcset — эти оптимизации ломают динамический скролл чата.
    // Причина: content-visibility:auto на main>div меняет layout-высоту
    // контейнера сообщений → containIntrinsicSize-заглушки (0 500px) сбивают
    // scroll anchor → поле ввода «улетает» вверх экрана.
    const runRenderOpts = () => {
        if (shouldSkipScroll) {
            log('[IHW] runRenderOpts: пропущено (AI-чат, shouldSkipScroll=true)');
            return;
        }
        const vh = window.innerHeight;

        // srcset у картинок вне вьюпорта — убираем, браузер не грузит тяжёлую версию впрок
        document.querySelectorAll('img[srcset]').forEach(img => {
            if (img.getBoundingClientRect().top > vh) img.removeAttribute('srcset');
        });

        // content-visibility:auto на блоках ниже 2 экранов — пропускаем рендер того
        // что пользователь ещё не видит; плееры исключаем чтобы не обнулить их размер
        document.querySelectorAll('article,section,.post,.content,.entry,main>div')
            .forEach((el, i) => {
                if (i > 1 && el.getBoundingClientRect().top > vh * 2
                    && !el.querySelector('video,iframe,[class*="player"],[id*="player"]')) {
                    el.style.contentVisibility = 'auto';
                    el.style.containIntrinsicSize = '0 500px';
                }
            });
    };

    /* ── Page: "Video Content" ──────────────────────────── */
    // Цель: найти главное видео (Detect & Play: Main Video on Page)
    // и запустить его как можно быстрее — это главный приоритет
    if (PAGE === 'Video Content') {

        // Заглушка внутренней аналитики YouTube (ytcsi — YouTube Client Side Instrumentation).
        // Эта система собирает тайминги и метрики в фоне, отправляет данные на серверы YouTube.
        // Замена на noop экономит CPU на каждой странице. (идея из PureYouTube)
        // Применяем только на видеохостингах — на обычных сайтах ytcsi нет.
        if (location.hostname.endsWith('youtube.com') || location.hostname.endsWith('youtu.be')) {
            const noop = () => {};
            window.ytcsi = { tick: noop, span: noop, info: noop, setTick: noop, lastTick: noop };
            window.ytStats = noop;
            log('[IHW] YouTube аналитика (ytcsi) заглушена');

            // Дополнительная заглушка yt.config_: отключаем внутреннее логирование YouTube.
            // ENABLE_LOGGING = false — безопасно, только блокирует отправку телеметрии.
            // ADS_DATA НЕ трогаем — YouTube использует его для внутренней логики страниц
            // (не только рекламы); обнуление ломает SPA-рендер вкладок «Видео»/«Трансляции».
            // YouTube устанавливает yt.config_ через inline-скрипты в <head>, поэтому
            // патчим после DOMContentLoaded, когда объект уже создан.
            // CSS will-change:transform на #masthead-container подсказывает браузеру
            // поднять шапку на отдельный GPU-слой — убирает дёргание при скролле.
            const _ytBootstrap = () => {
                try {
                    if (window.yt?.config_) {
                        window.yt.config_.ENABLE_LOGGING = false;
                        log('[IHW] YouTube yt.config_.ENABLE_LOGGING заглушен');
                    }
                } catch (e) { log('[IHW] yt.config_ недоступен:', e); }

                // will-change:transform на шапке — GPU-compositing без перерасчёта layout
                const _yt = document.createElement('style');
                _yt.textContent = 'ytd-masthead,#masthead-container{will-change:transform}';
                document.head.appendChild(_yt);
                log('[IHW] YouTube masthead: will-change:transform добавлен');
            };
            // Если DOM уже готов (SPA-навигация) — сразу, иначе ждём
            if (document.readyState === 'loading') {
                document.addEventListener('DOMContentLoaded', _ytBootstrap, { once: true });
            } else {
                _ytBootstrap();
            }
        }

        const boostMainVideo = () => {
            log('[IHW] Главное видео ищется на странице...');

            // --- Стратегия 1: нативный <video> ---
            const videos = [...document.querySelectorAll('video')];
            // Фильтруем видео: видимые по размеру + не дальше 1.5 экрана вниз
            // (исключаем плееры которые рендерятся вне viewport, но технически имеют размер)
            const visibleVideos = videos.filter(v =>
                v.offsetWidth > 0 && v.offsetHeight > 0 &&
                v.getBoundingClientRect().top < window.innerHeight * 1.5
            );

            if (visibleVideos.length) {
                const main = visibleVideos.reduce((a, b) =>
                    (b.offsetWidth * b.offsetHeight) > (a.offsetWidth * a.offsetHeight) ? b : a
                );
                log(`[IHW] Главное видео найдено (<video>): ${main.offsetWidth}x${main.offsetHeight}px | src: ${main.src || main.currentSrc || '(загружается)'}`);
                main.preload = 'auto';
                main.fetchPriority = 'high';
				// iOS Safari: playsinline предотвращает принудительный fullscreen при play().
                // Без атрибута iOS разворачивает видео на весь экран — нежелательно на
                // сайтах с нативным плеером (VK, OK, Dzen). Безвреден на Desktop.
                main.setAttribute('playsinline', '');
                visibleVideos.forEach(v => { if (v !== main && v.preload === 'auto') v.preload = 'metadata'; });
                if (main.readyState >= 2) {
                    log('[IHW] Главное видео: готово (readyState=' + main.readyState + '), запускаем');
                    main.play().catch(e => log('[IHW] play() заблокирован —', e.message));
                } else {
                    log('[IHW] Главное видео: ожидаем canplay (readyState=' + main.readyState + ')');
                    main.addEventListener('canplay', () => {
                        log('[IHW] canplay сработал, запускаем воспроизведение');
                        main.play().catch(e => log('[IHW] play() заблокирован —', e.message));
                    }, { once: true });
                }
                return true;
            }

            // --- Стратегия 2: плеер в <iframe> (Rutube, Vimeo, VK и др.) ---
            const playerIframes = [...document.querySelectorAll('iframe')].filter(fr => {
                if (fr.offsetWidth < 200 || fr.offsetHeight < 100) return false;
                const s = (fr.src || fr.name || fr.id || fr.className || '').toLowerCase();
                return /video|player|embed|rutube|vimeo|vk|ok\.ru|dzen|yandex|twitch|dailymotion|bilibili|tiktok/i.test(s);
            });
            if (playerIframes.length) {
                const main = playerIframes.reduce((a, b) =>
                    (b.offsetWidth * b.offsetHeight) > (a.offsetWidth * a.offsetHeight) ? b : a
                );
                log(`[IHW] Главное видео найдено (<iframe> плеер): ${main.offsetWidth}x${main.offsetHeight}px | src: ${main.src || '(нет src)'}`);
                if (main.dataset.lazySrc) { main.src = main.dataset.lazySrc; delete main.dataset.lazySrc; }
                main.loading = 'eager';
                main.fetchPriority = 'high';
                return true;
            }

            // --- Стратегия 3: кастомный плеер (div с data-атрибутами) ---
            const customPlayer = document.querySelector(
                '[class*="player"],[id*="player"],[class*="Player"],[id*="Player"],' +
                '[data-video],[data-player],[data-src*="video"]'
            );
            if (customPlayer && customPlayer.offsetWidth > 200) {
                log(`[IHW] Главное видео найдено (кастомный плеер): ${customPlayer.tagName}#${customPlayer.id}.${customPlayer.className.split(' ')[0]} | ${customPlayer.offsetWidth}x${customPlayer.offsetHeight}px`);
                return true;
            }

            if (videos.length) {
                log(`[IHW] Главное видео: найдено ${videos.length} <video>, но все нулевого размера (плеер ещё не отрисован)`);
            } else {
                log('[IHW] Главное видео: не найдено ни <video>, ни iframe-плеера, ни кастомного плеера');
            }
            return false;
        };

        // Повторяем поиск с нарастающим интервалом — плеер в SPA может появиться через 3–8с
        const RETRY_DELAYS = [1500, 3000, 5000, 8000];
        let retryIdx = 0;

        const tryBoost = () => {
            if (boostMainVideo()) return;
            if (retryIdx >= RETRY_DELAYS.length) {
                log('[IHW] Главное видео: все попытки исчерпаны');
                return;
            }
            const delay = RETRY_DELAYS[retryIdx++];
            log(`[IHW] Главное видео: повтор через ${delay / 1000}с (попытка ${retryIdx}/${RETRY_DELAYS.length})`);
            setTimeout(tryBoost, delay);
        };

        if (document.readyState === 'loading') {
            document.addEventListener('DOMContentLoaded', tryBoost, { once: true });
        } else {
            tryBoost();
        }

        // Пауза при скрытии вкладки — экономит CPU/GPU/АКБ.
        // Работает только для плееров с <video> в ГЛАВНОМ документе страницы:
        // YouTube и Twitch — нативный <video> доступен через querySelectorAll в main DOM.
        // Остальные хостинги (VK, OK, Bilibili, TikTok, Dzen и др.) держат плеер в
        // cross-origin iframe — браузер запрещает доступ к чужому документу из userscript.
        // PiP: document.pictureInPictureElement → видео в плавающем окне → не трогаем.
        // _ihw_wasPlaying — не возобновляем видео которое пользователь сам поставил на паузу.
        if (PAUSE_ON_HIDDEN) {
            document.addEventListener('visibilitychange', () => {
                // Ищем самый крупный видимый <video> — тот же критерий что и в boostMainVideo
                const vs = [...document.querySelectorAll('video')]
                    .filter(v => v.offsetWidth > 0 && v.offsetHeight > 0);
                if (!vs.length) return;
                const main = vs.reduce((a, b) =>
                    b.offsetWidth * b.offsetHeight > a.offsetWidth * a.offsetHeight ? b : a
                );
                // PiP активен — не вмешиваемся
                if (document.pictureInPictureElement) return;

                if (document.hidden) {
                    main._ihw_wasPlaying = !main.paused;
                    if (main._ihw_wasPlaying) {
                        main.pause();
                        log('[IHW] visibilitychange: вкладка скрыта → пауза');
                    }
                } else {
                    if (main._ihw_wasPlaying) {
                        // preload='auto' остался с boostMainVideo — не меняли при паузе.
                        // fetchPriority повторно ставим в 'high': после паузы браузер
                        // мог понизить приоритет сетевых запросов видеоэлемента.
                        main.fetchPriority = 'high';
                        main.play().catch(e => log('[IHW] visibilitychange: resume заблокирован —', e.message));
                        log('[IHW] visibilitychange: вкладка активна → воспроизведение');
                    }
                    main._ihw_wasPlaying = undefined;
                }
            });
        }
    }

    /* ── Page: "Mixed Content" ──────────────────────────── */
    // 1-й приоритет: текст + базовые стили (браузер делает сам)
    // 2-й приоритет: картинки первого экрана — eager + fetchPriority:high
    // 3-й приоритет: всё за экраном — lazy, iframe откладываем на скролл
    if (PAGE === 'Mixed Content') {

        // Восстанавливаем iframe при приближении (запас 300px до появления)
        const lazyIframeObserver = new IntersectionObserver(entries => {
            for (const e of entries) {
                if (e.isIntersecting && e.target.dataset.lazySrc) {
                    e.target.src = e.target.dataset.lazySrc;
                    delete e.target.dataset.lazySrc;
                    lazyIframeObserver.unobserve(e.target);
                }
            }
        }, { rootMargin: '300px' });

        const initMixed = () => {
            const vh = window.innerHeight;

            document.querySelectorAll('img').forEach(img => {
                const top = img.getBoundingClientRect().top;
                if (top <= vh) {
                    img.loading = 'eager';
                    img.decoding = 'async';
                    img.fetchPriority = 'high';
                } else {
                    img.loading = 'lazy';
                }
            });

            // FIX v1.1.6: пропускаем lazy-iframe на AI-чатах.
            // ChatGPT / Claude / Qwen могут использовать iframe для поля ввода
            // или вспомогательных компонентов. Удаление src у таких iframe приводит к
            // тому, что при программном скролле чата браузер не находит целевой элемент,
            // и поле ввода "улетает" вверх страницы.
            if (!shouldSkipScroll) {
                document.querySelectorAll('iframe').forEach(fr => {
                    if (fr.getBoundingClientRect().top > vh * 2 && fr.src) {
                        fr.dataset.lazySrc = fr.src;
                        fr.removeAttribute('src');
                        lazyIframeObserver.observe(fr);
                    }
                });
            } else {
                log('[IHW] initMixed: lazy-iframe пропущено (AI-чат)');
            }

            document.querySelectorAll('video').forEach(v => {
                v.autoplay = false;
                v.preload = 'metadata';
            });
        };

        if (document.readyState === 'loading') {
            document.addEventListener('DOMContentLoaded', initMixed, { once: true });
        } else {
            initMixed();
        }
    }

    /* ── Mobile: специфичные оптимизации ───────────────── */
    if (isMobile) {
        // Hover-анимации — на сенсоре не нужны, потребляют CPU при скролле
        const _mh = document.createElement('style');
        _mh.textContent = '@media(hover:none){*{transition:none!important;animation:none!important}}';
        (document.head || document.documentElement).appendChild(_mh);

        // Жёсткий запрет autoplay — на мобиле это трафик + АКБ
        document.addEventListener('play', e => {
            const el = e.target;
            if ((el.tagName === 'VIDEO' || el.tagName === 'AUDIO') && el.autoplay) {
                el.pause();
                el.autoplay = false;
            }
        }, true);
    }

    /* ── КНОПКА УПРАВЛЕНИЯ ──────────────────────────────── */
    function _renderBtn(isOff) {
        const btn = document.createElement('button');
        btn.style.cssText = [
            'position:fixed', 'bottom:52px', 'right:12px', 'z-index:2147483647',
            'font-size:11px', 'padding:3px 7px', 'border:none', 'border-radius:4px',
            'cursor:pointer', 'opacity:0.5', 'transition:opacity .2s,background .2s',
            'font-family:system-ui,sans-serif', 'line-height:1.4',
            isOff ? 'background:#888;color:#ddd' : 'background:#5a9fd4;color:#fff'
        ].join(';');
        btn.textContent = isOff ? 'OFF' : 'ON';
        btn.addEventListener('mouseenter', () => {
            btn.textContent = isOff ? 'Включить ускорение на этом сайте?' : 'Отключить ускорение на этом сайте?';
            btn.style.opacity = '0.95';
        });
        btn.addEventListener('mouseleave', () => {
            btn.textContent = isOff ? 'OFF' : 'ON';
            btn.style.opacity = '0.5';
        });
        btn.addEventListener('click', () => {
            isOff ? localStorage.removeItem(SITE_KEY) : localStorage.setItem(SITE_KEY, '1');
            location.reload();
        });
        _btn = btn;
        document.documentElement.appendChild(btn);
    }

    _renderBtn(false);

    /* ── DNS PREFETCH ДЛЯ ВТОРОГО ЭКРАНА (Mixed Content) ── */
    // Sentinel на top:2200px — когда пользователь долистывает до границы второго
    // экрана, на idle собираем внешние домены и добавляем dns-prefetch.
    // Трекеры фильтруются через isTracker() — в prefetch не попадают.
    // sendBeacon трекеров уже заблокирован выше — дополнительная фильтрация не нужна.
    let dnsPrefetchDone = false;

    const addDnsPrefetch = domains => {
        if (dnsPrefetchDone || !domains.length) return;

        // Отсеиваем трекеры — им dns-prefetch не нужен, мы их блокируем
        const clean = domains.filter(d => !isTracker('https://' + d));
        if (!clean.length) {
            log('[IHW] DNS prefetch: все найденные домены — трекеры, пропускаем');
            return;
        }

        log(`[IHW] DNS prefetch: резолвим ${clean.length} доменов → ${clean.join(', ')}`);
        const head = document.head || document.documentElement;
        clean.forEach(d => {
            const lnk = document.createElement('link');
            lnk.rel = 'dns-prefetch';
            lnk.href = '//' + d;
            head.appendChild(lnk);
        });
        dnsPrefetchDone = true;
    };

    const createSecondScreenSentinel = () => {
        if (dnsPrefetchDone) return;
        const sentinel = document.createElement('div');
        sentinel.style.cssText = 'position:absolute;top:2200px;left:0;width:1px;height:1px;pointer-events:none;visibility:hidden';
        document.documentElement.appendChild(sentinel);
        log('[IHW] Sentinel создан для второго экрана');

        const obs = new IntersectionObserver(entries => {
            if (!entries[0].isIntersecting || dnsPrefetchDone) return;
            obs.disconnect();
            sentinel.remove();
            log('[IHW] Второй экран подгружен — пользователь долистал до его границы');

            // Сбор доменов на idle — не тормозим скролл
            (window.requestIdleCallback || setTimeout).bind(window)(() => {
                const externalDomains = new Set();
                document.querySelectorAll('a[href^="http"], img[src^="http"], iframe[src^="http"]')
                    .forEach(el => {
                        try {
                            // location.origin как base — страховка для относительных путей
                            const h = new URL(el.href || el.src, location.origin).hostname;
                            // endsWith точнее чем includes — не отсеет notexample.com
                            if (h && !h.endsWith(location.hostname)) externalDomains.add(h);
                        } catch {}
                    });

                const list = [...externalDomains].slice(0, 10);
                if (list.length) {
                    addDnsPrefetch(list);
                } else {
                    log('[IHW] DNS prefetch: внешних доменов не найдено');
                }
            }, { timeout: 1000 });
        }, { rootMargin: '500px 0px' });

        obs.observe(sentinel);
    };

    /* ── ФИНАЛИЗАЦИЯ ────────────────────────────────────── */
    const onLoadHandler = () => {
        if (_initDone) return; // защита от двойного запуска
        _initDone = true;

        // runRenderOpts только для Mixed Content — на видеохостингах приоритет только видео,
        // а content-visibility и srcset-оптимизации там не нужны (нет статейных блоков).
        // requestIdleCallback ждёт реального простоя браузера; setTimeout — fallback (Safari < 18).
        if (PAGE === 'Mixed Content') {
            // runRenderOpts() содержит внутреннюю проверку shouldSkipScroll
            (window.requestIdleCallback || setTimeout).bind(window)(runRenderOpts, 500);
            // DNS prefetch sentinel — только не на чатах (там нет "второго экрана" в классическом понимании)
            if (!shouldSkipScroll) {
                setTimeout(createSecondScreenSentinel, 1200);
            }
        }

        setTimeout(() => mo.disconnect(), 4000);
    };

    // Надёжный запуск — три варианта на случай разного поведения Tampermonkey
    if (document.readyState === 'complete') {
        onLoadHandler();
    } else if (document.readyState === 'interactive') {
        onLoadHandler();
    } else {
        window.addEventListener('load', onLoadHandler, { once: true });
    }

    // Fallback: 7с достаточно для любой страницы
    setTimeout(() => {
        if (PAGE === 'Mixed Content' && !dnsPrefetchDone && !shouldSkipScroll) {
            createSecondScreenSentinel();
        }
    }, 7000);

})();