// ==UserScript== // @name 麦田影院(mtyy4.com) 广告净化 // @namespace https://github.com/mtyy-adblock // @version 3.5.1 // @description ① 移除麦田影院(mtyy4.com)的横幅/悬浮/弹窗广告、点击劫持透明层与第三方广告统计脚本;② 播放器增强(设置项全部集成在播放器自带的设置面板内):自动跳过片头片尾(全局配置、带校验、多路检测+巡检重试、换集自动重开)、记住播放倍速、临时 2 倍速(桌面端长按方向键,移动端长按播放区两侧透明热区) // @author You // @match *://*.mtyy4.com/* // @match *://*.mtyy1.cc/* // @run-at document-start // @grant GM_addStyle // @grant GM_setValue // @grant GM_getValue // @grant GM_registerMenuCommand // @grant unsafeWindow // @license MIT // ==/UserScript== (function () { 'use strict'; // ========================= 运行环境 ========================= // 使用 unsafeWindow 确保对页面全局的修改真正生效(脚本猫沙箱下 window 可能被代理) const w = (typeof unsafeWindow !== 'undefined' && unsafeWindow) ? unsafeWindow : window; const d = w.document; // ========================= 配置 ========================= const CONFIG = { // 已确认的广告/推广/统计域名(首页内联混淆脚本会动态拼装这些域名) adHosts: [ 'f0cmqjk.com', // 主广告脚本(:9003/sc/1198) '480ipil.com', // 移动端点位,会强制跳转 'cb1nvdt.com', // WebSocket 广告通道 'z8ioqja.com', // WebSocket 广告通道 '2svk4ps.com', // WebSocket 广告通道 'aizhantj.com', // 爱站统计追踪(:21233/tjjs) 'ieo0s3m.cc', // 横幅广告落地页(share_utm 推广) ], // 广告服务常用端口(非本站域名命中即拦截,防止域名轮换) adPorts: ['9003', '9903', '21233'], // 广告代码特征码:站点内联脚本里出现这些串,说明是广告逻辑(用于拦截其注册的劫持监听) adCodeMarkers: [ 'f0cmqjk.com', '480ipil.com', 'cb1nvdt.com', 'z8ioqja.com', '2svk4ps.com', 'aizhantj.com', 'ieo0s3m.cc', 'gvbqxwcb', ':9003', ':9903', ':21233', '/1198', ], // 本站及资源站白名单,永不拦截 selfHosts: ['mtyy4.com', 'mtyy1.cc', 'mtzy2.com', 'mtyy2.com'], // 站内广告图片目录 adPathKeys: ['/static/images/gg/'], // 广告容器选择器(data-id="ad-*" 为该站广告位标识) adSelectors: [ '.ec-ad', '.ec-ad-double', '.ads_w', '.ads_close_btn', '.slide-wap', '[data-id^="ad-"]', '[class*="ads_w"]', '[id*="gvbqxwcb"]', '[class*="gvbqxwcb"]', ], // 运行时开关 blockAutoPopups: GM_getValue('block_auto_popups', true), // 拦截非用户触发的弹窗 blockGhostLayer: GM_getValue('block_ghost_layer', true), // 拦截透明点击劫持层 debug: GM_getValue('debug_mode', false), }; // 拦截统计 const STATS = { script: 0, element: 0, popup: 0, network: 0, ghost: 0, hijack: 0, total: 0 }; function log(...args) { if (CONFIG.debug) console.log('[麦田去广告]', ...args); } function hit(type, info) { STATS[type] = (STATS[type] || 0) + 1; STATS.total++; log('已拦截', type, info); } // ========================= URL 判定 ========================= const hostCache = Object.create(null); function isSelfHost(host) { return CONFIG.selfHosts.some(h => host === h || host.endsWith('.' + h)); } function isAdUrl(url) { if (!url || typeof url !== 'string') return false; // 站内广告图片 if (CONFIG.adPathKeys.some(p => url.indexOf(p) !== -1)) return true; // 过滤 data: / blob: / javascript: 等 if (/^(data|blob|javascript|about|chrome-extension|moz-extension):/i.test(url)) return false; if (url.charAt(0) === '#' || url.charAt(0) === '/') return false; let u; try { u = new w.URL(url, w.location.href); } catch (e) { return false; } const host = u.hostname; if (hostCache[host] !== undefined) return hostCache[host]; let blocked = false; if (isSelfHost(host)) { blocked = false; } else if (CONFIG.adHosts.some(h => host === h || host.endsWith('.' + h))) { blocked = true; } else if (CONFIG.adPorts.indexOf(u.port) !== -1) { // 广告脚本固定的 9003 / 9903 / 21233 端口 blocked = true; } hostCache[host] = blocked; return blocked; } // ========================= 1. 拦截第三方广告/统计脚本 ========================= function hookScriptSrc() { const proto = w.HTMLScriptElement && w.HTMLScriptElement.prototype; if (!proto) return; const desc = Object.getOwnPropertyDescriptor(proto, 'src'); if (!desc || !desc.set) return; Object.defineProperty(proto, 'src', { configurable: true, enumerable: desc.enumerable, get: function () { return desc.get.call(this); }, set: function (value) { if (isAdUrl(value)) { hit('script', value); return; // 不写入 src,脚本永远不会发起请求 } desc.set.call(this, value); } }); log('已接管 HTMLScriptElement.src'); } function hookSetAttribute() { const orig = w.Element.prototype.setAttribute; w.Element.prototype.setAttribute = function (name, value) { const n = String(name || '').toLowerCase(); if ((n === 'src' || n === 'href') && isAdUrl(value)) { hit('script', value); return; } return orig.call(this, name, value); }; } // ========================= 2. 拦截广告网络请求 ========================= function hookNetwork() { // fetch const origFetch = w.fetch; if (origFetch) { w.fetch = function (input, init) { const url = typeof input === 'string' ? input : (input && input.url); if (isAdUrl(url)) { hit('network', url); return Promise.reject(new TypeError('Blocked by adblocker')); } return origFetch.call(w, input, init); }; } // XMLHttpRequest(不抛异常,改为指向空地址,避免打断站点逻辑) const XHR = w.XMLHttpRequest; if (XHR) { const origOpen = XHR.prototype.open; const origSend = XHR.prototype.send; XHR.prototype.open = function (method, url) { if (isAdUrl(url)) { hit('network', url); this.__adblocked = true; return origOpen.call(this, 'GET', 'about:blank'); } this.__adblocked = false; return origOpen.apply(this, arguments); }; XHR.prototype.send = function () { if (this.__adblocked) return; return origSend.apply(this, arguments); }; } // sendBeacon if (w.navigator && w.navigator.sendBeacon) { const origBeacon = w.navigator.sendBeacon.bind(w.navigator); w.navigator.sendBeacon = function (url, data) { if (isAdUrl(url)) { hit('network', url); return true; // 假装已发送 } return origBeacon(url, data); }; } // WebSocket(广告通道 wss://...:9903) const OrigWS = w.WebSocket; if (OrigWS) { function GuardedWS(url, protocols) { if (isAdUrl(url)) { hit('network', url); // 返回一个"死"对象,防止广告脚本因异常而反复重试 const dummy = { readyState: 3, bufferedAmount: 0, onopen: null, onmessage: null, onerror: null, onclose: null, close: function () {}, send: function () {}, addEventListener: function () {}, removeEventListener: function () {} }; return dummy; } return protocols === undefined ? new OrigWS(url) : new OrigWS(url, protocols); } GuardedWS.prototype = OrigWS.prototype; ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED'].forEach(function (k) { try { GuardedWS[k] = OrigWS[k]; } catch (e) {} }); w.WebSocket = GuardedWS; } // EventSource if (w.EventSource) { const OrigES = w.EventSource; function GuardedES(url, cfg) { if (isAdUrl(url)) { hit('network', url); throw new w.DOMException('Blocked by adblocker', 'SecurityError'); } return cfg === undefined ? new OrigES(url) : new OrigES(url, cfg); } GuardedES.prototype = OrigES.prototype; w.EventSource = GuardedES; } } // ========================= 3. 拦截跳转劫持与弹窗 ========================= function hookNavigation() { // location.assign / location.replace const LP = w.Location && w.Location.prototype; if (LP) { ['assign', 'replace'].forEach(function (name) { const orig = LP[name]; if (!orig) return; LP[name] = function (url) { if (isAdUrl(url)) { hit('popup', url); return; } return orig.call(this, url); }; }); // 部分浏览器允许重定义 href(失败也无害) try { const hd = Object.getOwnPropertyDescriptor(LP, 'href'); if (hd && hd.set && hd.configurable) { Object.defineProperty(LP, 'href', { configurable: true, enumerable: hd.enumerable, get: function () { return hd.get.call(this); }, set: function (value) { if (isAdUrl(value)) { hit('popup', value); return; } hd.set.call(this, value); } }); } } catch (e) {} } // 记录用户最后一次真实交互,用于区分"主动点击"和"自动弹窗" let lastInteract = Date.now(); ['pointerdown', 'mousedown', 'touchstart', 'keydown', 'click'].forEach(function (t) { d.addEventListener(t, function () { lastInteract = Date.now(); }, { capture: true, passive: true }); }); const origOpen = w.open; w.open = function (url) { if (isAdUrl(url)) { hit('popup', url); return null; } // 广告脚本常在页面加载后 1~2 秒自动 window.open if (CONFIG.blockAutoPopups && (Date.now() - lastInteract) > 1500) { hit('popup', url); return null; } return origOpen ? origOpen.apply(w, arguments) : null; }; // 拦截广告链接点击 / 点击劫持 d.addEventListener('click', function (e) { const link = e.target && e.target.closest ? e.target.closest('a[href]') : null; if (link && isAdUrl(link.getAttribute('href'))) { hit('popup', link.getAttribute('href')); e.preventDefault(); e.stopImmediatePropagation(); const box = link.closest('.ads_w, .ec-ad, [data-id^="ad-"]') || link; box.remove(); } }, true); } // ===================== 1.5 拦截广告的「点击劫持监听」 ===================== // 移动端恶性跳转的真凶:站点内联脚本会注册 document.addEventListener('touchend', fn), // 命中屏幕下方区域就直接执行 top.location = 广告地址。 // 这类 location 赋值(location.href / top.location / window.location)是 [LegacyUnforgeable], // 无法用属性改写拦截,所以要在它「注册监听器」的那一刻按特征码直接丢弃。 let guardInstalled = false; const HIJACK_EVENTS = ['touchend', 'touchstart', 'click', 'pointerup', 'pointerdown', 'mouseup', 'mousedown', 'pointermove', 'touchmove']; function isAdCode(fn) { let src = ''; try { src = String(fn); } catch (e) { return false; } if (!src || src.indexOf('[native code]') !== -1) return false; return CONFIG.adCodeMarkers.some(function (m) { return src.indexOf(m) !== -1; }); } function installEventListenerGuard() { if (guardInstalled) return; guardInstalled = true; const proto = w.EventTarget && w.EventTarget.prototype; if (proto && proto.addEventListener) { const origAdd = proto.addEventListener; proto.addEventListener = function (type, listener, options) { if (typeof listener === 'function' && HIJACK_EVENTS.indexOf(String(type)) !== -1 && isAdCode(listener)) { hit('hijack', '丢弃广告劫持监听 ' + type); return; } return origAdd.call(this, type, listener, options); }; } // 事件属性赋值(document.ontouchend = fn / window.onclick = fn ...)同样拦截 const props = ['ontouchend', 'ontouchstart', 'onclick', 'onmousedown', 'onmouseup', 'onpointerdown', 'onpointerup']; [w.Window && w.Window.prototype, w.Document && w.Document.prototype].forEach(function (pp) { if (!pp) return; props.forEach(function (name) { const d0 = Object.getOwnPropertyDescriptor(pp, name); if (!d0 || !d0.set || !d0.configurable) return; Object.defineProperty(pp, name, { configurable: true, enumerable: d0.enumerable, get: function () { return d0.get.call(this); }, set: function (fn) { if (typeof fn === 'function' && isAdCode(fn)) { hit('hijack', '丢弃广告劫持事件属性 ' + name); return; } d0.set.call(this, fn); } }); }); }); log('已开启广告劫持监听拦截'); } // ========================= 4. 样式隐藏(兜底,防闪烁) ========================= function injectCSS() { const css = [ '.ec-ad,.ec-ad-double,.ads_w,.ads_close_btn,.slide-wap,', '[data-id^="ad-"],[class*="ads_w"],[id*="gvbqxwcb"],[class*="gvbqxwcb"]{', ' display:none!important;visibility:hidden!important;opacity:0!important;', ' width:0!important;height:0!important;min-height:0!important;max-height:0!important;', ' margin:0!important;padding:0!important;border:0!important;overflow:hidden!important;', ' pointer-events:none!important;}', 'img[src*="/static/images/gg/"]{display:none!important;}', /* 广告脚本会给 body 加 100px 内边距占位,这里还原 */ 'body{padding-bottom:0!important;}' ].join('\n'); try { if (typeof GM_addStyle === 'function') { GM_addStyle(css); log('CSS 已注入(GM_addStyle)'); return; } } catch (e) {} const apply = function () { const host = d.head || d.documentElement; if (!host) return false; try { const st = d.createElement('style'); st.setAttribute('data-mtyy-adblock', '1'); st.textContent = css; host.appendChild(st); log('CSS 已注入(style 标签)'); return true; } catch (e) { return false; } }; if (!apply()) { // document-start 阶段 可能还不存在,稍后补注入 d.addEventListener('DOMContentLoaded', apply, { once: true }); } } // ========================= 5. DOM 清理 ========================= function isGhostLayer(el) { if (!el || el.nodeType !== 1) return false; if (el.tagName !== 'DIV' && el.tagName !== 'A' && el.tagName !== 'SECTION') return false; // 播放器内部的遮罩/锁屏/加载层一律不碰(ArtPlayer 的 .art-mask/.art-state 等) if (el.closest && el.closest('.art-video-player,.MacPlayer,#playleft')) return false; const cs = w.getComputedStyle(el); if (!cs) return false; if (cs.position !== 'fixed' && cs.position !== 'absolute') return false; const op = parseFloat(cs.opacity); if (!(op >= 0 && op < 0.06)) return false; // 近乎全透明 if (parseInt(cs.zIndex || '0', 10) < 50) return false; // 且层级很高 if (cs.backgroundColor === 'rgba(0, 0, 0, 0)') return false; if ((el.textContent || '').trim()) return false; // 无文字 if (el.children.length) return false; // 无子元素 const r = el.getBoundingClientRect(); if (r.width < 30 || r.height < 30) return false; return true; } function cleanNode(node) { if (!node || node.nodeType !== 1) return; // 5.1 广告容器 for (let i = 0; i < CONFIG.adSelectors.length; i++) { const sel = CONFIG.adSelectors[i]; let matched = false; try { matched = node.matches && node.matches(sel); } catch (e) {} if (matched) { hit('element', sel); node.remove(); return; } } // 5.2 指向广告域名的资源/链接 const tag = node.tagName; if (tag === 'SCRIPT' || tag === 'IFRAME' || tag === 'IMG' || tag === 'LINK' || tag === 'A' || tag === 'SOURCE' || tag === 'VIDEO') { const attr = (tag === 'LINK' || tag === 'A') ? 'href' : 'src'; const val = node.getAttribute(attr); if (val && isAdUrl(val)) { hit('element', tag + '[' + attr + ']=' + val); node.remove(); return; } } // 5.3 透明点击劫持层 if (CONFIG.blockGhostLayer && isGhostLayer(node)) { hit('ghost', node.className || node.tagName); node.remove(); return; } // 5.4 递归检查新增节点的子树 try { const inner = node.querySelectorAll(CONFIG.adSelectors.join(',')); for (let k = 0; k < inner.length; k++) { hit('element', 'sub'); inner[k].remove(); } } catch (e) {} if (CONFIG.blockGhostLayer) { try { const layers = node.querySelectorAll('div[style*="fixed"],div[style*="absolute"],div[style*="opacity"]'); for (let k = 0; k < layers.length; k++) { if (isGhostLayer(layers[k])) { hit('ghost', 'sub'); layers[k].remove(); } } } catch (e) {} } } function cleanAll() { // 全量扫描(低频调用) CONFIG.adSelectors.forEach(function (sel) { try { d.querySelectorAll(sel).forEach(function (el) { hit('element', sel); el.remove(); }); } catch (e) {} }); try { d.querySelectorAll('script[src],iframe[src],img[src],link[href],a[href],source[src]') .forEach(function (el) { const tag = el.tagName; const attr = (tag === 'LINK' || tag === 'A') ? 'href' : 'src'; const val = el.getAttribute(attr); if (val && isAdUrl(val)) { hit('element', tag + '=' + val); el.remove(); } }); } catch (e) {} if (CONFIG.blockGhostLayer) { try { d.querySelectorAll('div[style*="fixed"],div[style*="opacity"]').forEach(function (el) { if (isGhostLayer(el)) { hit('ghost', 'scan'); el.remove(); } }); } catch (e) {} } } // 用 rAF 合并 MutationObserver 回调,避免频繁回流 let pending = []; let scheduled = false; function schedule(nodes) { for (let i = 0; i < nodes.length; i++) pending.push(nodes[i]); if (scheduled) return; scheduled = true; const flush = function () { scheduled = false; const list = pending; pending = []; for (let i = 0; i < list.length; i++) { if (list[i].isConnected) cleanNode(list[i]); } }; if (w.requestAnimationFrame) w.requestAnimationFrame(flush); else w.setTimeout(flush, 16); } function startObserver() { const target = d.documentElement || d; const mo = new w.MutationObserver(function (records) { for (let i = 0; i < records.length; i++) { const added = records[i].addedNodes; for (let j = 0; j < added.length; j++) schedule([added[j]]); } }); mo.observe(target, { childList: true, subtree: true }); log('MutationObserver 已启动'); } // ========================= 6. 菜单 ========================= function registerMenu() { if (typeof GM_registerMenuCommand !== 'function') return; GM_registerMenuCommand('立即清理广告', function () { cleanAll(); }); GM_registerMenuCommand('拦截统计', function () { alert( '麦田影院广告净化 + 播放增强 v3.4.0\n' + '本次已拦截:' + STATS.total + ' 次\n' + ' 广告脚本/资源:' + STATS.script + '\n' + ' 广告元素:' + STATS.element + '\n' + ' 弹窗/劫持跳转:' + STATS.popup + '\n' + ' 广告网络请求:' + STATS.network + '\n' + ' 透明劫持层:' + STATS.ghost + '\n' + ' 劫持跳转监听:' + STATS.hijack ); }); GM_registerMenuCommand('切换「自动弹窗拦截」:' + (CONFIG.blockAutoPopups ? '开' : '关'), function () { const v = !CONFIG.blockAutoPopups; GM_setValue('block_auto_popups', v); alert('自动弹窗拦截已' + (v ? '开启' : '关闭') + ',刷新页面生效'); }); GM_registerMenuCommand('切换「调试日志」:' + (CONFIG.debug ? '开' : '关'), function () { const v = !CONFIG.debug; GM_setValue('debug_mode', v); alert('调试日志已' + (v ? '开启' : '关闭') + ',刷新页面生效'); }); } // ================================================================ // ============= 模块 B:播放设置(持久化 + 解析 + 校验) ============= // ================================================================ const SETTINGS_KEY = 'mtyy_player_cfg_v1'; const RATE_MIN = 0.25; // 倍速下限 const RATE_MAX = 4; // 倍速上限 const HOLD_MULT = 2; // 长按临时倍速(在基准倍速上 ×2) const HOLD_MS_KEY = 280; // 桌面端长按方向键判定阈值(毫秒) const HOLD_MS_TOUCH = 250; // 触屏端长按热区判定阈值(毫秒) const SEEK_STEP = 5; // 短按方向键快进秒数(与 ArtPlayer 默认一致) const MAX_DURATION = 86400; // 单项时长上限:24 小时 // —— 片头 / 片尾自动跳过的时序常量(v3.5.0)—— const SKIP_ARM_MS = 600; // 元数据就绪后的静置期:避开播放器的自动定位 / 续播 const SKIP_PATROL_MS = 250; // 巡检间隔:timeupdate 不可靠时(暂停 / 卡顿)的兜底触发源 const SKIP_MAX_TRIES = 8; // 单轮最多尝试次数,防止 seek 失效时反复定位 const SKIP_WINDOW_MS = 20000; // 元数据就绪后的最长尝试窗口 const SKIP_TOL = 0.5; // 时间容差(秒) const USER_SEEK_MS = 900; // 「进度条手势 → seeking」的关联窗口,用于区分用户拖动 const IS_TOP = (w.top === w.self); function GMget(key, def) { try { const v = GM_getValue(key); return (v === undefined || v === null) ? def : v; } catch (e) { return def; } } function GMset(key, value) { try { GM_setValue(key, value); } catch (e) {} } function clampRate(v) { v = Number(v); if (!isFinite(v) || v <= 0) return 1; v = Math.round(v * 100) / 100; // 避免浮点尾巴 return Math.min(RATE_MAX, Math.max(RATE_MIN, v)); } function normSeconds(v) { v = Number(v); if (!isFinite(v) || v <= 0) return 0; // 负数 / 0 / 空 → 关闭 return Math.min(MAX_DURATION, Math.round(v)); } function fmtDuration(sec) { sec = Math.max(0, Math.round(Number(sec) || 0)); const hh = Math.floor(sec / 3600); const mm = Math.floor((sec % 3600) / 60); const ss = sec % 60; let out = ''; if (hh) out += hh + '小时'; if (mm) out += mm + '分'; if (ss || !out) out += ss + '秒'; return out; } function fmtRate(r) { return String(Math.round(Number(r) * 100) / 100); } // 倍速文案:与站内自带「倍速」控件保持一致(整数补一位小数,如 2 → 2.0) function rateText(r) { const v = clampRate(r); return (v % 1 === 0) ? v.toFixed(1) : String(v); } function isTyping(el) { if (!el || el.nodeType !== 1) return false; const tag = el.tagName; return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable === true; } function addStyle(css, onceKey) { try { if (typeof GM_addStyle === 'function') { GM_addStyle(css); return; } } catch (e) {} const apply = function () { const host = d.head || d.documentElement; if (!host) return false; try { const st = d.createElement('style'); if (onceKey) st.setAttribute('data-mtyy', onceKey); st.textContent = css; host.appendChild(st); return true; } catch (e) { return false; } }; if (!apply()) d.addEventListener('DOMContentLoaded', apply, { once: true }); } // ---------- 配置读写(GM 存储 → 刷新/换页/换集后依然生效) ---------- function loadPlayerCfg() { let raw = GMget(SETTINGS_KEY, null); let o = null; if (raw) { try { o = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch (e) { o = null; } } if (!o || typeof o !== 'object') o = {}; return { intro: normSeconds(o.intro), outro: normSeconds(o.outro), rate: clampRate(o.rate === undefined ? 1 : o.rate), lastDuration: normSeconds(o.lastDuration) }; } let playerCfg = loadPlayerCfg(); function savePlayerCfg() { GMset(SETTINGS_KEY, JSON.stringify(playerCfg)); } function reloadPlayerCfg() { playerCfg = loadPlayerCfg(); return playerCfg; } // ---------- 「x分x秒」/ 纯秒数 解析 ---------- function parseDurationInput(text) { const raw = String(text == null ? '' : text).trim(); if (raw === '') return { ok: true, seconds: 0, empty: true }; // 留空 = 关闭 const s = raw .replace(/[0-9]/g, function (c) { return String.fromCharCode(c.charCodeAt(0) - 65248); }) .replace(/:/g, ':') .replace(/\s+/g, ''); if (/[-−﹣—]/.test(s)) return { ok: false, error: '不能填写负数' }; if (!/^[0-9分秒ms:.]+$/i.test(s)) { return { ok: false, error: '含有非法字符,只支持「分/秒」写法(如 1分30秒)或纯秒数(如 90)' }; } let seconds = null; let m = s.match(/^(\d+(?:\.\d+)?)分(?:(\d+(?:\.\d+)?)秒?)?$/); // 1分 / 1分30 / 1分30秒 / 1.5分 if (m) seconds = parseFloat(m[1]) * 60 + (m[2] ? parseFloat(m[2]) : 0); if (seconds === null) { m = s.match(/^(\d{1,3}):(\d{1,2})$/); // 1:30 if (m) seconds = parseInt(m[1], 10) * 60 + parseInt(m[2], 10); } if (seconds === null) { m = s.match(/^(\d+(?:\.\d+)?)(?:秒|s)?$/i); // 90 / 90秒 / 90s if (m) seconds = parseFloat(m[1]); } if (seconds === null || !isFinite(seconds)) { return { ok: false, error: '格式无法识别,请填写如「1分30秒」「90」这样的形式' }; } if (seconds < 0) return { ok: false, error: '不能填写负数' }; if (seconds > MAX_DURATION) return { ok: false, error: '不能超过 24 小时(86400 秒)' }; const val = Math.round(seconds); return { ok: true, seconds: val, empty: val === 0 }; } // durationHint > 0 时执行「不得超过视频总时长」校验 function validateDurationField(label, text, durationHint) { const p = parseDurationInput(text); if (!p.ok) return { ok: false, error: '「' + label + '」' + p.error }; if (!p.empty && durationHint > 0 && p.seconds > durationHint) { return { ok: false, error: '「' + label + '」' + fmtDuration(p.seconds) + ' 超过了视频总时长 ' + fmtDuration(durationHint) + ',已拒绝保存' }; } return { ok: true, seconds: p.seconds, empty: p.empty }; } // ================================================================ // ========== 模块 C:播放器增强(跳过片头片尾 + 记忆倍速) ========== // ================================================================ let rateHookInstalled = false; let lastGestureAt = 0; // 记录用户交互时刻,用于区分"用户主动调倍速"和"播放器内部重置倍速" ['pointerdown', 'mousedown', 'touchstart', 'keydown', 'click'].forEach(function (t) { d.addEventListener(t, function () { lastGestureAt = Date.now(); }, { capture: true, passive: true }); }); // 只把「进度条上的手势」算作用户拖动,避免播放器自动定位被误判成用户拖动 // (点击画面暂停、点设置按钮等都会触发全局手势,但不会改变播放位置) let lastSeekGestureAt = 0; const SEEK_CTL_SEL = '.art-progress,.art-progress-inner,.art-progress-loaded,.art-progress-played,' + '.art-progress-highlight,.art-progress-tip,.art-control-progress,[class*="progress"],' + '.art-control-time,.art-time,.art-control-time-duration'; function isSeekCtl(el) { try { if (!el || el.nodeType !== 1) return false; if (typeof el.closest === 'function') return !!el.closest(SEEK_CTL_SEL); } catch (e) {} return false; } ['pointerdown', 'mousedown', 'touchstart'].forEach(function (t) { d.addEventListener(t, function (e) { if (isSeekCtl(e.target)) lastSeekGestureAt = Date.now(); }, { capture: true, passive: true }); }); const PLAYER_CSS = [ /* 移动端:播放区两侧的透明长按加速热区(不铺底、不遮画面、不拦截手势) */ '.mtyy-zones{position:absolute;inset:0;z-index:15;pointer-events:none;}', '.mtyy-zone{position:absolute;top:0;bottom:0;width:22%;background:transparent;', ' pointer-events:none;}', '.mtyy-zone-left{left:0;}', '.mtyy-zone-right{right:0;}', /* 长按提示(热区无图标,靠这个提示告知位置) */ '.mtyy-boost-hint{position:absolute;left:50%;bottom:18%;transform:translateX(-50%);z-index:40;', ' padding:7px 14px;border-radius:999px;background:rgba(0,0,0,.55);color:#fff;white-space:nowrap;', ' font:500 12px/1.4 system-ui,-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;', ' letter-spacing:.5px;pointer-events:none;opacity:1;transition:opacity .4s ease;}', '.mtyy-boost-hint.mtyy-boost-hint-hide{opacity:0;}', /* 抑制 iOS/部分浏览器的长按原生菜单(视频信息 / 拷贝等) */ '.art-video-player video{-webkit-touch-callout:none;-webkit-user-select:none;user-select:none;}', /* 播放器「设置」面板里的行(沿用 ArtPlayer 的行高与配色变量) */ '.mtyy-set-row{display:flex;align-items:center;justify-content:space-between;gap:6px;', ' width:100%;height:100%;padding:0 5px;box-sizing:border-box;overflow:hidden;}', '.mtyy-set-left{display:flex;align-items:center;gap:5px;flex:0 0 auto;', ' font-size:12px;line-height:1;color:inherit;}', '.mtyy-set-left svg{display:block;}', '.mtyy-set-input{flex:1 1 auto;min-width:0;max-width:118px;height:24px;padding:0 6px;', ' border-radius:5px;background:#ffffff1f;border:1px solid #ffffff33;color:var(--art-font-color,#fff);', ' font-size:12px;line-height:1;text-align:center;outline:none;box-sizing:border-box;}', '.mtyy-set-input::placeholder{color:#ffffff66;font-size:11px;}', '.mtyy-set-input:focus{border-color:var(--art-theme,#FF6429);background:#ffffff2e;}', '.mtyy-set-input-bad,.mtyy-set-input-bad:focus{border-color:#ff5b5b!important;background:#ff5b5b33!important;}', '.mtyy-set-row-bad{background:#ff5b5b1f;}' ].join('\n'); function showFallbackToast(msg) { try { const host = d.body || d.documentElement; if (!host) return; let el = d.getElementById('mtyy-toast'); if (!el) { el = d.createElement('div'); el.id = 'mtyy-toast'; el.style.cssText = 'position:fixed;left:50%;bottom:12%;transform:translateX(-50%);' + 'z-index:2147483000;padding:8px 16px;border-radius:8px;background:rgba(0,0,0,.78);' + 'color:#fff;font:500 14px/1.5 system-ui,-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;' + 'pointer-events:none;opacity:0;transition:opacity .18s;'; host.appendChild(el); } el.textContent = msg; el.style.opacity = '1'; w.clearTimeout(showFallbackToast._t); showFallbackToast._t = w.setTimeout(function () { el.style.opacity = '0'; }, 1800); } catch (e) {} } // ---------- 播放器内设置项用的图标与倍速档位(与站内「倍速」控件选项保持一致) ---------- const ICON_CLOCK = '' + ''; const ICON_SPEED = '' + ''; const RATE_OPTIONS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3, 4]; // 是否「触屏为主」的设备:决定加速交互形态 // 触屏(手机/平板)→ 播放区两侧透明热区;鼠标/触控板电脑 → 控制栏按钮 + 键盘 const TOUCH_OK = (function () { try { if (typeof w.matchMedia === 'function') { if (w.matchMedia('(pointer: coarse)').matches) return true; // 主输入为手指 } else if (w.navigator && w.navigator.maxTouchPoints > 0) { return true; } } catch (e) {} const ua = (w.navigator && w.navigator.userAgent) || ''; return /android|iphone|ipad|ipod|mobile|harmonyos|windows phone|miuibrowser/i.test(ua); })(); // 长按结束后吞掉随之而来的那次 click,避免误触发播放器的「点击暂停/播放」 // 注意:仅吞一次,且新的触摸开始时会立即作废,绝不吞用户真正的点击 function suppressNextClick() { suppressNextClick.cancel(); const swallow = function (ev) { suppressNextClick.cancel(); if (ev.preventDefault) ev.preventDefault(); if (ev.stopImmediatePropagation) ev.stopImmediatePropagation(); ev.stopPropagation(); }; suppressNextClick._swallow = swallow; d.addEventListener('click', swallow, true); suppressNextClick._t = w.setTimeout(function () { suppressNextClick.cancel(); }, 700); } suppressNextClick.cancel = function () { if (suppressNextClick._swallow) { d.removeEventListener('click', suppressNextClick._swallow, true); suppressNextClick._swallow = null; } if (suppressNextClick._t) { w.clearTimeout(suppressNextClick._t); suppressNextClick._t = null; } }; function rateKey(r) { return String(r).replace('.', '_'); } // 用户主动选倍速:立即生效,并由倍速 hook 持久化为记忆值 function applyUserRate(r) { const val = clampRate(r); lastGestureAt = Date.now(); // 标记为用户主动调整 if (Player.art) { try { Player.art.playbackRate = val; return; } catch (e) {} } Player.writeRate(val); } // ---------- 倍速拦截:所有倍速写入都会经过这里(含站内自定义倍速控件) ---------- function installRateHook() { if (rateHookInstalled) return; const proto = w.HTMLMediaElement && w.HTMLMediaElement.prototype; if (!proto) return; const desc = Object.getOwnPropertyDescriptor(proto, 'playbackRate'); if (!desc || !desc.set) return; rateHookInstalled = true; Object.defineProperty(proto, 'playbackRate', { configurable: true, enumerable: desc.enumerable, get: function () { return desc.get.call(this); }, set: function (value) { const raw = Number(value); const wanted = (isFinite(raw) && raw > 0) ? raw : 1; const fixed = clampRate(wanted); // 上下限 0.25x - 4x,异常值直接收敛 const fromScript = Player.internal; desc.set.call(this, fixed); // 真正写入