// ==UserScript== // @name M-Team / NexusPHP 列表滚动优化器 // @namespace https://github.com/nexusphp-userscripts // @version 2.5.0 // @description 优化种子列表大海报加载与滚动性能,并提供不裁切的高清悬浮预览。 // @author zxszx // @license MIT // @homepageURL https://github.com/zxszx/mteam-scroll-optimizer // @supportURL https://github.com/zxszx/mteam-scroll-optimizer/issues // @downloadURL https://raw.githubusercontent.com/zxszx/mteam-scroll-optimizer/main/mteam-scroll-optimizer.user.js // @updateURL https://raw.githubusercontent.com/zxszx/mteam-scroll-optimizer/main/mteam-scroll-optimizer.user.js // @match *://m-team.cc/* // @match *://*.m-team.cc/* // @match *://m-team.io/* // @match *://*.m-team.io/* // @run-at document-start // @noframes // @grant GM_addStyle // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // ==/UserScript== (function () { 'use strict'; const SCRIPT_TAG = '[M-Team Scroll Optimizer]'; const SETTING_PREFIX = 'mto.'; const defaults = Object.freeze({ enabled: true, blockHoverWhileScrolling: true, lazyListImages: true, tightPosterLoading: true, serializePosterDecoding: true, posterLoadConcurrency: 2, preferSmallDmmPosters: false, posterOptimizationDelayMs: 120, hoverPreview: true, previewDelayMs: 180, previewUpscaleSmallImages: true, previewUpscaleMax: 2, previewMinLongEdgePx: 720, modernRowContainment: true, experimentalTableRowContainment: false, quietDelayMs: 220, debug: false, }); const stats = { listRoots: 0, rows: 0, modernRows: 0, tableRows: 0, images: 0, heavyImages: 0, posterImages: 0, posterParks: 0, posterRestores: 0, serializedPosterLoads: 0, posterQueueDrops: 0, smallPosterRewrites: 0, smallPosterFallbacks: 0, previewRequests: 0, previewLoads: 0, previewErrors: 0, blockedHoverEvents: 0, hiddenPreviewOverlays: 0, scanBatches: 0, }; function readSetting(name) { try { return typeof GM_getValue === 'function' ? GM_getValue(SETTING_PREFIX + name, defaults[name]) : defaults[name]; } catch (error) { console.warn(SCRIPT_TAG, `读取设置 ${name} 失败`, error); return defaults[name]; } } function writeSetting(name, value) { try { if (typeof GM_setValue === 'function') { GM_setValue(SETTING_PREFIX + name, value); } } catch (error) { console.warn(SCRIPT_TAG, `保存设置 ${name} 失败`, error); } } const settings = Object.fromEntries( Object.keys(defaults).map((name) => [name, readSetting(name)]), ); function registerMenu(label, callback) { if (typeof GM_registerMenuCommand === 'function') { GM_registerMenuCommand(label, callback); } } function registerToggle(name, title, reload = true) { const state = settings[name] ? '✓' : '✗'; registerMenu(`${state} ${title}`, () => { writeSetting(name, !settings[name]); if (reload) { location.reload(); } }); } registerToggle('enabled', '启用优化器'); registerToggle('blockHoverWhileScrolling', '滚动时屏蔽悬浮预览'); registerToggle('lazyListImages', '列表图片延迟加载'); registerToggle('tightPosterLoading', '海报仅在视口附近加载'); registerToggle('serializePosterDecoding', '海报限流加载解码(当前屏优先)'); registerToggle('preferSmallDmmPosters', '列表优先使用 DMM 小海报'); registerToggle('hoverPreview', '鼠标悬浮显示高清完整海报'); registerToggle('previewUpscaleSmallImages', '悬浮预览适度放大小图'); registerToggle('modernRowContainment', '现代列表屏外渲染优化'); registerToggle('experimentalTableRowContainment', '实验:传统表格行屏外渲染'); registerToggle('debug', '调试日志'); registerMenu('输出本页优化统计', () => { console.table({ ...stats, scrolling: isScrolling }); console.info(SCRIPT_TAG, '设置', { ...settings }); }); if (!settings.enabled) { console.info(SCRIPT_TAG, '已停用,可从 Tampermonkey 菜单重新启用。'); return; } const DETAIL_LINK_SELECTOR = [ 'a[href*="details.php?id="]', 'a[href*="/torrent/"]', 'a[href*="/torrents/"]', 'a[href*="/detail/"]', 'a[href*="/details/"]', 'a[href*="torrentId="]', ].join(','); const ROW_SELECTOR = [ 'tr', '[role="row"]', '.torrent-list-row', '[class*="torrent-list-row"]', '[class*="TorrentList_row"]', '[class*="MuiTableRow-root"]', '.ant-table-row', ].join(','); const MODERN_ROOT_SELECTOR = [ '[role="rowgroup"]', '[class*="MuiTableBody-root"]', '.ant-table-tbody', '.torrent-list', '[class*="torrent-list"]', '[class*="TorrentList"]', ].join(','); const KNOWN_LIST_SELECTOR = [ 'table.torrents', 'table#torrenttable', 'table[id*="torrent"]', 'table[class*="torrent"]', '.torrent-list', '[class*="torrent-list"]', '[class*="TorrentList"]', ].join(','); const PREVIEW_CANDIDATE_SELECTOR = [ '[role="tooltip"]', '#overDiv', '#tooltip', '.MuiTooltip-popper', '.MuiPopover-root', '.ant-tooltip', '.ant-popover', '[class*="tooltip" i]', '[class*="popover" i]', '[class*="hoverbox" i]', ].join(','); // 保存页中的实际列表海报选择器。页面 CSS 已把它固定为 52x70px, // 没有必要为这个尺寸解码 0.5–3.5MB 的 pl.jpg 原图。 const POSTER_SELECTOR = 'img.torrent-list__thumbnail'; const TRANSPARENT_PIXEL = 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs='; const css = ` /* 仅现代块级列表行默认启用。auto 会保留可访问性与页内查找。 */ html.mto-modern-containment [data-mto-modern-row] { content-visibility: auto !important; contain-intrinsic-block-size: auto 64px !important; } /* table-row containment 存在浏览器兼容风险,因此只作为菜单中的实验项。 */ html.mto-table-containment [data-mto-table-row] { content-visibility: auto !important; contain-intrinsic-block-size: auto 64px !important; } /* 滚动时只暂停列表内的 CSS 动画,不对整个页面做通配符样式失效。 */ html.mto-scrolling [data-mto-list] img, html.mto-scrolling [data-mto-list] [class*="icon" i], html.mto-scrolling [data-mto-list] [class*="badge" i] { animation-play-state: paused !important; } /* 隐藏已经打开的预览。visibility 不改变布局,也不会影响普通弹窗。 */ html.mto-scrolling [data-mto-preview-overlay], html.mto-scrolling #overDiv, html.mto-scrolling #tooltip, html.mto-scrolling .MuiTooltip-popper, html.mto-scrolling .ant-tooltip, html.mto-scrolling .ant-popover { visibility: hidden !important; pointer-events: none !important; } /* 独立预览层不继承 .dmm-poster 的 object-position:right 裁切规则。 */ #mto-hover-preview { position: fixed !important; inset: 0 !important; z-index: 2147483646 !important; display: none !important; place-items: center !important; padding: 4vh 4vw !important; box-sizing: border-box !important; pointer-events: none !important; background: transparent !important; } #mto-hover-preview[data-visible="true"] { display: grid !important; } #mto-hover-preview[data-status="loading"]::before { content: "加载高清预览…"; position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); padding: 8px 12px; border-radius: 6px; color: #fff; background: rgba(0, 0, 0, 0.72); box-shadow: 0 4px 18px rgba(0, 0, 0, 0.32); font: 13px/1.4 system-ui, sans-serif; } #mto-hover-preview-image { display: none !important; width: var(--mto-preview-width, auto) !important; height: var(--mto-preview-height, auto) !important; min-width: 0 !important; min-height: 0 !important; max-width: min(92vw, 1400px) !important; max-height: 88vh !important; margin: 0 !important; border: 1px solid rgba(255, 255, 255, 0.78) !important; border-radius: 5px !important; box-sizing: border-box !important; object-fit: contain !important; object-position: center center !important; transform: none !important; box-shadow: 0 10px 38px rgba(0, 0, 0, 0.58) !important; background: rgba(18, 18, 18, 0.92) !important; } #mto-hover-preview[data-status="ready"] #mto-hover-preview-image { display: block !important; } html.mto-scrolling #mto-hover-preview { display: none !important; } /* 旧“馒头种子列表页图片预览”使用这个 id。即使旧脚本未停用, 也只保留新版的单一预览层,避免大小两张图片叠加。 */ html #img-preview { display: none !important; visibility: hidden !important; pointer-events: none !important; } `; if (typeof GM_addStyle === 'function') { GM_addStyle(css); } else { const style = document.createElement('style'); style.textContent = css; document.documentElement.appendChild(style); } const rootElement = document.documentElement; rootElement.classList.toggle('mto-modern-containment', Boolean(settings.modernRowContainment)); rootElement.classList.toggle( 'mto-table-containment', Boolean(settings.experimentalTableRowContainment), ); let isScrolling = false; let scrollingUntil = 0; let scrollingTimer = 0; function finishScrollingWhenQuiet() { const remaining = scrollingUntil - performance.now(); if (remaining > 0) { scrollingTimer = window.setTimeout(finishScrollingWhenQuiet, remaining); return; } scrollingTimer = 0; isScrolling = false; rootElement.classList.remove('mto-scrolling'); pumpPosterRestoreQueue(); debugLog('滚动结束'); } function noteScrolling() { scrollingUntil = performance.now() + Number(settings.quietDelayMs || 220); if (!isScrolling) { isScrolling = true; rootElement.classList.add('mto-scrolling'); hideHoverPreview(true, 'scroll'); tagVisiblePreviewOverlays(document); debugLog('滚动开始'); } // 使用一个按截止时间续期的定时器,避免每个 scroll 事件都 clear/setTimeout。 if (!scrollingTimer) { scrollingTimer = window.setTimeout( finishScrollingWhenQuiet, Number(settings.quietDelayMs || 220), ); } } window.addEventListener('scroll', noteScrolling, { capture: true, passive: true }); window.addEventListener('wheel', noteScrolling, { capture: true, passive: true }); window.addEventListener('touchmove', noteScrolling, { capture: true, passive: true }); const posterStates = new WeakMap(); let posterOptimizationActive = false; const posterRestoreQueue = new Set(); const activePosterLoads = new Set(); const posterObserver = typeof IntersectionObserver === 'function' ? new IntersectionObserver(handlePosterIntersections, { // 比 Chromium 原生 lazy loading 的预取窗口更窄,但仍预留约十余行。 root: null, rootMargin: '900px 0px', threshold: 0, }) : null; function smallDmmPosterUrl(source) { if (!settings.preferSmallDmmPosters || !source || source.startsWith('data:')) { return source; } // DMM/FANZA 商品图常用 *pl.jpg 表示大图、*ps.jpg 表示列表小图。 // 只改文件名后缀;若站点或作品没有小图,会由 error 处理器回退。 return source.replace(/pl(\.jpe?g)(?=([?#]|$))/i, 'ps$1'); } function setPosterSource(image, source) { const state = posterStates.get(image); if (!state || image.getAttribute('src') === source) { return; } state.scriptIsWriting = true; image.setAttribute('src', source); image.removeAttribute('srcset'); queueMicrotask(() => { const latest = posterStates.get(image); if (latest) { latest.scriptIsWriting = false; } }); } function parkPoster(image, force = false) { const state = posterStates.get(image); if (!state || (!force && state.intersecting)) { return; } if (state.restoreQueued) { state.restoreQueued = false; posterRestoreQueue.delete(image); stats.posterQueueDrops += 1; } if (state.cancelPendingLoad) { state.cancelPendingLoad(); state.cancelPendingLoad = null; } if (state.parked) { return; } state.parked = true; setPosterSource(image, TRANSPARENT_PIXEL); stats.posterParks += 1; } function restorePoster(image) { const state = posterStates.get(image); if (!state) { return; } state.parked = false; const source = state.smallFailed ? state.originalSrc : state.preferredSrc; setPosterSource(image, source); stats.posterRestores += 1; } function waitForPosterDecode(image) { const state = posterStates.get(image); if (!state) { return Promise.resolve(); } return new Promise((resolve) => { let finished = false; const timeout = window.setTimeout(finish, 5000); function finish() { if (finished) { return; } finished = true; clearTimeout(timeout); image.removeEventListener('load', onLoad); image.removeEventListener('error', onError); if (state.cancelPendingLoad === finish) { state.cancelPendingLoad = null; } resolve(); } function decodeThenFinish() { if (typeof image.decode === 'function') { image.decode().catch(() => {}).finally(finish); } else { finish(); } } function onLoad() { decodeThenFinish(); } function onError() { // 小图错误处理器会先切回原图;下一任务要等回退图完成解码。 window.setTimeout(decodeThenFinish, 0); } state.cancelPendingLoad = finish; if (image.complete) { if (image.naturalWidth > 0) { decodeThenFinish(); } else { // complete 也可能表示请求已经失败;不能让坏图独占队列 5 秒。 finish(); } } else { image.addEventListener('load', onLoad, { once: true, passive: true }); image.addEventListener('error', onError, { once: true, passive: true }); } }); } function queuePosterRestore(image) { const state = posterStates.get(image); if (!state || state.restoreQueued || activePosterLoads.has(image) || (!state.parked && image.complete && image.naturalWidth > 0)) { return; } state.restoreQueued = true; posterRestoreQueue.add(image); pumpPosterRestoreQueue(); } function posterRestorePriority(image) { const rect = image.getBoundingClientRect(); const viewportHeight = Math.max(document.documentElement.clientHeight, window.innerHeight || 0); const visible = rect.bottom > 0 && rect.top < viewportHeight; const distance = visible ? Math.abs((rect.top + rect.bottom) / 2 - viewportHeight / 2) : rect.top >= viewportHeight ? rect.top - viewportHeight : -rect.bottom; // 当前屏幕内的缩略图永远先于上下预加载区,屏内再从中心向外加载。 return (visible ? -1_000_000 : 0) + distance; } function takeNextPosterRestore() { let selectedImage = null; let selectedState = null; let selectedPriority = Infinity; for (const image of posterRestoreQueue) { const state = posterStates.get(image); if (!state?.restoreQueued || !state.intersecting || !image.isConnected) { posterRestoreQueue.delete(image); if (state?.restoreQueued) { state.restoreQueued = false; stats.posterQueueDrops += 1; } continue; } const priority = posterRestorePriority(image); if (priority < selectedPriority) { selectedImage = image; selectedState = state; selectedPriority = priority; } } if (selectedImage && selectedState) { posterRestoreQueue.delete(selectedImage); selectedState.restoreQueued = false; return { image: selectedImage, state: selectedState }; } return null; } function pumpPosterRestoreQueue() { if (isScrolling) { return; } const concurrency = Math.min(4, Math.max(1, Number(settings.posterLoadConcurrency) || 2)); while (activePosterLoads.size < concurrency) { const next = takeNextPosterRestore(); if (!next) { return; } const { image } = next; activePosterLoads.add(image); image.fetchPriority = posterRestorePriority(image) < 0 ? 'high' : 'low'; restorePoster(image); stats.serializedPosterLoads += 1; waitForPosterDecode(image).finally(() => { activePosterLoads.delete(image); const latest = posterStates.get(image); // 加载期间可能滚出再滚回;待旧任务收尾后必须重新入队。 if (latest?.intersecting && latest.parked && image.isConnected) { queuePosterRestore(image); } requestAnimationFrame(pumpPosterRestoreQueue); }); } } function handlePosterIntersections(entries) { for (const entry of entries) { const image = entry.target; const state = posterStates.get(image); if (!state) { continue; } state.intersecting = entry.isIntersecting; if (entry.isIntersecting) { if (state.parkTimer) { clearTimeout(state.parkTimer); state.parkTimer = 0; } if (settings.serializePosterDecoding) { queuePosterRestore(image); } else { restorePoster(image); } } else if (settings.tightPosterLoading && !state.parkTimer) { if (state.restoreQueued) { state.restoreQueued = false; posterRestoreQueue.delete(image); stats.posterQueueDrops += 1; } // 避免在视口边缘来回滚动时反复切换图片。 state.parkTimer = window.setTimeout(() => { state.parkTimer = 0; parkPoster(image); }, 800); } } } function preparePoster(image) { if (!(image instanceof HTMLImageElement) || !image.matches(POSTER_SELECTOR) || posterStates.has(image)) { return; } const originalSrc = image.getAttribute('src'); if (!originalSrc || originalSrc.startsWith('data:')) { return; } const preferredSrc = smallDmmPosterUrl(originalSrc); const state = { originalSrc, preferredSrc, originalSrcset: image.getAttribute('srcset') || '', intersecting: true, parked: false, smallFailed: false, scriptIsWriting: false, parkTimer: 0, restoreQueued: false, cancelPendingLoad: null, }; posterStates.set(image, state); image.setAttribute('data-mto-poster', ''); stats.posterImages += 1; if (preferredSrc !== originalSrc) { stats.smallPosterRewrites += 1; } image.addEventListener('error', () => { const current = posterStates.get(image); if (!current || current.smallFailed || image.getAttribute('src') !== current.preferredSrc) { return; } current.smallFailed = true; stats.smallPosterFallbacks += 1; if (current.intersecting) { setPosterSource(image, current.originalSrc); } else { parkPoster(image); } }, { passive: true }); image.decoding = 'async'; image.loading = 'lazy'; if (posterObserver && posterOptimizationActive) { if (settings.serializePosterDecoding) { state.intersecting = false; parkPoster(image, true); } posterObserver.observe(image); } } function activatePosterOptimization() { if (posterOptimizationActive) { return; } posterOptimizationActive = true; if (!posterObserver) { return; } document.querySelectorAll(POSTER_SELECTOR).forEach((image) => { preparePoster(image); const state = posterStates.get(image); if (settings.serializePosterDecoding && state) { state.intersecting = false; parkPoster(image, true); } posterObserver.observe(image); }); debugLog('海报视口调度已延迟启用'); } function schedulePosterOptimizationActivation() { const beginAfterFirstPaint = () => { requestAnimationFrame(() => { requestAnimationFrame(() => { window.setTimeout( activatePosterOptimization, Math.max(0, Number(settings.posterOptimizationDelayMs || 120)), ); }); }); }; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', beginAfterFirstPaint, { once: true }); } else { beginAfterFirstPaint(); } } function preparePosters(container) { if (!(container instanceof Element || container instanceof Document)) { return; } if (container instanceof HTMLImageElement) { preparePoster(container); } container.querySelectorAll(POSTER_SELECTOR).forEach(preparePoster); } function handlePosterSourceMutation(image) { const state = posterStates.get(image); if (!state || state.scriptIsWriting) { return; } const source = image.getAttribute('src'); if (!source || source === TRANSPARENT_PIXEL || source === state.preferredSrc) { return; } // React 重新渲染可能把 prop 中的大图 URL 写回 DOM;记住新值并重新调度。 state.originalSrc = source; state.preferredSrc = smallDmmPosterUrl(source); state.smallFailed = false; // 站点刚写回了真实地址,因此 DOM 已不再处于 parked 状态。 state.parked = false; if (state.intersecting) { if (settings.serializePosterDecoding) { queuePosterRestore(image); } else { restorePoster(image); } } else { parkPoster(image); } } let hoverPreviewElement = null; let hoverPreviewImage = null; let hoverPreviewTimer = 0; let hoverPreviewTrigger = null; let hoverPreviewToken = 0; let hoverPreviewFallback = ''; let hoverPreviewTriedFallback = false; function ensureHoverPreview() { if (hoverPreviewElement?.isConnected) { return hoverPreviewElement; } if (!document.body) { return null; } const preview = document.createElement('div'); preview.id = 'mto-hover-preview'; preview.setAttribute('aria-hidden', 'true'); preview.dataset.visible = 'false'; preview.dataset.status = 'idle'; const image = document.createElement('img'); image.id = 'mto-hover-preview-image'; image.alt = ''; image.decoding = 'async'; image.setAttribute('fetchpriority', 'high'); preview.appendChild(image); document.body.appendChild(preview); image.addEventListener('load', async () => { const token = Number(image.dataset.token || 0); try { await image.decode(); } catch (_) { // load 已成功;某些浏览器对已完成或缓存图片的 decode() 仍可能拒绝。 } if (token !== hoverPreviewToken || !hoverPreviewTrigger) { return; } sizeHoverPreviewImage(); preview.dataset.status = 'ready'; stats.previewLoads += 1; }, { passive: true }); image.addEventListener('error', () => { const token = Number(image.dataset.token || 0); if (token !== hoverPreviewToken) { return; } if (!hoverPreviewTriedFallback && hoverPreviewFallback && hoverPreviewFallback !== image.getAttribute('src')) { hoverPreviewTriedFallback = true; image.setAttribute('src', hoverPreviewFallback); return; } stats.previewErrors += 1; hideHoverPreview(true, 'image-error'); }, { passive: true }); hoverPreviewElement = preview; hoverPreviewImage = image; return preview; } function previewSourcesFor(image) { const state = posterStates.get(image); const original = state?.originalSrc || image.getAttribute('src') || ''; const current = image.currentSrc || image.getAttribute('src') || ''; const preferred = state?.preferredSrc || current; const fallback = [preferred, current] .find((source) => source && source !== original && source !== TRANSPARENT_PIXEL && !source.startsWith('data:')) || ''; return { original, fallback }; } function sizeHoverPreviewImage() { if (!hoverPreviewImage?.naturalWidth || !hoverPreviewImage?.naturalHeight) { return; } const naturalWidth = hoverPreviewImage.naturalWidth; const naturalHeight = hoverPreviewImage.naturalHeight; const longestEdge = Math.max(naturalWidth, naturalHeight); const desiredScale = settings.previewUpscaleSmallImages ? Math.max(1, Math.min( Number(settings.previewUpscaleMax || 2), Number(settings.previewMinLongEdgePx || 720) / longestEdge, )) : 1; const fitScale = Math.min( Math.min(window.innerWidth * 0.92, 1400) / naturalWidth, window.innerHeight * 0.88 / naturalHeight, ); const scale = Math.max(0.01, Math.min(desiredScale, fitScale)); hoverPreviewImage.style.setProperty( '--mto-preview-width', `${Math.round(naturalWidth * scale)}px`, ); hoverPreviewImage.style.setProperty( '--mto-preview-height', `${Math.round(naturalHeight * scale)}px`, ); } function showHoverPreview(image, trigger) { if (!settings.hoverPreview || isScrolling || !image?.isConnected || !trigger?.isConnected) { return; } const preview = ensureHoverPreview(); if (!preview) { return; } const { original, fallback } = previewSourcesFor(image); if (!original || original === TRANSPARENT_PIXEL || original.startsWith('data:')) { return; } hoverPreviewToken += 1; hoverPreviewTrigger = trigger; hoverPreviewFallback = fallback; hoverPreviewTriedFallback = false; hoverPreviewImage.dataset.token = String(hoverPreviewToken); hoverPreviewImage.style.removeProperty('--mto-preview-width'); hoverPreviewImage.style.removeProperty('--mto-preview-height'); hoverPreviewImage.removeAttribute('src'); preview.dataset.status = 'loading'; preview.dataset.visible = 'true'; preview.dataset.source = original; preview.dataset.lastHideReason = ''; hoverPreviewImage.setAttribute('src', original); stats.previewRequests += 1; } function hideHoverPreview(cancelPending = false, reason = 'manual') { if (hoverPreviewTimer) { clearTimeout(hoverPreviewTimer); hoverPreviewTimer = 0; } if (cancelPending) { hoverPreviewToken += 1; } hoverPreviewTrigger = null; hoverPreviewFallback = ''; if (hoverPreviewElement) { hoverPreviewElement.dataset.visible = 'false'; hoverPreviewElement.dataset.status = 'idle'; hoverPreviewElement.dataset.lastHideReason = reason; } if (hoverPreviewImage) { // 鼠标快速划走时中止尚未完成的高清图请求/解码。 hoverPreviewImage.removeAttribute('src'); } } function posterTriggerFromTarget(target) { if (!(target instanceof Element)) { return null; } const wrapper = target.closest('.ant-image, [data-mto-poster-trigger]'); if (wrapper) { const image = wrapper.querySelector(POSTER_SELECTOR); return image ? { image, trigger: wrapper } : null; } const image = target.closest(POSTER_SELECTOR); return image ? { image, trigger: image } : null; } function scheduleHoverPreview(image, trigger) { if (!settings.hoverPreview || isScrolling || hoverPreviewTrigger === trigger) { return; } hideHoverPreview(true, 'new-trigger'); hoverPreviewTrigger = trigger; const delay = Math.max(0, Number(settings.previewDelayMs || 180)); hoverPreviewTimer = window.setTimeout(() => { hoverPreviewTimer = 0; // 等待期间鼠标可能已经移开或滚动开始。 // pointerout/focusout 会清空 trigger;这里不依赖 :hover,避免 iframe、 // 浏览器无障碍焦点或 Shadow DOM 中 :hover 状态报告不一致。 if (hoverPreviewTrigger === trigger && !isScrolling) { showHoverPreview(image, trigger); } }, delay); } document.addEventListener('pointerover', (event) => { if (event.pointerType === 'touch') { return; } const match = posterTriggerFromTarget(event.target); if (!match || match.trigger.contains(event.relatedTarget)) { return; } scheduleHoverPreview(match.image, match.trigger); }, true); document.addEventListener('pointerout', (event) => { if (!hoverPreviewTrigger || hoverPreviewTrigger.contains(event.relatedTarget)) { return; } const match = posterTriggerFromTarget(event.target); if (match?.trigger === hoverPreviewTrigger) { hideHoverPreview(true, 'pointerout'); } }, true); document.addEventListener('focusin', (event) => { const match = posterTriggerFromTarget(event.target); if (match) { scheduleHoverPreview(match.image, match.trigger); } }, true); document.addEventListener('focusout', (event) => { if (hoverPreviewTrigger && !hoverPreviewTrigger.contains(event.relatedTarget)) { hideHoverPreview(true, 'focusout'); } }, true); document.addEventListener('keydown', (event) => { if (event.key === 'Escape') { hideHoverPreview(true, 'escape'); } }, true); window.addEventListener('blur', () => hideHoverPreview(true, 'window-blur'), { passive: true }); window.addEventListener('resize', () => { if (hoverPreviewElement?.dataset.status === 'ready') { sizeHoverPreviewImage(); } }, { passive: true }); document.addEventListener('visibilitychange', () => { if (document.hidden) { hideHoverPreview(true, 'document-hidden'); } }, { passive: true }); function findTorrentRow(element) { if (!(element instanceof Element)) { return null; } const row = element.closest(ROW_SELECTOR); if (!row || (row.tagName === 'TR' && !row.querySelector('td'))) { return null; } return row; } function findListRoot(row) { if (row.tagName === 'TR') { return row.closest('table'); } return row.closest(MODERN_ROOT_SELECTOR) || row.parentElement; } function markListRoot(root) { if (!(root instanceof Element) || root.hasAttribute('data-mto-list')) { return; } root.setAttribute('data-mto-list', ''); stats.listRoots += 1; } function markTorrentRow(row) { if (!(row instanceof Element) || row.hasAttribute('data-mto-row')) { return; } row.setAttribute('data-mto-row', ''); stats.rows += 1; if (row.tagName === 'TR') { row.setAttribute('data-mto-table-row', ''); stats.tableRows += 1; } else { row.setAttribute('data-mto-modern-row', ''); stats.modernRows += 1; } markListRoot(findListRoot(row)); processImages(row); } function looksLikeHeavyImage(image) { const source = String(image.currentSrc || image.src || image.dataset.src || ''); const hint = `${image.className || ''} ${image.alt || ''} ${source}`.toLowerCase(); const hinted = /cover|poster|preview|thumbnail|thumb|screenshot|douban|imdb/.test(hint); const declaredLarge = Number(image.getAttribute('width')) >= 96 || Number(image.getAttribute('height')) >= 96; const naturallyLarge = image.complete && (image.naturalWidth >= 160 || image.naturalHeight >= 160); return hinted || declaredLarge || naturallyLarge; } function processImage(image) { if (!(image instanceof HTMLImageElement) || image.hasAttribute('data-mto-image')) { return; } image.setAttribute('data-mto-image', ''); stats.images += 1; preparePoster(image); // decoding=async 不改变视觉结果,只允许浏览器避免在主线程同步解码。 image.decoding = 'async'; if (settings.lazyListImages && !image.hasAttribute('loading')) { image.loading = 'lazy'; } if (!image.matches(POSTER_SELECTOR) && looksLikeHeavyImage(image)) { stats.heavyImages += 1; if (settings.lazyListImages && !image.hasAttribute('fetchpriority')) { image.setAttribute('fetchpriority', 'low'); } } else if (!image.complete) { // 图片完成后只做一次分类;不会在滚动路径中读取布局。 image.addEventListener('load', () => { if (looksLikeHeavyImage(image)) { stats.heavyImages += 1; } }, { once: true, passive: true }); } } function processImages(container) { if (!(container instanceof Element || container instanceof Document)) { return; } if (container instanceof HTMLImageElement) { processImage(container); } container.querySelectorAll('img').forEach(processImage); } function inspectDetailLinks(container) { if (!(container instanceof Element || container instanceof Document)) { return; } if (container instanceof Element && container.matches(DETAIL_LINK_SELECTOR)) { markTorrentRow(findTorrentRow(container)); } container.querySelectorAll(DETAIL_LINK_SELECTOR).forEach((link) => { markTorrentRow(findTorrentRow(link)); }); } function inspectKnownLists(container) { if (!(container instanceof Element || container instanceof Document)) { return; } const candidates = []; if (container instanceof Element && container.matches(KNOWN_LIST_SELECTOR)) { candidates.push(container); } candidates.push(...container.querySelectorAll(KNOWN_LIST_SELECTOR)); for (const candidate of candidates) { const rows = candidate.querySelectorAll(ROW_SELECTOR); // 至少 3 个数据行,避免把导航栏里名称碰巧含 torrent 的节点算成列表。 const dataRows = [...rows].filter((row) => row.tagName !== 'TR' || row.querySelector('td')); if (dataRows.length < 3) { continue; } markListRoot(candidate); dataRows.forEach(markTorrentRow); } } function tagPreviewOverlay(element) { if (!(element instanceof Element) || element.hasAttribute('data-mto-preview-overlay')) { return; } const roleIsTooltip = element.getAttribute('role') === 'tooltip'; const containsImage = element instanceof HTMLImageElement || Boolean(element.querySelector('img')); if (!roleIsTooltip && !containsImage) { return; } const position = getComputedStyle(element).position; if (roleIsTooltip || position === 'fixed' || position === 'absolute') { element.setAttribute('data-mto-preview-overlay', ''); stats.hiddenPreviewOverlays += 1; } } function tagVisiblePreviewOverlays(container) { if (!(container instanceof Element || container instanceof Document)) { return; } if (container instanceof Element && container.matches(PREVIEW_CANDIDATE_SELECTOR)) { tagPreviewOverlay(container); } container.querySelectorAll(PREVIEW_CANDIDATE_SELECTOR).forEach(tagPreviewOverlay); } function isPreviewTrigger(target) { if (!(target instanceof Element)) { return false; } const row = target.closest('[data-mto-row]'); if (!row) { return false; } return Boolean(target.closest('a, img, [data-src], [data-original], [class*="preview" i]')); } function blockHoverDuringScroll(event) { if (!settings.blockHoverWhileScrolling || !isScrolling || !isPreviewTrigger(event.target)) { return; } // 不 preventDefault:点击、滚轮和键盘行为保持原样;仅阻止站点收到“移入”。 event.stopImmediatePropagation(); stats.blockedHoverEvents += 1; } for (const eventName of ['pointerover', 'mouseover', 'mouseenter']) { document.addEventListener(eventName, blockHoverDuringScroll, true); } function debugLog(...args) { if (settings.debug) { console.debug(SCRIPT_TAG, ...args); } } const pendingRoots = new Set(); let scanHandle = 0; function scheduleScan(node) { if (!(node instanceof Element || node instanceof Document)) { return; } // React 一次 commit 可能产生大量互相包含的 addedNodes。只保留最外层根, // 防止对同一批 6000+ DOM 节点重复 querySelectorAll,拖慢首屏。 for (const root of pendingRoots) { if (root === node || root.contains?.(node)) { return; } if (node.contains?.(root)) { pendingRoots.delete(root); } } pendingRoots.add(node); if (scanHandle) { return; } const flush = () => { scanHandle = 0; const roots = [...pendingRoots]; pendingRoots.clear(); stats.scanBatches += 1; for (const root of roots) { inspectDetailLinks(root); inspectKnownLists(root); if (isScrolling) { tagVisiblePreviewOverlays(root); } } debugLog('扫描完成', { ...stats }); }; if (typeof requestIdleCallback === 'function') { scanHandle = requestIdleCallback(flush, { timeout: 180 }); } else { scanHandle = window.setTimeout(flush, 32); } } const observer = new MutationObserver((mutations) => { const addedRoots = new Set(); const collectRoot = (node) => { if (!(node instanceof Element || node instanceof Document)) { return; } for (const root of addedRoots) { if (root === node || root.contains?.(node)) { return; } if (node.contains?.(root)) { addedRoots.delete(root); } } addedRoots.add(node); }; for (const mutation of mutations) { if (mutation.type === 'attributes') { if (mutation.target instanceof HTMLImageElement && mutation.target.matches(POSTER_SELECTOR)) { handlePosterSourceMutation(mutation.target); } continue; } for (const node of mutation.addedNodes) { collectRoot(node); } } for (const node of addedRoots) { preparePosters(node); scheduleScan(node); } }); observer.observe(document, { childList: true, subtree: true, attributes: true, attributeFilter: ['src', 'srcset'], }); // 捕获动态图片的 load,兼容 React/Vue 先插入空 img、稍后再设置 src 的情况。 document.addEventListener('load', (event) => { const image = event.target; if (image instanceof HTMLImageElement && image.closest('[data-mto-row]')) { processImage(image); } }, true); if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { preparePosters(document); scheduleScan(document); }, { once: true }); } else { preparePosters(document); scheduleScan(document); } schedulePosterOptimizationActivation(); })();