// ==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); // 真正写入