Lite H5 Video Control

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

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey, Greasemonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

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

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल..

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

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला 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);

})();