☰

Hypnotube Fixer

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

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴、Greasemonkey 油猴子 或 Violentmonkey 暴力猴,才能安装此脚本。

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

您需要先安装一款用户脚本管理器扩展,例如 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);
})();