Lite H5 Video Control

Lite version of video control script. Supports: Seek, Volume, Speed, Fullscreen, PiP, OSD, Rotate, Mirror, Mute.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Lite H5 Video Control
// @name:zh-CN   轻量H5视频控制脚本
// @name:zh-TW   轻量H5视频控制脚本
// @namespace    http://tampermonkey.net/
// @version      4.6.0
// @description  Lite version of video control script. Supports: Seek, Volume, Speed, Fullscreen, PiP, OSD, Rotate, Mirror, Mute.
// @description:zh-CN 轻量级HTML5视频控制脚本,支持倍速播放、快进快退、音量控制、全屏、画中画、网页全屏、镜像翻转、旋转等功能,带有美观的OSD提示。
// @description:zh-TW 轻量级HTML5视频控制脚本,支持倍速播放、快进快退、音量控制、全屏、画中画、网页全屏、镜像翻转、旋转等功能,带有美观的OSD提示。
// @author       dogchild
// @license      MIT
// @icon         data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA1MTIgNTEyIj48cGF0aCBkPSJNMjU2IDhDMTE5IDggOCAxMTkgOCAyNTZzMTExIDI0OCAyNDggMjQ4IDI0OC0xMTEgMjQ4LTI0OFMzOTMgOCAyNTYgOHptMTE1LjcgMjcybC0xNzYgMTAxYy0xNS44IDguOC0zNS43LTIuNS0zNS43LTIxVjE1MmMwLTE4LjQgMTkuOC0yOS44IDM1LjctMjFsMTc2IDEwMWMxNi40IDkuMiAxNi40IDMyLjkgMCA0MnoiLz48L3N2Zz4=
// @match        *://*/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// @grant        unsafeWindow
// @run-at       document-start
// ==/UserScript==

