Lite version of video control script. Supports: Seek, Volume, Speed, Fullscreen, PiP, OSD, Rotate, Mirror, Mute.
// ==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);
})();