Instagram Enhanced

Контроль громкости, скорость, перемотка в Reels + даты публикаций

スクリプトをインストールするには、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         Instagram Enhanced
// @namespace    http://tampermonkey.net/
// @version      5.51
// @description  Контроль громкости, скорость, перемотка в Reels + даты публикаций
// @author       You
// @match        https://www.instagram.com/*
// @match        https://www.tiktok.com/*
// @match        https://vk.com/*
// @match        https://m.vk.com/*
// @match        https://vkvideo.ru/*
// @match        https://m.vkvideo.ru/*
// @match        https://www.youtube.com/*
// @match        https://m.youtube.com/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_openInTab
// @grant        unsafeWindow
// @run-at       document-idle
// ==/UserScript==

(function () {
    'use strict';

    let savedVolume = GM_getValue('reelsVolume', 0.5);
    let savedSpeed = GM_getValue('reelsSpeed', 1);
    let savedAudioLeveling = GM_getValue('reelsAudioLeveling', true);
    let processedVideos = new WeakSet();
    let processedContainers = new WeakSet();
    const processedComments = new WeakSet();
    const videoControls = new WeakMap();
    const audioLevelingNodes = new WeakMap();
    const host = location.hostname;
    const isInstagramHost = host.endsWith('instagram.com');
    let sharedAudioContext = null;

    const style = document.createElement('style');
    style.textContent = `
        .video-overlay {
            position: absolute;
            top: 0;
            left: 0;
            right: 0;
            bottom: 0;
            z-index: 9999;
            pointer-events: none;
        }

        .video-overlay > * {
            pointer-events: auto;
        }

        .volume-control-wrapper {
            position: absolute;
            bottom: 100px;
            right: 12px;
            display: flex;
            flex-direction: column;
            align-items: center;
            opacity: 0;
            transition: opacity 0.2s ease;
            z-index: 10000;
        }

        .speed-control-wrapper {
            position: absolute;
            top: 12px;
            left: 12px;
            opacity: 0;
            transition: opacity 0.2s ease;
            z-index: 10000;
        }

        .speed-button {
            background: rgba(0, 0, 0, 0.6);
            color: white;
            border: none;
            border-radius: 4px;
            padding: 6px 10px;
            font-size: 12px;
            cursor: pointer;
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
            font-weight: 500;
            backdrop-filter: blur(10px);
            transition: all 0.15s ease;
            letter-spacing: 0.3px;
        }

        .speed-button:hover {
            background: rgba(0, 0, 0, 0.75);
        }

        .speed-button:active {
            transform: scale(0.95);
        }

        .speed-menu {
            position: absolute;
            top: 100%;
            left: 0;
            margin-top: 4px;
            background: rgba(0, 0, 0, 0.85);
            border-radius: 6px;
            padding: 4px;
            display: none;
            flex-direction: column;
            gap: 2px;
            backdrop-filter: blur(20px);
            min-width: 120px;
        }

        .speed-menu.active {
            display: flex;
        }

        .speed-option {
            background: transparent;
            color: white;
            border: none;
            padding: 8px 12px;
            font-size: 13px;
            cursor: pointer;
            border-radius: 4px;
            text-align: left;
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
            transition: all 0.1s;
            font-weight: 400;
        }

        .speed-option:hover {
            background: rgba(255, 255, 255, 0.1);
        }

        .speed-option.active {
            background: rgba(255, 255, 255, 0.15);
            font-weight: 500;
        }

        /* Seek bar ВНУТРИ рилса, снизу по центру, выше нативных кнопок */
        .seek-bar-wrapper {
            position: absolute;
            bottom: 50px;
            left: 50%;
            transform: translateX(-50%);
            width: 85%;
            opacity: 0;
            transition: opacity 0.2s ease;
            z-index: 10000;
        }

        .seek-bar-container {
            background: rgba(0, 0, 0, 0.6);
            backdrop-filter: blur(10px);
            border-radius: 6px;
            padding: 8px 12px;
        }

        .seek-bar {
            width: 100%;
            height: 3px;
            -webkit-appearance: none;
            appearance: none;
            background: linear-gradient(to right,
                white 0%,
                white var(--progress, 0%),
                rgba(255, 255, 255, 0.3) var(--progress, 0%),
                rgba(255, 255, 255, 0.3) 100%);
            outline: none;
            border-radius: 2px;
            cursor: pointer;
            transition: height 0.15s;
        }

        .seek-bar:hover {
            height: 4px;
        }

        .seek-bar::-webkit-slider-thumb {
            -webkit-appearance: none;
            width: 12px;
            height: 12px;
            background: white;
            border-radius: 50%;
            cursor: pointer;
            transition: transform 0.15s;
        }

        .seek-bar::-webkit-slider-thumb:hover {
            transform: scale(1.15);
        }

        .seek-bar::-moz-range-thumb {
            width: 12px;
            height: 12px;
            background: white;
            border-radius: 50%;
            cursor: pointer;
            border: none;
        }

        .time-display {
            color: white;
            font-size: 11px;
            margin-top: 5px;
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
            font-weight: 400;
            opacity: 0.9;
            text-align: center;
        }

        .volume-slider-container {
            background: rgba(0, 0, 0, 0.6);
            backdrop-filter: blur(10px);
            border-radius: 20px;
            padding: 10px 8px;
            display: flex;
            flex-direction: column;
            align-items: center;
            gap: 10px;
        }

        .volume-slider-vertical {
            -webkit-appearance: slider-vertical;
            writing-mode: bt-lr;
            width: 3px;
            height: 80px;
            background: linear-gradient(to top,
                white 0%,
                white var(--volume-progress, 50%),
                rgba(255, 255, 255, 0.3) var(--volume-progress, 50%),
                rgba(255, 255, 255, 0.3) 100%);
            outline: none;
            border-radius: 2px;
            cursor: pointer;
        }

        .volume-slider-vertical::-webkit-slider-thumb {
            -webkit-appearance: none;
            width: 12px;
            height: 12px;
            background: white;
            border-radius: 50%;
            cursor: pointer;
            transition: transform 0.15s;
        }

        .volume-slider-vertical::-webkit-slider-thumb:hover {
            transform: scale(1.15);
        }

        .volume-slider-vertical::-moz-range-thumb {
            width: 12px;
            height: 12px;
            background: white;
            border-radius: 50%;
            cursor: pointer;
            border: none;
        }

        .volume-icon {
            width: 24px;
            height: 24px;
            cursor: pointer;
            transition: transform 0.15s;
        }

        .volume-icon:hover {
            transform: scale(1.1);
        }

        .volume-icon:active {
            transform: scale(0.95);
        }

        .ig-date-label {
            color: rgb(115, 115, 115);
            font-size: 12px;
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
            font-weight: 400;
            white-space: nowrap;
            pointer-events: none;
        }

        /* Hide Instagram's native mute button — volume is handled by our slider */
        button[aria-label="Включить звук"],
        button[aria-label="Выключить звук"],
        button[aria-label="Mute"],
        button[aria-label="Unmute"],
        button[aria-label="Ton an"],
        button[aria-label="Ton aus"],
        button[aria-label="Activer le son"],
        button[aria-label="Désactiver le son"],
        button[aria-label="Activar sonido"],
        button[aria-label="Silenciar"],
        button[aria-label="Attiva audio"],
        button[aria-label="Disattiva audio"] {
            display: none !important;
        }

        /* Fallback: hide by data attribute Instagram uses on mute buttons */
        [data-bloks-name="bk.components.Flexbox"] button svg + * ~ button,
        ._9ym9 { display: none !important; }
    `;
    document.head.appendChild(style);

    const processedDates = new WeakSet();

    function formatDate(dateStr) {
        const d = new Date(dateStr);
        if (isNaN(d)) return null;
        return d.toLocaleString('ru-RU', {
            day: '2-digit', month: '2-digit', year: 'numeric',
            hour: '2-digit', minute: '2-digit'
        });
    }

    function injectDateLabel(timeEl) {
        if (processedDates.has(timeEl)) return;
        const datetime = timeEl.getAttribute('datetime');
        if (!datetime) return;
        const formatted = formatDate(datetime);
        if (!formatted) return;
        processedDates.add(timeEl);

        const label = document.createElement('span');
        label.className = 'ig-date-label';
        label.textContent = '· ' + formatted;

        const parent = timeEl.parentElement;
        if (parent) {
            parent.style.display = 'flex';
            parent.style.alignItems = 'center';
            parent.style.flexWrap = 'wrap';
            parent.style.gap = '4px';
        }
        timeEl.insertAdjacentElement('afterend', label);
    }

    function processDates() {
        if (!isInstagramHost) return;
        document.querySelectorAll('time[datetime]').forEach(injectDateLabel);
    }

    function formatTime(seconds) {
        const mins = Math.floor(seconds / 60);
        const secs = Math.floor(seconds % 60);
        return `${mins}:${secs.toString().padStart(2, '0')}`;
    }

    function updateSpeedButton(video, button, menu) {
        const currentSpeed = video.playbackRate;
        const newText = currentSpeed === 1 ? '⚡ Скорость' : `⚡ ${currentSpeed}x`;
        if (button.textContent !== newText) {
            button.textContent = newText;
        }

        if (menu) {
            const options = menu.querySelectorAll('.speed-option');
            options.forEach(opt => {
                const optSpeed = parseFloat(opt.dataset.speed);
                if (Math.abs(optSpeed - currentSpeed) < 0.01) {
                    opt.classList.add('active');
                } else {
                    opt.classList.remove('active');
                }
            });
        }
    }

    function updateVolumeSlider(video, slider, icon) {
        const currentVol = video.volume;
        const sliderVal = Math.round(currentVol * 100);

        if (Math.abs(slider.value - sliderVal) > 1) {
            slider.value = sliderVal;
            slider.style.setProperty('--volume-progress', `${sliderVal}%`);
            updateVolumeIcon(icon, currentVol);
        }
    }

    function createSpeedControl(overlay, video) {
        const speedWrapper = document.createElement('div');
        speedWrapper.className = 'speed-control-wrapper';

        const currentSpeed = video.playbackRate || savedSpeed;
        const speedText = currentSpeed === 1 ? '⚡ Скорость' : `⚡ ${currentSpeed}x`;

        speedWrapper.innerHTML = `
            <button class="speed-button">${speedText}</button>
            <div class="speed-menu">
                <button class="speed-option" data-speed="0.5">0.5x</button>
                <button class="speed-option" data-speed="1">Обычная</button>
                <button class="speed-option" data-speed="1.25">1.25x</button>
                <button class="speed-option" data-speed="1.5">1.5x</button>
                <button class="speed-option" data-speed="1.75">1.75x</button>
                <button class="speed-option" data-speed="2">2x</button>
            </div>
        `;

        const button = speedWrapper.querySelector('.speed-button');
        const menu = speedWrapper.querySelector('.speed-menu');
        const options = speedWrapper.querySelectorAll('.speed-option');

        const controls = videoControls.get(video) || {};
        controls.speedButton = button;
        controls.speedMenu = menu;
        videoControls.set(video, controls);

        updateSpeedButton(video, button, menu);

        button.addEventListener('click', function (e) {
            e.stopPropagation();
            menu.classList.toggle('active');
        });

        options.forEach(option => {
            option.addEventListener('click', function (e) {
                e.stopPropagation();
                const speed = parseFloat(this.dataset.speed);
                savedSpeed = speed;
                GM_setValue('reelsSpeed', savedSpeed);

                if (video) {
                    video.playbackRate = savedSpeed;
                    updateSpeedButton(video, button, menu);
                }

                menu.classList.remove('active');
            });
        });

        overlay.appendChild(speedWrapper);
        return speedWrapper;
    }

    function createSeekBar(overlay, video) {
        const seekWrapper = document.createElement('div');
        seekWrapper.className = 'seek-bar-wrapper';
        seekWrapper.innerHTML = `
            <div class="seek-bar-container">
                <input type="range" class="seek-bar" min="0" max="100" value="0" step="0.1" style="--progress: 0%">
                <div class="time-display">0:00 / 0:00</div>
            </div>
        `;

        const seekBar = seekWrapper.querySelector('.seek-bar');
        const timeDisplay = seekWrapper.querySelector('.time-display');
        let isSeeking = false;

        video.addEventListener('timeupdate', function () {
            if (!isSeeking && video.duration) {
                const percent = (video.currentTime / video.duration) * 100;
                seekBar.value = percent;
                seekBar.style.setProperty('--progress', `${percent}%`);
                timeDisplay.textContent = `${formatTime(video.currentTime)} / ${formatTime(video.duration)}`;
            }
        });

        video.addEventListener('loadedmetadata', function () {
            timeDisplay.textContent = `0:00 / ${formatTime(video.duration)}`;
        });

        seekBar.addEventListener('input', function (e) {
            e.stopPropagation();
            isSeeking = true;
            this.style.setProperty('--progress', `${this.value}%`);
            const time = (this.value / 100) * video.duration;
            timeDisplay.textContent = `${formatTime(time)} / ${formatTime(video.duration)}`;
        });

        seekBar.addEventListener('change', function (e) {
            e.stopPropagation();
            const time = (this.value / 100) * video.duration;
            video.currentTime = time;
            isSeeking = false;
        });

        overlay.appendChild(seekWrapper);
        return seekWrapper;
    }

    function createVolumeControl(overlay, video) {
        const volumeWrapper = document.createElement('div');
        volumeWrapper.className = 'volume-control-wrapper';

        const currentVol = Math.round(savedVolume * 100);
        const volumeIcon = savedVolume === 0 ?
            `<svg class="volume-icon" fill="white" viewBox="0 0 24 24"><path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/></svg>` :
            `<svg class="volume-icon" fill="white" viewBox="0 0 24 24"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/></svg>`;

        volumeWrapper.innerHTML = `
            <div class="volume-slider-container">
                <input type="range" class="volume-slider-vertical" min="0" max="100" value="${currentVol}" orient="vertical" style="--volume-progress: ${currentVol}%">
                ${volumeIcon}
            </div>
        `;

        const slider = volumeWrapper.querySelector('.volume-slider-vertical');
        const icon = volumeWrapper.querySelector('.volume-icon');

        const controls = videoControls.get(video) || {};
        controls.volumeSlider = slider;
        controls.volumeIcon = icon;
        videoControls.set(video, controls);

        slider.addEventListener('input', function (e) {
            e.stopPropagation();
            savedVolume = this.value / 100;
            GM_setValue('reelsVolume', savedVolume);
            this.style.setProperty('--volume-progress', `${this.value}%`);
            if (video) video.volume = savedVolume;
            updateVolumeIcon(icon, savedVolume);
        });

        icon.addEventListener('click', function (e) {
            e.stopPropagation();
            if (savedVolume > 0) {
                slider.value = 0;
                savedVolume = 0;
            } else {
                slider.value = 50;
                savedVolume = 0.5;
            }
            slider.style.setProperty('--volume-progress', `${slider.value}%`);
            GM_setValue('reelsVolume', savedVolume);
            if (video) video.volume = savedVolume;
            updateVolumeIcon(icon, savedVolume);
        });

        overlay.appendChild(volumeWrapper);
        return volumeWrapper;
    }

    function updateVolumeIcon(icon, volume) {
        const newPath = volume === 0 ?
            '<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>' :
            '<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>';

        if (icon.innerHTML !== newPath) {
            icon.innerHTML = newPath;
        }
    }

    function getAudioContext() {
        if (sharedAudioContext) return sharedAudioContext;
        const audioWindow = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;
        const AudioContextClass = audioWindow.AudioContext || audioWindow.webkitAudioContext;
        if (!AudioContextClass) return null;
        sharedAudioContext = new AudioContextClass();
        return sharedAudioContext;
    }

    function resumeAudioContext() {
        const ctx = getAudioContext();
        if (ctx && ctx.state === 'suspended') {
            ctx.resume().catch(() => {});
        }
    }

    function clamp(value, min, max) {
        return Math.min(max, Math.max(min, value));
    }

    function isVideoMostlyVisible(video) {
        const rect = video.getBoundingClientRect();
        if (rect.width < 80 || rect.height < 80) return false;
        const visibleWidth = Math.min(rect.right, innerWidth) - Math.max(rect.left, 0);
        const visibleHeight = Math.min(rect.bottom, innerHeight) - Math.max(rect.top, 0);
        if (visibleWidth <= 0 || visibleHeight <= 0) return false;
        return (visibleWidth * visibleHeight) / (rect.width * rect.height) > 0.35;
    }

    // ================================================================
    // ГЛОБАЛЬНАЯ НОРМА ГРОМКОСТИ
    // ================================================================
    // Одно число на всю сессию: типичная громкость роликов на этом сайте.
    // Все ролики приводятся К ЭТОЙ норме, а не к громкости текущего клипа.
    //
    // Норма обновляется медленно (alpha~0.05 на ролик) — после первого
    // десятка просмотренных роликов стабилизируется на их средней громкости.
    // Сохраняется в GM, поэтому переживает перезагрузку страницы.
    //
    // ref считается БЕЗ участия громкости конкретного клипа — только от
    // ползунка. Это значит: ползунок выше → все ролики громче, но соотношение
    // между ними остаётся выровненным.
    // ================================================================
    const NORM_KEY = `audioNormLong_${location.hostname}`;
    let globalNormLong = GM_getValue(NORM_KEY, 0);
    let lastNormSaveAt = 0;

    function updateGlobalNorm(longRms) {
        // Принимаем только разумные значения — отсекаем мусор от роликов
        // с речью на пределе слышимости или странными артефактами.
        if (!(longRms >= 0.002 && longRms <= 0.20)) return;
        if (globalNormLong <= 0) {
            globalNormLong = longRms;
        } else {
            // Скользящее среднее в log-домене — устойчивее к выбросам.
            const logCur = Math.log(globalNormLong);
            const logNew = Math.log(longRms);
            globalNormLong = Math.exp(logCur * 0.92 + logNew * 0.08);
        }
        const now = performance.now();
        if (now - lastNormSaveAt > 5000) {
            lastNormSaveAt = now;
            try { GM_setValue(NORM_KEY, globalNormLong); } catch (_) {}
        }
    }

    // Кэш по URL: чтобы при возврате на конкретный ролик gain ставился
    // сразу правильный, без секунды оглушения.
    const loudnessCache = (window.__igEnhancedLoudnessCache__ ||= new Map());

    function srcKey(video) {
        return video.currentSrc || video.src || '';
    }

    function setupAudioLeveling(video) {
        if (!savedAudioLeveling || audioLevelingNodes.has(video)) return;

        const ctx = getAudioContext();
        if (!ctx) return;

        const analyser = ctx.createAnalyser();
        const data = new Uint8Array(2048);
        let currentGain = 1;
        let rafId = 0;
        let noSignalFrames = 0;
        let lastDebugAt = 0;
        let lastWarnAt = 0;

        // Окно RMS-проб за последние ~6 секунд (по 1 пробе в кадр ~60Hz).
        // Берём 70-й перцентиль из этого окна — это устойчивая оценка
        // громкости ролика, не подверженная скачкам на отдельных слогах.
        const RMS_WINDOW = 360;
        const rmsWindow = new Float32Array(RMS_WINDOW);
        let rmsWindowIdx = 0;
        let rmsWindowFilled = 0;

        // Зафиксированная оценка громкости ролика. Считается ОДИН раз
        // когда окно заполнилось, дальше не пересчитывается.
        // Это и есть стабильная "громкость этого ролика".
        let clipLoudness = 0;
        let clipLoudnessFrozen = false;

        let framesSinceStart = 0;

        try {
            const source = ctx.createMediaElementSource(video);
            const autoGain = ctx.createGain();
            const limiter = ctx.createDynamicsCompressor();

            analyser.fftSize = 2048;
            // Лимитер — страховка от клиппинга. Не основной инструмент.
            limiter.threshold.value = -2;
            limiter.knee.value = 3;
            limiter.ratio.value = 16;
            limiter.attack.value = 0.003;
            limiter.release.value = 0.15;
            autoGain.gain.value = currentGain;

            source.connect(analyser);
            source.connect(autoGain);
            autoGain.connect(limiter);
            limiter.connect(ctx.destination);
            audioLevelingNodes.set(video, { source, analyser, autoGain, limiter });
            console.info('[AudioLeveling] attached', location.hostname, video.currentSrc || video.src || location.href);
        } catch (e) {
            audioLevelingNodes.set(video, { error: e });
            console.warn('[AudioLeveling] failed to attach', location.hostname, e);
            return;
        }

        // Стартовый gain: если есть глобальная норма — выставляем сразу
        // baseGain = reference/norm, чтобы первая секунда играла на ожидаемой громкости.
        const seedStartGain = () => {
            if (globalNormLong > 0.0008) {
                const startVol = clamp(video.volume, 0.001, 1);
                const startRef = 0.10 * Math.pow(startVol, 0.7);
                currentGain = clamp(startRef / globalNormLong, 0.40, 3.0);
                try {
                    audioLevelingNodes.get(video).autoGain.gain.setValueAtTime(
                        currentGain, ctx.currentTime
                    );
                } catch (_) {}
            }
        };
        seedStartGain();

        // Если для этого ролика уже знаем громкость — стартуем с неё, замороженной.
        const cachedLoudness = loudnessCache.get(srcKey(video));
        if (cachedLoudness && cachedLoudness > 0) {
            clipLoudness = cachedLoudness;
            clipLoudnessFrozen = true;
            framesSinceStart = 1000;
        }

        // Возвращает 70-й перцентиль из окна (только заполненная часть).
        function percentile70() {
            const n = rmsWindowFilled;
            if (n < 30) return 0;
            // Копируем значения и сортируем.
            const arr = Array.from(rmsWindow.subarray(0, n));
            arr.sort((a, b) => a - b);
            // 70-й перцентиль: достаточно высокий чтобы игнорить тишину/паузы,
            // но не пиковый, чтобы не реагировать на отдельные басы.
            return arr[Math.floor(n * 0.70)];
        }

        const updateGain = () => {
            if (!document.contains(video)) {
                cancelAnimationFrame(rafId);
                return;
            }

            const isActiveVideo = !video.paused
                && !video.muted
                && video.volume > 0
                && ctx.state === 'running'
                && isVideoMostlyVisible(video);

            if (isActiveVideo) {
                analyser.getByteTimeDomainData(data);

                let sum = 0;
                let peak = 0;
                for (let i = 0; i < data.length; i++) {
                    const centered = Math.abs(data[i] - 128);
                    if (centered > peak) peak = centered;
                    sum += centered * centered;
                }

                const rms = Math.sqrt(sum / data.length) / 128;
                peak = peak / 128;
                const siteVolume = clamp(video.volume, 0, 1);

                // ============================================================
                // ВЫРАВНИВАНИЕ ЧЕРЕЗ ОКНО + ЗАМОРОЖЕННАЯ ПОПРАВКА
                // ============================================================
                // 1) Копим RMS-пробы в скользящем окне ~6 секунд.
                // 2) После 2 секунд (120 кадров) считаем 70-й перцентиль —
                //    это и есть громкость ролика. ЗАМОРАЖИВАЕМ её.
                // 3) Дальше gain не меняется кадр-в-кадр. Меняется только
                //    при смене ролика (новый замер) или ползунка (новый ref).
                // 4) Без COMPRESSION — даём настоящий полный gain, но в
                //    жёстких границах (x0.5..x2.5 от базового), чтобы один
                //    тихий ролик не уехал в +12 дБ относительно остальных.
                // ============================================================

                if (rms > 0.0008) {
                    noSignalFrames = 0;
                    framesSinceStart++;

                    // Записываем в кольцевой буфер.
                    rmsWindow[rmsWindowIdx] = rms;
                    rmsWindowIdx = (rmsWindowIdx + 1) % RMS_WINDOW;
                    if (rmsWindowFilled < RMS_WINDOW) rmsWindowFilled++;

                    // Через 2 секунды — замораживаем оценку громкости ролика.
                    if (!clipLoudnessFrozen && framesSinceStart >= 120) {
                        clipLoudness = percentile70();
                        if (clipLoudness > 0.0008) {
                            clipLoudnessFrozen = true;
                            const k = srcKey(video);
                            if (k) loudnessCache.set(k, clipLoudness);
                            updateGlobalNorm(clipLoudness);
                            console.info(
                                `[AudioLeveling] FROZEN clipLoudness=${clipLoudness.toFixed(4)} norm=${globalNormLong.toFixed(4)} ratio=${(clipLoudness/(globalNormLong||1)).toFixed(2)}`
                            );
                        }
                    }
                } else {
                    noSignalFrames++;
                }

                // Куда хотим привести типичный ролик — функция от ползунка.
                const reference = 0.10 * Math.pow(siteVolume, 0.7);

                // Норма сайта (или временный fallback на оценку текущего ролика).
                const norm = globalNormLong > 0.0008
                    ? globalNormLong
                    : (clipLoudnessFrozen ? clipLoudness : 0);

                // Жёсткие границы поправки ролика (НЕ всего gain).
                // x0.5..x2.0 = ±6 дБ. Этого достаточно чтобы выровнять
                // 90% типичного разброса, но один сломанный ролик не уедет.
                const CLIP_CORR_MIN = 0.5;
                const CLIP_CORR_MAX = 2.0;

                // Общие границы итогового gain.
                const MAX_BOOST = 4.0;
                const MIN_CUT   = 0.25;

                if (norm > 0.0008) {
                    // baseGain — переводит "норму сайта" в желаемую громкость.
                    // Этот множитель ОДИНАКОВ для всех роликов.
                    const baseGain = reference / norm;

                    // Поправка только если громкость ролика уже измерена.
                    let clipCorrection = 1;
                    if (clipLoudnessFrozen && clipLoudness > 0.0008) {
                        clipCorrection = clamp(
                            norm / clipLoudness,
                            CLIP_CORR_MIN,
                            CLIP_CORR_MAX
                        );
                    }

                    let desiredGain = baseGain * clipCorrection;
                    desiredGain = clamp(desiredGain, MIN_CUT, MAX_BOOST);

                    // Пик-защита.
                    if (peak > 0.001) {
                        const peakGuard = 0.95 / peak;
                        if (peakGuard < desiredGain) desiredGain = peakGuard;
                    }

                    // Сглаживание. После заморозки gain практически перестаёт
                    // меняться — стоит пиковая защита и реакция на ползунок.
                    const goingDown = desiredGain < currentGain;
                    let smoothing;
                    if (!clipLoudnessFrozen) {
                        smoothing = 0.05;       // до заморозки — gain почти неподвижен
                    } else if (goingDown) {
                        smoothing = 0.30;       // от пика — быстрее вниз
                    } else {
                        smoothing = 0.08;       // плавно вверх к норме
                    }
                    currentGain += (desiredGain - currentGain) * smoothing;

                    audioLevelingNodes.get(video).autoGain.gain.setTargetAtTime(
                        currentGain,
                        ctx.currentTime,
                        goingDown ? 0.020 : 0.080
                    );
                }

                const now = performance.now();
                if (now - lastDebugAt > 2000) {
                    lastDebugAt = now;
                    console.info(
                        `[AudioLeveling] rms=${rms.toFixed(4)} clip=${clipLoudness.toFixed(4)}${clipLoudnessFrozen?'F':' '} norm=${globalNormLong.toFixed(4)} ref=${reference.toFixed(4)} peak=${peak.toFixed(3)} gain=${currentGain.toFixed(2)} vol=${video.volume.toFixed(2)} f=${framesSinceStart}`
                    );
                    if (noSignalFrames > 180 && now - lastWarnAt > 10000) {
                        lastWarnAt = now;
                        console.warn('[AudioLeveling] audio graph attached, but no signal. Browser may block Web Audio for this stream.');
                    }
                }
            } else if (Math.abs(currentGain - 1) > 0.01) {
                currentGain += (1 - currentGain) * 0.2;
                audioLevelingNodes.get(video).autoGain.gain.setTargetAtTime(
                    currentGain,
                    ctx.currentTime,
                    0.05
                );
            }

            rafId = requestAnimationFrame(updateGain);
        };

        // На смене источника — сброс окна и подтягивание кэша/нормы.
        const onSrcChange = () => {
            framesSinceStart = 0;
            rmsWindowIdx = 0;
            rmsWindowFilled = 0;
            const cached = loudnessCache.get(srcKey(video));
            if (cached && cached > 0) {
                clipLoudness = cached;
                clipLoudnessFrozen = true;
                framesSinceStart = 1000;
            } else {
                clipLoudness = 0;
                clipLoudnessFrozen = false;
            }
            seedStartGain();
        };
        video.addEventListener('loadstart', onSrcChange);
        video.addEventListener('emptied', onSrcChange);

        const resume = () => resumeAudioContext();
        video.addEventListener('play', resume);
        video.addEventListener('playing', resume);
        video.addEventListener('click', resume);
        video.addEventListener('volumechange', resume);
        rafId = requestAnimationFrame(updateGain);
    }

    ['pointerdown', 'keydown', 'touchstart'].forEach(eventName => {
        document.addEventListener(eventName, resumeAudioContext, { capture: true, passive: true });
    });

    // Unmute a video and apply saved volume, fighting Instagram's muted=true
    function applyVolume(video) {
        if (!isInstagramHost) return;
        if (savedVolume > 0) {
            video.muted = false;
        }
        video.volume = savedVolume;
        video.playbackRate = savedSpeed;
    }

    function setupVideo(video) {
        if (processedVideos.has(video)) return;
        processedVideos.add(video);

        applyVolume(video);
        setupAudioLeveling(video);
        if (!isInstagramHost) return;

        // Instagram often re-mutes on loadedmetadata / canplay / play — intercept all of them
        video.addEventListener('loadedmetadata', function () { applyVolume(this); });
        video.addEventListener('canplay',        function () { applyVolume(this); });
        video.addEventListener('play',           function () { applyVolume(this); });

        // Also intercept Instagram setting video.muted = true via the property setter
        try {
            const proto = Object.getPrototypeOf(video);
            const desc = Object.getOwnPropertyDescriptor(proto, 'muted')
                      || Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'muted');
            if (desc && desc.set) {
                Object.defineProperty(video, 'muted', {
                    get: function () { return desc.get ? desc.get.call(this) : false; },
                    set: function (val) {
                        // If IG tries to mute and we have saved volume > 0, ignore it
                        if (val === true && savedVolume > 0) return;
                        desc.set.call(this, val);
                    },
                    configurable: true,
                });
            }
        } catch (e) { /* ignore — not critical */ }
    }

    function findVideoContainer(video) {
        return video.parentElement;
    }

    function findPlayerContainer(video) {
        // Reels: --x-height на предке
        const reels = video.closest('[style*="--x-height"]');
        if (reels) return reels;

        // Feed/пост: ищем data-instancekey с максимальным перекрытием с видео.
        // Его родитель — это нужный нам контейнер (xyzq4qe / аналог).
        const vr = video.getBoundingClientRect();
        if (vr.width > 0 && vr.height > 0) {
            let bestEl = null, bestArea = 0;
            document.querySelectorAll('[data-instancekey]').forEach(inst => {
                const r = inst.getBoundingClientRect();
                const ow = Math.min(vr.right, r.right) - Math.max(vr.left, r.left);
                const oh = Math.min(vr.bottom, r.bottom) - Math.max(vr.top, r.top);
                if (ow > 0 && oh > 0 && ow * oh > bestArea) {
                    bestArea = ow * oh;
                    bestEl = inst.parentElement || inst;
                }
            });
            if (bestEl) return bestEl;
        }

        // Fallback: ищем общего предка, у которого data-instancekey — ПРЯМОЙ ребёнок.
        let el = video.parentElement;
        while (el && el !== document.body) {
            const parent = el.parentElement;
            if (!parent) break;
            if ([...parent.children].some(c => c !== el && c.hasAttribute('data-instancekey'))) return parent;
            el = parent;
        }
        return video.parentElement;
    }

    function processContainer(container, video) {
        if (processedContainers.has(container)) return;
        processedContainers.add(container);

        const videoContainer = findVideoContainer(video);
        videoContainer.classList.add('video-container');
        if (window.getComputedStyle(videoContainer).position === 'static') {
            videoContainer.style.position = 'relative';
        }

        const reelContainer = findPlayerContainer(video);
        if (window.getComputedStyle(reelContainer).position === 'static') {
            reelContainer.style.position = 'relative';
        }

        const overlay = document.createElement('div');
        overlay.className = 'video-overlay';
        reelContainer.appendChild(overlay);  // добавляем последним — выше data-instancekey

        const volumeWrapper = createVolumeControl(overlay, video);
        const seekWrapper = createSeekBar(overlay, video);
        const speedWrapper = createSpeedControl(overlay, video);

        const controlWrappers = [volumeWrapper, seekWrapper, speedWrapper].filter(Boolean);

        const showControls = () => controlWrappers.forEach(w => w.style.opacity = '1');
        const hideControls = () => controlWrappers.forEach(w => w.style.opacity = '0');

        let hideTimer;
        const scheduleHide = () => { hideTimer = setTimeout(hideControls, 300); };
        const cancelHide = () => clearTimeout(hideTimer);

        const hoverTargets = [reelContainer, ...controlWrappers];
        hoverTargets.forEach(el => {
            el.addEventListener('mouseenter', () => { cancelHide(); showControls(); });
            el.addEventListener('mouseleave', scheduleHide);
        });
    }


    function findAndProcessComments() {
        if (!isInstagramHost) return;
        // Instagram использует CSS-переменные для размеров панели комментариев
        // Ищем контейнер со style="--x-maxHeight: XXX; --x-width: YYY;"

        const allDivs = document.querySelectorAll('div[style*="--x-maxHeight"][style*="--x-width"]');

        for (const target of allDivs) {
            // Проверяем, что это действительно панель комментариев
            if (target.offsetHeight < 300 || target.offsetWidth < 200) continue;

            // Пропускаем уже обработанные
            if (processedComments.has(target)) continue;

            processedComments.add(target);

            console.log('🎯 Найдена панель комментариев Instagram!', target);

            // Получаем текущие размеры из CSS-переменных
            const currentStyle = target.getAttribute('style') || '';
            const maxHeightMatch = currentStyle.match(/--x-maxHeight:\s*([\d.]+)px/);
            const widthMatch = currentStyle.match(/--x-width:\s*([\d.]+)px/);

            const defaultHeight = maxHeightMatch ? parseFloat(maxHeightMatch[1]) : 600;
            const defaultWidth = widthMatch ? parseFloat(widthMatch[1]) : 400;

            // Загружаем сохраненные размеры
            const savedWidth = GM_getValue('commentsWidth', defaultWidth);
            const savedHeight = GM_getValue('commentsHeight', defaultHeight);

            console.log('📐 Сохраненные размеры:', savedWidth, 'x', savedHeight);

            // УБИРАЕМ CSS-переменные и делаем контейнер resizable
            target.style.setProperty('--x-maxHeight', 'none', 'important');
            target.style.setProperty('--x-width', 'auto', 'important');

            // Устанавливаем обычные CSS-свойства с сохраненными размерами
            target.style.width = savedWidth + 'px';
            target.style.height = savedHeight + 'px';
            target.style.minWidth = '350px';
            target.style.minHeight = '400px';
            target.style.maxWidth = '95vw';
            target.style.maxHeight = '95vh';
            target.style.resize = 'both';
            target.style.overflow = 'auto';
            target.style.border = '2px solid rgba(255, 255, 255, 0.3)';
            target.style.borderRadius = '12px';
            target.style.boxShadow = '0 4px 30px rgba(0,0,0,0.3)';
            target.style.display = 'flex';
            target.style.flexDirection = 'column';
            target.style.position = 'relative';

            // Добавляем визуальный индикатор resize
            const resizeHint = document.createElement('div');
            resizeHint.className = 'resize-hint-instagram-comments';
            resizeHint.style.cssText = `
                position: absolute;
                bottom: 0;
                right: 0;
                width: 20px;
                height: 20px;
                background: linear-gradient(135deg, transparent 50%, rgba(255,255,255,0.3) 50%);
                cursor: nwse-resize;
                pointer-events: none;
                border-bottom-right-radius: 10px;
            `;
            target.appendChild(resizeHint);

            // Функция для адаптации содержимого
            const fixContent = () => {
                // Находим все прямые дети
                const children = Array.from(target.children);

                children.forEach((child, index) => {
                    if (child.className === 'resize-hint-instagram-comments') return;

                    const childStyle = window.getComputedStyle(child);

                    // Первый ребенок (обычно header) - фиксированный
                    if (index === 0 || child.offsetHeight < 100) {
                        child.style.flexShrink = '0';
                        child.style.flexGrow = '0';
                    } else {
                        // Остальное содержимое - растягивается
                        child.style.flex = '1 1 auto';
                        child.style.minHeight = '0';
                        child.style.overflow = 'auto';
                        child.style.display = 'flex';
                        child.style.flexDirection = 'column';
                    }
                });

                // Все скроллируемые элементы внутри
                const scrollables = target.querySelectorAll('[style*="flex: 1 1 auto"]');
                scrollables.forEach(el => {
                    if (el !== target) {
                        el.style.minHeight = '0';
                        el.style.height = 'auto';
                        el.style.maxHeight = 'none';

                        // Убираем CSS-переменные у вложенных элементов
                        el.style.setProperty('--x-maxHeight', 'none', 'important');
                        el.style.setProperty('--x-minHeight', '0', 'important');
                    }
                });
            };

            // Применяем фикс
            fixContent();

            // Отслеживаем изменения
            const observer = new MutationObserver(() => {
                requestAnimationFrame(fixContent);
            });

            observer.observe(target, {
                childList: true,
                subtree: true,
                attributes: true,
                attributeFilter: ['style', 'class']
            });

            // Отслеживаем resize и СОХРАНЯЕМ размеры
            let saveTimeout;
            const resizeObserver = new ResizeObserver(() => {
                fixContent();

                // Сохраняем размеры с небольшой задержкой (чтобы не спамить при изменении)
                clearTimeout(saveTimeout);
                saveTimeout = setTimeout(() => {
                    const newWidth = parseInt(target.style.width);
                    const newHeight = parseInt(target.style.height);

                    if (newWidth && newHeight) {
                        GM_setValue('commentsWidth', newWidth);
                        GM_setValue('commentsHeight', newHeight);
                        console.log('💾 Размеры сохранены:', newWidth, 'x', newHeight);
                    }
                }, 500); // Сохраняем через 500мс после окончания изменения размера
            });
            resizeObserver.observe(target);

            console.log('✅ Панель комментариев теперь resizable с автосохранением!');
        }
    }


    // Hide Instagram's native mute/unmute button (we have our own volume slider)
    function hideNativeMuteButtons() {
        if (!isInstagramHost) return;
        document.querySelectorAll('button').forEach(btn => {
            const label = (btn.getAttribute('aria-label') || '').toLowerCase();
            if (label.includes('mute') || label.includes('unmute') ||
                label.includes('звук') || label.includes('son') ||
                label.includes('audio') || label.includes('silencia')) {
                btn.style.setProperty('display', 'none', 'important');
            }
        });
    }

    function findAndProcessVideos() {
        const videos = document.querySelectorAll('video');
        videos.forEach(video => {
            if (video.offsetWidth < 50 || video.offsetHeight < 50) return;

            setupVideo(video);

            if (isInstagramHost) {
                const container = video.parentElement;

                if (container && !processedContainers.has(container)) {
                    processContainer(container, video);
                }
            }
        });

        hideNativeMuteButtons();
        findAndProcessComments();
    }

    setInterval(function () {
        if (!isInstagramHost) return;
        document.querySelectorAll('video').forEach(video => {
            if (Math.abs(video.volume - savedVolume) > 0.05) {
                video.volume = savedVolume;
            }
            if (savedVolume > 0 && video.muted) {
                video.muted = false;
            }
            if (Math.abs(video.playbackRate - savedSpeed) > 0.05) {
                video.playbackRate = savedSpeed;
            }

            const controls = videoControls.get(video);
            if (controls) {
                if (controls.speedButton && controls.speedMenu) {
                    updateSpeedButton(video, controls.speedButton, controls.speedMenu);
                }
                if (controls.volumeSlider && controls.volumeIcon) {
                    updateVolumeSlider(video, controls.volumeSlider, controls.volumeIcon);
                }
            }
        });
    }, 1000);

    let debounceTimer;
    const observer = new MutationObserver(function () {
        clearTimeout(debounceTimer);
        debounceTimer = setTimeout(() => { findAndProcessVideos(); processDates(); rewritePostLinks(); }, 150);
    });

    observer.observe(document.body, {
        childList: true,
        subtree: true
    });
    redirectPostPageToReels();
    findAndProcessVideos();
    processDates();
    rewritePostLinks();

    let lastUrl = location.href;
    new MutationObserver(function () {
        const url = location.href;
        if (url !== lastUrl) {
            lastUrl = url;
            processedVideos = new WeakSet();
            processedContainers = new WeakSet();
            setTimeout(() => {
                redirectPostPageToReels();
                findAndProcessVideos();
                processDates();
                rewritePostLinks();
                initLikesPage();
            }, 300);
        }
    }).observe(document, { subtree: true, childList: true });

    // ── /p/ → /reel/ REWRITER ───────────────────────────────────────────────

    let fetchPatched = false;
    // ig_cache_key (decoded) → media_code (shortcode). Built from wbloks responses.
    const codeByMediaId = new Map();

    function rewritePostLinks() {
        if (!isInstagramHost) return;
        document.querySelectorAll('a[href*="/p/"]').forEach(a => {
            if (a.dataset.reelPatched) return;
            const m = a.href.match(/\/p\/([A-Za-z0-9_-]+)/);
            if (!m) return;
            a.dataset.reelPatched = '1';
            a.href = `https://www.instagram.com/reels/${m[1]}/`;
        });
    }

    function redirectPostPageToReels() {
        if (!isInstagramHost) return;
        const m = location.pathname.match(/^\/p\/([A-Za-z0-9_-]+)\/?$/);
        if (!m) return;

        const target = new URL(location.href);
        target.pathname = `/reels/${m[1]}/`;
        location.replace(target.href);
    }

    // Parse wbloks response: extract media_id → media_code mappings.
    // Instagram uses Bloks DSL format (not JSON):
    //   (bk.action.array.Make, "media_id", "media_code", ...)   ← column names
    //   (bk.action.array.Make, "3891891610077986313_67659193585", "DYCxsybBX4J", ...)  ← values
    // So we find the column-name arrays, note positions of "media_id" and "media_code",
    // then extract values from the matching value arrays that follow.
    function indexWbloksResponse(text) {
        let count = 0;

        // The response body is a JSON string — so actual quote chars are escaped as \"
        // Real data looks like: \"3891891610077986313_67659193585\", \"DYCxsybBX4J\"
        // We need to match both escaped (\" ... \") and unescaped (" ... ") variants.
        // Use a pattern that handles both: optional backslash before each quote.
        const re = /\\?"(\d{10,25}_(\d{5,20}))\\?"\s*,\s*\\?"([A-Za-z0-9_-]{5,20})\\?"/g;
        let m;
        while ((m = re.exec(text)) !== null) {
            const rawId = m[1];   // e.g. "3891891610077986313_67659193585"
            const part2 = m[2];
            const code  = m[3];   // e.g. "DYCxsybBX4J"

            // Skip known non-shortcode type strings
            if (['clips', 'feed', 'reel', 'igtv', 'story', 'post'].includes(code)) continue;
            // Shortcodes are 8-15 chars, skip if outside that range
            if (code.length < 7 || code.length > 16) continue;

            const part1 = rawId.split('_')[0];
            const full  = part1 + part2;  // concatenated (matches ig_cache_key decode)

            codeByMediaId.set(rawId, code);  // "part1_part2"
            codeByMediaId.set(full,  code);  // "part1part2"
            codeByMediaId.set(part1, code);  // just part1 (fallback)
            count++;
        }

        console.log(`[IG] wbloks indexed: +${count} posts (total: ${codeByMediaId.size}), response length: ${text.length}`);
        return count;
    }

    // Get shortcode by reading ig_cache_key from a thumbnail img URL
    // ig_cache_key decodes to a full 19-digit media_id (part1+part2 concatenated).
    // The wbloks indexer stores part1 (first ~10 digits) as a Map key.
    // So we do a prefix search: find a stored key that is a prefix of idStr.
    function shortcodeForImg(img) {
        if (!img || !img.src) return null;
        const m = img.src.match(/ig_cache_key=([^&]+)/);
        if (!m) return null;
        try {
            const keyPart = decodeURIComponent(m[1]).split('.')[0];
            const idStr = atob(keyPart).trim();
            console.log(`[IG] ig_cache_key decoded: "${idStr}" (mapSize=${codeByMediaId.size})`);

            // 1. Direct hits: full id, or just the first numeric part
            let result = codeByMediaId.get(idStr)
                || codeByMediaId.get(idStr.split('_')[0])
                || null;

            // 2. Prefix / suffix scan for numeric ids:
            //    idStr may be part1+part2 concat; stored keys include full concat AND part1.
            //    Also handles case where stored key is longer (part1_part2) and idStr is prefix.
            if (!result && /^\d{10,}$/.test(idStr)) {
                for (const [key, code] of codeByMediaId) {
                    if (!/^\d{10,}$/.test(key)) continue;
                    if (idStr === key || idStr.startsWith(key) || key.startsWith(idStr)) {
                        result = code;
                        break;
                    }
                }
            }

            console.log(`[IG] shortcode lookup: "${idStr}" → ${result}`);
            return result;
        } catch (e) {
            console.log('[IG] ig_cache_key decode error:', e);
        }
        return null;
    }

    // Patch fetch + XHR to capture wbloks responses (read-only, doesn't block)
    function patchFetchForWbloks() {
        if (fetchPatched) return;
        fetchPatched = true;
        const w = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;

        const handleWbloks = (url, getText) => {
            if (!url.includes('/async/wbloks/')) return;
            console.log('[IG] wbloks fetch caught:', url.slice(0, 150));
            getText().then(t => {
                console.log(`[IG] wbloks body length: ${t.length}, contains "clips": ${t.includes('clips')}, contains "media_code": ${t.includes('media_code')}`);
                const indexed = indexWbloksResponse(t);
                // If nothing indexed, dump a sample around "media_code" to help debug
                if (indexed === 0 && t.includes('media_code')) {
                    const idx = t.indexOf('media_code');
                    console.log('[IG] media_code context sample:', t.slice(Math.max(0, idx - 200), idx + 300));
                }
            }).catch(e => console.log('[IG] wbloks read error:', e));
        };

        const origFetch = w.fetch;
        w.fetch = function (input, init) {
            const url = typeof input === 'string' ? input : (input && input.url) || '';
            const p = origFetch.apply(this, arguments);
            handleWbloks(url, () => p.then(res => res.clone().text()));
            return p;
        };

        const XHR = w.XMLHttpRequest;
        const origOpen = XHR.prototype.open;
        XHR.prototype.open = function (method, url) {
            this._igUrl = url;
            return origOpen.apply(this, arguments);
        };
        const origSend = XHR.prototype.send;
        XHR.prototype.send = function () {
            const url = String(this._igUrl || '');
            if (url.includes('/async/wbloks/')) {
                this.addEventListener('load', () => {
                    handleWbloks(url, () => Promise.resolve(this.responseText));
                });
            }
            return origSend.apply(this, arguments);
        };
    }

    function initLikesPage() {
        if (!isInstagramHost) return;
        if (!location.pathname.includes('/your_activity/interactions/likes')) return;

        patchFetchForWbloks();

        // Known aria-label values for post thumbnails across languages
        const POST_LABELS = [
            'Изображение публикации',   // Russian
            'Post Image',               // English
            'Bild des Beitrags',        // German
            'Image de la publication',  // French
            'Imagen de publicación',    // Spanish
            'Immagine del post',        // Italian
            'Imagem da publicação',     // Portuguese
        ];

        document.addEventListener('mousedown', e => {
            if (e.button !== 1) return;

            // Accept clicks on [role="button"] with a known post aria-label,
            // OR on any img inside the likes grid (fallback for unknown locales).
            const item = e.target.closest('[role="button"]');
            const isKnownLabel = item && POST_LABELS.includes(item.getAttribute('aria-label'));
            const hasImg = item && item.querySelector('img');

            // If it's a [role="button"] with an img but unknown label, still allow it
            // as long as it's NOT a sort/filter button (those never contain an img).
            if (!item || (!isKnownLabel && !hasImg)) return;

            // ALWAYS prevent default — otherwise browser does autoscroll on middle-click
            e.preventDefault();
            e.stopPropagation();

            const img = item.querySelector('img') || (e.target.tagName === 'IMG' ? e.target : null);

            // Try shortcode from ig_cache_key first
            let code = shortcodeForImg(img);

            // Fallback 1: look for a /p/ or /reel/ href on a nearby <a> ancestor
            if (!code) {
                const anchor = e.target.closest('a[href]') || item.closest('a[href]') || item.querySelector('a[href]');
                if (anchor) {
                    const mReel = anchor.href.match(/\/reel\/([A-Za-z0-9_-]+)/);
                    const mPost = anchor.href.match(/\/p\/([A-Za-z0-9_-]+)/);
                    code = (mReel && mReel[1]) || (mPost && mPost[1]) || null;
                }
            }

            // Fallback 2: scan all <a> inside the item
            if (!code) {
                for (const a of item.querySelectorAll('a[href]')) {
                    const mReel = a.href.match(/\/reel\/([A-Za-z0-9_-]+)/);
                    const mPost = a.href.match(/\/p\/([A-Za-z0-9_-]+)/);
                    code = (mReel && mReel[1]) || (mPost && mPost[1]) || null;
                    if (code) break;
                }
            }

            if (!code) {
                console.log(`[IG] post not indexed yet (mapSize=${codeByMediaId.size}). Scroll down to load more.`);
                return;
            }
            GM_openInTab(`https://www.instagram.com/reels/${code}/`, { active: false, insert: true });
        }, true);

        // Block auxclick so browser doesn't fire any fallback navigation
        document.addEventListener('auxclick', e => {
            if (e.button === 1) { e.preventDefault(); e.stopPropagation(); }
        }, true);
    }

    initLikesPage();

})();