☰

Hypnotube Fixer

Volume memory, frame stepping, hotkeys, download, screenshot, thumbnail, loop, and playlist/related tools for Hypnotube

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey, Greasemonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да инсталирате разширение, като например Tampermonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Userscripts.

За да инсталирате скрипта, трябва да инсталирате разширение като Tampermonkey.

За да инсталирате този скрипт, трябва да имате инсталиран скриптов мениджър.

(Вече имам скриптов мениджър, искам да го инсталирам!)

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

(Вече имам инсталиран мениджър на стиловете, искам да го инсталирам!)

// ==UserScript==
// @name         Hypnotube Fixer
// @namespace    https://github.com/Thunderhawkk
// @version      1.0
// @description  Volume memory, frame stepping, hotkeys, download, screenshot, thumbnail, loop, and playlist/related tools for Hypnotube
// @author       Thunderhawkk
// @license      MIT
// @match        https://hypnotube.com/*
// @match        https://www.hypnotube.com/*
// @icon         https://cdn.hypnotube.com/templates/hypnotube/images/touch/fav.png
// @run-at       document-idle
// @grant        GM_addStyle
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// @grant        GM_xmlhttpRequest
// @grant        GM_download
// @connect      hypnotube.com
// @connect      media.hypnotube.com
// @connect      ht-media.hypnotube.com
// @connect      cdn.hypnotube.com
// @connect      *
// ==/UserScript==