(function () {
    'use strict';

    // --- Internationalization (i18n) ---
    const LANG = navigator.language.startsWith('zh') ? 'zh' : 'en';

    const TEXT = {
        en: {
            vol: 'Volume',
            mute: 'Muted',
            unmute: 'Unmuted',
            seekFwd: 'Forward',
            seekBwd: 'Rewind',
            speed: 'Speed',
            mirrorOn: 'Mirror On',
            mirrorOff: 'Mirror Off',
            rotate: 'Rotate',
            webFS: 'Web Fullscreen',
            webFSForced: 'Web Fullscreen (Forced)',
            webFSNative: 'Web Fullscreen (Native)',
            exitWebFS: 'Exit Web Fullscreen',
            exitFS: 'Exit Fullscreen',
            fullscreen: 'Fullscreen',
            fsAPI: 'Fullscreen (API)',
            pipOn: 'Picture-in-Picture',
            pipOff: 'Exit Picture-in-Picture',
            tryDblClick: 'Try Double-Click',
            next: 'Playing Next',
            prev: 'Playing Previous',
            nextNotFound: 'Next button not found',
            prevNotFound: 'Previous button not found',
            settingsTitle: 'Lite Video Control Settings',
            menuSettings: 'Settings',
            save: 'Save',
            cancel: 'Cancel',
            saved: 'Settings Saved',
            conflict: 'Conflict detected!',
            conflictMsg: 'Cannot save: Resolve conflicts first',
            showOSDLabel: 'Show OSD notifications',
            osdFontSizeLabel: 'OSD font size (px)',
            osdFontSizeEmptyMsg: 'Cannot save: Enter an OSD font size',
            osdFontSizeNaNMsg: 'Cannot save: OSD font size must be a number',
            osdFontSizeRangeMsg: 'Cannot save: OSD font size must be an integer between 10 and 32',
            keys: {
                seekForward: 'Seek Forward (Small)', seekBackward: 'Seek Backward (Small)',
                seekForwardLarge: 'Seek Forward (Large)', seekBackwardLarge: 'Seek Backward (Large)',
                volUp: 'Volume Up (Small)', volDown: 'Volume Down (Small)',
                mute: 'Toggle Mute', mirror: 'Toggle Mirror', rotate: 'Rotate 90°',
                speedUp: 'Speed Up', speedDown: 'Speed Down', speedReset: 'Reset Speed',
                fullscreen: 'Native Fullscreen', webFullscreen: 'Web Fullscreen',
                pip: 'Picture-in-Picture',
                nextVideo: 'Next Video', prevVideo: 'Previous Video',
                speed1: 'Speed 1x', speed2: 'Speed 1.3x', speed3: 'Speed 1.5x', speed4: 'Speed 2x'
            }
        },
        zh: {
            showOSDLabel: '\u663e\u793a OSD \u63d0\u793a',
            osdFontSizeLabel: 'OSD \u5b57\u53f7 (px)',
            osdFontSizeEmptyMsg: '\u65e0\u6cd5\u4fdd\u5b58: \u8bf7\u8f93\u5165 OSD \u5b57\u53f7',
            osdFontSizeNaNMsg: '\u65e0\u6cd5\u4fdd\u5b58: OSD \u5b57\u53f7\u5fc5\u987b\u662f\u6570\u5b57',
            osdFontSizeRangeMsg: '\u65e0\u6cd5\u4fdd\u5b58: OSD \u5b57\u53f7\u5fc5\u987b\u662f 10-32 \u4e4b\u95f4\u7684\u6574\u6570',
            vol: '音量',
            mute: '已静音',
            unmute: '已取消静音',
            seekFwd: '快进',
            seekBwd: '快退',
            speed: '倍速',
            mirrorOn: '镜像开启',
            mirrorOff: '镜像关闭',
            rotate: '旋转',
            webFS: '网页全屏',
            webFSForced: '网页全屏 (强制)',
            webFSNative: '网页全屏 (原生)',
            exitWebFS: '退出网页全屏',
            exitFS: '退出全屏',
            fullscreen: '全屏',
            fsAPI: '全屏 (API)',
            pipOn: '画中画',
            pipOff: '退出画中画',
            tryDblClick: '尝试双击',
            next: '播放下一集',
            prev: '播放上一集',
            nextNotFound: '未找到下一集按钮',
            prevNotFound: '未找到上一集按钮',
            settingsTitle: '视频控制脚本设置',
            menuSettings: '设置',
            save: '保存',
            cancel: '取消',
            saved: '设置已保存',
            conflict: '按键冲突!',
            conflictMsg: '无法保存: 请先解决按键冲突',
            keys: {
                seekForward: '快进 (小幅)', seekBackward: '快退 (小幅)',
                seekForwardLarge: '快进 (大幅)', seekBackwardLarge: '快退 (大幅)',
                volUp: '音量增大 (小幅)', volDown: '音量减小 (小幅)',
                mute: '静音/取消静音', mirror: '镜像翻转', rotate: '旋转 90°',
                speedUp: '加速', speedDown: '减速', speedReset: '重置速度',
                fullscreen: '全屏', webFullscreen: '网页全屏',
                pip: '画中画',
                nextVideo: '下一集', prevVideo: '上一集',
                speed1: '1倍速', speed2: '1.3倍速', speed3: '1.5倍速', speed4: '2倍速'
            }
        }
    };

    const T = TEXT[LANG];
    const pageWindow = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
    const SYNTHETIC_KEY_EVENT_FLAG = '__liteVideoSyntheticKeyEvent';

    // --- Configuration & State ---
    const CONFIG_STORAGE_KEY = 'lite_video_config';
    const SPEED_STORAGE_KEY = 'lite_video_preferred_speed';
    const MIN_OSD_FONT_SIZE = 10;
    const MAX_OSD_FONT_SIZE = 32;
    const DEFAULT_OSD_FONT_SIZE = 24;

    const DEFAULT_CONFIG = {
        seekSmall: 5,
        seekLarge: 30,
        volSmall: 0.05,
        speedStep: 0.1,
        showOSD: true,
        osdFontSize: DEFAULT_OSD_FONT_SIZE,
        keys: {
            seekForward: 'ArrowRight',
            seekBackward: 'ArrowLeft',
            seekForwardLarge: 'Shift+ArrowRight',
            seekBackwardLarge: 'Shift+ArrowLeft',
            volUp: 'ArrowUp',
            volDown: 'ArrowDown',
            mute: 'm',
            mirror: 'Shift+m',
            rotate: 'Shift+r',
            speedUp: 'c',
            speedDown: 'x',
            speedReset: 'z',
            speed1: '1',
            speed2: '2',
            speed3: '3',
            speed4: '4',
            fullscreen: 'Enter',
            webFullscreen: 'Shift+Enter',
            pip: 'Shift+i',
            nextVideo: 'Shift+n',
            prevVideo: 'Shift+p'
        }
    };

    /**
     * Settings object backed by Tampermonkey storage.
     */
    let config = GM_getValue(CONFIG_STORAGE_KEY, DEFAULT_CONFIG);

    /**
     * Normalize the OSD font size into a safe integer pixel range so the UI,
     * saved config, and runtime rendering all follow the same rules.
     *
     * @param {number|string} value
     * @param {number} [fallback=DEFAULT_OSD_FONT_SIZE]
     * @returns {number}
     */
    function normalizeOSDFontSize(value, fallback = DEFAULT_OSD_FONT_SIZE) {
        const fallbackNumber = Number(fallback);
        const safeFallback = Number.isFinite(fallbackNumber) ? Math.round(fallbackNumber) : DEFAULT_OSD_FONT_SIZE;
        const normalizedFallback = Math.min(MAX_OSD_FONT_SIZE, Math.max(MIN_OSD_FONT_SIZE, safeFallback));

        if (value === null || value === undefined) {
            return normalizedFallback;
        }

        if (typeof value === 'string' && value.trim() === '') {
            return normalizedFallback;
        }

        const numericValue = Number(value);

        if (!Number.isFinite(numericValue)) {
            return normalizedFallback;
        }

        return Math.min(MAX_OSD_FONT_SIZE, Math.max(MIN_OSD_FONT_SIZE, Math.round(numericValue)));
    }

    // --- Config Migration ---
    // Ensure new keys exist in user's config if they upgraded from an older version
    let configChanged = false;
    if (!config || typeof config !== 'object') {
        config = {
            ...DEFAULT_CONFIG,
            keys: { ...DEFAULT_CONFIG.keys }
        };
        configChanged = true;
    }
    if (!config.keys || typeof config.keys !== 'object') {
        config.keys = { ...DEFAULT_CONFIG.keys };
        configChanged = true;
    }
    if (typeof config.showOSD !== 'boolean') {
        config.showOSD = DEFAULT_CONFIG.showOSD;
        configChanged = true;
    }
    const normalizedOSDFontSize = normalizeOSDFontSize(config.osdFontSize, DEFAULT_CONFIG.osdFontSize);
    if (config.osdFontSize !== normalizedOSDFontSize) {
        config.osdFontSize = normalizedOSDFontSize;
        configChanged = true;
    }
    if (Object.prototype.hasOwnProperty.call(config, 'volLarge')) {
        delete config.volLarge;
        configChanged = true;
    }
    if (Object.prototype.hasOwnProperty.call(config.keys, 'volUpLarge')) {
        delete config.keys.volUpLarge;
        configChanged = true;
    }
    if (Object.prototype.hasOwnProperty.call(config.keys, 'volDownLarge')) {
        delete config.keys.volDownLarge;
        configChanged = true;
    }
    for (const [key, val] of Object.entries(DEFAULT_CONFIG.keys)) {
        if (!config.keys[key]) {
            config.keys[key] = val;
            configChanged = true;
        }
    }
    if (configChanged) GM_setValue(CONFIG_STORAGE_KEY, config);

    // --- Site-Specific Constants & Selectors ---
    const SITE_CONFIG = {
        // Wrapper: The container element that should be fullscreened
        wrappers: [
            '.bpx-player-container',     // Bilibili (New)
            '.html5-video-player',       // YouTube / Generic
            '.player-container',         // Generic
            '.video-wrapper',            // Generic
            '.art-video-player',         // ArtPlayer
            '.bilibili-player',          // Bilibili (Old)
            '.xgplayer',                 // XGPlayer (Douyin/Xigua) — must be above xg-video-container
            'xg-video-container',        // XGPlayer (Fallback)
            '[data-testid="videoPlayer"]'// X (Twitter)
        ],
        // Native Fullscreen Buttons
        fullscreen: {
            selectors: [
                '.ytp-fullscreen-button',               // YouTube
                '.bpx-player-ctrl-full',                // Bilibili (New)
                '.bilibili-player-video-btn-fullscreen',// Bilibili (Old)
                '.squirtle-video-fullscreen',           // Bilibili (Old)
                '.art-control-fullscreen',              // ArtPlayer
                '.wbpv-fullscreen-control',             // Weibo
                '[data-a-target="player-fullscreen-button"]', // Twitch
                '.player-fullscreen-btn',               // Generic
                '.xgplayer-fullscreen .xgplayer-icon',  // XGPlayer (inner icon receives click events)
                '.xgplayer-fullscreen',                 // XGPlayer (fallback)
                '[data-e2e="xgplayer-fullscreen"]',     // XGPlayer
                '.vjs-fullscreen-control',              // VideoJS
                '[data-testid="videoPlayer"] [aria-label="全屏"]', // X
                '[data-testid="videoPlayer"] [aria-label="Fullscreen"]' // X
            ],
            keywords: ['fullscreen', '全屏', 'full-screen'],
            // Exclude these if found in fuzzy match to avoid False Positives (e.g. "Web Fullscreen")
            exclude: ['web', '网页', 'page', 'theater', 'wide', '宽屏']
        },
        // Web Fullscreen / Theatre Mode Buttons
        webFullscreen: {
            selectors: [
                '.bpx-player-ctrl-web',                 // Bilibili (New)
                '.bilibili-player-video-btn-web-fullscreen', // Bilibili (Old)
                '.squirtle-video-pagefullscreen',       // Bilibili (Old)
                '.ytp-size-button',                     // YouTube (Theater)
                '[data-a-target="player-theatre-mode-button"]', // Twitch
                '.player-fullpage-btn',                 // Generic
                '.xgplayer-page-full-screen .xgplayer-icon', // XGPlayer (inner icon receives click events)
                '.xgplayer-page-full-screen',           // XGPlayer (fallback)
                '[data-e2e="xgplayer-page-full-screen"]'// XGPlayer
            ],
            keywords: ['web fullscreen', '网页全屏', 'theater']
        },
        // Next/Prev Buttons
        next: {
            selectors: [
                '.ytp-next-button',                 // YouTube
                '.bpx-player-ctrl-next',            // Bilibili (New)
                '.bilibili-player-video-btn-next',  // Bilibili (Old)
                '.squirtle-video-next',             // Bilibili (Old)
                '[data-e2e="xgplayer-next"] .xgplayer-icon', // XGPlayer (inner)
                '[data-e2e="xgplayer-next"]'        // XGPlayer
            ],
            keywords: ['next', '下一集', '下一个']
        },
        prev: {
            selectors: [
                '.ytp-prev-button',                 // YouTube
                '.bpx-player-ctrl-prev',            // Bilibili (New)
            ],
            keywords: ['previous', 'prev', '上一集', '上一个']
        },
        audio: {
            bilibiliMute: {
                selectors: [
                    '.bpx-player-ctrl-mute',
                    '.bpx-player-ctrl-volume .bpx-player-ctrl-volume-icon',
                    '.bilibili-player-video-btn-volume',
                    '.squirtle-video-volume',
                    '.squirtle-volume-icon'
                ],
                keywords: ['mute', '静音', '取消静音']
            }
        },
        // Sites where Double-Click Fullscreen should be tried if button not found
        dblClickWhitelist: ['bilibili.com', 'youtube.com', 'twitch.tv']
    };

    // --- Runtime State ---
    let preferredSpeed = normalizePlaybackRate(GM_getValue(SPEED_STORAGE_KEY, 1.0));
    let lastSpeed = preferredSpeed === 1 ? 1.0 : preferredSpeed;
    let osdTimer = null;
    let rememberedSpeedSyncTimer = null;
    const webFullscreenStyleCache = new Map(); // Store original styles for ancestors during Web Fullscreen
    const managedVideos = new WeakSet();

    /* 
     * Global CSS Injection
     * Purpose: 
     * 1. Fix Hupu/Generic sites where video containers don't fill the screen in Native Fullscreen.
     * 2. Ensure OSD visibility by handling wrapper layout.
     */
    const style = document.createElement('style');
    style.textContent = `
        /* When a container (section, div) is fullscreened, ensure it and its video fill the screen */
        :fullscreen {
            width: 100vw !important;
            height: 100vh !important;
            max-width: 100vw !important;
            max-height: 100vh !important;
            margin: 0 !important;
            padding: 0 !important;
            background: black !important;
            display: flex !important;
            align-items: center !important;
            justify-content: center !important;
            position: relative !important;
        }
        :fullscreen > video {
            width: 100% !important;
            height: 100% !important;
            max-width: 100% !important;
            max-height: 100% !important;
            object-fit: contain !important;
            background: black !important;
        }
        /* Fallback for when video element itself is fullscreen */
        video:fullscreen, video:-webkit-full-screen {
            width: 100vw !important;
            height: 100vh !important;
            object-fit: contain !important;
            background: black !important;
        }
    `;
    (document.head || document.documentElement).appendChild(style);

    /**
     * Show On-Screen Display (OSD) notification.
     * Handles displaying text feedback to the user.
     * Dynamically adjusts mount point and position based on whether the browser is in Fullscreen mode.
     * 
     * @param {string} text - The message to display.
     * @param {HTMLVideoElement} [video] - The video element to anchor OSD to (optional, used for positioning).
     */
    function showOSD(text, video) {
        // Keep OSD behavior centralized so every action respects the same toggle.
        if (config.showOSD === false) {
            return;
        }

        let osd = document.getElementById('lite-video-osd');

        // Lazy creation of OSD element
        if (!osd) {
            osd = document.createElement('div');
            osd.id = 'lite-video-osd';
            osd.style.cssText = `
                position: absolute;
                background: rgba(0, 0, 0, 0.7);
                color: white;
                padding: 10px 20px;
                border-radius: 5px;
                font-size: 24px;
                z-index: 2147483647;
                pointer-events: none;
                font-family: sans-serif;
                transition: opacity 0.3s;
                opacity: 0;
                text-shadow: 1px 1px 2px black;
                white-space: nowrap;
                top: 20px;
                left: 20px;
            `;
        }

        osd.style.fontSize = normalizeOSDFontSize(config.osdFontSize, DEFAULT_CONFIG.osdFontSize) + 'px';

        // Determine correct mount point to ensure visibility
        let mountPoint = document.body;

        if (document.fullscreenElement) {
            // In native fullscreen, OSD must be INSIDE the fullscreen container to be visible.
            // If the fullscreen element is an iframe or generic container, we append to it.
            // Note: If the fullscreen element is a VIDEO tag (rare with our fix, but possible),
            // appending execution will fail silently or do nothing, which is an accepted limitation.
            mountPoint = document.fullscreenElement;

            // Force Absolute positioning relative to the fullscreen container
            osd.style.position = 'absolute';
            osd.style.top = '20px';
            osd.style.left = '20px';
        } else {
            // In Windowed mode, use Fixed positioning relative to the Viewport
            osd.style.position = 'fixed';

            // Allow positioning OSD relative to the specific video if provided
            if (video) {
                const rect = video.getBoundingClientRect();
                // Ensure it doesn't go off-screen (negative values)
                osd.style.top = Math.max(0, rect.top + 20) + 'px';
                osd.style.left = Math.max(0, rect.left + 20) + 'px';
            } else {
                // Default top-left fallback
                osd.style.top = '20px';
                osd.style.left = '20px';
            }
        }

        // Move OSD to correct mount point if it has changed
        if (osd.parentNode !== mountPoint) {
            mountPoint.appendChild(osd);
        }

        osd.textContent = text;
        osd.style.display = 'block';

        // Trigger reflow to restart CSS transition
        void osd.offsetWidth;
        osd.style.opacity = '1';

        // Clear previous timer to prevent premature hiding
        if (osdTimer) clearTimeout(osdTimer);
        osdTimer = setTimeout(() => {
            osd.style.opacity = '0';
        }, 1500);
    }

    /**
     * Normalize playback rate values before they are stored or applied.
     * Keeping one decimal place matches the shortcut step logic and avoids
     * floating point drift when values are restored on later pages.
     *
     * @param {number|string} rate
     * @param {number} [fallback=1.0]
     * @returns {number}
     */
    function normalizePlaybackRate(rate, fallback = 1.0) {
        const numericRate = Number(rate);
        if (!Number.isFinite(numericRate) || numericRate < 0.1) {
            return fallback;
        }
        return Math.round(numericRate * 10) / 10;
    }

    /**
     * Retrieve all video elements from the document, traversing Shadow DOMs.
     * Optimized: Uses iterative TreeWalker to avoid recursion limits and improve performance.
     * 
     * @param {Node} root - The root node to start searching from (default: document).
     * @returns {HTMLVideoElement[]} - Array of found video elements.
     */
    function getAllVideos(root = document) {
        let videos = [];

        // Add videos from current root
        const nodes = root.querySelectorAll('video');
        for (let i = 0; i < nodes.length; i++) {
            videos.push(nodes[i]);
        }

        // Traverse Shadow Roots
        const treeWalker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, {
            acceptNode: (node) => node.shadowRoot ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP
        });

        let node;
        while ((node = treeWalker.nextNode())) {
            // Recursively check Shadow Roots (breadth-first implicit via walker)
            videos = videos.concat(getAllVideos(node.shadowRoot));
        }
        return videos;
    }

    /**
     * Persist the user's preferred cross-page speed.
     * We intentionally keep lastSpeed pointing at the last non-1x value so the
     * reset shortcut can still toggle back from 1x to the prior faster/slower speed.
     *
     * @param {number} rate
     * @returns {number}
     */
    function rememberPreferredSpeed(rate) {
        preferredSpeed = normalizePlaybackRate(rate, preferredSpeed);
        GM_setValue(SPEED_STORAGE_KEY, preferredSpeed);

        if (preferredSpeed !== 1) {
            lastSpeed = preferredSpeed;
        }

        return preferredSpeed;
    }

    /**
     * Build a stable per-source key for the current video source.
     *
     * @param {HTMLVideoElement} video
     * @returns {string}
     */
    function getVideoSourceKey(video) {
        return video.currentSrc || video.src || 'inline-video';
    }

    /**
     * Apply the remembered speed directly to the given video.
     * This function assumes the caller has already decided the video is the
     * correct playback target for auto-restore.
     *
     * @param {HTMLVideoElement} video
     */
    function applyPreferredSpeed(video) {
        if (!video) return;

        const targetSpeed = normalizePlaybackRate(preferredSpeed, 1.0);
        const sourceKey = getVideoSourceKey(video);

        // Only auto-restore once per source so later local/native speed changes
        // remain in effect for this source across pause/resume cycles.
        if (video._liteAppliedSpeedKey === sourceKey) {
            return;
        }

        video.playbackRate = targetSpeed;
        video.playbackRate = normalizePlaybackRate(video.playbackRate, targetSpeed);
        video._liteAppliedSpeedKey = sourceKey;
    }

    /**
     * Only restore remembered speed when the playing video matches the current
     * active-video heuristic used by keyboard controls.
     *
     * @param {HTMLVideoElement} video
     */
    function maybeApplyPreferredSpeed(video) {
        if (!video) return;
        if (getActiveVideo() !== video) return;
        applyPreferredSpeed(video);
    }

    /**
     * Attach one-time listeners per video so remembered speed works on new tabs,
     * refreshed pages, and player instances that reuse the same video element.
     *
     * @param {HTMLVideoElement} video
     */
    function bindRememberedSpeed(video) {
        if (!video || managedVideos.has(video)) return;
        managedVideos.add(video);

        video.addEventListener('loadstart', () => {
            // Reset the per-source auto-restore marker so a reused video element
            // can restore the remembered speed again for the next source.
            video._liteAppliedSpeedKey = '';
        });

        video.addEventListener('play', () => {
            maybeApplyPreferredSpeed(video);
        });
    }

    const observedMutationRoots = new WeakSet();
    const pendingRememberedSpeedRoots = new Set();

    function observeMutationRoot(root) {
        if (!root || observedMutationRoots.has(root)) return;
        observedMutationRoots.add(root);
        videoObserver.observe(root, { childList: true, subtree: true });
    }

    /**
     * Observe any Shadow Roots contained in the given subtree so we can detect
     * videos added by component-based players without rescanning the whole page
     * on unrelated light-DOM mutations.
     *
     * @param {Node} root
     * @returns {boolean} - Whether any observed Shadow Root currently contains videos.
     */
    function observeShadowRootsInSubtree(root) {
        if (!root) return false;

        let hasVideoInShadowRoot = false;
        const treeWalker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, {
            acceptNode: (node) => node.shadowRoot ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP
        });

        let node;
        while ((node = treeWalker.nextNode())) {
            const shadowRoot = node.shadowRoot;
            observeMutationRoot(shadowRoot);

            if (!hasVideoInShadowRoot && shadowRoot.querySelector('video')) {
                hasVideoInShadowRoot = true;
            }

            if (observeShadowRootsInSubtree(shadowRoot)) {
                hasVideoInShadowRoot = true;
            }
        }

        return hasVideoInShadowRoot;
    }

    /**
     * Bind remembered-speed listeners only within the given subtree instead of
     * rescanning the entire document on every relevant mutation batch.
     *
     * @param {Node} root
     * @returns {boolean} - Whether this subtree contains any videos.
     */
    function bindRememberedSpeedInSubtree(root) {
        if (!root) return false;

        let hasVideo = false;
        const isElement = root.nodeType === Node.ELEMENT_NODE;

        if (isElement && root.tagName === 'VIDEO') {
            bindRememberedSpeed(root);
            hasVideo = true;
        }

        if (typeof root.querySelectorAll === 'function') {
            const videos = root.querySelectorAll('video');
            for (let i = 0; i < videos.length; i++) {
                bindRememberedSpeed(videos[i]);
            }
            if (videos.length > 0) {
                hasVideo = true;
            }
        }

        const treeWalker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, {
            acceptNode: (node) => node.shadowRoot ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP
        });

        let node;
        while ((node = treeWalker.nextNode())) {
            const shadowRoot = node.shadowRoot;
            observeMutationRoot(shadowRoot);

            if (bindRememberedSpeedInSubtree(shadowRoot)) {
                hasVideo = true;
            }
        }

        return hasVideo;
    }

    function bindRememberedSpeedToAllVideos() {
        return bindRememberedSpeedInSubtree(document.documentElement);
    }

    /**
     * Inspect only newly added nodes to decide whether remembered-speed sync
     * work is necessary. This avoids rescanning the full document on unrelated
     * page updates such as comments, feeds, or counters.
     *
     * @param {Node} node
     * @returns {boolean}
     */
    function nodeMayAffectRememberedSpeed(node) {
        if (!node) return false;

        const isElement = node.nodeType === Node.ELEMENT_NODE;
        const isFragment = node.nodeType === Node.DOCUMENT_FRAGMENT_NODE;
        if (!isElement && !isFragment) return false;

        if (isElement && node.tagName === 'VIDEO') {
            observeShadowRootsInSubtree(node);
            return true;
        }

        const hasVideoInSubtree = typeof node.querySelector === 'function' && !!node.querySelector('video');
        const hasVideoInShadowRoots = observeShadowRootsInSubtree(node);

        if (isElement && node.shadowRoot) {
            observeMutationRoot(node.shadowRoot);
            if (node.shadowRoot.querySelector('video')) {
                return true;
            }
        }

        return hasVideoInSubtree || hasVideoInShadowRoots;
    }

    function queueRememberedSpeedSubtree(root) {
        if (!root) return;
        pendingRememberedSpeedRoots.add(root);
        scheduleRememberedSpeedSync();
    }

    function scheduleRememberedSpeedSync() {
        if (rememberedSpeedSyncTimer) return;

        rememberedSpeedSyncTimer = setTimeout(() => {
            rememberedSpeedSyncTimer = null;
            if (pendingRememberedSpeedRoots.size === 0) return;

            const pendingRoots = Array.from(pendingRememberedSpeedRoots);
            pendingRememberedSpeedRoots.clear();

            let sawVideo = false;
            for (let i = 0; i < pendingRoots.length; i++) {
                const root = pendingRoots[i];
                observeShadowRootsInSubtree(root);
                if (bindRememberedSpeedInSubtree(root)) {
                    sawVideo = true;
                }
            }

            if (sawVideo) {
                const activeVideo = getActiveVideo();
                if (activeVideo && !activeVideo.paused && activeVideo.readyState > 2) {
                    maybeApplyPreferredSpeed(activeVideo);
                }
            }
        }, 100);
    }

    /**
     * Identify the "Active" video to control.
     * Heuristic Priority: 
     * 1. Currently Playing (and visible) - Fastest check.
     * 2. Largest Visible Video in Viewport.
     * 3. First Found Video (Fallback).
     * 
     * @returns {HTMLVideoElement|null}
     */
    function getActiveVideo() {
        const videos = getAllVideos();
        if (videos.length === 0) return null;

        const viewportHeight = window.innerHeight;
        const viewportWidth = window.innerWidth;
        const center_x = viewportWidth / 2;
        const center_y = viewportHeight / 2;

        let bestCandidate = null;
        let minDistance = Infinity;

        // Priority 1: Playing videos closest to center
        const playingVideos = videos.filter(v => !v.paused && v.style.display !== 'none' && v.readyState > 2);

        if (playingVideos.length > 0) {
            for (const v of playingVideos) {
                const rect = v.getBoundingClientRect();
                const v_center_x = rect.left + rect.width / 2;
                const v_center_y = rect.top + rect.height / 2;
                // Check if roughly in viewport
                if (v_center_x > 0 && v_center_x < viewportWidth && v_center_y > 0 && v_center_y < viewportHeight) {
                    const distance = Math.hypot(v_center_x - center_x, v_center_y - center_y);
                    if (distance < minDistance) {
                        minDistance = distance;
                        bestCandidate = v;
                    }
                }
            }
            if (bestCandidate) return bestCandidate;
        }

        // Priority 2: Largest visible video in viewport (Fallback)
        let maxArea = 0;
        bestCandidate = null; // Reset bestCandidate for this priority
        for (let i = 0; i < videos.length; i++) {
            const v = videos[i];
            if (v.style.display === 'none') continue;
            const rect = v.getBoundingClientRect();
            if (rect.width === 0 || rect.height === 0) continue;

            const area = rect.width * rect.height;
            const centerX = rect.left + rect.width / 2;
            const centerY = rect.top + rect.height / 2;

            const inViewport = (centerX >= 0 && centerX <= viewportWidth && centerY >= 0 && centerY <= viewportHeight);

            if (inViewport && area > maxArea) {
                maxArea = area;
                bestCandidate = v;
            }
        }

        return bestCandidate || videos[0];
    }

    // --- Helper Functions ---

    /**
     * Simulate a mouse click on an element.
     * Dispatches mousedown/mouseup events to satisfy frameworks (React, Vue) that listen to them.
     */
    function simulateClick(element) {
        if (!element) return;
        const outputWindow = element.ownerDocument.defaultView || window;
        const opts = { bubbles: true, cancelable: true, view: outputWindow };
        element.dispatchEvent(new MouseEvent('mousedown', opts));
        element.dispatchEvent(new MouseEvent('mouseup', opts));
        element.click();
    }

    /**
     * Search for native player buttons using selectors or accessibility labels.
     * Used for Next/Prev/Fullscreen actions.
     * 
     * @param {HTMLElement} wrapper - The container to search within.
     * @param {string[]} selectors - CSS selectors for precise targeting.
     * @param {string[]} keywords - Keywords to match against aria-label/title/text.
     * @returns {HTMLElement|null}
     */
    function findControlBtn(wrapper, selectors, keywords, excludeKeywords = []) {
        if (!wrapper) return null;

        // 1. Precise Selector Match (allow hidden buttons — e.g. YouTube hides next/prev in fullscreen)
        for (const sel of selectors) {
            const btn = wrapper.querySelector(sel);
            if (btn) return btn;
        }

        // 2. Fuzzy Keyword Match (Fallback)
        if (keywords && keywords.length > 0) {
            const elements = wrapper.querySelectorAll('button, [role="button"], div, span, i');
            for (let i = 0; i < elements.length; i++) {
                const el = elements[i];
                // Skip invisible elements early
                if (!el.offsetParent) continue;

                // Skip our own OSD element — its text (e.g. "退出全屏") can false-match
                if (el.id === 'lite-video-osd') continue;

                const attrStr = (el.title || '') + (el.getAttribute('aria-label') || '') + (el.innerText || '');
                const lowerAttr = attrStr.toLowerCase();

                // Check exclusions
                if (excludeKeywords.some(ex => lowerAttr.includes(ex))) continue;

                for (const key of keywords) {
                    if (lowerAttr.includes(key)) return el;
                }
            }
        }
        return null;
    }

    function clampVolume(value, fallback = 0) {
        const numericValue = Number(value);
        if (!Number.isFinite(numericValue)) {
            return Math.min(1, Math.max(0, Number(fallback) || 0));
        }
        return Math.min(1, Math.max(0, numericValue));
    }

    function normalizeControllerVolume(value, fallback = 0) {
        const numericValue = Number(value);
        if (!Number.isFinite(numericValue)) {
            return clampVolume(fallback, 0);
        }
        return numericValue > 1 ? clampVolume(numericValue / 100, fallback) : clampVolume(numericValue, fallback);
    }

    function isHostIn(domains) {
        const host = String(window.location.hostname || '').toLowerCase();
        for (let i = 0; i < domains.length; i++) {
            const domain = domains[i].toLowerCase();
            if (host === domain || host.endsWith(`.${domain}`)) {
                return true;
            }
        }
        return false;
    }

    function readVolumeState(video, controller = null) {
        let volume = clampVolume(video.volume, 0);
        let muted = !!video.muted;

        if (controller) {
            if (typeof controller.getVolume === 'function') {
                try {
                    volume = clampVolume(controller.getVolume(), volume);
                } catch (error) {
                    console.debug('Lite Video Control: native volume read failed', error);
                }
            }
            if (typeof controller.isMuted === 'function') {
                try {
                    muted = !!controller.isMuted();
                } catch (error) {
                    console.debug('Lite Video Control: native mute read failed', error);
                }
            }
        }

        return { volume, muted };
    }

    function showVolumeState(video, controller = null) {
        const state = readVolumeState(video, controller);
        if (state.muted) {
            showOSD(T.mute, video);
            return;
        }
        showOSD(`${T.vol} ${Math.round(state.volume * 100)}%`, video);
    }

    function invokeFirstMethod(target, methodNames, args = []) {
        if (!target) return false;
        for (let i = 0; i < methodNames.length; i++) {
            const methodName = methodNames[i];
            if (typeof target[methodName] === 'function') {
                try {
                    target[methodName](...args);
                    return true;
                } catch (error) {
                    console.debug(`Lite Video Control: ${methodName} failed`, error);
                }
            }
        }
        return false;
    }

    function readMutedFlag(target, keys) {
        if (!target) return null;
        for (let i = 0; i < keys.length; i++) {
            const key = keys[i];
            const value = target[key];
            if (typeof value === 'function') {
                try {
                    return !!value.call(target);
                } catch (error) {
                    console.debug(`Lite Video Control: ${key} read failed`, error);
                }
            } else if (typeof value === 'boolean') {
                return value;
            }
        }
        return null;
    }

    function getYouTubePlayerRoot(video) {
        return video.closest('.html5-video-player')
            || document.getElementById('movie_player')
            || document.querySelector('.html5-video-player')
            || getWrapper(video)
            || null;
    }

    function getYouTubeMuteButton(root) {
        if (!root) return null;
        return root.querySelector('.ytp-mute-button');
    }

    function getYouTubeVolumeSlider(root) {
        if (!root) return null;
        return root.querySelector('.ytp-volume-panel .ytp-input-slider')
            || root.querySelector('.ytp-volume-slider .ytp-input-slider')
            || root.querySelector('.ytp-volume-panel input[type="range"]')
            || root.querySelector('.ytp-input-slider[aria-valuemin="0"][aria-valuemax="100"]')
            || null;
    }

    function dispatchYouTubeSliderEvent(slider, type) {
        slider.dispatchEvent(new Event(type, { bubbles: true, cancelable: true, composed: true }));
    }

    function getYouTubeVolumePanel(root) {
        if (!root) return null;
        return root.querySelector('.ytp-volume-panel[role="slider"]')
            || root.querySelector('.ytp-volume-panel')
            || null;
    }

    function createSyntheticKeyboardEvent(key, keyCode) {
        const event = new KeyboardEvent('keydown', {
            key,
            code: key,
            bubbles: true,
            cancelable: true
        });
        event[SYNTHETIC_KEY_EVENT_FLAG] = true;

        const readonlyProps = { keyCode, which: keyCode, charCode: 0 };
        for (const [prop, value] of Object.entries(readonlyProps)) {
            try {
                Object.defineProperty(event, prop, { get: () => value });
            } catch (error) {
                console.debug(`Lite Video Control: failed to define ${prop} on keyboard event`, error);
            }
        }

        return event;
    }

    function wait(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }

    async function confirmVideoState(video, predicate, timeoutMs = 500, intervalMs = 50) {
        if (predicate()) {
            return true;
        }

        return await new Promise((resolve) => {
            let settled = false;
            let timerId = null;
            let intervalId = null;

            const cleanup = () => {
                video.removeEventListener('volumechange', onVolumeChange);
                if (timerId !== null) {
                    clearTimeout(timerId);
                }
                if (intervalId !== null) {
                    clearInterval(intervalId);
                }
            };

            const finish = (result) => {
                if (settled) return;
                settled = true;
                cleanup();
                resolve(result);
            };

            const check = () => {
                if (predicate()) {
                    finish(true);
                }
            };

            const onVolumeChange = () => {
                check();
            };

            video.addEventListener('volumechange', onVolumeChange);
            intervalId = setInterval(check, intervalMs);
            timerId = setTimeout(() => finish(predicate()), timeoutMs);
            check();
        });
    }

    // YouTube stores remembered volume through its own UI event chain, so the
    // mute button and slider should be preferred over the public player API.
    function getYouTubeUIAudioController(video) {
        if (!isHostIn(['youtube.com', 'youtu.be'])) return null;

        const root = getYouTubePlayerRoot(video);
        const muteButton = getYouTubeMuteButton(root);
        const volumePanel = getYouTubeVolumePanel(root);
        const slider = getYouTubeVolumeSlider(root);
        if (!muteButton && !volumePanel && !slider) return null;

        return {
            getVolume() {
                return clampVolume(video.volume, 0);
            },
            isMuted() {
                return !!video.muted;
            },
            async setVolume(value) {
                if (!volumePanel && !slider) return false;

                const targetVolume = clampVolume(value, video.volume);
                const beforeVolume = clampVolume(video.volume, 0);
                const beforeMuted = !!video.muted;
                const targetPercent = Math.round(targetVolume * 100);
                const currentPercent = Math.round(beforeVolume * 100);
                if (currentPercent === targetPercent && (!beforeMuted || targetPercent === 0)) {
                    return true;
                }

                if (volumePanel) {
                    const increase = targetVolume > beforeVolume || (beforeMuted && targetPercent > 0);
                    const key = increase ? 'ArrowRight' : 'ArrowLeft';
                    const keyCode = increase ? 39 : 37;

                    if (typeof volumePanel.focus === 'function') {
                        volumePanel.focus();
                    }
                    volumePanel.dispatchEvent(createSyntheticKeyboardEvent(key, keyCode));

                    return await confirmVideoState(video, () => {
                        const afterVolume = clampVolume(video.volume, beforeVolume);
                        const afterMuted = !!video.muted;
                        return Math.abs(afterVolume - beforeVolume) >= 0.01 || afterMuted !== beforeMuted;
                    }, 500, 50);
                }

                const outputWindow = slider.ownerDocument.defaultView || window;
                const mouseOpts = { bubbles: true, cancelable: true, view: outputWindow };

                slider.dispatchEvent(new MouseEvent('mousedown', mouseOpts));
                if (typeof slider.focus === 'function') {
                    slider.focus();
                }
                slider.value = String(targetPercent);
                try {
                    slider.valueAsNumber = targetPercent;
                } catch (error) {
                    console.debug('Lite Video Control: YouTube slider valueAsNumber failed', error);
                }
                dispatchYouTubeSliderEvent(slider, 'input');
                dispatchYouTubeSliderEvent(slider, 'change');
                slider.dispatchEvent(new MouseEvent('mouseup', mouseOpts));

                return await confirmVideoState(video, () => {
                    const afterVolume = clampVolume(video.volume, beforeVolume);
                    const afterMuted = !!video.muted;
                    return Math.abs(afterVolume - beforeVolume) >= 0.01 || afterMuted !== beforeMuted;
                }, 500, 50);
            },
            async setMuted(muted) {
                if (!muteButton) return false;

                const targetMuted = !!muted;
                const currentMuted = !!video.muted;
                if (currentMuted === targetMuted) {
                    return true;
                }

                simulateClick(muteButton);
                return await confirmVideoState(video, () => !!video.muted === targetMuted, 500, 50);
            }
        };
    }

    // Native-first audio keeps the existing shortcut semantics while delegating
    // the actual state mutation to player-owned APIs when we can identify them safely.
    function getYouTubePlayerApiAudioController(video) {
        if (!isHostIn(['youtube.com', 'youtu.be'])) return null;

        const candidates = [
            pageWindow.movie_player,
            document.getElementById('movie_player'),
            getYouTubePlayerRoot(video)
        ];

        for (let i = 0; i < candidates.length; i++) {
            const player = candidates[i];
            if (!player) continue;
            if (
                typeof player.getVolume === 'function' &&
                typeof player.setVolume === 'function' &&
                typeof player.isMuted === 'function' &&
                typeof player.mute === 'function' &&
                typeof player.unMute === 'function'
            ) {
                return {
                    getVolume() {
                        return normalizeControllerVolume(player.getVolume(), video.volume);
                    },
                    setVolume(value) {
                        player.setVolume(Math.round(clampVolume(value, video.volume) * 100));
                        return true;
                    },
                    isMuted() {
                        return !!player.isMuted();
                    },
                    setMuted(muted) {
                        return invokeFirstMethod(player, muted ? ['mute'] : ['unMute']);
                    }
                };
            }
        }

        return null;
    }

    function getArtPlayerAudioController(video) {
        const artPlayerCtor = pageWindow.Artplayer;
        const instances = artPlayerCtor && Array.isArray(artPlayerCtor.instances) ? artPlayerCtor.instances : null;
        if (!instances) return null;

        for (let i = 0; i < instances.length; i++) {
            const art = instances[i];
            if (!art) continue;

            try {
                const templateVideo = art.template && art.template.$video ? art.template.$video : null;
                const queriedVideo = typeof art.query === 'function' ? art.query('.art-video') : null;
                const containerMatches = art.container && typeof art.container.contains === 'function' ? art.container.contains(video) : false;
                if (art.video !== video && templateVideo !== video && queriedVideo !== video && !containerMatches) {
                    continue;
                }

                return {
                    getVolume() {
                        return clampVolume(art.volume, video.volume);
                    },
                    setVolume(value) {
                        art.volume = clampVolume(value, video.volume);
                        return true;
                    },
                    isMuted() {
                        return !!art.muted;
                    },
                    setMuted(muted) {
                        art.muted = !!muted;
                        return true;
                    }
                };
            } catch (error) {
                console.debug('Lite Video Control: ArtPlayer adapter failed', error);
            }
        }

        return null;
    }

    function getBilibiliMuteButton(video) {
        if (!isHostIn(['bilibili.com'])) return null;
        const wrapper = getWrapper(video) || document.body;
        return findControlBtn(
            wrapper,
            SITE_CONFIG.audio.bilibiliMute.selectors,
            SITE_CONFIG.audio.bilibiliMute.keywords
        );
    }

    function getBilibiliAudioController(video) {
        if (!isHostIn(['bilibili.com'])) return null;

        const player = pageWindow.player;
        const muteButton = getBilibiliMuteButton(video);
        const hasVolumeApi = player && typeof player.getVolume === 'function' && typeof player.setVolume === 'function';
        if (!hasVolumeApi && !muteButton) return null;

        const getVolumeScale = () => {
            if (!hasVolumeApi) return 1;
            try {
                const currentVolume = Number(player.getVolume());
                return Number.isFinite(currentVolume) && currentVolume > 1 ? 100 : 1;
            } catch (error) {
                console.debug('Lite Video Control: Bilibili volume scale read failed', error);
                return 1;
            }
        };

        const setBilibiliMuted = (muted) => {
            const currentMuted = readMutedFlag(player, ['isMute', 'isMuted', 'getMute', 'getMuted']);
            if (currentMuted !== null && currentMuted === muted) return true;

            if (muted) {
                if (invokeFirstMethod(player, ['mute'], [])) return true;
                if (invokeFirstMethod(player, ['setMute', 'setMuted', 'setIsMute'], [true])) return true;
            } else {
                if (invokeFirstMethod(player, ['unMute', 'unmute'], [])) return true;
                if (invokeFirstMethod(player, ['setMute', 'setMuted', 'setIsMute'], [false])) return true;
            }

            if (muteButton) {
                const domMuted = !!video.muted;
                if (domMuted !== muted) {
                    simulateClick(muteButton);
                }
                return confirmVideoState(video, () => {
                    const resolvedMuted = readMutedFlag(player, ['isMute', 'isMuted', 'getMute', 'getMuted']);
                    return (resolvedMuted === null ? !!video.muted : resolvedMuted) === muted;
                }, 200, 50);
            }

            return false;
        };

        return {
            getVolume() {
                if (!hasVolumeApi) return clampVolume(video.volume, 0);
                return normalizeControllerVolume(player.getVolume(), video.volume);
            },
            setVolume(value) {
                if (!hasVolumeApi) return false;
                const volume = clampVolume(value, video.volume);
                const scale = getVolumeScale();
                player.setVolume(scale === 100 ? Math.round(volume * 100) : volume);
                return true;
            },
            isMuted() {
                const muted = readMutedFlag(player, ['isMute', 'isMuted', 'getMute', 'getMuted']);
                return muted === null ? !!video.muted : muted;
            },
            setMuted(muted) {
                return setBilibiliMuted(!!muted);
            }
        };
    }

    function getNativeAudioControllers(video) {
        const controllers = [];
        const pushController = (controller) => {
            if (controller) {
                controllers.push(controller);
            }
        };

        pushController(getYouTubeUIAudioController(video));
        pushController(getYouTubePlayerApiAudioController(video));
        pushController(getBilibiliAudioController(video));
        pushController(getArtPlayerAudioController(video));

        return controllers;
    }

    async function tryAdjustNativeVolume(video, delta) {
        const controllers = getNativeAudioControllers(video);
        for (let i = 0; i < controllers.length; i++) {
            const controller = controllers[i];
            if (typeof controller.getVolume !== 'function' || typeof controller.setVolume !== 'function') {
                continue;
            }

            try {
                const currentState = readVolumeState(video, controller);
                if (delta > 0 && currentState.muted) {
                    if (typeof controller.setMuted !== 'function' || !await controller.setMuted(false)) {
                        continue;
                    }
                }

                const nextVolume = clampVolume(currentState.volume + delta, currentState.volume);
                if (!await controller.setVolume(nextVolume)) {
                    continue;
                }

                showVolumeState(video, controller);
                return true;
            } catch (error) {
                console.debug('Lite Video Control: native volume adjust failed', error);
            }
        }

        return false;
    }

    async function tryToggleNativeMute(video) {
        const controllers = getNativeAudioControllers(video);
        for (let i = 0; i < controllers.length; i++) {
            const controller = controllers[i];
            if (typeof controller.setMuted !== 'function') {
                continue;
            }

            try {
                const currentState = readVolumeState(video, controller);
                if (!await controller.setMuted(!currentState.muted)) {
                    continue;
                }
                showVolumeState(video, controller);
                return true;
            } catch (error) {
                console.debug('Lite Video Control: native mute toggle failed', error);
            }
        }

        return false;
    }

    // --- Action Handlers ---

    function clickControlBtn(video, actionType) {
        const wrapper = getWrapper(video) || document.body;
        let selectors = [];
        let keywords = [];
        let osdText = '';

        if (actionType === 'next') {
            selectors = SITE_CONFIG.next.selectors;
            keywords = SITE_CONFIG.next.keywords;
            osdText = T.next;
        } else if (actionType === 'prev') {
            selectors = SITE_CONFIG.prev.selectors;
            keywords = SITE_CONFIG.prev.keywords;
            osdText = T.prev;
        }

        const btn = findControlBtn(wrapper, selectors, keywords);
        if (btn) {
            simulateClick(btn);
            showOSD(osdText, video);
        } else {
            showOSD(actionType === 'next' ? T.nextNotFound : T.prevNotFound, video);
        }
    }

    function adjustSeek(video, delta) {
        if (Number.isFinite(video.duration)) {
            video.currentTime = Math.min(video.duration, Math.max(0, video.currentTime + delta));
        } else {
            // For live streams or infinite buffers, just try setting it
            video.currentTime += delta;
        }
        showOSD(`${delta > 0 ? T.seekFwd : T.seekBwd} ${Math.abs(delta)}s`, video);
    }

    async function adjustVolume(video, delta) {
        try {
            if (await tryAdjustNativeVolume(video, delta)) {
                return;
            }
        } catch (error) {
            console.debug('Lite Video Control: native volume path failed unexpectedly', error);
        }

        if (delta > 0 && video.muted) {
            video.muted = false;
        }
        video.volume = clampVolume(video.volume + delta, video.volume);
        showVolumeState(video);
    }

    async function toggleMute(video) {
        try {
            if (await tryToggleNativeMute(video)) {
                return;
            }
        } catch (error) {
            console.debug('Lite Video Control: native mute path failed unexpectedly', error);
        }

        video.muted = !video.muted;
        showVolumeState(video);
    }

    function adjustSpeed(video, action) {
        if (action === 'reset') {
            // Toggle between 1.0 and last used speed
            if (video.playbackRate === 1) {
                video.playbackRate = lastSpeed === 1 ? config.speedStep + 1 : lastSpeed;
            } else {
                lastSpeed = video.playbackRate;
                video.playbackRate = 1;
            }
        } else if (action === 'up') {
            video.playbackRate += config.speedStep;
        } else if (action === 'down') {
            video.playbackRate = Math.max(0.1, video.playbackRate - config.speedStep);
        } else if (typeof action === 'number') {
            video.playbackRate = action;
        }
        // Round to 1 decimal place to prevent floating point errors (e.g. 1.10000002)
        video.playbackRate = normalizePlaybackRate(video.playbackRate, 1.0);
        // Only shortcut-driven speed changes update the cross-page remembered speed.
        // This keeps previews, ads, and site-managed side videos from overwriting it.
        rememberPreferredSpeed(video.playbackRate);
        video._liteAppliedSpeedKey = getVideoSourceKey(video);
        showOSD(`${T.speed} ${video.playbackRate}x`, video);
    }

    /**
     * Apply CSS Transforms (Rotate & Mirror).
     * Calculates the necessary scale factor to fit the rotated video within its container/viewport.
     */
    function applyTransform(video) {
        const rotate = video._rotateDeg || 0;
        const mirror = video._isMirrored ? -1 : 1;
        let scale = 1;

        // Auto-fit logic for 90/270 degree rotation
        if (rotate % 180 !== 0) {
            const vW = video.offsetWidth || video.videoWidth;
            const vH = video.offsetHeight || video.videoHeight;

            if (vW && vH) {
                let cW, cH;
                // Use viewport size if in fullscreen (Web or Native)
                if (video._isWebFullscreen || document.fullscreenElement) {
                    cW = window.innerWidth;
                    cH = window.innerHeight;
                } else {
                    cW = vW; // Fallback to self-size
                    cH = vH;
                }

                // Fit Logic: when rotated, Video Height becomes visible Width, etc.
                const scaleW = cW / vH;
                const scaleH = cH / vW;
                scale = Math.min(scaleW, scaleH);
            }
        }

        const transformValue = `rotate(${rotate}deg) scaleX(${mirror}) scale(${scale})`;
        video.style.setProperty('transform', transformValue, 'important');
        video.style.setProperty('transform-origin', 'center center', 'important');
    }

    function toggleMirror(video) {
        video._isMirrored = !video._isMirrored;
        applyTransform(video);
        showOSD(video._isMirrored ? T.mirrorOn : T.mirrorOff, video);
    }

    function rotateVideo(video) {
        video._rotateDeg = (video._rotateDeg || 0) + 90;
        if (video._rotateDeg >= 360) video._rotateDeg = 0;
        applyTransform(video);
        showOSD(`${T.rotate} ${video._rotateDeg}°`, video);
    }

    /**
     * Toggle Picture-in-Picture mode using the standard browser PiP API.
     */
    function togglePiP(video) {
        if (document.pictureInPictureElement) {
            document.exitPictureInPicture().catch(e => console.error(e));
            showOSD(T.pipOff, video);
        } else if (document.pictureInPictureEnabled) {
            // Some sites (e.g. Douyin) add disablePictureInPicture attribute — force remove it
            video.removeAttribute('disablePictureInPicture');
            video.disablePictureInPicture = false;
            video.requestPictureInPicture().catch(e => console.error(e));
            showOSD(T.pipOn, video);
        }
    }

    /**
     * Force "Web Fullscreen" Mode.
     * This simulates fullscreen by setting fixed positioning and high z-index on the video.
     * It also iterates up the DOM tree to nuke z-indexes/transforms of ancestors (Stacking Contexts).
     */
    function enableManualWebFullscreen(video) {
        webFullscreenStyleCache.clear();

        // 1. Style Video Element
        video._prevStyle = video.style.cssText;
        video.style.cssText += 'position:fixed !important; top:0 !important; left:0 !important; width:100vw !important; height:100vh !important; z-index:2147483647 !important; background:black !important; object-fit:contain !important;';

        applyTransform(video);

        // 2. Fix Ancestor Stacking Contexts
        // We must flatten layout contexts so the fixed video isn't clipped or obscured.
        let el = video.parentElement;
        while (el && el !== document.documentElement) {
            const style = window.getComputedStyle(el);
            // Cache original styles
            webFullscreenStyleCache.set(el, {
                transform: el.style.transform,
                zIndex: el.style.zIndex,
                position: el.style.position,
                contain: el.style.contain,
                filter: el.style.filter,
                willChange: el.style.willChange
            });

            // Flatten properties that create stacking contexts
            if (style.transform !== 'none') el.style.setProperty('transform', 'none', 'important');
            if (style.filter !== 'none') el.style.setProperty('filter', 'none', 'important');
            if (style.perspective !== 'none') el.style.setProperty('perspective', 'none', 'important');
            if (style.backdropFilter !== 'none') el.style.setProperty('backdrop-filter', 'none', 'important');
            if (style.willChange !== 'auto') el.style.setProperty('will-change', 'auto', 'important');
            el.style.setProperty('contain', 'none', 'important');
            el.style.setProperty('z-index', '2147483647', 'important');

            if (style.position === 'static') {
                el.style.setProperty('position', 'relative', 'important');
            }

            el = el.parentElement;
        }

        video._isWebFullscreen = true;
        showOSD(T.webFSForced, video);
    }

    function disableManualWebFullscreen(video) {
        // Restore Video Styles
        video.style.cssText = video._prevStyle || '';

        // Restore Ancestor Styles
        for (const [el, styles] of webFullscreenStyleCache) {
            if (styles.transform) el.style.transform = styles.transform; else el.style.removeProperty('transform');
            if (styles.zIndex) el.style.zIndex = styles.zIndex; else el.style.removeProperty('z-index');
            if (styles.position) el.style.position = styles.position; else el.style.removeProperty('position');
            if (styles.contain) el.style.contain = styles.contain; else el.style.removeProperty('contain');
            if (styles.filter) el.style.filter = styles.filter; else el.style.removeProperty('filter');
            if (styles.willChange) el.style.willChange = styles.willChange; else el.style.removeProperty('will-change');
        }
        webFullscreenStyleCache.clear(); // Free memory

        applyTransform(video);
        video._isWebFullscreen = false;
        showOSD(T.exitWebFS, video);
    }

    /**
     * Get the appropriate wrapper element for Fullscreen.
     * Priority:
     * 1. Known Player Containers (YouTube, Bilibili, etc.)
     * 2. Smart Climb: Walk up from video to find nearest ancestor containing
     *    player control buttons (identified by aria-label/title with fullscreen keywords).
     *    This handles sites where video and controls are siblings (Zhihu, Douyu, etc.).
     * 3. Closest <section> (Robust fallback for generic sites like Hupu)
     * 4. Direct Parent (Last resort)
     */
    function getWrapper(v) {
        for (const selector of SITE_CONFIG.wrappers) {
            const w = v.closest(selector);
            if (w) return w;
        }

        // Smart Climb: find nearest ancestor that contains player control buttons
        const controlQuery = [
            'button[aria-label*="全屏"]',
            'button[aria-label*="fullscreen" i]',
            'button[aria-label*="full-screen" i]',
            'button[aria-label*="theater" i]',
            'button[aria-label*="theatre" i]',
            '[title*="全屏"]',
            '[title*="fullscreen" i]',
            '[title*="full-screen" i]',
            '[title*="theater" i]',
            '[title*="theatre" i]'
        ].join(',');

        let el = v.parentElement;
        while (el && el !== document.body) {
            if (el.querySelector(controlQuery)) return el;
            el = el.parentElement;
        }

        // Fallback: Use parent section to ensure OSD visibility and Transform support
        const section = v.closest('section');
        if (section) return section;

        return v.parentElement || v;
    }

    /**
     * Main Fullscreen Toggle Logic.
     */
    function toggleFullscreen(video, mode) {
        const wrapper = getWrapper(video);

        if (mode === 'web') {
            if (video._isWebFullscreen) {
                disableManualWebFullscreen(video);
            } else {
                // Try finding Native "Web Fullscreen" / "Theatre Mode" buttons first
                const btn = findControlBtn(wrapper, SITE_CONFIG.webFullscreen.selectors, SITE_CONFIG.webFullscreen.keywords);
                if (btn) {
                    simulateClick(btn);
                    showOSD(T.webFSNative, video);
                } else {
                    enableManualWebFullscreen(video);
                }
            }
        } else {
            // Native Fullscreen
            if (document.fullscreenElement) {
                if (document.exitFullscreen) document.exitFullscreen();
                else if (document.webkitExitFullscreen) document.webkitExitFullscreen();
                showOSD(T.exitFS, video);
                video.focus();
            } else {
                // 1. Try Known Native Buttons
                const searchRoot = (wrapper === video) ? document : wrapper;
                const btn = findControlBtn(searchRoot, SITE_CONFIG.fullscreen.selectors, SITE_CONFIG.fullscreen.keywords, SITE_CONFIG.fullscreen.exclude);

                if (btn) {
                    simulateClick(btn);
                    showOSD(T.fullscreen, video);
                } else {
                    // 2. Double Click Fallback (Whitelist)
                    const host = window.location.hostname;
                    if (SITE_CONFIG.dblClickWhitelist.some(site => host.includes(site))) {
                        video.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true }));
                        showOSD(T.tryDblClick, video);
                    } else {
                        // 3. API Force Fallback
                        const target = wrapper || video;
                        if (target.requestFullscreen) target.requestFullscreen();
                        else if (target.webkitRequestFullscreen) target.webkitRequestFullscreen();
                        else if (video.requestFullscreen) video.requestFullscreen();

                        showOSD(T.fsAPI, video);
                    }
                }
            }
        }
    }

    // --- Settings UI ---
    function createSettingsUI() {
        if (document.getElementById('lite-video-settings')) return;

        const container = document.createElement('div');
        container.id = 'lite-video-settings';
        container.style.cssText = `
            position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
            background: #222; color: #eee; padding: 20px; border-radius: 8px;
            z-index: 2147483647; box-shadow: 0 0 15px rgba(0,0,0,0.5);
            font-family: sans-serif; min-width: 500px; max-height: 80vh; overflow-y: auto;
        `;

        const form = document.createElement('div');
        form.style.display = 'grid';
        form.style.gridTemplateColumns = '1fr 1fr';
        form.style.columnGap = '10px';
        form.style.rowGap = '6px';

        const descriptions = T.keys;

        const inputs = [];
        let msgSpan = null;

        const clearSettingsMessage = () => {
            if (msgSpan) {
                msgSpan.textContent = '';
            }
        };

        const shakeSettingsPanel = () => {
            container.animate([
                { transform: 'translate(-50%, -50%) translateX(0)' },
                { transform: 'translate(-50%, -50%) translateX(-5px)' },
                { transform: 'translate(-50%, -50%) translateX(5px)' },
                { transform: 'translate(-50%, -50%) translateX(0)' }
            ], { duration: 200 });
        };

        const osdControlsRow = document.createElement('div');
        osdControlsRow.style.cssText = `
            display: flex;
            align-items: center;
            justify-content: space-between;
            gap: 16px;
            margin-bottom: 16px;
            flex-wrap: wrap;
        `;

        // OSD visibility is stored as a top-level preference because it affects
        // every notification, not just shortcut customization.
        const showOSDRow = document.createElement('label');
        showOSDRow.style.cssText = `
            display: flex;
            align-items: center;
            gap: 8px;
            cursor: pointer;
            margin: 0;
        `;

        const showOSDCheckbox = document.createElement('input');
        showOSDCheckbox.type = 'checkbox';
        showOSDCheckbox.checked = config.showOSD !== false;
        showOSDCheckbox.style.margin = '0';

        const showOSDText = document.createElement('span');
        showOSDText.textContent = T.showOSDLabel;

        showOSDRow.appendChild(showOSDCheckbox);
        showOSDRow.appendChild(showOSDText);
        osdControlsRow.appendChild(showOSDRow);

        const osdFontSizeRow = document.createElement('label');
        osdFontSizeRow.style.cssText = `
            display: flex;
            align-items: center;
            gap: 8px;
            margin: 0;
        `;

        const osdFontSizeText = document.createElement('span');
        osdFontSizeText.textContent = T.osdFontSizeLabel;

        const osdFontSizeInput = document.createElement('input');
        osdFontSizeInput.type = 'number';
        osdFontSizeInput.min = String(MIN_OSD_FONT_SIZE);
        osdFontSizeInput.max = String(MAX_OSD_FONT_SIZE);
        osdFontSizeInput.step = '1';
        osdFontSizeInput.value = String(normalizeOSDFontSize(config.osdFontSize, DEFAULT_CONFIG.osdFontSize));
        osdFontSizeInput.style.cssText = 'width: 88px; background: #333; color: white; border: 1px solid #555; padding: 5px; box-sizing: border-box;';

        osdFontSizeRow.appendChild(osdFontSizeText);
        osdFontSizeRow.appendChild(osdFontSizeInput);
        osdControlsRow.appendChild(osdFontSizeRow);
        container.appendChild(osdControlsRow);

        const clearOSDFontSizeInputError = () => {
            osdFontSizeInput.style.border = '1px solid #555';
            osdFontSizeInput.style.backgroundColor = '#333';
            osdFontSizeInput.title = '';
        };

        const setOSDFontSizeInputError = (message) => {
            osdFontSizeInput.style.border = '1px solid #ff4444';
            osdFontSizeInput.style.backgroundColor = '#3e1111';
            osdFontSizeInput.title = message;
            clearSettingsMessage();
            if (msgSpan) {
                msgSpan.textContent = message;
            }
            shakeSettingsPanel();
        };

        const validateOSDFontSizeInput = () => {
            const rawValue = osdFontSizeInput.value;

            if (osdFontSizeInput.validity.badInput) {
                return T.osdFontSizeNaNMsg;
            }

            if (typeof rawValue !== 'string' || rawValue.trim() === '') {
                return T.osdFontSizeEmptyMsg;
            }

            const numericValue = Number(rawValue);
            if (!Number.isFinite(numericValue)) {
                return T.osdFontSizeNaNMsg;
            }

            if (!Number.isInteger(numericValue) || numericValue < MIN_OSD_FONT_SIZE || numericValue > MAX_OSD_FONT_SIZE) {
                return T.osdFontSizeRangeMsg;
            }

            return '';
        };

        clearOSDFontSizeInputError();
        showOSDCheckbox.addEventListener('change', clearSettingsMessage);
        osdFontSizeInput.addEventListener('input', () => {
            clearSettingsMessage();
            clearOSDFontSizeInputError();
        });

        // Conflict Checking Logic (Allocated only when UI is open)
        const checkConflicts = () => {
            const keyMap = new Map();
            const conflicts = new Set();

            inputs.forEach(input => {
                const k = input.value.toLowerCase();
                if (!keyMap.has(k)) keyMap.set(k, []);
                keyMap.get(k).push(input);
            });

            for (const [k, list] of keyMap) {
                if (list.length > 1) {
                    list.forEach(input => conflicts.add(input));
                }
            }

            inputs.forEach(input => {
                if (conflicts.has(input)) {
                    input.style.border = '1px solid #ff4444';
                    input.style.backgroundColor = '#3e1111';
                    input.title = T.conflict;
                } else {
                    input.style.border = '1px solid #555';
                    input.style.backgroundColor = '#333';
                    input.title = '';
                }
            });

            return conflicts.size > 0;
        };

        Object.entries(descriptions).forEach(([key, desc]) => {
            // Skip config keys irrelevant to UI if any

            const label = document.createElement('label');
            label.textContent = desc;
            const input = document.createElement('input');
            input.type = 'text';
            input.value = config.keys[key] || '';
            input.style.cssText = 'width: 100%; background: #333; color: white; border: 1px solid #555; padding: 5px; box-sizing: border-box;';
            input.dataset.key = key;

            input.addEventListener('keydown', (e) => {
                e.preventDefault();
                let keyStr = '';
                if (e.shiftKey) keyStr += 'Shift+';
                if (e.ctrlKey) keyStr += 'Ctrl+';
                if (e.altKey) keyStr += 'Alt+';
                let k = e.key;
                if (k === ' ') k = 'Space';
                if (['Shift', 'Control', 'Alt'].includes(k)) return;

                keyStr += k.length === 1 ? k.toLowerCase() : k;
                input.value = keyStr;

                clearSettingsMessage();
                checkConflicts();
            });

            const div = document.createElement('div');
            div.appendChild(label);
            div.appendChild(input);
            form.appendChild(div);
            inputs.push(input);
        });

        container.appendChild(form);

        // Initial check
        checkConflicts();

        // Footer Buttons
        const btnRow = document.createElement('div');
        btnRow.style.marginTop = '20px';
        btnRow.style.textAlign = 'right';
        btnRow.style.display = 'flex';
        btnRow.style.justifyContent = 'space-between';
        btnRow.style.alignItems = 'center';

        msgSpan = document.createElement('span');
        msgSpan.style.color = '#ff4444';
        msgSpan.style.fontSize = '12px';
        btnRow.appendChild(msgSpan);

        const btns = document.createElement('div');

        const saveBtn = document.createElement('button');
        saveBtn.textContent = T.save;
        saveBtn.style.cssText = 'padding: 8px 16px; background: #4CAF50; color: white; border: none; cursor: pointer; border-radius: 4px;';
        saveBtn.onclick = () => {
            clearSettingsMessage();
            clearOSDFontSizeInputError();

            const hasConflict = checkConflicts();
            if (hasConflict) {
                msgSpan.textContent = T.conflictMsg;
                shakeSettingsPanel();
                return;
            }

            const osdFontSizeError = validateOSDFontSizeInput();
            if (osdFontSizeError) {
                setOSDFontSizeInputError(osdFontSizeError);
                return;
            }

            const newKeys = { ...config.keys };
            inputs.forEach(i => newKeys[i.dataset.key] = i.value);
            config.keys = newKeys;
            config.showOSD = showOSDCheckbox.checked;
            config.osdFontSize = Number(osdFontSizeInput.value);
            GM_setValue(CONFIG_STORAGE_KEY, config);
            document.body.removeChild(container);
            showOSD(T.saved);
        };

        const cancelBtn = document.createElement('button');
        cancelBtn.textContent = T.cancel;
        cancelBtn.style.cssText = 'padding: 8px 16px; background: #666; color: white; border: none; margin-right: 10px; cursor: pointer; border-radius: 4px;';
        cancelBtn.onclick = () => document.body.removeChild(container);

        btns.appendChild(cancelBtn);
        btns.appendChild(saveBtn);
        btnRow.appendChild(btns);

        container.appendChild(btnRow);
        document.body.appendChild(container);
    }

    const videoObserver = new MutationObserver((mutations) => {
        for (let i = 0; i < mutations.length; i++) {
            const addedNodes = mutations[i].addedNodes;
            for (let j = 0; j < addedNodes.length; j++) {
                if (nodeMayAffectRememberedSpeed(addedNodes[j])) {
                    queueRememberedSpeedSubtree(addedNodes[j]);
                }
            }
        }
    });

    let isRememberedSpeedObserverStarted = false;

    function startRememberedSpeedSync() {
        if (!document.documentElement || isRememberedSpeedObserverStarted) {
            return;
        }

        isRememberedSpeedObserverStarted = true;
        observeMutationRoot(document.documentElement);
        observeShadowRootsInSubtree(document.documentElement);
        queueRememberedSpeedSubtree(document.documentElement);
    }

    startRememberedSpeedSync();
    document.addEventListener('readystatechange', startRememberedSpeedSync);
    document.addEventListener('DOMContentLoaded', () => {
        queueRememberedSpeedSubtree(document.documentElement);
    }, { once: true });

    // --- Global Keyboard Event Listener ---
    document.addEventListener('keydown', (e) => {
        if (e[SYNTHETIC_KEY_EVENT_FLAG]) {
            return;
        }

        // Prevent triggering controls when typing in input fields
        const tag = document.activeElement.tagName;
        if (['INPUT', 'TEXTAREA', 'SELECT'].includes(tag) || document.activeElement.isContentEditable) {
            return;
        }

        // Build Key String
        let keyStr = '';
        if (e.shiftKey) keyStr += 'Shift+';
        if (e.ctrlKey) keyStr += 'Ctrl+';
        if (e.metaKey) keyStr += 'Meta+';
        if (e.altKey) keyStr += 'Alt+';

        let k = e.key;
        if (k === ' ') k = 'Space';
        if (k.length === 1) k = k.toLowerCase(); // Case insensitive for single letters

        keyStr += k;

        // Match Key to Action
        // Use loop to find action since config.keys is an object
        const actionEntry = Object.entries(config.keys).find(([_, val]) => val.toLowerCase() === keyStr.toLowerCase());

        if (actionEntry) {
            const video = getActiveVideo();
            if (!video) return;

            // Stop browser default handling for these shortcuts
            e.preventDefault();
            e.stopPropagation();

            const action = actionEntry[0];
            switch (action) {
                case 'seekForward': adjustSeek(video, config.seekSmall); break;
                case 'seekBackward': adjustSeek(video, -config.seekSmall); break;
                case 'seekForwardLarge': adjustSeek(video, config.seekLarge); break;
                case 'seekBackwardLarge': adjustSeek(video, -config.seekLarge); break;
                case 'volUp': adjustVolume(video, config.volSmall); break;
                case 'volDown': adjustVolume(video, -config.volSmall); break;
                case 'mute': toggleMute(video); break;
                case 'speedUp': adjustSpeed(video, 'up'); break;
                case 'speedDown': adjustSpeed(video, 'down'); break;
                case 'speedReset': adjustSpeed(video, 'reset'); break;
                case 'speed1': adjustSpeed(video, 1.0); break;
                case 'speed2': adjustSpeed(video, 1.3); break;
                case 'speed3': adjustSpeed(video, 1.5); break;
                case 'speed4': adjustSpeed(video, 2.0); break;
                case 'fullscreen': toggleFullscreen(video, 'native'); break;
                case 'webFullscreen': toggleFullscreen(video, 'web'); break;
                case 'rotate': rotateVideo(video); break;
                case 'mirror': toggleMirror(video); break;
                case 'pip': togglePiP(video); break;
                case 'nextVideo': clickControlBtn(video, 'next'); break;
                case 'prevVideo': clickControlBtn(video, 'prev'); break;
            }
        }
    }, { capture: true }); // Capture phase to override site events

    // Register Menu
    GM_registerMenuCommand(T.menuSettings, createSettingsUI);

})();