Added single-entry settings menu, long-press functions, Global/Independent settings toggle, Scroll range reduction, Partial feature disable. Key5 long-press toggle speed, Key1/3 long-press non-linear acceleration. Added initial volume memory, video switch focus. Volume save debounce, clear settings. Force unmute protection. Fixed volume modifier key support for wheel events. Optimized long-press detection time and Key 1/3 repeat rate.
// ==UserScript==
// @name Scroll Volume Dx Edition
// @name:zh-TW 滾動音量 Dx 版
// @name:zh-CN 滚动音量 Dx 版
// @namespace http://tampermonkey.net/
// @version 10.38
// @description:zh-TW 新增單一入口設定選單,長按功能、全域/獨立設定切換、滾輪範圍縮減、局部功能停用。Key5 長按來回變速,Key1/3 長按非線性加速。新增初始音量記憶、影片切換焦點。音量儲存防抖、設定清除功能。強制解除靜音保護。修復音量修飾鍵於滾輪事件之支援。優化長按判定時間與按鍵1/3重複頻率。
// @description:zh-CN 新增单一入口设置菜单,长按功能、全域/独立设定切换、滚轮范围缩减、局部功能停用。Key5 长按来回变速,Key1/3 长按非线性加速。新增初始音量记忆、影片切换焦点。音量储存防抖、设定清除功能。强制解除静音保护。修复音量修饰键于滚轮事件之支持。优化长按判定时间与按键1/3重复频率。
// @description Added single-entry settings menu, long-press functions, Global/Independent settings toggle, Scroll range reduction, Partial feature disable. Key5 long-press toggle speed, Key1/3 long-press non-linear acceleration. Added initial volume memory, video switch focus. Volume save debounce, clear settings. Force unmute protection. Fixed volume modifier key support for wheel events. Optimized long-press detection time and Key 1/3 repeat rate.
// @match https://*/*
// @match https://www.youtube.com/*
// @match https://www.youtube-nocookie.com/*
// @match https://www.bilibili.com/*
// @match https://live.bilibili.com/*
// @match https://www.twitch.tv/*
// @match https://store.steampowered.com/*
// @match https://www.facebook.com/*
// @exclude https://www.youtube.com/live_chat*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_registerMenuCommand
// @license MIT
// ==/UserScript==
(function() {
'use strict';
const LANG = /^zh-(cn|tw|hk|mo|sg)/i.test(navigator.language) ? 'zh' : 'en';
const i18n = {
zh: {
menuTitle: '🚀 滾動音量 Dx 設定選單',
menuMode: '1. 切換設定模式 (當前:',
menuStep: '2. 步進設定',
menuInitVol: '3. 初始音量開關 (當前:',
menuVolume: '4. 音量設定',
menuKeySettings: '5. 按鍵設定',
menuScrollRange: '6. 滾輪調節範圍 (%)',
menuFullscreen: '7. 全螢幕模式',
menuDisable: '8. 停用功能',
menuClear: '9. 清除設定',
menuExit: '0. 退出設定',
modeGlobal: '全域)',
modeDomain: '獨立)',
promptConfirm: '設定已保存',
promptClearConfirm: '確定清除當前模式設定?',
promptCleared: '設定已清除',
keySettingsMenu: '按鍵設定選單\n1. 設定鍵 1/3 (變速)\n2. 設定鍵 4 (倒退)\n3. 設定鍵 6 (加速)\n4. 設定鍵 5 (來回變速)\n5. 設定鍵 7/9 (長按行為)\n6. 設定鍵 / * (切換行為)\n7. 所有鍵長按判定時間 (毫秒)\n0. 返回',
lp13Menu: '鍵 1/3 變速設定 (當前:',
lp13Opts: { 1: '1. 固定倍率 (每次 10%)', 2: '2. 預定義加速 (分段)', 3: '3. 自定義加減速' },
lp13Custom: '輸入自定義倍率 (逗號分隔):',
lp13Interval: '鍵 1/3 長按間隔 (毫秒):',
lp4Step: '按鍵 4 倒退步進 (秒)',
lp4Interval: '按鍵 4 倒退間隔 (毫秒)',
lp6Start: '按鍵 6 起始倍率 (1.0=正常)',
lp6Interval: '按鍵 6 加速間隔 (毫秒)',
lp6Step: '按鍵 6 逐步加速倍率 (0=禁用自動加速 1=100%)',
lp6Max: '按鍵 6 加速上限倍率 (0=無上限)',
lp5Rate: '按鍵 5 長按變速倍率',
lp79Menu: '鍵 7/9 長按行為設定 (當前:',
lp79Opts: { 1: '1. 重複 X 倍長步進', 2: '2. 切換播放清單', 3: '3. 網頁返回' },
lp79Interval: '重複間隔 (毫秒)',
lp79Mult: '長步進倍率 (X 倍):',
divMulMenu: '鍵 / * 切換行為設定 (當前:',
divMulOpts: { 1: '1. 切換播放清單', 2: '2. 切換影片元素', 3: '3. 網頁返回' },
lpDetectTime: '所有鍵長按判定時間 (毫秒) [建議不低於 150ms]:',
disableMenu: '停用功能選單\n1. 停用滾輪',
disableMenu2: '\n2. 停用鍵盤',
disableMenu3: '\n3. 停用指定鍵',
disableMenu0: '\n0. 返回',
keyMapPrompt: '輸入停用鍵編號 (逗號分隔):\n0:Numpad0(切換倍率), 1:Numpad1(減速), 2:Numpad2(音量 -)\n3:Numpad3(加速), 4:Numpad4(倒退), 5:Numpad5(播放)\n6:Numpad6(前進), 7:Numpad7(長步進), 8:Numpad8(音量 +)\n9:Numpad9(長步進), 10:Space(播放), 11:Enter(全螢幕)\n12:+(進度 +), 13:-(進度 -), 14:Left, 15:Right\n16:Up, 17:Down, 18:NumpadDivide(上一影片), 19:NumpadMultiply(下一影片)',
modifierPrompt: '選擇音量微調修飾鍵:',
modifierOptions: { 1: '1. Alt 鍵', 2: '2. Ctrl 鍵', 3: '3. Shift 鍵', 4: '4. Meta 鍵 (⌘)', 5: '5. 關閉此功能' },
fullscreenPrompt: '選擇全螢幕模式:',
fullscreenOpts: { 1: '1. 原生按鈕點擊', 2: '2. 原生 API 切換', 3: '3. 網頁全螢幕 (Web Fullscreen)' },
stepMenu: '步進設定\n1. 步進 (秒): ',
stepMenuLong: '\n2. 長步進 (秒): ',
volumeMenu: '音量設定',
volumeStep: '1. 音量步進 (%): ',
volumeModifier: '2. 音量修飾鍵',
volumeInitVal: '3. 初始音量預設值 (%)',
volumeLocked: ' (🔒 平台衝突,強制關閉)',
statusOn: '啟用)',
statusOff: '停用)',
videoSwitched: '已切換至影片 %d/%d',
fsModeWarning: '注意:此平台不支持模式 1,將自動使用模式 2,但不會修改您的全域設定值',
divMulModeWarning: '注意:此平台不支持播放清單切換,將自動使用影片元素切換,但不會修改您的全域設定值'
},
en: {
menuTitle: '🚀 Scroll Volume Dx Settings',
menuMode: '1. Toggle Setting Mode (Current: ',
menuStep: '2. Step Settings',
menuInitVol: '3. Initial Volume Toggle (Current: ',
menuVolume: '4. Volume Settings',
menuKeySettings: '5. Key Settings',
menuScrollRange: '6. Scroll Trigger Range (%)',
menuFullscreen: '7. Fullscreen Mode',
menuDisable: '8. Disable Features',
menuClear: '9. Clear Settings',
menuExit: '0. Exit',
modeGlobal: 'Global)',
modeDomain: 'Independent)',
promptConfirm: 'Settings Saved',
promptClearConfirm: 'Confirm clear current mode settings?',
promptCleared: 'Settings Cleared',
keySettingsMenu: 'Key Settings Menu\n1. Key 1/3 (Rate)\n2. Key 4 (Rewind)\n3. Key 6 (Speedup)\n4. Key 5 (Toggle Speed)\n5. Key 7/9 (Long Press)\n6. Key / * (Switch)\n7. Long-press Detect Time (ms)\n0. Back',
lp13Menu: 'Key 1/3 Rate Settings (Current: ',
lp13Opts: { 1: '1. Fixed Rate (10% per step)', 2: '2. Pre-defined Acceleration', 3: '3. Custom Rates' },
lp13Custom: 'Enter custom rates (comma separated):',
lp13Interval: 'Key 1/3 Long-press Interval (ms):',
lp4Step: 'Key 4 rewind step (seconds)',
lp4Interval: 'Key 4 rewind interval (milliseconds)',
lp6Start: 'Key 6 start rate (1.0=normal)',
lp6Interval: 'Key 6 acceleration interval (milliseconds)',
lp6Step: 'Key 6 rate step per interval (0=disable auto-accel, 1=100%)',
lp6Max: 'Key 6 max rate limit (0=no limit)',
lp5Rate: 'Key 5 long-press toggle rate',
lp79Menu: 'Key 7/9 Long Press Behavior (Current: ',
lp79Opts: { 1: '1. Repeat X times Long Step', 2: '2. Switch Playlist', 3: '3. Browser Back/Forward' },
lp79Interval: 'Repeat Interval (ms)',
lp79Mult: 'Long Step Multiplier (X times):',
divMulMenu: 'Key / * Switch Behavior (Current: ',
divMulOpts: { 1: '1. Switch Playlist', 2: '2. Switch Video Element', 3: '3. Browser Back/Forward' },
lpDetectTime: 'All Keys Long-press Detect Time (ms) [Recommended >= 150ms]:',
disableMenu: 'Disable Features\n1. Disable Wheel',
disableMenu2: '\n2. Disable Keyboard',
disableMenu3: '\n3. Disable Specific Keys',
disableMenu0: '\n0. Back',
keyMapPrompt: 'Enter key IDs to disable (comma separated):\n0:Numpad0(Toggle Rate), 1:Numpad1(Rate-), 2:Numpad2(Vol-)\n3:Numpad3(Rate+), 4:Numpad4(Rewind), 5:Numpad5(Play)\n6:Numpad6(Forward), 7:Numpad7(Long Step), 8:Numpad8(Vol+)\n9:Numpad9(Long Step), 10:Space(Play), 11:Enter(Fullscreen)\n12:+(Prog+), 13:-(Prog-), 14:Left, 15:Right\n16:Up, 17:Down, 18:NumpadDivide(Prev Video), 19:NumpadMultiply(Next Video)',
modifierPrompt: 'Select volume modifier key:',
modifierOptions: { 1: '1. Alt key', 2: '2. Ctrl key', 3: '3. Shift key', 4: '4. Meta key (⌘)', 5: '5. Disable feature' },
fullscreenPrompt: 'Select fullscreen mode:',
fullscreenOpts: { 1: '1. Native Button Click', 2: '2. Native API toggle', 3: '3. Web Fullscreen' },
stepMenu: 'Step Settings\n1. Step Time (s): ',
stepMenuLong: '\n2. Long Step Time (s): ',
volumeMenu: 'Volume Settings',
volumeStep: '1. Volume Step (%): ',
volumeModifier: '2. Volume Modifier',
volumeInitVal: '3. Initial Volume Default (%)',
volumeLocked: ' (🔒 Platform conflict, forced off)',
statusOn: 'ON)',
statusOff: 'OFF)',
videoSwitched: 'Switched to video %d/%d',
fsModeWarning: 'Note: This platform does not support mode 1, will use mode 2 automatically without modifying your global setting',
divMulModeWarning: 'Note: This platform does not support playlist switch, will use video element switch automatically without modifying your global setting'
}
};
const STORAGE_MODE_KEY = 'SV_SettingsMode';
const STORAGE_GLOBAL_KEY = 'SV_Config';
const STORAGE_DOMAIN_INIT_VOL = 'SV_DomainInitVol';
const getDomainId = () => location.hostname.split('.').slice(-2).join('_');
const STORAGE_DOMAIN_KEY = (domain) => `SV_Config_${domain}`;
const DEFAULT_CONFIG = {
stepTime: 5,
stepTimeLong: 30,
stepVolume: 10,
modifierKey: 5,
fineVolumeStep: 1,
fullscreenMode: 1,
scrollTriggerRange: 100,
disableWheel: false,
disableKeyboard: false,
disabledKeys: [],
longPress4Step: 1,
longPress4Interval: 100,
longPress6StartRate: 2.0,
longPress6Interval: 1000,
longPress6Step: 1.0,
longPress6MaxRate: 10,
longPress5SlowRate: 0.5,
longPress13Mode: 1,
longPress13CustomRates: '0.5,1.0,1.5,2.0',
longPress13Acceleration: 0.1,
longPress13Interval: 100,
longPress79Mode: 1,
longPress79Interval: 500,
longPress79Multiplier: 1,
longPressDivMulMode: 1,
initialVolumeEnabled: false,
initialVolumeValue: 10,
longPressDetectTime: 200
};
let CONFIG = {};
let isDomainMode = false;
let globalConfig = {};
let domainInitVols = {};
let volumeSaveTimer = null;
let lastRectUpdate = 0;
const isBilibili = /www\.bilibili\.com/.test(location.hostname);
const isSteam = /steam(community|powered)\.com/.test(location.hostname);
const isVolumeLocked = isBilibili || isSteam;
const PLATFORM = (() => {
const host = location.hostname;
if (/youtube\.com|youtu\.be|youtube-nocookie\.com/.test(host)) return "YOUTUBE";
if (/www\.bilibili\.com/.test(host)) return "BILIBILI";
if (/twitch\.tv/.test(host)) return "TWITCH";
if (/steam(community|powered)\.com/.test(host)) return "STEAM";
if (/facebook\.com|fb\.watch/.test(host)) return "FACEBOOK";
return "GENERIC";
})();
let cachedVideo = null;
let lastVideoCheck = 0;
let videoElements = [];
let currentVideoIndex = 0;
let activeVideoId = null;
const videoStateMap = new WeakMap();
const generateVideoId = (video) => `${video.nodeName}_${video.src}_${video.clientWidth}x${video.clientHeight}`;
function getStorageKey() {
return isDomainMode ? STORAGE_DOMAIN_KEY(getDomainId()) : STORAGE_GLOBAL_KEY;
}
function loadConfig() {
const mode = GM_getValue(STORAGE_MODE_KEY, {});
const domain = getDomainId();
isDomainMode = (mode[domain] === true);
globalConfig = { ...DEFAULT_CONFIG, ...(GM_getValue(STORAGE_GLOBAL_KEY, {}) || {}) };
domainInitVols = GM_getValue(STORAGE_DOMAIN_INIT_VOL, {});
if (isDomainMode) {
const domainConfig = GM_getValue(STORAGE_DOMAIN_KEY(domain), {});
CONFIG = { ...globalConfig, ...domainConfig };
} else {
CONFIG = { ...globalConfig };
}
if (isVolumeLocked) {
CONFIG.initialVolumeEnabled = false;
}
}
function saveConfig() {
if (isDomainMode) {
const domain = getDomainId();
const toSave = {};
Object.keys(CONFIG).forEach(k => {
if (CONFIG[k] !== globalConfig[k]) toSave[k] = CONFIG[k];
});
GM_setValue(STORAGE_DOMAIN_KEY(domain), toSave);
} else {
const toSave = {};
Object.keys(CONFIG).forEach(k => {
if (CONFIG[k] !== DEFAULT_CONFIG[k]) toSave[k] = CONFIG[k];
});
GM_setValue(STORAGE_GLOBAL_KEY, toSave);
globalConfig = { ...DEFAULT_CONFIG, ...toSave };
}
}
function saveDomainInitVol(vol) {
if (isVolumeLocked) return;
const domain = getDomainId();
domainInitVols[domain] = vol;
if (volumeSaveTimer) clearTimeout(volumeSaveTimer);
volumeSaveTimer = setTimeout(() => {
GM_setValue(STORAGE_DOMAIN_INIT_VOL, domainInitVols);
}, 500);
}
function getDomainInitVol() {
if (isVolumeLocked) return CONFIG.initialVolumeValue;
const domain = getDomainId();
return domainInitVols[domain] !== undefined ? domainInitVols[domain] : CONFIG.initialVolumeValue;
}
function clearSettings() {
if (confirm(i18n[LANG].promptClearConfirm)) {
if (isDomainMode) {
GM_setValue(STORAGE_DOMAIN_KEY(getDomainId()), {});
} else {
GM_setValue(STORAGE_GLOBAL_KEY, {});
}
loadConfig();
alert(i18n[LANG].promptCleared);
}
}
function toggleConfigMode() {
const domain = getDomainId();
const modes = GM_getValue(STORAGE_MODE_KEY, {});
modes[domain] = !isDomainMode;
GM_setValue(STORAGE_MODE_KEY, modes);
isDomainMode = !isDomainMode;
loadConfig();
alert(i18n[LANG].promptConfirm);
}
function openMainMenu() {
let loop = true;
while (loop) {
const t = i18n[LANG];
const modeText = isDomainMode ? t.modeDomain : t.modeGlobal;
const initVolStatus = CONFIG.initialVolumeEnabled ? t.statusOn : t.statusOff;
const lockedText = isVolumeLocked ? t.volumeLocked : '';
const menuStr = `${t.menuTitle}\n${t.menuMode}${modeText}\n${t.menuStep}\n${t.menuInitVol}${initVolStatus}${lockedText}\n${t.menuVolume}\n${t.menuKeySettings}\n${t.menuScrollRange}\n${t.menuFullscreen}\n${t.menuDisable}\n${t.menuClear}\n${t.menuExit}`;
const choice = prompt(menuStr, '0');
if (choice === null) break;
switch (choice) {
case '1': toggleConfigMode(); break;
case '2': showStepMenu(); break;
case '3':
if (!isVolumeLocked) {
CONFIG.initialVolumeEnabled = !CONFIG.initialVolumeEnabled;
saveConfig();
alert(t.promptConfirm);
} else {
alert('此平台因衝突強制關閉此功能');
}
break;
case '4': showVolumeMenu(); break;
case '5': showKeySettings(); break;
case '6': setNumConfig('scrollTriggerRange', t.menuScrollRange, 0, 100); break;
case '7': setFullscreenMode(); break;
case '8': showDisableMenu(); break;
case '9': clearSettings(); break;
case '0': loop = false; break;
}
}
}
function showStepMenu() {
const t = i18n[LANG];
const stepVal = prompt(`${t.stepMenu}${CONFIG.stepTime}`, CONFIG.stepTime);
if (stepVal !== null && !isNaN(stepVal)) {
CONFIG.stepTime = Math.max(0, parseFloat(stepVal));
const stepLongVal = prompt(`${t.stepMenu}${CONFIG.stepTime}${t.stepMenuLong}`, CONFIG.stepTimeLong);
if (stepLongVal !== null && !isNaN(stepLongVal)) {
CONFIG.stepTimeLong = Math.max(0, parseFloat(stepLongVal));
saveConfig();
alert(t.promptConfirm);
}
}
}
function showVolumeMenu() {
let loop = true;
while (loop) {
const t = i18n[LANG];
const choice = prompt(`${t.volumeMenu}\n${t.volumeStep}${CONFIG.stepVolume}\n${t.volumeModifier}\n${t.volumeInitVal}\n0. 返回`, '0');
if (choice === null) break;
switch (choice) {
case '1':
const stepVal = prompt(t.volumeStep, CONFIG.stepVolume);
if (stepVal !== null && !isNaN(stepVal)) {
CONFIG.stepVolume = Math.max(0, Math.min(100, parseFloat(stepVal)));
saveConfig();
alert(t.promptConfirm);
}
break;
case '2':
const modChoice = prompt(`${t.modifierPrompt}\n${Object.values(t.modifierOptions).join('\n')}`, CONFIG.modifierKey);
if (modChoice && t.modifierOptions[modChoice]) {
CONFIG.modifierKey = parseInt(modChoice);
const fine = prompt('Set Fine Volume Step (%)', CONFIG.fineVolumeStep);
if (fine && !isNaN(fine)) CONFIG.fineVolumeStep = parseFloat(fine);
saveConfig();
alert(t.promptConfirm);
}
break;
case '3':
const val = prompt(t.volumeInitVal, CONFIG.initialVolumeValue);
if (val !== null && !isNaN(val)) {
CONFIG.initialVolumeValue = Math.max(0, Math.min(100, parseFloat(val)));
saveConfig();
alert(t.promptConfirm);
}
break;
case '0': loop = false; break;
}
}
}
function setNumConfig(key, promptText, min = 0, max = 9999) {
const val = prompt(promptText, CONFIG[key]);
if (val !== null && !isNaN(val)) {
let num = parseFloat(val);
if (num < min) num = min;
if (num > max) num = max;
CONFIG[key] = num;
saveConfig();
alert(i18n[LANG].promptConfirm);
}
}
function setFullscreenMode() {
const t = i18n[LANG];
const handler = PLATFORM_HANDLERS[PLATFORM];
let options = {};
if (handler.hasNativeFullscreenBtn) {
options[1] = t.fullscreenOpts[1];
}
options[2] = t.fullscreenOpts[2];
options[3] = t.fullscreenOpts[3];
const currentMode = CONFIG.fullscreenMode;
const displayMode = options[currentMode] ? currentMode : 2;
const choice = prompt(`${t.fullscreenPrompt}\n${Object.values(options).join('\n')}${!handler.hasNativeFullscreenBtn && currentMode === 1 ? '\n' + t.fsModeWarning : ''}`, displayMode);
if (choice && options[choice]) {
CONFIG.fullscreenMode = parseInt(choice);
saveConfig();
alert(t.promptConfirm);
}
}
function showKeySettings() {
let loop = true;
while (loop) {
const t = i18n[LANG];
const handler = PLATFORM_HANDLERS[PLATFORM];
const choice = prompt(t.keySettingsMenu, '0');
if (choice === null) break;
switch (choice) {
case '1':
const mode13 = prompt(`${t.lp13Menu}${CONFIG.longPress13Mode})\n${Object.values(t.lp13Opts).join('\n')}`, CONFIG.longPress13Mode);
if (mode13 && t.lp13Opts[mode13]) {
CONFIG.longPress13Mode = parseInt(mode13);
if (CONFIG.longPress13Mode === 3) {
const rates = prompt(t.lp13Custom, CONFIG.longPress13CustomRates);
if (rates) CONFIG.longPress13CustomRates = rates;
}
const interval13 = prompt(t.lp13Interval, CONFIG.longPress13Interval);
if (interval13 !== null && !isNaN(interval13)) {
CONFIG.longPress13Interval = Math.max(50, parseInt(interval13));
}
saveConfig();
alert(t.promptConfirm);
}
break;
case '2':
const s4 = prompt(t.lp4Step, CONFIG.longPress4Step);
const i4 = prompt(t.lp4Interval, CONFIG.longPress4Interval);
if (s4 && i4) { CONFIG.longPress4Step = parseFloat(s4); CONFIG.longPress4Interval = parseFloat(i4); saveConfig(); alert(t.promptConfirm); }
break;
case '3':
const r6 = prompt(t.lp6Start, CONFIG.longPress6StartRate);
const i6 = prompt(t.lp6Interval, CONFIG.longPress6Interval);
const s6 = prompt(t.lp6Step, CONFIG.longPress6Step);
const m6 = prompt(t.lp6Max, CONFIG.longPress6MaxRate);
if (r6 && i6 && s6 && m6 !== null) {
CONFIG.longPress6StartRate = parseFloat(r6);
CONFIG.longPress6Interval = parseFloat(i6);
CONFIG.longPress6Step = parseFloat(s6);
CONFIG.longPress6MaxRate = parseFloat(m6);
saveConfig(); alert(t.promptConfirm);
}
break;
case '4':
const r5 = prompt(t.lp5Rate, CONFIG.longPress5SlowRate);
if (r5) { CONFIG.longPress5SlowRate = parseFloat(r5); saveConfig(); alert(t.promptConfirm); }
break;
case '5':
const mode79 = prompt(`${t.lp79Menu}${CONFIG.longPress79Mode})\n${Object.values(t.lp79Opts).join('\n')}`, CONFIG.longPress79Mode);
if (mode79 && t.lp79Opts[mode79]) {
CONFIG.longPress79Mode = parseInt(mode79);
const interval = prompt(t.lp79Interval, CONFIG.longPress79Interval);
if (interval && !isNaN(interval)) CONFIG.longPress79Interval = parseInt(interval);
if (CONFIG.longPress79Mode === 1) {
const mult = prompt(t.lp79Mult, CONFIG.longPress79Multiplier);
if (mult && !isNaN(mult)) CONFIG.longPress79Multiplier = parseFloat(mult);
}
saveConfig(); alert(t.promptConfirm);
}
break;
case '6':
const availableOpts = {};
if (handler.hasPlaylist) { availableOpts[1] = t.divMulOpts[1]; }
availableOpts[2] = t.divMulOpts[2];
availableOpts[3] = t.divMulOpts[3];
const currentMode = CONFIG.longPressDivMulMode;
const displayMode = availableOpts[currentMode] ? currentMode : 2;
const modeDM = prompt(`${t.divMulMenu}${displayMode})\n${Object.values(availableOpts).join('\n')}${!handler.hasPlaylist && currentMode === 1 ? '\n' + t.divMulModeWarning : ''}`, displayMode);
if (modeDM && availableOpts[modeDM]) {
CONFIG.longPressDivMulMode = parseInt(modeDM);
saveConfig();
alert(t.promptConfirm);
}
break;
case '7':
const detTime = prompt(t.lpDetectTime, CONFIG.longPressDetectTime);
if (detTime !== null && !isNaN(detTime)) {
let num = parseInt(detTime);
if (num < 150) {
if (!confirm('警告:設定低於 150ms 可能導致誤觸短按與長按切換,是否繼續?')) break;
}
CONFIG.longPressDetectTime = Math.max(50, num);
saveConfig();
alert(t.promptConfirm);
}
break;
case '0': loop = false; break;
}
}
}
function showDisableMenu() {
let loop = true;
while (loop) {
const t = i18n[LANG];
const wStatus = CONFIG.disableWheel ? '(Enabled)' : '(Disabled)';
const kStatus = CONFIG.disableKeyboard ? '(Enabled)' : '(Disabled)';
const choice = prompt(`${t.disableMenu}${wStatus}${t.disableMenu2}${kStatus}${t.disableMenu3}${t.disableMenu0}`, '0');
if (choice === null) break;
switch (choice) {
case '1': CONFIG.disableWheel = !CONFIG.disableWheel; saveConfig(); alert(t.promptConfirm); break;
case '2': CONFIG.disableKeyboard = !CONFIG.disableKeyboard; saveConfig(); alert(t.promptConfirm); break;
case '3':
const keys = prompt(t.keyMapPrompt, CONFIG.disabledKeys.join(','));
if (keys !== null) {
CONFIG.disabledKeys = keys.split(',').map(k => parseInt(k.trim())).filter(k => !isNaN(k));
saveConfig();
alert(t.promptConfirm);
}
break;
case '0': loop = false; break;
}
}
}
function getVideoState(video) {
if (!videoStateMap.has(video)) {
videoStateMap.set(video, { lastCustomRate: 1.0, isDefaultRate: true, lastManualRate: 1.0 });
}
return videoStateMap.get(video);
}
function setActiveVideo(video, autoPlay = true) {
if (!video) return false;
const idx = videoElements.indexOf(video);
if (idx !== -1) {
currentVideoIndex = idx;
activeVideoId = generateVideoId(video);
cachedVideo = video;
lastVideoCheck = performance.now();
applyInitialVolume(video);
if (autoPlay && video.paused && PLATFORM !== 'BILIBILI') {
video.play().catch(() => {});
}
showVolume(video.volume * 100, video);
return true;
}
return false;
}
function applyInitialVolume(video) {
if (!CONFIG.initialVolumeEnabled || !video) return;
const vol = getDomainInitVol();
if (video.muted) {
video.muted = false;
}
if (Math.abs(video.volume - vol / 100) > 0.01) {
video.volume = vol / 100;
}
showVolume(vol, video);
}
function getVideoElement() {
updateVideoElements();
if (activeVideoId) {
const activeVideo = videoElements.find(v => generateVideoId(v) === activeVideoId);
if (activeVideo && document.contains(activeVideo)) { cachedVideo = activeVideo; return cachedVideo; }
}
if (cachedVideo && document.contains(cachedVideo) && (performance.now() - lastVideoCheck < 300)) { return cachedVideo; }
const handler = PLATFORM_HANDLERS[PLATFORM] || PLATFORM_HANDLERS.GENERIC;
cachedVideo = handler.getVideo();
lastVideoCheck = performance.now();
if (cachedVideo) {
const idx = videoElements.indexOf(cachedVideo);
if (idx === -1) {
updateVideoElements();
currentVideoIndex = videoElements.indexOf(cachedVideo);
if (currentVideoIndex === -1) currentVideoIndex = 0;
activeVideoId = generateVideoId(cachedVideo);
} else {
currentVideoIndex = idx;
activeVideoId = generateVideoId(cachedVideo);
}
applyInitialVolume(cachedVideo);
}
return cachedVideo;
}
function updateVideoElements() {
const allVideos = Array.from(document.querySelectorAll('video'));
videoElements = allVideos.filter(v => {
const rect = v.getBoundingClientRect();
if (PLATFORM === 'STEAM') {
return v.offsetParent !== null && rect.width > 50 && rect.height > 50;
}
return v.offsetParent !== null && v.readyState > 0 && rect.width > 50 && rect.height > 50;
});
if (videoElements.length === 0 && allVideos.length > 0) {
videoElements = allVideos;
}
}
function switchToNextVideo() {
if (cachedVideo && !cachedVideo.paused) {
cachedVideo.pause();
}
updateVideoElements();
if (videoElements.length < 2) {
return null;
}
currentVideoIndex = (currentVideoIndex + 1) % videoElements.length;
cachedVideo = videoElements[currentVideoIndex];
activeVideoId = generateVideoId(cachedVideo);
lastVideoCheck = performance.now();
applyInitialVolume(cachedVideo);
if (PLATFORM === 'STEAM') {
cachedVideo.focus();
cachedVideo.click();
}
if (cachedVideo.paused && PLATFORM !== 'BILIBILI') {
cachedVideo.play().catch(() => {});
}
showVolume(i18n[LANG].videoSwitched.replace('%d/%d', `${currentVideoIndex + 1}/${videoElements.length}`), cachedVideo);
return cachedVideo;
}
function switchToPrevVideo() {
if (cachedVideo && !cachedVideo.paused) {
cachedVideo.pause();
}
updateVideoElements();
if (videoElements.length < 2) {
return null;
}
currentVideoIndex = (currentVideoIndex - 1 + videoElements.length) % videoElements.length;
cachedVideo = videoElements[currentVideoIndex];
activeVideoId = generateVideoId(cachedVideo);
lastVideoCheck = performance.now();
applyInitialVolume(cachedVideo);
if (PLATFORM === 'STEAM') {
cachedVideo.focus();
cachedVideo.click();
}
if (cachedVideo.paused && PLATFORM !== 'BILIBILI') {
cachedVideo.play().catch(() => {});
}
showVolume(i18n[LANG].videoSwitched.replace('%d/%d', `${currentVideoIndex + 1}/${videoElements.length}`), cachedVideo);
return cachedVideo;
}
function clampVolume(vol) { return Math.round(Math.max(0, Math.min(100, vol)) * 100) / 100; }
function commonAdjustVolume(video, delta) {
if (!video) return;
if (video.muted) video.muted = false;
const isFineAdjust = Math.abs(delta) === CONFIG.fineVolumeStep;
const actualDelta = isFineAdjust ? delta : (delta > 0 ? CONFIG.stepVolume : -CONFIG.stepVolume);
const newVolume = clampVolume((video.volume * 100) + actualDelta);
video.volume = newVolume / 100;
saveDomainInitVol(newVolume);
showVolume(newVolume, video);
return newVolume;
}
let volumeDisplay = null;
function showVolume(vol, video) {
if (!volumeDisplay) volumeDisplay = createVolumeDisplay();
const text = typeof vol === 'string' ? vol : `${Math.round(vol)}%`;
if (volumeDisplay.textContent === text && volumeDisplay.style.opacity === '1') return;
volumeDisplay.textContent = text;
volumeDisplay.style.opacity = '1';
const now = performance.now();
if (video && video.isConnected && (now - lastRectUpdate > 100)) {
const rect = video.getBoundingClientRect();
volumeDisplay.style.position = 'fixed';
volumeDisplay.style.left = `${rect.left + rect.width / 2}px`;
volumeDisplay.style.top = `${rect.top + rect.height / 2}px`;
volumeDisplay.style.transform = 'translate(-50%, -50%)';
const fontSize = Math.max(12, Math.min(48, Math.floor(rect.width / 20)));
volumeDisplay.style.fontSize = `${fontSize}px`;
lastRectUpdate = now;
} else if (!video) {
volumeDisplay.style.position = 'fixed';
volumeDisplay.style.left = '50%';
volumeDisplay.style.top = '50%';
volumeDisplay.style.transform = 'translate(-50%, -50%)';
volumeDisplay.style.fontSize = '24px';
}
if (volumeDisplay.hideTimer) clearTimeout(volumeDisplay.hideTimer);
volumeDisplay.hideTimer = setTimeout(() => volumeDisplay.style.opacity = '0', 1000);
}
function createVolumeDisplay() {
const display = document.createElement('div');
display.id = 'dynamic-volume-display';
Object.assign(display.style, {
zIndex: 2147483647, padding: '10px 20px', borderRadius: '8px',
backgroundColor: 'rgba(0, 0, 0, 0.7)', color: '#fff', fontFamily: 'Arial, sans-serif',
opacity: '0', transition: 'opacity 0.3s', pointerEvents: 'none', textAlign: 'center'
});
document.body.appendChild(display);
return display;
}
let isWebFullscreened = false;
let originalElement = null;
let originalParent = null;
let originalStyles = {};
let originalParentStyles = {};
let webFullscreenContainer = null;
function toggleWebFullscreen(video, handler) {
if (!video) return;
const fsElement = handler.getFullscreenElement(video);
if (!fsElement) return;
if (isWebFullscreened) {
if (webFullscreenContainer && webFullscreenContainer.contains(fsElement)) { webFullscreenContainer.removeChild(fsElement); }
if (webFullscreenContainer && document.body.contains(webFullscreenContainer)) { document.body.removeChild(webFullscreenContainer); webFullscreenContainer = null; }
if (originalParent && !originalParent.contains(fsElement)) { originalParent.appendChild(fsElement); }
Object.assign(fsElement.style, originalStyles);
if (originalParent) { Object.assign(originalParent.style, originalParentStyles); }
isWebFullscreened = false;
originalElement = null;
originalParent = null;
} else {
originalElement = fsElement;
originalParent = fsElement.parentElement;
if (!originalParent) return;
originalStyles = { position: fsElement.style.position, top: fsElement.style.top, left: fsElement.style.left, width: fsElement.style.width, height: fsElement.style.height, zIndex: fsElement.style.zIndex, objectFit: fsElement.style.objectFit, objectPosition: fsElement.style.objectPosition };
originalParentStyles = { position: originalParent.style.position, overflow: originalParent.style.overflow };
if (!webFullscreenContainer) {
webFullscreenContainer = document.createElement('div');
webFullscreenContainer.id = 'web-fullscreen-container';
Object.assign(webFullscreenContainer.style, {
position: 'fixed', zIndex: '2147483645', backgroundColor: 'black',
display: 'flex', alignItems: 'flex-start', justifyContent: 'center',
top: '0', left: '0', width: '100%', height: '100%',
margin: '0', padding: '0', overflow: 'hidden'
});
}
Object.assign(originalParent.style, { position: 'static', overflow: 'visible' });
originalParent.removeChild(fsElement);
webFullscreenContainer.appendChild(fsElement);
document.body.appendChild(webFullscreenContainer);
fsElement.style.position = 'relative';
fsElement.style.top = ''; fsElement.style.left = '';
fsElement.style.width = '100%'; fsElement.style.height = '100%';
fsElement.style.maxHeight = '100vh'; fsElement.style.zIndex = '';
fsElement.style.objectFit = 'contain'; fsElement.style.objectPosition = 'center';
isWebFullscreened = true;
}
}
function toggleNativeFullscreen(video) {
if (!video) return;
try {
if (document.fullscreenElement) { document.exitFullscreen(); } else {
let elementToFullscreen = video;
for (let i = 0; i < 2; i++) { elementToFullscreen = elementToFullscreen.parentElement || elementToFullscreen; }
elementToFullscreen.requestFullscreen?.() || elementToFullscreen.webkitRequestFullscreen?.() || elementToFullscreen.msRequestFullscreen?.() || video.requestFullscreen?.() || video.webkitRequestFullscreen?.() || video.msRequestFullscreen?.();
}
} catch (e) { console.error('Fullscreen error:', e); }
}
function simulateAltT() {
const video = document.querySelector('.video-ref video') || document.querySelector('video');
if (video) {
video.focus();
video.click();
}
const eventInit = {
key: 't',
code: 'KeyT',
keyCode: 84,
which: 84,
altKey: true,
bubbles: true,
cancelable: true
};
const keydownEvent = new KeyboardEvent('keydown', eventInit);
const keyupEvent = new KeyboardEvent('keyup', eventInit);
const target = video || document.body;
target.dispatchEvent(keydownEvent);
setTimeout(() => {
target.dispatchEvent(keyupEvent);
}, 50);
}
function getEffectiveFullscreenMode(handler) {
if (CONFIG.fullscreenMode === 1 && !handler.hasNativeFullscreenBtn) {
return 2;
}
return CONFIG.fullscreenMode;
}
function getEffectiveDivMulMode(handler) {
if (CONFIG.longPressDivMulMode === 1 && !handler.hasPlaylist) {
return 2;
}
return CONFIG.longPressDivMulMode;
}
const PLATFORM_HANDLERS = {
YOUTUBE: {
getVideo: () => {
const embedVideo = document.querySelector('.html5-main-video.video-stream');
if (embedVideo) return embedVideo;
return document.querySelector('video, ytd-player video') || findVideoInIframes();
},
getFullscreenElement: (video) => document.querySelector('#movie_player') || video,
hasNativeFullscreenBtn: true,
adjustVolume: (video, delta) => {
const ytPlayer = document.querySelector('#movie_player');
if (video) video.muted = false;
if (ytPlayer?.getVolume) {
const currentVol = ytPlayer.getVolume();
const newVol = clampVolume(currentVol + delta);
ytPlayer.setVolume(newVol);
if (video) video.volume = newVol / 100;
saveDomainInitVol(newVol);
showVolume(newVol, video);
} else if (video) { commonAdjustVolume(video, delta); }
},
toggleFullscreen: (video, handler) => {
const effectiveMode = getEffectiveFullscreenMode(handler);
const currentVideo = getVideoElement();
switch (effectiveMode) {
case 1: document.querySelector('.ytp-fullscreen-button')?.click(); break;
case 2: toggleNativeFullscreen(currentVideo); break;
case 3: toggleWebFullscreen(currentVideo, handler); break;
}
},
specialKeys: {
'Space': () => {},
'Numpad7': () => document.querySelector('.ytp-prev-button')?.click(),
'Numpad9': () => document.querySelector('.ytp-next-button')?.click()
},
hasPlaylist: true,
switchPlaylistPrev: () => document.querySelector('.ytp-prev-button')?.click(),
switchPlaylistNext: () => document.querySelector('.ytp-next-button')?.click()
},
BILIBILI: {
getVideo: () => document.querySelector('.bpx-player-video-wrap video') || findVideoInIframes(),
getFullscreenElement: (video) => document.querySelector('.bpx-player-container') || video,
hasNativeFullscreenBtn: true,
adjustVolume: commonAdjustVolume,
toggleFullscreen: (video, handler) => {
const effectiveMode = getEffectiveFullscreenMode(handler);
const currentVideo = getVideoElement();
switch (effectiveMode) {
case 1: document.querySelector('.bpx-player-ctrl-full')?.click(); break;
case 2: toggleNativeFullscreen(currentVideo); break;
case 3:
const webBtnEnter = document.querySelector('.bpx-player-ctrl-web.bpx-player-ctrl-btn:not(.bpx-state-entered)');
const webBtnExit = document.querySelector('.bpx-state-entered.bpx-player-ctrl-web.bpx-player-ctrl-btn');
if (webBtnExit) {
webBtnExit.click();
} else if (webBtnEnter) {
webBtnEnter.click();
}
break;
}
},
specialKeys: {
'Space': () => {},
'Numpad2': () => {},
'Numpad8': () => {},
'Numpad4': () => {},
'Numpad6': () => {},
'Numpad7': () => document.querySelector('.bpx-player-ctrl-prev')?.click(),
'Numpad9': () => document.querySelector('.bpx-player-ctrl-next')?.click()
},
hasPlaylist: true,
switchPlaylistPrev: () => document.querySelector('.bpx-player-ctrl-prev')?.click(),
switchPlaylistNext: () => document.querySelector('.bpx-player-ctrl-next')?.click()
},
TWITCH: {
getVideo: () => document.querySelector('.video-ref video') || findVideoInIframes(),
getFullscreenElement: (video) => video.parentElement || video,
hasNativeFullscreenBtn: true,
adjustVolume: commonAdjustVolume,
toggleFullscreen: (video, handler) => {
const effectiveMode = getEffectiveFullscreenMode(handler);
const currentVideo = getVideoElement();
switch (effectiveMode) {
case 1: document.querySelector('[data-a-target="player-fullscreen-button"]')?.click(); break;
case 2: toggleNativeFullscreen(currentVideo); break;
case 3: simulateAltT(); break;
}
},
specialKeys: {
'Numpad7': () => simulateKeyPress('ArrowLeft'),
'Numpad9': () => simulateKeyPress('ArrowRight')
},
hasPlaylist: false
},
STEAM: {
getVideo: () => {
const videos = Array.from(document.querySelectorAll('video'));
const playingVideo = videos.find(v => v.offsetParent !== null && !v.paused);
if (playingVideo) return playingVideo;
const visibleVideo = videos.find(v => v.offsetParent !== null);
if (visibleVideo) return visibleVideo;
return findVideoInIframes();
},
getFullscreenElement: (video) => {
return video.closest('.game_hover_activated') || video.parentElement || video;
},
hasNativeFullscreenBtn: false,
adjustVolume: commonAdjustVolume,
toggleFullscreen: (video, handler) => {
const effectiveMode = getEffectiveFullscreenMode(handler);
const currentVideo = getVideoElement();
if (!currentVideo) return;
if (effectiveMode === 2) {
const container = currentVideo.closest('.game_hover_activated') || currentVideo.parentElement;
if (container && !document.fullscreenElement) {
container.requestFullscreen?.().catch(() => currentVideo.requestFullscreen?.());
} else {
document.exitFullscreen?.();
}
} else if (effectiveMode === 3) {
toggleWebFullscreen(currentVideo, handler);
}
},
handleWheel: function(e) {
if (CONFIG.disableWheel || isInputElement(e.target)) return;
const video = this.getVideo();
if (!video) return;
if (!checkScrollRange(e, video)) return;
e.preventDefault();
e.stopPropagation();
const delta = -Math.sign(e.deltaY);
const modifierDelta = getModifierDelta(e);
this.adjustVolume(video, delta * modifierDelta);
showVolume(video.volume * 100, video);
},
hasPlaylist: true,
switchPlaylistPrev: () => {
const btn = document.querySelector('div.KkkFLdIE4YTPfdeoIQoo2._1EBlS2FQmejekYhQDC-Kmh[data-keepcontrols="true"]');
if (btn) btn.click();
},
switchPlaylistNext: () => {
const btn = document.querySelector('div.KkkFLdIE4YTPfdeoIQoo2._2XmwxD18W2cEXHlvhYbcyJ[data-keepcontrols="true"]');
if (btn) btn.click();
}
},
FACEBOOK: {
getVideo: () => document.querySelector('video[data-video-container]') || document.querySelector('video[playsinline]') || findVideoInIframes(),
getFullscreenElement: (video) => video.closest('[data-pagelet="MainFeed"]') || video.parentElement || video,
hasNativeFullscreenBtn: false,
adjustVolume: commonAdjustVolume,
toggleFullscreen: (video, handler) => {
const effectiveMode = getEffectiveFullscreenMode(handler);
const currentVideo = getVideoElement();
if (effectiveMode === 2) {
toggleNativeFullscreen(currentVideo);
} else if (effectiveMode === 3) {
toggleWebFullscreen(currentVideo, handler);
}
},
hasPlaylist: false
},
GENERIC: {
getVideo: () => document.querySelector('video.player') || findVideoInIframes() || document.querySelector('video, .video-player video, .video-js video, .player-container video'),
getFullscreenElement: (video) => video,
hasNativeFullscreenBtn: false,
adjustVolume: commonAdjustVolume,
toggleFullscreen: (video, handler) => {
const effectiveMode = getEffectiveFullscreenMode(handler);
const currentVideo = getVideoElement();
if (effectiveMode === 2) {
toggleNativeFullscreen(currentVideo);
} else if (effectiveMode === 3) {
toggleWebFullscreen(currentVideo, handler);
}
},
hasPlaylist: false
}
};
function findVideoInIframes() {
const iframes = document.querySelectorAll('iframe');
for (const iframe of iframes) {
try {
const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document;
return iframeDoc?.querySelector('video');
} catch {}
}
return null;
}
function simulateKeyPress(key) {
document.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
}
function isInputElement(target) {
return /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName) || target.isContentEditable;
}
function checkScrollRange(e, video) {
if (CONFIG.scrollTriggerRange >= 100) return true;
const rect = video.getBoundingClientRect();
const marginX = (rect.width * (100 - CONFIG.scrollTriggerRange)) / 200;
const marginY = (rect.height * (100 - CONFIG.scrollTriggerRange)) / 200;
return e.clientX >= rect.left + marginX && e.clientX <= rect.right - marginX &&
e.clientY >= rect.top + marginY && e.clientY <= rect.bottom - marginY;
}
function getModifierDelta(e) {
if (CONFIG.modifierKey === 5) return CONFIG.stepVolume;
const requiredModifier = { 1: 'altKey', 2: 'ctrlKey', 3: 'shiftKey', 4: 'metaKey' }[CONFIG.modifierKey];
const otherModifiers = ['altKey', 'ctrlKey', 'shiftKey', 'metaKey'].filter(k => k !== requiredModifier).some(k => e[k]);
if (e[requiredModifier] && !otherModifiers) {
return CONFIG.fineVolumeStep;
}
return CONFIG.stepVolume;
}
const longPressTimers = {};
const longPressIntervals = {};
const longPressState = {};
const prePressState = {};
let lastKey0Time = 0;
function getCustomRateIndex(rates, currentRate) {
let closestIdx = 0;
let minDiff = Math.abs(rates[0] - currentRate);
for (let i = 1; i < rates.length; i++) {
const diff = Math.abs(rates[i] - currentRate);
if (diff < minDiff) {
minDiff = diff;
closestIdx = i;
}
}
return closestIdx;
}
function adjustRate13(video, direction) {
const state = getVideoState(video);
let newRate = video.playbackRate;
if (CONFIG.longPress13Mode === 3) {
const rates = CONFIG.longPress13CustomRates.split(',').map(r => parseFloat(r.trim())).filter(r => !isNaN(r));
if (rates.length > 0) {
const idx = getCustomRateIndex(rates, newRate);
let newIdx = idx + direction;
if (newIdx < 0) newIdx = 0;
if (newIdx >= rates.length) newIdx = rates.length - 1;
newRate = rates[newIdx];
}
} else {
newRate = Math.max(0.1, Math.min(16, newRate + (0.1 * direction)));
}
video.playbackRate = parseFloat(newRate.toFixed(1));
state.lastCustomRate = video.playbackRate;
state.isDefaultRate = (video.playbackRate === 1.0);
state.lastManualRate = video.playbackRate;
showVolume(video.playbackRate * 100, video);
}
function startLongPress(key, video, handler) {
if (longPressTimers[key]) return;
longPressTimers[key] = setTimeout(() => {
longPressState[key] = true;
if (key === 'Numpad4') {
prePressState[key] = { wasPlaying: !video.paused };
if (!video.paused) video.pause();
longPressIntervals[key] = setInterval(() => {
if (video) video.currentTime = Math.max(0, video.currentTime - CONFIG.longPress4Step);
}, CONFIG.longPress4Interval);
} else if (key === 'Numpad6') {
prePressState[key] = { wasPlaying: !video.paused };
if (video.paused) video.play();
let rate = CONFIG.longPress6StartRate;
video.playbackRate = rate;
longPressIntervals[key] = setInterval(() => {
rate += CONFIG.longPress6Step;
if (CONFIG.longPress6MaxRate > 0 && rate > CONFIG.longPress6MaxRate) rate = CONFIG.longPress6MaxRate;
if (video) video.playbackRate = rate;
}, CONFIG.longPress6Interval);
} else if (key === 'Numpad5') {
prePressState[key] = { paused: video.paused, rate: video.playbackRate };
video.playbackRate = CONFIG.longPress5SlowRate;
if (video.paused) video.play();
} else if (key === 'Numpad1' || key === 'Numpad3') {
const direction = (key === 'Numpad3') ? 1 : -1;
if (CONFIG.longPress13Mode === 3) {
const rates = CONFIG.longPress13CustomRates.split(',').map(r => parseFloat(r.trim())).filter(r => !isNaN(r));
if (rates.length > 0) {
let idx = getCustomRateIndex(rates, video.playbackRate);
longPressIntervals[key] = setInterval(() => {
idx += direction;
if (idx < 0) idx = 0;
if (idx >= rates.length) idx = rates.length - 1;
if (video) {
video.playbackRate = rates[idx];
showVolume(video.playbackRate * 100, video);
}
}, CONFIG.longPress13Interval);
}
} else if (CONFIG.longPress13Mode === 2) {
let rate = video.playbackRate;
longPressIntervals[key] = setInterval(() => {
let step = 0.1;
if (rate >= 2.0 && rate < 3.0) step = 0.2;
else if (rate >= 3.0) step = 1.0;
rate = Math.max(0.1, Math.min(16, rate + (step * direction)));
if (video) {
video.playbackRate = rate;
showVolume(rate * 100, video);
}
}, CONFIG.longPress13Interval);
} else {
let rate = video.playbackRate;
longPressIntervals[key] = setInterval(() => {
rate = Math.max(0.1, Math.min(16, rate + (0.1 * direction)));
if (video) {
video.playbackRate = rate;
showVolume(rate * 100, video);
}
}, CONFIG.longPress13Interval);
}
} else if (key === 'Numpad7' || key === 'Numpad9') {
const direction = (key === 'Numpad9') ? 1 : -1;
const mode = CONFIG.longPress79Mode;
prePressState[key] = { wasPlaying: !video.paused };
if (video.paused) video.play();
if (mode === 1) {
const step = CONFIG.stepTimeLong * CONFIG.longPress79Multiplier;
longPressIntervals[key] = setInterval(() => {
if (video) video.currentTime += direction * step;
}, CONFIG.longPress79Interval);
} else if (mode === 2 && handler.hasPlaylist) {
if (handler.specialKeys?.[key]) {
handler.specialKeys[key]();
}
} else if (mode === 3) {
if (direction === 1) {
history.forward();
} else {
history.back();
}
}
}
}, CONFIG.longPressDetectTime);
}
function cancelLongPress(key, video) {
if (longPressTimers[key]) {
clearTimeout(longPressTimers[key]);
delete longPressTimers[key];
if (!longPressState[key]) {
handleShortPress(key, video);
}
}
if (longPressState[key]) {
if (longPressIntervals[key]) {
clearInterval(longPressIntervals[key]);
delete longPressIntervals[key];
}
if (key === 'Numpad4' && prePressState[key]) {
if (prePressState[key].wasPlaying && video.paused) video.play();
delete prePressState[key];
} else if (key === 'Numpad6' && prePressState[key]) {
if (!prePressState[key].wasPlaying && !video.paused) video.pause();
if (video) video.playbackRate = 1.0;
delete prePressState[key];
} else if (key === 'Numpad5' && prePressState[key]) {
const state = getVideoState(video);
const restoreRate = state.lastManualRate !== undefined ? state.lastManualRate : 1.0;
video.playbackRate = restoreRate;
if (prePressState[key].paused) { video.pause(); } else { video.play(); }
delete prePressState[key];
} else if (key === 'Numpad1' || key === 'Numpad3') {
const state = getVideoState(video);
state.lastCustomRate = video.playbackRate;
state.isDefaultRate = (video.playbackRate === 1.0);
} else if (key === 'Numpad7' || key === 'Numpad9') {
if (prePressState[key]) {
if (!prePressState[key].wasPlaying && !video.paused) video.pause();
delete prePressState[key];
}
}
delete longPressState[key];
}
}
function handleShortPress(key, video) {
if (!video) return;
if (key === 'Numpad4') video.currentTime = Math.max(0, video.currentTime - CONFIG.stepTime);
if (key === 'Numpad6') video.currentTime += CONFIG.stepTime;
if (key === 'Numpad7') video.currentTime -= CONFIG.stepTimeLong;
if (key === 'Numpad9') video.currentTime += CONFIG.stepTimeLong;
if (key === 'Numpad1' || key === 'Numpad3') {
adjustRate13(video, key === 'Numpad3' ? 1 : -1);
}
if (key === 'Numpad5' || key === 'Space') video && video[video.paused ? 'play' : 'pause']();
}
function handleDivMulAction(key, handler) {
const effectiveMode = getEffectiveDivMulMode(handler);
const direction = (key === 'NumpadMultiply') ? 1 : -1;
if (effectiveMode === 1 && handler.hasPlaylist) {
if (direction === 1) {
handler.switchPlaylistNext?.();
} else {
handler.switchPlaylistPrev?.();
}
} else if (effectiveMode === 2 || !handler.hasPlaylist) {
if (direction === 1) {
switchToNextVideo();
} else {
switchToPrevVideo();
}
} else if (effectiveMode === 3) {
if (direction === 1) {
history.forward();
} else {
history.back();
}
}
}
function handleKeyEvent(e) {
if (CONFIG.disableKeyboard || isInputElement(e.target)) return;
if (PLATFORM === "FACEBOOK" && e.code === "NumpadDivide") {
e.preventDefault();
e.stopPropagation();
}
const keyMap = {
'Numpad0': 0, 'Numpad1': 1, 'Numpad2': 2, 'Numpad3': 3, 'Numpad4': 4, 'Numpad5': 5,
'Numpad6': 6, 'Numpad7': 7, 'Numpad8': 8, 'Numpad9': 9, 'Space': 10, 'Enter': 11,
'NumpadAdd': 12, 'NumpadSubtract': 13, 'ArrowLeft': 14, 'ArrowRight': 15, 'ArrowUp': 16, 'ArrowDown': 17,
'NumpadDivide': 18, 'NumpadMultiply': 19
};
let keyId = keyMap[e.code];
if (CONFIG.disabledKeys.includes(keyId)) return;
const handler = PLATFORM_HANDLERS[PLATFORM];
const isCustomModifier = (() => {
if (CONFIG.modifierKey === 5) return false;
const requiredModifier = { 1: 'altKey', 2: 'ctrlKey', 3: 'shiftKey', 4: 'metaKey' }[CONFIG.modifierKey];
const otherModifiers = ['altKey', 'ctrlKey', 'shiftKey', 'metaKey'].filter(k => k !== requiredModifier).some(k => e[k]);
return e[requiredModifier] && !otherModifiers;
})();
const hasOtherModifiers = e.altKey || e.ctrlKey || e.shiftKey || e.metaKey;
if (!isCustomModifier && hasOtherModifiers) return;
const longPressKeys = ['Numpad4', 'Numpad6', 'Numpad1', 'Numpad3', 'Numpad7', 'Numpad9', 'Numpad5'];
if (longPressKeys.includes(e.code)) {
const video = getVideoElement();
if (e.type === 'keydown') {
startLongPress(e.code, video, handler);
e.preventDefault();
} else if (e.type === 'keyup') {
cancelLongPress(e.code, video);
e.preventDefault();
}
return;
}
if (e.type !== 'keydown') return;
if (handler.specialKeys?.[e.code]) { handler.specialKeys[e.code](); e.preventDefault(); return; }
if (e.code === 'NumpadDivide' || e.code === 'NumpadMultiply') {
e.preventDefault();
handleDivMulAction(e.code, handler);
return;
}
if (e.code === 'Numpad0') {
const now = performance.now();
if (now - lastKey0Time < 300) { e.preventDefault(); return; }
lastKey0Time = now;
const video = getVideoElement();
if (video) togglePlaybackRate(video);
e.preventDefault();
return;
}
if (e.code === 'Enter' || e.code === 'NumpadEnter') {
const currentVideo = getVideoElement();
handler.toggleFullscreen(currentVideo, handler);
e.preventDefault();
return;
}
const video = getVideoElement();
if (isCustomModifier) {
const volumeActions = {
'Numpad8': () => handler.adjustVolume(video, CONFIG.fineVolumeStep),
'Numpad2': () => handler.adjustVolume(video, -CONFIG.fineVolumeStep)
};
if (volumeActions[e.code]) { volumeActions[e.code](); e.preventDefault(); return; }
}
const actions = {
'NumpadAdd': () => video && (video.currentTime += video.duration * 0.1),
'NumpadSubtract': () => video && (video.currentTime -= video.duration * 0.1),
'Numpad8': () => video && handler.adjustVolume(video, CONFIG.stepVolume),
'Numpad2': () => video && handler.adjustVolume(video, -CONFIG.stepVolume),
'Numpad4': () => video && (video.currentTime -= CONFIG.stepTime),
'Numpad6': () => video && (video.currentTime += CONFIG.stepTime)
};
if (actions[e.code]) { actions[e.code](); e.preventDefault(); }
}
function togglePlaybackRate(video) {
const state = getVideoState(video);
if (state.isDefaultRate) {
video.playbackRate = state.lastCustomRate;
state.isDefaultRate = false;
} else {
state.lastCustomRate = video.playbackRate;
video.playbackRate = 1.0;
state.isDefaultRate = true;
}
state.lastManualRate = video.playbackRate;
showVolume(video.playbackRate * 100, video);
}
function handleVideoWheel(e) {
if (CONFIG.disableWheel || isInputElement(e.target)) return;
const video = e.target.closest('video');
if (!video) return;
if (!checkScrollRange(e, video)) return;
e.preventDefault();
e.stopPropagation();
const normalizedDelta = -Math.sign(e.deltaY);
const modifierDelta = getModifierDelta(e);
PLATFORM_HANDLERS[PLATFORM].adjustVolume(video, normalizedDelta * modifierDelta);
}
function handleTwitchWheel(e) {
if (CONFIG.disableWheel || isInputElement(e.target)) return;
const video = getVideoElement();
if (!video) return;
if (!checkScrollRange(e, video)) return;
e.preventDefault();
e.stopPropagation();
const delta = -Math.sign(e.deltaY);
const modifierDelta = getModifierDelta(e);
const volumeChange = delta * modifierDelta;
PLATFORM_HANDLERS.TWITCH.adjustVolume(video, volumeChange);
showVolume(video.volume * 100, video);
}
function setupEventDelegation() {
document.addEventListener('wheel', (e) => {
if (PLATFORM === 'TWITCH') {
handleTwitchWheel(e);
} else if (PLATFORM === 'STEAM') {
PLATFORM_HANDLERS.STEAM.handleWheel(e);
} else if (PLATFORM === 'YOUTUBE') {
const embedVideo = document.querySelector('.html5-main-video.video-stream');
if (embedVideo && e.target.closest('.html5-main-video, .ytp-chrome-bottom, .player-controls-content, .player-controls-background')) {
if (CONFIG.disableWheel || isInputElement(e.target)) return;
if (!checkScrollRange(e, embedVideo)) return;
e.preventDefault();
e.stopPropagation();
const delta = -Math.sign(e.deltaY);
const modifierDelta = getModifierDelta(e);
PLATFORM_HANDLERS.YOUTUBE.adjustVolume(embedVideo, delta * modifierDelta);
}
} else {
handleVideoWheel(e);
}
}, { passive: false, capture: true });
document.addEventListener('click', (e) => {
const video = e.target.closest('video');
if (video && PLATFORM !== 'BILIBILI') {
setActiveVideo(video, false);
}
}, true);
}
function init() {
loadConfig();
GM_registerMenuCommand(i18n[LANG].menuTitle, openMainMenu);
document.addEventListener('keydown', handleKeyEvent, true);
document.addEventListener('keyup', handleKeyEvent, true);
setupEventDelegation();
const observer = new MutationObserver(() => {
if (observer.timer) clearTimeout(observer.timer);
observer.timer = setTimeout(() => {
updateVideoElements();
}, 200);
});
observer.observe(document.body, { childList: true, subtree: true });
setInterval(() => {
updateVideoElements();
}, 5000);
updateVideoElements();
}
init();
})();