(function () {
    'use strict';

    console.log('[Hypnotube Fixer v1.0] Loaded');

    // =====================================================================
    // Defaults + GM-storage backed settings
    // Each setting is read through a getter (validated) and written on save.
    // =====================================================================
    const DEFAULTS = {
        defaultVol: 20,
        videoFps: 30,
        framesToSkip: 1,
        ssFolder: 'Hypnotube',
        ssMode: 'auto',
        hideBigPlay: true,
        hideAds: false,
        showVol: true,
        playlistFullscreen: false
    };

    const SS_MODES = ['auto', 'preview', 'hide'];

    function clamp(n, lo, hi) {
        n = Number(n);
        if (isNaN(n)) n = lo;
        return Math.min(hi, Math.max(lo, n));
    }
    function intVal(v, fallback, lo, hi) {
        const n = parseInt(v, 10);
        return clamp(isNaN(n) ? fallback : n, lo, hi);
    }
    function boolVal(v, fallback) {
        if (v === undefined || v === null) return fallback;
        return v === true || v === 'true' || v === 1 || v === '1';
    }
    function clamp01(n) {
        n = Number(n);
        if (isNaN(n)) return 0;
        return Math.min(1, Math.max(0, n));
    }

    const SETTINGS = {
        get defaultVol() { return intVal(GM_getValue('ht_defaultVol', DEFAULTS.defaultVol), DEFAULTS.defaultVol, 0, 100); },
        set defaultVol(v) { GM_setValue('ht_defaultVol', intVal(v, DEFAULTS.defaultVol, 0, 100)); },

        get videoFps() { return intVal(GM_getValue('ht_videoFps', DEFAULTS.videoFps), DEFAULTS.videoFps, 1, 120); },
        set videoFps(v) { GM_setValue('ht_videoFps', intVal(v, DEFAULTS.videoFps, 1, 120)); },

        get framesToSkip() { return intVal(GM_getValue('ht_framesToSkip', DEFAULTS.framesToSkip), DEFAULTS.framesToSkip, 1, 120); },
        set framesToSkip(v) { GM_setValue('ht_framesToSkip', intVal(v, DEFAULTS.framesToSkip, 1, 120)); },

        get ssFolder() {
            const v = GM_getValue('ht_ssFolder', DEFAULTS.ssFolder);
            return (typeof v === 'string' && v.trim()) ? v.trim() : DEFAULTS.ssFolder;
        },
        set ssFolder(v) { GM_setValue('ht_ssFolder', (typeof v === 'string' && v.trim()) ? v.trim() : DEFAULTS.ssFolder); },

        get ssMode() {
            const v = GM_getValue('ht_ssMode', DEFAULTS.ssMode);
            return SS_MODES.indexOf(v) >= 0 ? v : DEFAULTS.ssMode;
        },
        set ssMode(v) { GM_setValue('ht_ssMode', SS_MODES.indexOf(v) >= 0 ? v : DEFAULTS.ssMode); },

        get hideBigPlay() { return boolVal(GM_getValue('ht_hideBigPlay', DEFAULTS.hideBigPlay), DEFAULTS.hideBigPlay); },
        set hideBigPlay(v) { GM_setValue('ht_hideBigPlay', !!v); },

        get hideAds() { return boolVal(GM_getValue('ht_hideAds', DEFAULTS.hideAds), DEFAULTS.hideAds); },
        set hideAds(v) { GM_setValue('ht_hideAds', !!v); },

        get showVol() { return boolVal(GM_getValue('ht_showVol', DEFAULTS.showVol), DEFAULTS.showVol); },
        set showVol(v) { GM_setValue('ht_showVol', !!v); },

        get playlistFullscreen() { return boolVal(GM_getValue('ht_playlistFullscreen', DEFAULTS.playlistFullscreen), DEFAULTS.playlistFullscreen); },
        set playlistFullscreen(v) { GM_setValue('ht_playlistFullscreen', !!v); }
    };

    // =====================================================================
    // Module state
    // =====================================================================
    let manualVolChange = false;
    let volSaveTimer = null;
    let toastTimer = null;
    let styleEl = null;
    let lastVideoEl = null;
    let activeDownloadRequest = null;
    let isDownloading = false;
    let screenshotHidden = false;
    let playlistFullscreenActive = false;
    let playlistFullscreenDismissed = false;

    const ICONS = {
        download: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>',
        camera: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>',
        image: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>',
        loop: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 1l4 4-4 4"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><path d="M7 23l-4-4 4-4"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>'
    };

    // =====================================================================
    // Utilities
    // =====================================================================
    function escapeHtml(str) {
        return String(str)
            .replace(/&/g, '&amp;')
            .replace(/</g, '&lt;')
            .replace(/>/g, '&gt;')
            .replace(/"/g, '&quot;')
            .replace(/'/g, '&#39;');
    }

    function toast(msg, type, duration) {
        type = type || 'info';
        duration = duration || 3000;
        const existing = document.querySelector('.ht-toast');
        if (existing) existing.remove();
        if (toastTimer) clearTimeout(toastTimer);
        const el = document.createElement('div');
        el.className = 'ht-toast';
        el.textContent = msg;
        el.style.cssText = `
            position: fixed; bottom: 80px; right: 20px; z-index: 100000;
            padding: 12px 20px; border-radius: 10px; font: 14px/1.4 sans-serif;
            color: #fff; max-width: 320px; transform: translateY(20px); opacity: 0;
            transition: all 0.3s ease; pointer-events: none;
            background: ${type === 'success' ? 'rgba(34,197,94,0.95)' : type === 'error' ? 'rgba(239,68,68,0.95)' : 'rgba(30,30,30,0.95)'};
            border: 1px solid ${type === 'success' ? 'rgba(34,197,94,0.3)' : type === 'error' ? 'rgba(239,68,68,0.3)' : 'rgba(255,255,255,0.1)'};
            backdrop-filter: blur(12px); box-shadow: 0 8px 32px rgba(0,0,0,0.4);`;
        document.body.appendChild(el);
        requestAnimationFrame(function () { el.style.transform = 'translateY(0)'; el.style.opacity = '1'; });
        toastTimer = setTimeout(function () {
            el.style.transform = 'translateY(20px)'; el.style.opacity = '0';
            setTimeout(function () { el.remove(); }, 300);
        }, duration);
    }

    function getVideoEl() {
        return document.querySelector('video#plyr_player') || document.querySelector('video');
    }

    function isWatchPage() {
        return /\/video\//.test(window.location.pathname) || !!document.querySelector('video#plyr_player');
    }

    function isPlaylistPage() {
        if (document.body && document.body.classList.contains('page-index-playlist')) return true;
        return window.location.pathname.indexOf('/playlist/') === 0;
    }

    function getVideoId() {
        const m = window.location.pathname.match(/\/video\/(?:.*-)?(\d+)\.html/);
        return m ? m[1] : null;
    }

    function getVideoTitle() {
        let el = document.querySelector('.item-tr-col h1');
        if (!el) el = document.querySelector('.box-container h1');
        if (!el) {
            const h1s = Array.prototype.slice.call(document.querySelectorAll('h1'));
            for (let i = 0; i < h1s.length; i++) {
                if ((h1s[i].textContent || '').trim() !== 'Related Content') { el = h1s[i]; break; }
            }
        }
        let title = el ? (el.textContent || '').trim() : '';
        if (!title) title = document.title || '';
        title = title.replace(/[\\/:*?"<>|]/g, '_').trim();
        if (title.length > 150) title = title.slice(0, 150).trim();
        return title || 'Hypnotube';
    }

    function formatTime(seconds) {
        if (!seconds) return '00-00';
        const h = Math.floor(seconds / 3600);
        const m = Math.floor((seconds % 3600) / 60);
        const s = Math.floor(seconds % 60);
        return h > 0 ? `${h}-${m.toString().padStart(2, '0')}-${s.toString().padStart(2, '0')}` : `${m.toString().padStart(2, '0')}-${s.toString().padStart(2, '0')}`;
    }

    function isHiddenInInput() {
        const el = document.activeElement;
        if (!el) return false;
        const tag = (el.tagName || '').toUpperCase();
        if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
        if (el.isContentEditable) return true;
        if (el.closest && el.closest('.ht-settings-overlay')) return true;
        return false;
    }

    // =====================================================================
    // Injected CSS (single block, regenerated when settings change)
    // =====================================================================
    function buildCss(s) {
        const parts = [];
        parts.push(`
            .ht-ctl { position: relative; }
            .ht-ctl:hover { color: #fff; }
            .ht-ctl.active { color: #22c55e !important; }
            .ht-ctl.ht-active-download { color: #f97316 !important; }
            .ht-ctl svg { width: 18px; height: 18px; display: block; pointer-events: none; }
            .ht-ctl-label {
                position: absolute; top: -15px; left: 50%; transform: translateX(-50%);
                font-size: 10px; font-weight: 700; color: #fff; background: rgba(0,0,0,0.85);
                padding: 1px 4px; border-radius: 3px; white-space: nowrap; pointer-events: none;
            }
            #ht-vol-display {
                color: #fff; font-size: 12px; font-weight: 700; margin-left: 6px;
                background: rgba(0,0,0,0.6); padding: 2px 5px; border-radius: 4px;
            }
            .ht-filter-wrap { display: flex; align-items: center; gap: 8px; margin: 10px 0; }
            .ht-filter-bar {
                flex: 1; box-sizing: border-box; padding: 8px 12px; background: #1b1b1b;
                border: 1px solid #3a3a3a; color: #fff; border-radius: 6px; font-size: 13px; outline: none;
            }
            .ht-filter-bar:focus { border-color: #f97316; }
            .ht-filter-bar::placeholder { color: #777; }
            .ht-filter-count { font-size: 12px; color: #aaa; white-space: nowrap; }

            #ht-settings-gear {
                position: fixed; bottom: 20px; right: 20px; z-index: 99999;
                width: 42px; height: 42px; border-radius: 50%; border: 1px solid #444;
                background: #222; color: #fff; font-size: 20px; line-height: 1; cursor: pointer;
                box-shadow: 0 4px 16px rgba(0,0,0,0.5);
            }
            #ht-settings-gear:hover { background: #333; border-color: #f97316; }

            .ht-fs-exit {
                position: fixed; top: 10px; right: 10px; z-index: 10001;
                width: 36px; height: 36px; border-radius: 50%; border: 1px solid #444;
                background: rgba(20,20,20,0.85); color: #fff; font-size: 18px; cursor: pointer;
            }
            .ht-playlist-fullscreen {
                position: fixed !important; inset: 0 !important;
                width: 100vw !important; height: 100vh !important;
                max-width: none !important; max-height: none !important;
                margin: 0 !important; z-index: 9999 !important; background: #000 !important;
            }

            .ht-settings-overlay {
                position: fixed; inset: 0; background: rgba(0,0,0,0.75); z-index: 100000;
                display: flex; align-items: center; justify-content: center;
            }
            .ht-settings-modal {
                width: 360px; max-width: calc(100vw - 30px); max-height: 88vh; overflow-y: auto;
                background: #1c1c1e; border: 1px solid rgba(255,255,255,0.1); border-radius: 12px;
                color: #eee; font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
                box-shadow: 0 20px 60px rgba(0,0,0,0.6);
            }
            .ht-settings-head {
                display: flex; align-items: center; justify-content: space-between;
                padding: 14px 18px; border-bottom: 1px solid rgba(255,255,255,0.08);
            }
            .ht-settings-head h3 { margin: 0; font-size: 16px; }
            .ht-settings-close { background: transparent; border: none; color: #aaa; font-size: 18px; cursor: pointer; }
            .ht-settings-close:hover { color: #fff; }
            .ht-settings-body { padding: 14px 18px; }
            .ht-row { margin-bottom: 12px; }
            .ht-row > label { display: block; font-size: 12px; font-weight: 600; color: #ddd; margin-bottom: 5px; }
            .ht-row small { display: block; color: #888; font-size: 11px; font-weight: 400; margin-top: 3px; }
            .ht-row input[type="number"], .ht-row input[type="text"], .ht-row select {
                width: 100%; box-sizing: border-box; background: #2a2a2a; color: #fff;
                border: 1px solid #444; border-radius: 6px; padding: 7px 9px; font-size: 13px; outline: none;
            }
            .ht-row input:focus, .ht-row select:focus { border-color: #f97316; }
            .ht-row > label.ht-check { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #ddd; margin-bottom: 0; }
            .ht-check input { width: 16px; height: 16px; flex: 0 0 auto; }
            .ht-settings-foot { padding: 14px 18px; border-top: 1px solid rgba(255,255,255,0.08); }
            .ht-save-btn {
                width: 100%; background: #f97316; color: #fff; border: none; border-radius: 8px;
                padding: 10px; font-size: 14px; font-weight: 700; cursor: pointer;
            }
            .ht-save-btn:hover { background: #ea580c; }
        `);

        // Toggleable rules
        if (s.hideBigPlay) parts.push('.plyr__control--overlaid { display: none !important; }');
        if (s.hideAds) parts.push('#playerOverlay, .top-ban-item { display: none !important; }');
        if (!s.showVol) parts.push('#ht-vol-display { display: none !important; }');
        return parts.join('\n');
    }

    function applyStyles() {
        const css = buildCss(SETTINGS);
        if (styleEl && styleEl.parentNode) {
            styleEl.textContent = css;
            return;
        }
        if (typeof GM_addStyle === 'function') {
            styleEl = GM_addStyle(css) || document.getElementById('ht-style');
        }
        if (!styleEl) {
            styleEl = document.getElementById('ht-style');
            if (!styleEl) {
                styleEl = document.createElement('style');
                styleEl.id = 'ht-style';
                document.head.appendChild(styleEl);
            }
            styleEl.textContent = css;
        } else if (!styleEl.id) {
            styleEl.id = 'ht-style';
        }
    }

    // =====================================================================
    // Volume module
    // =====================================================================
    function getTargetVolume() {
        const stored = GM_getValue('ht_volume', null);
        if (stored !== null && stored !== undefined && stored !== '') {
            const n = parseFloat(stored);
            if (!isNaN(n)) return clamp01(n);
        }
        return clamp01(SETTINGS.defaultVol / 100);
    }

    function onVideoVolumeChange() {
        updateVolDisplay();
        const video = getVideoEl();
        if (!video) return;
        const started = parseInt(video.dataset.htVolStarted || '0', 10);
        if (!manualVolChange && started && (Date.now() - started > 500)) {
            if (Math.abs(video.volume - getTargetVolume()) > 0.01) manualVolChange = true;
        }
        if (manualVolChange) {
            clearTimeout(volSaveTimer);
            volSaveTimer = setTimeout(function () {
                try {
                    GM_setValue('ht_volume', video.volume);
                } catch (err) {
                    console.warn('[Hypnotube Fixer] Failed to persist volume', err);
                }
            }, 300);
        }
    }

    function syncVolume() {
        const video = getVideoEl();
        if (!video) return;

        // Reset per-video state when the player swaps to a new <video> element.
        if (video !== lastVideoEl) {
            lastVideoEl = video;
            manualVolChange = false;
        }

        if (!video.dataset.htVolListener) {
            video.dataset.htVolListener = '1';
            video.addEventListener('volumechange', onVideoVolumeChange);
        }

        // Haven-style retry: apply the target volume for ~3s after load,
        // and stop as soon as the user changes it manually.
        if (!video.dataset.htVolStarted) {
            video.dataset.htVolStarted = String(Date.now());
            if (!manualVolChange) {
                // Only set volume; never force-unmute (a deliberate mute must survive reloads).
                try { video.volume = getTargetVolume(); }
                catch (err) { console.warn('[Hypnotube Fixer] Volume apply failed', err); }
            }
        }

        if (manualVolChange) return;
        if (Date.now() - parseInt(video.dataset.htVolStarted, 10) < 3000) {
            const target = getTargetVolume();
            if (Math.abs(video.volume - target) > 0.01) {
                try { video.volume = target; } catch (err) { console.warn('[Hypnotube Fixer] Volume retry failed', err); }
            }
        }
    }

    function updateVolDisplay() {
        const video = getVideoEl();
        const volArea = document.querySelector('.plyr__volume');
        let label = document.getElementById('ht-vol-display');

        if (!SETTINGS.showVol || !video || !volArea) {
            if (label) label.remove();
            return;
        }
        if (!label) {
            label = document.createElement('span');
            label.id = 'ht-vol-display';
            label.className = 'ht-vol-display';
            if (volArea.parentNode) volArea.parentNode.insertBefore(label, volArea.nextSibling);
            else volArea.appendChild(label);
        }
        label.textContent = Math.round(video.volume * 100) + '%';
    }

    // =====================================================================
    // Screenshot module
    // NOTE: media.hypnotube.com (BunnyCDN) sends no Access-Control-Allow-Origin,
    // so we NEVER set video.crossOrigin (it would break playback). Direct canvas
    // capture therefore taints the canvas and toDataURL() can throw SecurityError,
    // which is why the VTT preview fallback exists.
    // =====================================================================
    function triggerAnchorDownload(url, filename) {
        const a = document.createElement('a');
        a.href = url;
        a.download = filename;
        document.body.appendChild(a);
        a.click();
        document.body.removeChild(a);
    }

    function saveDataUrl(dataUrl, filename) {
        const path = 'Script Screenshots/' + SETTINGS.ssFolder + '/' + filename;
        if (typeof GM_download === 'function') {
            try {
                GM_download({
                    url: dataUrl, name: path, saveAs: false,
                    onerror: function (err) {
                        console.warn('[Hypnotube Fixer] GM_download failed, using anchor fallback', err);
                        triggerAnchorDownload(dataUrl, filename);
                    }
                });
                return;
            } catch (err) {
                console.warn('[Hypnotube Fixer] GM_download threw, using anchor fallback', err);
            }
        }
        triggerAnchorDownload(dataUrl, filename);
    }

    function saveRemoteUrl(url, filename) {
        const path = 'Script Screenshots/' + SETTINGS.ssFolder + '/' + filename;
        if (typeof GM_download === 'function') {
            try {
                GM_download({
                    url: url, name: path, saveAs: false,
                    onerror: function (err) {
                        console.warn('[Hypnotube Fixer] GM_download failed, using anchor fallback', err);
                        triggerAnchorDownload(url, filename);
                    }
                });
                return;
            } catch (err) {
                console.warn('[Hypnotube Fixer] GM_download threw, using anchor fallback', err);
            }
        }
        triggerAnchorDownload(url, filename);
    }

    function captureDirectFrame(video, title) {
        if (!video.videoWidth || !video.videoHeight) return false;
        let dataUrl = null;
        try {
            const canvas = document.createElement('canvas');
            canvas.width = video.videoWidth;
            canvas.height = video.videoHeight;
            canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height);
            dataUrl = canvas.toDataURL('image/png');
        } catch (err) {
            console.warn('[Hypnotube Fixer] Direct canvas capture failed (tainted/CORS):', err && err.name ? err.name : err);
            return false;
        }
        if (!dataUrl || dataUrl === 'data:,') {
            console.warn('[Hypnotube Fixer] Direct canvas capture returned an empty data URL');
            return false;
        }
        saveDataUrl(dataUrl, title + ' - ' + formatTime(video.currentTime) + '.png');
        toast('📸 Saved exact frame', 'success');
        return true;
    }

    function parseVttCues(text) {
        const cues = [];
        const lines = String(text).replace(/\r/g, '').split('\n');
        const longRe = /(\d{1,2}):(\d{2}):(\d{2})[.,](\d{3})\s*-->\s*(\d{1,2}):(\d{2}):(\d{2})[.,](\d{3})/;
        const shortRe = /(\d{1,2}):(\d{2})[.,](\d{3})\s*-->\s*(\d{1,2}):(\d{2})[.,](\d{3})/;
        const urlRe = /https?:\/\/\S+?\.(?:jpg|jpeg|png|webp|avif)\b/i;

        for (let i = 0; i < lines.length; i++) {
            const line = lines[i].trim();
            if (!line) continue;
            let start = null;
            let m = line.match(longRe);
            if (m) {
                start = parseInt(m[1], 10) * 3600 + parseInt(m[2], 10) * 60 + parseInt(m[3], 10) + parseInt(m[4], 10) / 1000;
            } else {
                m = line.match(shortRe);
                if (m) start = parseInt(m[1], 10) * 60 + parseInt(m[2], 10) + parseInt(m[3], 10) / 1000;
            }
            if (start === null) continue;

            let urlM = line.match(urlRe);
            if (!urlM && lines[i + 1]) urlM = lines[i + 1].trim().match(urlRe);
            if (urlM) cues.push({ start: start, url: urlM[0] });
        }
        return cues;
    }

    function fetchPreviewImage(imgUrl, cueStart, title, onFail) {
        GM_xmlhttpRequest({
            method: 'GET',
            url: imgUrl,
            responseType: 'blob',
            onload: function (res) {
                if (res.status !== 200 || !res.response) {
                    console.warn('[Hypnotube Fixer] Preview image fetch failed:', res.status);
                    if (onFail) onFail(); else toast('❌ Preview image unavailable', 'error');
                    return;
                }
                const objectUrl = URL.createObjectURL(res.response);
                const img = new Image();
                img.onload = function () {
                    try {
                        const canvas = document.createElement('canvas');
                        canvas.width = img.naturalWidth || img.width;
                        canvas.height = img.naturalHeight || img.height;
                        canvas.getContext('2d').drawImage(img, 0, 0);
                        let dataUrl = null;
                        try {
                            dataUrl = canvas.toDataURL('image/png');
                        } catch (err) {
                            console.warn('[Hypnotube Fixer] Preview canvas export failed', err);
                        }
                        if (dataUrl && dataUrl !== 'data:,') {
                            saveDataUrl(dataUrl, title + ' - ' + formatTime(cueStart) + ' (preview).png');
                            toast('📸 Saved nearest preview frame (5s granularity, lower res)', 'info');
                        } else if (onFail) {
                            onFail();
                        } else {
                            toast('❌ Preview capture failed', 'error');
                        }
                    } finally {
                        URL.revokeObjectURL(objectUrl);
                    }
                };
                img.onerror = function () {
                    URL.revokeObjectURL(objectUrl);
                    console.warn('[Hypnotube Fixer] Preview image could not be decoded');
                    if (onFail) onFail(); else toast('❌ Preview image could not be decoded', 'error');
                };
                img.src = objectUrl;
            },
            onerror: function () {
                console.warn('[Hypnotube Fixer] Preview image request error');
                if (onFail) onFail(); else toast('❌ Preview image download failed', 'error');
            }
        });
    }

    function capturePreviewFrame(video, title, onFail) {
        const id = getVideoId();
        if (!id) {
            console.warn('[Hypnotube Fixer] No video id found for VTT preview');
            if (onFail) onFail(); else toast('❌ Could not determine video id', 'error');
            return;
        }
        const vttUrl = 'https://hypnotube.com/webvtt/' + id + '.vtt';
        GM_xmlhttpRequest({
            method: 'GET',
            url: vttUrl,
            onload: function (res) {
                if (res.status !== 200 || !res.responseText) {
                    console.warn('[Hypnotube Fixer] VTT fetch failed:', res.status);
                    if (onFail) onFail(); else toast('❌ Preview track unavailable', 'error');
                    return;
                }
                const cues = parseVttCues(res.responseText);
                if (!cues.length) {
                    console.warn('[Hypnotube Fixer] No cues parsed from VTT');
                    if (onFail) onFail(); else toast('❌ No preview frames found', 'error');
                    return;
                }
                const now = video.currentTime;
                let best = cues[0];
                let bestDist = Math.abs(cues[0].start - now);
                for (let i = 1; i < cues.length; i++) {
                    const d = Math.abs(cues[i].start - now);
                    if (d < bestDist) { bestDist = d; best = cues[i]; }
                }
                fetchPreviewImage(best.url, best.start, title, onFail);
            },
            onerror: function () {
                console.warn('[Hypnotube Fixer] VTT request error');
                if (onFail) onFail(); else toast('❌ Preview request failed', 'error');
            }
        });
    }

    function toggleOverlaysForScreenshot() {
        const nodes = document.querySelectorAll('.plyr__controls, .plyr__control--overlaid, #playerOverlay');
        if (!screenshotHidden) {
            nodes.forEach(function (el) {
                if (el.dataset.htHidden) return;
                el.dataset.htHidden = '1';
                el.style.setProperty('display', 'none', 'important');
            });
            screenshotHidden = true;
            toast('🖼️ Overlays hidden — take your OS screenshot; press S again to restore', 'info', 5000);
        } else {
            nodes.forEach(function (el) {
                if (!el.dataset.htHidden) return;
                delete el.dataset.htHidden;
                el.style.removeProperty('display');
            });
            screenshotHidden = false;
            toast('🔄 Overlays restored', 'info', 1500);
        }
    }

    function takeScreenshot() {
        const video = getVideoEl();
        if (!video) { toast('❌ No video found', 'error'); return; }

        // A hidden overlay state always takes priority: next invocation restores it.
        if (screenshotHidden) { toggleOverlaysForScreenshot(); return; }

        const mode = SETTINGS.ssMode;

        if (mode === 'hide') { toggleOverlaysForScreenshot(); return; }

        const title = getVideoTitle();
        const failToHide = function () { toggleOverlaysForScreenshot(); };

        if (mode === 'preview') {
            capturePreviewFrame(video, title, failToHide);
            return;
        }

        // "auto": exact frame first, then VTT preview, then hide overlays.
        if (captureDirectFrame(video, title)) return;
        capturePreviewFrame(video, title, failToHide);
    }

    // =====================================================================
    // Thumbnail download
    // =====================================================================
    function getThumbUrl() {
        const og = document.querySelector('meta[property="og:image"]');
        if (og && og.content) return og.content;
        const v = getVideoEl();
        if (v && v.poster) return v.poster;
        const img = document.querySelector('.item-col img');
        if (img && img.src) return img.src;
        return null;
    }

    function downloadThumbnail() {
        const url = getThumbUrl();
        if (!url) { toast('❌ Thumbnail not found', 'error'); return; }
        saveRemoteUrl(url, getVideoTitle() + ' - thumb.jpg');
        toast('🖼️ Thumbnail downloading…', 'info', 2000);
    }

    // =====================================================================
    // MP4 download module
    // =====================================================================
    function qualityScore(v) {
        if (v === null || v === undefined) return 0;
        const s = String(v).trim().toUpperCase();
        if (s === 'HD') return 100000;   // HD is always higher than SD
        if (s === 'SD') return -100000;
        const n = parseInt(s, 10);
        return isNaN(n) ? 0 : n;
    }

    function findBestSource() {
        const video = getVideoEl();
        if (!video) return null;
        const sources = Array.prototype.slice.call(video.querySelectorAll('source[src]'));

        // Quality logic: if several <source> elements exist, pick the highest "sizes".
        if (sources.length > 1) {
            let best = null;
            let bestScore = -Infinity;
            sources.forEach(function (s) {
                const score = qualityScore(s.getAttribute('sizes'));
                if (score > bestScore) { bestScore = score; best = s; }
            });
            if (best) return best.src || best.getAttribute('src');
        }

        if (video.currentSrc) return video.currentSrc;
        if (sources.length === 1) return sources[0].src || sources[0].getAttribute('src');
        if (video.src) return video.src;
        return null;
    }

    function setBtnLabel(btn, text) {
        let label = btn.querySelector('.ht-ctl-label');
        if (!label) {
            label = document.createElement('span');
            label.className = 'ht-ctl-label';
            btn.appendChild(label);
        }
        label.textContent = text;
    }

    function resetDownloadBtn(btn) {
        if (!btn) return;
        btn.classList.remove('ht-active-download');
        const label = btn.querySelector('.ht-ctl-label');
        if (label) label.remove();
    }

    function doDownload(url, title, btn) {
        isDownloading = true;
        btn.classList.add('ht-active-download');
        setBtnLabel(btn, '0% (×)');

        activeDownloadRequest = GM_xmlhttpRequest({
            method: 'GET',
            url: url,
            responseType: 'blob',
            // No Referer header is required for media.hypnotube.com (verified: plain GET -> 206).
            // Omitting it avoids managers rejecting forbidden headers.
            onprogress: function (e) {
                if (e.lengthComputable) {
                    const p = Math.floor((e.loaded / e.total) * 100);
                    setBtnLabel(btn, p + '% (×)');
                } else {
                    setBtnLabel(btn, '…(×)');
                }
            },
            onload: function (r) {
                isDownloading = false;
                activeDownloadRequest = null;
                resetDownloadBtn(btn);
                if (r.status === 200 && r.response) {
                    const u = URL.createObjectURL(r.response);
                    const a = document.createElement('a');
                    a.href = u;
                    a.download = title + ' - Hypnotube.mp4';
                    document.body.appendChild(a);
                    a.click();
                    setTimeout(function () { a.remove(); URL.revokeObjectURL(u); }, 1000);
                    toast('✅ Download complete', 'success');
                } else {
                    toast('❌ Download failed (HTTP ' + r.status + ')', 'error');
                }
            },
            onerror: function () {
                isDownloading = false;
                activeDownloadRequest = null;
                resetDownloadBtn(btn);
                toast('❌ Network error', 'error');
            },
            onabort: function () {
                isDownloading = false;
                activeDownloadRequest = null;
                resetDownloadBtn(btn);
            }
        });
    }

    function handleDownloadClick(btn) {
        // Second click aborts an in-flight download.
        if (isDownloading) {
            if (activeDownloadRequest && typeof activeDownloadRequest.abort === 'function') {
                activeDownloadRequest.abort();
            }
            isDownloading = false;
            activeDownloadRequest = null;
            resetDownloadBtn(btn);
            toast('⬇️ Download cancelled', 'info');
            return;
        }

        const src = findBestSource();
        if (!src) {
            toast('❌ No video source found', 'error');
            resetDownloadBtn(btn);
            return;
        }
        doDownload(src, getVideoTitle(), btn);
    }

    // =====================================================================
    // Loop toggle
    // =====================================================================
    function syncLoopButton(btn) {
        if (!btn) btn = document.getElementById('ht-loop-btn');
        if (!btn) return;
        const video = getVideoEl();
        btn.classList.toggle('active', !!(video && video.loop));
    }

    function toggleLoop(btn) {
        const video = getVideoEl();
        if (!video) { toast('❌ No video found', 'error'); return; }
        video.loop = !video.loop;
        syncLoopButton(btn || document.getElementById('ht-loop-btn'));
        toast(video.loop ? '🔁 Loop on' : '🔁 Loop off', 'info', 1500);
    }

    // =====================================================================
    // Player control buttons
    // =====================================================================
    function createControl(id, svg, title, onClick) {
        const b = document.createElement('button');
        b.type = 'button';
        b.id = id;
        b.className = 'plyr__controls__item plyr__control ht-ctl';
        b.title = title;
        b.setAttribute('aria-label', title);
        b.innerHTML = svg; // static, trusted inline SVG
        b.addEventListener('click', function (e) {
            e.preventDefault();
            e.stopPropagation();
            onClick();
        });
        return b;
    }

    function injectButtons() {
        const controls = document.querySelector('.plyr__controls');
        if (!controls) return;

        if (!document.getElementById('ht-dl-btn')) {
            const dl = createControl('ht-dl-btn', ICONS.download, 'Download MP4', function () { handleDownloadClick(dl); });
            controls.appendChild(dl);
        }
        if (!document.getElementById('ht-ss-btn')) {
            const ss = createControl('ht-ss-btn', ICONS.camera, 'Screenshot (S)', function () { takeScreenshot(); });
            controls.appendChild(ss);
        }
        if (!document.getElementById('ht-thumb-btn')) {
            const th = createControl('ht-thumb-btn', ICONS.image, 'Download thumbnail', function () { downloadThumbnail(); });
            controls.appendChild(th);
        }
        if (!document.getElementById('ht-loop-btn')) {
            const lp = createControl('ht-loop-btn', ICONS.loop, 'Toggle loop (L)', function () { toggleLoop(lp); });
            controls.appendChild(lp);
            syncLoopButton(lp);
        }
    }

    // =====================================================================
    // Filtering helpers + rails
    // =====================================================================
    function getItemMatchText(item) {
        let text = '';
        const titleEl = item.querySelector('.title');
        if (titleEl) text = titleEl.textContent || '';
        if (!text) {
            const a = item.querySelector('a[title]');
            if (a) text = a.getAttribute('title') || '';
        }
        if (!text) {
            const a = item.querySelector('a[href*="/video/"]');
            if (a) text = a.textContent || '';
        }
        if (!text) text = item.textContent || '';
        // Cap matching text length to avoid perf issues on large grids.
        return text.toLowerCase().slice(0, 300);
    }

    function applyFilter(items, rawQuery, countEl) {
        const q = (rawQuery || '').trim().toLowerCase();
        let shown = 0;
        const total = items.length;
        for (let i = 0; i < items.length; i++) {
            const item = items[i];
            if (!item || !item.isConnected) continue;
            const match = !q || getItemMatchText(item).indexOf(q) >= 0;
            item.style.display = match ? '' : 'none';
            if (match) shown++;
        }
        if (countEl) countEl.textContent = q ? (shown + ' of ' + total) : '';
    }

    function makeFilterBar(placeholder, onInput) {
        const wrap = document.createElement('div');
        wrap.className = 'ht-filter-wrap';
        const input = document.createElement('input');
        input.type = 'text';
        input.className = 'ht-filter-bar';
        input.placeholder = placeholder;
        input.autocomplete = 'off';
        input.spellcheck = false;
        const count = document.createElement('span');
        count.className = 'ht-filter-count';
        wrap.appendChild(input);
        wrap.appendChild(count);
        input.addEventListener('input', function () { onInput(input.value, count); });
        return { wrap: wrap, input: input, count: count };
    }

    function initRelatedFilter() {
        if (!isWatchPage()) return;
        // Note the site's misspelling: "reltated-sec".
        const section = document.querySelector('section.reltated-sec');
        if (!section || section.querySelector('.ht-filter-bar')) return;

        const items = Array.prototype.slice.call(section.querySelectorAll('.item-col'));
        if (!items.length) return;

        const bar = makeFilterBar('Search related content…', function (q, count) { applyFilter(items, q, count); });
        section.insertBefore(bar.wrap, section.firstChild);
    }

    function initPlaylistWidgetFilter() {
        if (!isWatchPage()) return;
        const widget = document.querySelector('.playlist-widget');
        if (!widget || widget.querySelector('.ht-filter-bar')) return;

        // The widget's internal markup is unverified, so gather every plausible
        // clickable/text-bearing descendant, then keep only the deepest matches
        // (so filtering does not hide an ancestor and its child together).
        const all = Array.prototype.slice.call(widget.querySelectorAll(
            'a, [role="button"], li, button, div[class*="item"], div[class*="video"], div[class*="entry"]'
        ));
        const candidates = all.filter(function (el) {
            if (el.closest && el.closest('.ht-filter-wrap')) return false;
            const t = (el.textContent || '').trim();
            return t.length > 1 && t.length <= 300;
        });
        const leaves = candidates.filter(function (el) {
            return !candidates.some(function (other) { return other !== el && el.contains(other); });
        });
        if (!leaves.length) return;

        const bar = makeFilterBar('Search playlist queue…', function (q, count) { applyFilter(leaves, q, count); });
        widget.insertBefore(bar.wrap, widget.firstChild);
    }

    function initPlaylistPageFilter() {
        if (!isPlaylistPage()) return;
        // Only filter the main listing grid, never the filter aside.
        const main = document.querySelector('.content-col') || document.querySelector('.main-col');
        if (!main || main.querySelector('.ht-filter-bar')) return;

        const items = Array.prototype.slice.call(main.querySelectorAll('.item-col')).filter(function (it) {
            return !it.closest('.aside-main-col');
        });
        if (!items.length) return;

        const bar = makeFilterBar('Search this playlist…', function (q, count) { applyFilter(items, q, count); });
        main.insertBefore(bar.wrap, main.firstChild);
    }

    // =====================================================================
    // Optional auto-maximize for in-player playlist
    // =====================================================================
    function exitPlaylistFullscreen() {
        const player = document.querySelector('.plyr--video.ht-playlist-fullscreen');
        if (player) player.classList.remove('ht-playlist-fullscreen');
        const btn = document.getElementById('ht-fs-exit');
        if (btn) btn.remove();
        playlistFullscreenActive = false;
        playlistFullscreenDismissed = true;
    }

    function applyPlaylistFullscreen() {
        const enable = SETTINGS.playlistFullscreen;
        const widget = document.querySelector('.playlist-widget');
        const eligible = enable && !playlistFullscreenDismissed && isWatchPage() && !!widget;

        if (!eligible) {
            if (playlistFullscreenActive) {
                const player = document.querySelector('.plyr--video.ht-playlist-fullscreen');
                if (player) player.classList.remove('ht-playlist-fullscreen');
                const btn = document.getElementById('ht-fs-exit');
                if (btn) btn.remove();
                playlistFullscreenActive = false;
            }
            return;
        }

        const player = document.querySelector('.plyr--video');
        if (!player) return;

        if (!playlistFullscreenActive) {
            player.classList.add('ht-playlist-fullscreen');
            playlistFullscreenActive = true;
        }
        if (!document.getElementById('ht-fs-exit')) {
            const b = document.createElement('button');
            b.id = 'ht-fs-exit';
            b.type = 'button';
            b.className = 'ht-fs-exit';
            b.textContent = '✕';
            b.title = 'Exit playlist fullscreen';
            b.addEventListener('click', function (e) {
                e.preventDefault();
                e.stopPropagation();
                exitPlaylistFullscreen();
            });
            document.body.appendChild(b);
        }
    }

    // =====================================================================
    // Hotkeys
    // =====================================================================
    function stepVideo(delta) {
        const video = getVideoEl();
        if (!video) return;
        try {
            video.currentTime = Math.max(0, video.currentTime + delta);
        } catch (err) {
            console.warn('[Hypnotube Fixer] Seek failed', err);
        }
    }

    function handleKeydown(e) {
        if (e.ctrlKey || e.metaKey || e.altKey) return;
        if (isHiddenInInput()) return;

        const key = e.key;
        const step = SETTINGS.framesToSkip / SETTINGS.videoFps;

        // Frame stepping: step = framesToSkip / videoFps seconds.
        if (key === ',' || key === '<') { stepVideo(-step); e.preventDefault(); return; }
        if (key === '.' || key === '>') { stepVideo(step); e.preventDefault(); return; }

        // Shift+O opens settings.
        if (e.shiftKey && key && key.toLowerCase() === 'o') { e.preventDefault(); createSettingsUI(); return; }

        // S = screenshot, L = loop (plain keys only).
        if (!e.shiftKey && (key === 's' || key === 'S')) { e.preventDefault(); takeScreenshot(); return; }
        if (!e.shiftKey && (key === 'l' || key === 'L')) { e.preventDefault(); toggleLoop(); return; }

        // NOTE: We intentionally do NOT bind or override space, k, m, f, or the
        // arrow keys. Plyr already handles those globally, and hijacking them
        // breaks normal playback controls (play/pause, mute, fullscreen, seek).
    }

    // =====================================================================
    // Settings panel
    // =====================================================================
    function createSettingsUI() {
        if (document.getElementById('ht-settings-overlay')) return;
        const s = SETTINGS;
        const selected = function (v) { return s.ssMode === v ? ' selected' : ''; };

        const overlay = document.createElement('div');
        overlay.id = 'ht-settings-overlay';
        overlay.className = 'ht-settings-overlay';
        overlay.innerHTML = `
            <div class="ht-settings-modal">
                <div class="ht-settings-head">
                    <h3>⚙️ Hypnotube Fixer</h3>
                    <button class="ht-settings-close" id="ht-settings-close" title="Close">✕</button>
                </div>
                <div class="ht-settings-body">
                    <div class="ht-row">
                        <label>Default Volume % <small>Starting volume, 0–100 (0 = mute)</small></label>
                        <input type="number" id="ht-set-vol" min="0" max="100" value="${escapeHtml(String(s.defaultVol))}">
                    </div>
                    <div class="ht-row">
                        <label>Assumed FPS <small>Frame rate used for frame stepping</small></label>
                        <input type="number" id="ht-set-fps" min="1" max="120" value="${escapeHtml(String(s.videoFps))}">
                    </div>
                    <div class="ht-row">
                        <label>Frames to Skip <small>Per , / . keypress</small></label>
                        <input type="number" id="ht-set-skip" min="1" max="120" value="${escapeHtml(String(s.framesToSkip))}">
                    </div>
                    <div class="ht-row">
                        <label>Screenshot Subfolder <small>Inside "Script Screenshots"</small></label>
                        <input type="text" id="ht-set-folder" value="${escapeHtml(s.ssFolder)}">
                    </div>
                    <div class="ht-row">
                        <label>Screenshot Mode <small>Auto = exact frame, else VTT preview, else hide overlays</small></label>
                        <select id="ht-set-ssmode">
                            <option value="auto"${selected('auto')}>Auto</option>
                            <option value="preview"${selected('preview')}>Preview frame (VTT)</option>
                            <option value="hide"${selected('hide')}>Hide overlays (manual)</option>
                        </select>
                    </div>
                    <div class="ht-row">
                        <label class="ht-check"><input type="checkbox" id="ht-set-bigplay"${s.hideBigPlay ? ' checked' : ''}> Hide Plyr big play button</label>
                    </div>
                    <div class="ht-row">
                        <label class="ht-check"><input type="checkbox" id="ht-set-ads"${s.hideAds ? ' checked' : ''}> Hide ad / overlay elements</label>
                    </div>
                    <div class="ht-row">
                        <label class="ht-check"><input type="checkbox" id="ht-set-showvol"${s.showVol ? ' checked' : ''}> Show on-screen volume %</label>
                    </div>
                    <div class="ht-row">
                        <label class="ht-check"><input type="checkbox" id="ht-set-plfs"${s.playlistFullscreen ? ' checked' : ''}> Auto-maximize player when an in-player playlist exists</label>
                        <small>Expands the player to fill the window; use the ✕ button to exit. Off by default.</small>
                    </div>
                </div>
                <div class="ht-settings-foot">
                    <button class="ht-save-btn" id="ht-set-save">Save &amp; Close</button>
                </div>
            </div>`;
        document.body.appendChild(overlay);

        function close() {
            document.removeEventListener('keydown', onKey, true);
            overlay.remove();
        }
        function onKey(ev) {
            if (ev.key === 'Escape') { ev.preventDefault(); close(); }
        }

        overlay.querySelector('#ht-settings-close').addEventListener('click', close);
        overlay.addEventListener('click', function (e) { if (e.target === overlay) close(); });
        document.addEventListener('keydown', onKey, true);

        overlay.querySelector('#ht-set-save').addEventListener('click', function () {
            const volEl = overlay.querySelector('#ht-set-vol');
            const fpsEl = overlay.querySelector('#ht-set-fps');
            const skipEl = overlay.querySelector('#ht-set-skip');
            const folderEl = overlay.querySelector('#ht-set-folder');
            const modeEl = overlay.querySelector('#ht-set-ssmode');

            SETTINGS.defaultVol = intVal(volEl.value, DEFAULTS.defaultVol, 0, 100);
            SETTINGS.videoFps = intVal(fpsEl.value, DEFAULTS.videoFps, 1, 120);
            SETTINGS.framesToSkip = intVal(skipEl.value, DEFAULTS.framesToSkip, 1, 120);
            SETTINGS.ssFolder = folderEl.value;
            SETTINGS.ssMode = modeEl.value;
            SETTINGS.hideBigPlay = overlay.querySelector('#ht-set-bigplay').checked;
            SETTINGS.hideAds = overlay.querySelector('#ht-set-ads').checked;
            SETTINGS.showVol = overlay.querySelector('#ht-set-showvol').checked;
            SETTINGS.playlistFullscreen = overlay.querySelector('#ht-set-plfs').checked;

            // Treat the newly saved default as the current volume too, so the
            // change is audible immediately and survives the ht_volume override.
            try { GM_setValue('ht_volume', SETTINGS.defaultVol / 100); } catch (err) {
                console.warn('[Hypnotube Fixer] Could not persist volume on save', err);
            }

            // Re-apply everything immediately.
            manualVolChange = false;
            const video = getVideoEl();
            if (video) delete video.dataset.htVolStarted;
            playlistFullscreenDismissed = false;
            applyStyles();
            syncVolume();
            updateVolDisplay();
            applyPlaylistFullscreen();

            toast('✅ Settings saved', 'success');
            close();
        });
    }

    function ensureGear() {
        if (document.getElementById('ht-settings-gear')) return;
        const btn = document.createElement('button');
        btn.id = 'ht-settings-gear';
        btn.type = 'button';
        btn.title = 'Hypnotube Fixer settings (Shift+O)';
        btn.textContent = '⚙️';
        btn.addEventListener('click', function (e) {
            e.preventDefault();
            createSettingsUI();
        });
        document.body.appendChild(btn);
    }

    // =====================================================================
    // Poll / init
    // =====================================================================
    function poll() {
        ensureGear();
        injectButtons();
        syncVolume();

        if (isWatchPage()) {
            updateVolDisplay();
            initRelatedFilter();
            initPlaylistWidgetFilter();
            applyPlaylistFullscreen();
        }
        initPlaylistPageFilter();
    }

    function init() {
        applyStyles();
        ensureGear();

        if (typeof GM_registerMenuCommand === 'function') {
            GM_registerMenuCommand('Hypnotube Fixer: Settings', createSettingsUI);
        }

        document.addEventListener('keydown', handleKeydown, false);

        poll();
        setInterval(poll, 1000);
    }

    if (document.body) init(); else window.addEventListener('load', init);
})();