Files
Tampermonkey_scripts/Chaturbate/chaturbate-thumbnails-2x.user.js

1062 lines
55 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ==UserScript==
// @name Chaturbate 缩略图放大 2 倍
// @namespace https://chaturbate.com/
// @version 0.10.7
// @description 放大当前 Chaturbate 房间列表、发现页轮播、关注下拉与悬停预览缩略图
// @match https://chaturbate.com/*
// @match https://*.chaturbate.com/*
// @updateURL http://127.0.0.1:49234/chaturbate-thumbnails-2x.user.js
// @downloadURL http://127.0.0.1:49234/chaturbate-thumbnails-2x.user.js
// @run-at document-start
// @grant GM_addStyle
// @grant GM_registerMenuCommand
// @grant unsafeWindow
// ==/UserScript==
/*
* --- Microsoft Edge脚本完全不生效时可先试 ---
*
* 1) 地址栏打开 edge://extensions开启「开发人员模式」Developer mode位置依版本可能在左侧栏或页面底部
* 2) 无痕窗口Tampermonkey →「详细信息」→「在 InPrivate 中允许」。
* 3) Tampermonkey 仪表盘确认脚本已启用;域名匹配当前站点(含 zh-hans 等语言子域)。
*/
/*
* 工作原则:
*
* 1) 只做“站点原始尺寸 * 倍率”的放大。
* 不重写站点原有排版模型,不用 object-fit / aspect-ratio / 自定义行高去替代站点布局。
* 图片宽高和卡片宽度使用 THUMBNAIL_SCALE卡片整体高度使用 CARD_HEIGHT_SCALE。
*
* 2) 各模块必须自己探测原始值,再乘倍率。
* 没有探测到原始值,就不放大该模块;不拿默认尺寸猜页面。
*
* 3) 延迟探测,不临时禁用样式。
* CSS 先注入但所有模块默认关闭;等页面原始 DOM 渲染后,逐个模块探测原始尺寸,成功后才打开该模块。
* 模块一旦探测成功就锁定,避免后续 mutation 把已经放大的尺寸当成原始值再次相乘。
*
* 4) 探测结果缓存的是“原始尺寸”,不是放大后的尺寸。
* 缓存按模块保存;命中缓存就直接设置 CSS 变量并启用模块,未命中才延迟探测。
* 油猴菜单可清除缓存,清除后刷新页面即可重新探测。
*
* 5) 顶部“关注”弹窗的 FOLLOW_DROPDOWN_SHIFT_X 只是位置微调,不属于缩略图倍率。
*
* 6) DEBUG 只用于控制台与 window.tmThumbScaleDebug() 诊断,不应该弹窗或显示页面调试面板。
*/
(function () {
"use strict";
// ========== 配置项 ==========
// 图片宽高和卡片宽度倍率2 表示按站点原始尺寸放大到 2 倍。
const THUMBNAIL_SCALE = 2;
// 卡片整体高度倍率:信息栏、边距等不需要和图片一样放大到 2 倍,过大会产生空白。
const CARD_HEIGHT_SCALE = 1.55;
// 顶部「关注」弹出层水平偏移;只调位置,不参与缩略图放大倍率。
const FOLLOW_DROPDOWN_SHIFT_X = 10;
// 调试开关控制控制台日志、html data-tm-* 标记和 window.tmThumbScaleDebug()。
const DEBUG = true;
// ===========================
const VER = "0.10.7";
// 缓存只保存“站点原始尺寸”,不要保存乘过倍率后的尺寸。
// 这样以后只改 THUMBNAIL_SCALE / CARD_HEIGHT_SCALE 时,可以让缓存自动失效并重新探测,避免旧倍率污染新布局。
const CACHE_KEY = "tm-thumb-scale:size-cache:v7";
const CACHE_SCHEMA = 7;
const log = (...args) => {
if (DEBUG) console.log("[tm-thumb-scale]", ...args);
};
if (DEBUG) {
try {
document.documentElement.setAttribute("data-tm-thumb-scale", "1");
document.documentElement.setAttribute("data-tm-thumb-scale-ver", VER);
} catch (_) {}
}
const css = `
/*
* CSS 总开关说明:
*
* 下面每一组规则都挂在 html[data-tm-thumb-scale-xxx="1"] 后面。
* JS 没有探测到对应模块的原始尺寸前,不会设置这个 data 属性。
* 这样 CSS 可以提前注入,但不会提前改变页面原始布局,延迟探测才能读到真实原始值。
*/
/* 首页当前列表 */
/*
* home 模块:
* - 适用范围:主页、分类页等普通 #roomlist_root 房间列表。
* - 探测值:卡片原始宽高 + 缩略图原始宽高。
* - 放大方式:卡片和缩略图分别按自己的原始宽高乘倍率。
* - 注意:关注页列表单独用 followingList 模块,不和 home 共用缓存。
*/
html[data-tm-thumb-scale-home="1"] #main #roomlist_root ul.RoomCardGrid,
html[data-tm-thumb-scale-home="1"] #roomlist_root ul.RoomCardGrid,
html[data-tm-thumb-scale-home="1"] #main #roomlist_root ul.list:has(li.roomCard),
html[data-tm-thumb-scale-home="1"] #roomlist_root ul.list:has(li.roomCard) {
/* 改成 grid是为了让放大后的卡片按新宽度自然换行。 */
display: grid !important;
/* --tm-thumb-home-width 来自当前页面原始卡片宽度JS 写入时已经乘过倍率。 */
grid-template-columns: repeat(auto-fill, minmax(var(--tm-thumb-home-width), 1fr)) !important;
/* 保留一个稳定间距,避免放大后卡片互相贴住。 */
gap: 0.6em 0.75em !important;
}
html[data-tm-thumb-scale-home="1"] #roomlist_root ul.RoomCardGrid > li.RoomCard,
html[data-tm-thumb-scale-home="1"] #roomlist_root ul.list:has(li.roomCard) li.roomCard {
/* 列宽交给 grid 控制,清掉站点原本写死的 li 宽度限制。 */
width: auto !important;
min-width: 0 !important;
max-width: none !important;
height: var(--tm-thumb-home-card-height) !important;
min-height: var(--tm-thumb-home-card-height) !important;
max-height: var(--tm-thumb-home-card-height) !important;
}
html[data-tm-thumb-scale-home="1"] #roomlist_root ul.RoomCardGrid .RoomCardThumbnail,
html[data-tm-thumb-scale-home="1"] #roomlist_root ul.list:has(li.roomCard) .room_thumbnail_container {
width: var(--tm-thumb-home-thumb-width) !important;
height: var(--tm-thumb-home-thumb-height) !important;
display: block !important;
max-width: none !important;
box-sizing: border-box !important;
}
html[data-tm-thumb-scale-home="1"] #roomlist_root ul.RoomCardGrid .RoomCardThumbnail img,
html[data-tm-thumb-scale-home="1"] #roomlist_root ul.list:has(li.roomCard) .room_thumbnail_container img,
html[data-tm-thumb-scale-home="1"] #roomlist_root ul.list:has(li.roomCard) .room_thumbnail {
width: var(--tm-thumb-home-thumb-width) !important;
height: var(--tm-thumb-home-thumb-height) !important;
/* block 可以消掉图片 inline baseline 带来的细小空隙。 */
display: block !important;
max-width: none !important;
box-sizing: border-box !important;
}
/* 关注页当前列表 */
/*
* followingList 模块:
* - 适用范围:/followed-cams/ 关注页里的 #roomlist_root 房间列表。
* - 为什么和 home 分开:两者 DOM 选择器很像,但站点可能给不同页面不同原始列宽。
* - 缓存也单独存 followingList避免首页探测值污染关注页。
* - 探测值同样是 card 宽高 + thumb 宽高四项。
*/
html[data-tm-thumb-scale-following-list="1"] #main #roomlist_root ul.RoomCardGrid,
html[data-tm-thumb-scale-following-list="1"] #roomlist_root ul.RoomCardGrid,
html[data-tm-thumb-scale-following-list="1"] #main #roomlist_root ul.list:has(li.roomCard),
html[data-tm-thumb-scale-following-list="1"] #roomlist_root ul.list:has(li.roomCard) {
display: grid !important;
grid-template-columns: repeat(auto-fill, minmax(var(--tm-thumb-following-list-width), 1fr)) !important;
gap: 0.6em 0.75em !important;
}
html[data-tm-thumb-scale-following-list="1"] #roomlist_root ul.RoomCardGrid > li.RoomCard,
html[data-tm-thumb-scale-following-list="1"] #roomlist_root ul.list:has(li.roomCard) li.roomCard {
width: auto !important;
min-width: 0 !important;
max-width: none !important;
height: var(--tm-thumb-following-list-card-height) !important;
min-height: var(--tm-thumb-following-list-card-height) !important;
max-height: var(--tm-thumb-following-list-card-height) !important;
}
html[data-tm-thumb-scale-following-list="1"] #roomlist_root ul.RoomCardGrid .RoomCardThumbnail,
html[data-tm-thumb-scale-following-list="1"] #roomlist_root ul.list:has(li.roomCard) .room_thumbnail_container {
width: var(--tm-thumb-following-list-thumb-width) !important;
height: var(--tm-thumb-following-list-thumb-height) !important;
display: block !important;
max-width: none !important;
box-sizing: border-box !important;
}
html[data-tm-thumb-scale-following-list="1"] #roomlist_root ul.RoomCardGrid .RoomCardThumbnail img,
html[data-tm-thumb-scale-following-list="1"] #roomlist_root ul.list:has(li.roomCard) .room_thumbnail_container img,
html[data-tm-thumb-scale-following-list="1"] #roomlist_root ul.list:has(li.roomCard) .room_thumbnail {
width: var(--tm-thumb-following-list-thumb-width) !important;
height: var(--tm-thumb-following-list-thumb-height) !important;
display: block !important;
max-width: none !important;
box-sizing: border-box !important;
}
/* 直播间页底部「更多这样的直播间」 */
/*
* related 模块:
* - 适用范围:直播间页面底部“更多这样的房间”。
* - 探测值:卡片原始宽高 + 缩略图原始宽高。
* - 放大方式:卡片和缩略图分别按自己的原始宽高乘倍率,避免把 card 宽度误当 thumb 宽度。
*/
html[data-tm-thumb-scale-related="1"] .BaseRoomContents ul.RoomCardGrid,
html[data-tm-thumb-scale-related="1"] #main.roomPage ul.list:has(li.roomCard),
html[data-tm-thumb-scale-related="1"] .BaseRoomContents ul.list:has(li.roomCard) {
display: grid !important;
grid-template-columns: repeat(auto-fill, minmax(var(--tm-thumb-related-width), 1fr)) !important;
gap: 0.6em 0.75em !important;
}
html[data-tm-thumb-scale-related="1"] .BaseRoomContents ul.RoomCardGrid > li.RoomCard,
html[data-tm-thumb-scale-related="1"] #main.roomPage ul.list:has(li.roomCard) li.roomCard,
html[data-tm-thumb-scale-related="1"] .BaseRoomContents ul.list:has(li.roomCard) li.roomCard {
width: auto !important;
min-width: 0 !important;
max-width: none !important;
/* 卡片整体高度也要放大;只放大缩略图高度时,下方用户名/人数栏可能被压住。 */
height: var(--tm-thumb-related-card-height) !important;
min-height: var(--tm-thumb-related-card-height) !important;
max-height: var(--tm-thumb-related-card-height) !important;
}
html[data-tm-thumb-scale-related="1"] .BaseRoomContents ul.RoomCardGrid .RoomCardThumbnail,
html[data-tm-thumb-scale-related="1"] #main.roomPage ul.list:has(li.roomCard) .room_thumbnail_container,
html[data-tm-thumb-scale-related="1"] .BaseRoomContents ul.list:has(li.roomCard) .room_thumbnail_container {
/* --tm-thumb-related-thumb-width 来自原始缩略图宽度,不直接复用卡片宽度。 */
width: var(--tm-thumb-related-thumb-width) !important;
/* --tm-thumb-related-height 来自原始缩略图容器高度。 */
height: var(--tm-thumb-related-height) !important;
display: block !important;
max-width: none !important;
box-sizing: border-box !important;
}
html[data-tm-thumb-scale-related="1"] .BaseRoomContents ul.RoomCardGrid .RoomCardThumbnail img,
html[data-tm-thumb-scale-related="1"] #main.roomPage ul.list:has(li.roomCard) .room_thumbnail_container img,
html[data-tm-thumb-scale-related="1"] #main.roomPage ul.list:has(li.roomCard) .room_thumbnail,
html[data-tm-thumb-scale-related="1"] .BaseRoomContents ul.list:has(li.roomCard) .room_thumbnail_container img,
html[data-tm-thumb-scale-related="1"] .BaseRoomContents ul.list:has(li.roomCard) .room_thumbnail {
/* 图片宽度也按原始缩略图宽度放大,避免卡片边框/间隙被算进图片宽度。 */
width: var(--tm-thumb-related-thumb-width) !important;
/* 图片和容器使用同一个高度,避免图片溢出或下方信息栏错位。 */
height: var(--tm-thumb-related-height) !important;
display: block !important;
max-width: none !important;
box-sizing: border-box !important;
}
/* 顶部「关注」下拉 */
/*
* follow 模块:
* - 适用范围:顶部“关注”菜单弹出的房间列表。
* - 探测值:弹窗中单个房间卡片宽高 + 图片宽高。
* - 放大方式:卡片和图片各按自己的原始宽高乘倍率;弹窗总宽度按 2 列计算。
*/
html[data-tm-thumb-scale-follow="1"] .FollowedDropdown {
/* 96vw 防止弹窗在窄窗口里横向溢出。 */
width: min(96vw, var(--tm-thumb-follow-width)) !important;
min-width: min(96vw, var(--tm-thumb-follow-width)) !important;
max-width: min(96vw, var(--tm-thumb-follow-width)) !important;
box-sizing: border-box !important;
}
html[data-tm-thumb-scale-follow="1"] .FollowedDropdown__container,
html[data-tm-thumb-scale-follow="1"] .FollowedDropdown__rooms {
width: 100% !important;
max-width: none !important;
box-sizing: border-box !important;
}
html[data-tm-thumb-scale-follow="1"] .FollowedDropdown__rooms {
display: grid !important;
/* 当前设计保持两列,列宽来自原始卡片宽度 * 倍率。 */
grid-template-columns: repeat(2, minmax(var(--tm-thumb-follow-card-width), 1fr)) !important;
grid-gap: 0.6em 0.75em !important;
}
html[data-tm-thumb-scale-follow="1"] .FollowedDropdown__room {
width: var(--tm-thumb-follow-card-width) !important;
min-width: var(--tm-thumb-follow-card-width) !important;
max-width: var(--tm-thumb-follow-card-width) !important;
height: var(--tm-thumb-follow-card-height) !important;
min-height: var(--tm-thumb-follow-card-height) !important;
max-height: var(--tm-thumb-follow-card-height) !important;
}
html[data-tm-thumb-scale-follow="1"] .FollowedDropdown__room-link {
width: 100% !important;
height: var(--tm-thumb-follow-card-height) !important;
display: block !important;
}
html[data-tm-thumb-scale-follow="1"] .FollowedDropdown__room-image {
width: var(--tm-thumb-follow-thumb-width) !important;
height: var(--tm-thumb-follow-thumb-height) !important;
display: block !important;
max-width: none !important;
box-sizing: border-box !important;
}
html[data-tm-thumb-scale-follow="1"] .HeaderNavBar__following .FollowedDropdown {
/* 只做用户要求的右移微调,不参与缩略图尺寸计算。 */
transform: translateX(${FOLLOW_DROPDOWN_SHIFT_X}px) !important;
}
/*
* 悬停「关注」星星等出现的浮层预览(多为 react-tooltipportal 挂在 body
* 仅放大直播间缩略图域名,避免误伤图标类小图
*/
html[data-tm-thumb-scale-tooltip="1"] body > div[id^="react-tooltip"] img[src*="live.mmcdn.com"],
html[data-tm-thumb-scale-tooltip="1"] body > div[id^="react-tooltip"] img[src*="thumb.live.mmcdn"],
html[data-tm-thumb-scale-tooltip="1"] [data-floating-ui-portal] img[src*="live.mmcdn.com"] {
/* tooltip 模块只改直播缩略图域名,避免误伤页面里的小图标。 */
width: var(--tm-thumb-tooltip-width) !important;
max-width: min(96vw, var(--tm-thumb-tooltip-width)) !important;
height: var(--tm-thumb-tooltip-height) !important;
display: block !important;
box-sizing: border-box !important;
}
/* 发现页:轮播缩略图 + 容器高度 */
/*
* 发现页模块:
* - 适用范围:发现页轮播,比如“最受欢迎”。
* - 探测值:
* 1. li 卡片宽高;
* 2. 缩略图容器高度;
* 3. triple/double/single 三种轮播 ul 高度;
* 4. 对应箭头容器高度。
* - 放大方式:图片宽高和卡片宽度乘 THUMBNAIL_SCALE卡片高度、轮播容器高度、箭头高度乘 CARD_HEIGHT_SCALE。
* - 启用条件:全部值都探测到才打开发现页放大,缺一项就不动,避免局部放大导致错位。
*/
html[data-tm-thumb-scale-discover="1"] .carousel-root .triple-rows ul.list,
html[data-tm-thumb-scale-discover="1"] #discover_root .triple-rows ul.list {
/* 三行轮播的整体高度按 CARD_HEIGHT_SCALE 放大,避免卡片下方出现过多空白。 */
height: var(--tm-thumb-discover-triple-ul) !important;
}
html[data-tm-thumb-scale-discover="1"] .carousel-root .triple-rows .carousel-arrow-container,
html[data-tm-thumb-scale-discover="1"] #discover_root .triple-rows .carousel-arrow-container {
/* 箭头容器也按 CARD_HEIGHT_SCALE 放大,否则右侧箭头会和轮播高度不一致。 */
height: var(--tm-thumb-discover-triple-arrow) !important;
}
html[data-tm-thumb-scale-discover="1"] .carousel-root .double-rows ul.list,
html[data-tm-thumb-scale-discover="1"] #discover_root .double-rows ul.list {
height: var(--tm-thumb-discover-double-ul) !important;
}
html[data-tm-thumb-scale-discover="1"] .carousel-root .double-rows .carousel-arrow-container,
html[data-tm-thumb-scale-discover="1"] #discover_root .double-rows .carousel-arrow-container {
height: var(--tm-thumb-discover-double-arrow) !important;
}
html[data-tm-thumb-scale-discover="1"] .carousel-root .single-row ul.list,
html[data-tm-thumb-scale-discover="1"] #discover_root .single-row ul.list {
height: var(--tm-thumb-discover-single-ul) !important;
}
html[data-tm-thumb-scale-discover="1"] .carousel-root .single-row .carousel-arrow-container,
html[data-tm-thumb-scale-discover="1"] #discover_root .single-row .carousel-arrow-container {
height: var(--tm-thumb-discover-single-arrow) !important;
}
html[data-tm-thumb-scale-discover="1"] .carousel-root .room-list-carousel ul.list > li,
html[data-tm-thumb-scale-discover="1"] #discover_root .room-list-carousel ul.list > li {
/* 卡片宽度来自原始 li 宽度,例如日志里出现过 182 -> 364。 */
width: var(--tm-thumb-discover-width) !important;
min-width: var(--tm-thumb-discover-width) !important;
max-width: var(--tm-thumb-discover-width) !important;
/* 卡片整体高度也必须放大,否则用户名/人数信息区会压住下一行。 */
height: var(--tm-thumb-discover-card-height) !important;
min-height: var(--tm-thumb-discover-card-height) !important;
max-height: var(--tm-thumb-discover-card-height) !important;
/* flex-basis 和 width 保持一致,轮播横向滑动距离才稳定。 */
flex: 0 0 var(--tm-thumb-discover-width) !important;
box-sizing: border-box !important;
vertical-align: top !important;
}
html[data-tm-thumb-scale-discover="1"] .carousel-root .room-list-carousel .room_thumbnail_container,
html[data-tm-thumb-scale-discover="1"] #discover_root .room-list-carousel .room_thumbnail_container {
display: block !important;
width: var(--tm-thumb-discover-thumb-width) !important;
max-width: none !important;
box-sizing: border-box !important;
}
html[data-tm-thumb-scale-discover="1"] .carousel-root .room-list-carousel .room_thumbnail_container img,
html[data-tm-thumb-scale-discover="1"] .carousel-root .room-list-carousel .room_thumbnail,
html[data-tm-thumb-scale-discover="1"] #discover_root .room-list-carousel .room_thumbnail_container img,
html[data-tm-thumb-scale-discover="1"] #discover_root .room-list-carousel .room_thumbnail {
width: var(--tm-thumb-discover-thumb-width) !important;
/* 缩略图高度来自原始 thumb 高度,例如 101 -> 202。 */
height: var(--tm-thumb-discover-height) !important;
display: block !important;
max-width: none !important;
box-sizing: border-box !important;
}
`;
const STYLE_ID = "tm-thumb-scale-style";
// 只观察一次 head避免重复创建 MutationObserver。
let observerStarted = false;
// 只观察一次 body页面内容变化时用于捕捉动态弹窗/tooltip。
let pageObserverStarted = false;
// 探测防抖计时器mutation 很多时只执行最后一次探测。
let measureTimer = null;
// 记录样式注入次数,主要用于调试确认 GM_addStyle 是否真的执行。
let injectCount = 0;
// 记录最后一次注入方式GM_addStyle、手动 style、失败等。
let lastInjectMethod = "none";
// GM_addStyle 在某些环境不会返回可查询的 style 节点,所以单独记录逻辑状态。
let styleInjected = false;
// 最近一次 window.tmThumbScaleDebug() 返回的数据。
let lastReport = null;
// 最近一次尺寸探测结果,控制台里排错主要看这个。
let lastMeasure = null;
// 模块锁:某模块一旦成功应用缓存或探测值,就不再重新探测。
const moduleReady = {
home: false,
followingList: false,
related: false,
follow: false,
tooltip: false,
discover: false,
};
const setDebugAttr = (name, value) => {
// 调试属性写到 html 上,方便在 Elements 面板确认脚本是否运行。
if (!DEBUG) return;
try {
document.documentElement.setAttribute(`data-tm-thumb-scale-${name}`, String(value));
} catch (_) {}
};
const scaledPx = (value) => `${Math.round(value * THUMBNAIL_SCALE)}px`;
const scaledCardHeightPx = (value) => `${Math.round(value * CARD_HEIGHT_SCALE)}px`;
const scaledCardHeightFromThumbPx = (cardHeight, thumbHeight) => {
const nonThumbHeight = Math.max(0, cardHeight - thumbHeight);
return `${Math.round((thumbHeight * THUMBNAIL_SCALE) + nonThumbHeight)}px`;
};
const rootStyle = () => document.documentElement && document.documentElement.style;
const isFollowingListPage = () => {
try {
const path = typeof location === "undefined" ? "" : location.pathname;
return /\/followed-cams\/?$/i.test(path) || /\/followed\/?$/i.test(path);
} catch (_) {
return false;
}
};
// 所有 CSS 变量都在这里统一乘倍率。调用方传入的必须是页面原始尺寸。
const setVar = (name, value) => {
const style = rootStyle();
if (!style || !Number.isFinite(value) || value <= 0) return false;
style.setProperty(name, scaledPx(value));
return true;
};
// 只给 card 整体高度使用。图片宽高仍然走 setVar(),继续乘 THUMBNAIL_SCALE。
const setCardHeightVar = (name, value) => {
const style = rootStyle();
if (!style || !Number.isFinite(value) || value <= 0) return false;
style.setProperty(name, scaledCardHeightPx(value));
return true;
};
const setCardHeightFromThumbVar = (name, cardHeight, thumbHeight) => {
const style = rootStyle();
if (!style || !Number.isFinite(cardHeight) || !Number.isFinite(thumbHeight) || cardHeight <= 0 || thumbHeight <= 0) return false;
style.setProperty(name, scaledCardHeightFromThumbPx(cardHeight, thumbHeight));
return true;
};
// 每个模块的 CSS 都由 html[data-tm-thumb-scale-模块名="1"] 控制。
// 未探测到原始值时不设置开关,对应模块完全不放大,避免用错误尺寸猜页面。
const setModuleReady = (name, ready) => {
try {
const attrName = name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
const attr = `data-tm-thumb-scale-${attrName}`;
if (ready) document.documentElement.setAttribute(attr, "1");
else document.documentElement.removeAttribute(attr);
} catch (_) {}
};
const isRect = (value) => (
// 缓存读取时做最低限度校验,防止坏数据进入 CSS 变量。
value
&& Number.isFinite(value.width)
&& Number.isFinite(value.height)
&& value.width > 0
&& value.height > 0
);
const cloneRect = (value) => (isRect(value) ? {
// 缓存不需要亚像素无限精度,保留两位小数足够复现布局。
width: Math.round(value.width * 100) / 100,
height: Math.round(value.height * 100) / 100,
} : null);
const readCache = () => {
try {
// localStorage 可能被浏览器策略禁用,所以所有缓存操作都要 try/catch。
if (typeof localStorage === "undefined") return null;
// 缓存格式错误、倍率不同、结构版本不同,都视为无缓存。
const parsed = JSON.parse(localStorage.getItem(CACHE_KEY) || "null");
if (
!parsed
|| parsed.schema !== CACHE_SCHEMA
|| parsed.scale !== THUMBNAIL_SCALE
|| parsed.cardHeightScale !== CARD_HEIGHT_SCALE
|| !parsed.modules
) return null;
return parsed;
} catch (_) {
return null;
}
};
const writeCache = (cache) => {
try {
// 写入失败不影响脚本运行,只是下次还会重新探测。
if (typeof localStorage === "undefined") return false;
localStorage.setItem(CACHE_KEY, JSON.stringify(cache));
return true;
} catch (_) {
return false;
}
};
const updateCacheModule = (name, data) => {
// 模块级缓存:发现页、首页、关注下拉等互不影响。
// 只更新当前模块,保留其它模块已经探测好的原始尺寸。
const cache = readCache() || {
schema: CACHE_SCHEMA,
scale: THUMBNAIL_SCALE,
cardHeightScale: CARD_HEIGHT_SCALE,
updatedAt: 0,
modules: {},
};
cache.updatedAt = Date.now();
cache.modules[name] = data;
writeCache(cache);
};
const clearCache = () => {
try {
// 油猴菜单调用这里;清完后不强制刷新,由用户自己刷新页面重新探测。
if (typeof localStorage !== "undefined") localStorage.removeItem(CACHE_KEY);
} catch (_) {}
log("cache cleared; refresh page to detect sizes again");
};
const registerMenu = () => {
try {
// GM_registerMenuCommand 需要在 header 里 @grant没有该 API 时静默跳过。
if (typeof GM_registerMenuCommand === "function") {
GM_registerMenuCommand("清除 Chaturbate 缩略图探测缓存", clearCache);
}
} catch (_) {}
};
const applyCachedModule = (name, data) => {
if (!data || moduleReady[name]) return false;
// 缓存命中时直接写 CSS 变量并打开模块开关。
// 这里仍然通过 setVar() 乘倍率,因此缓存中始终保持原始尺寸。
if (name === "home" && !isFollowingListPage() && isRect(data.homeCard) && isRect(data.homeThumb)) {
moduleReady.home = [
setVar("--tm-thumb-home-width", data.homeCard.width),
setCardHeightVar("--tm-thumb-home-card-height", data.homeCard.height),
setVar("--tm-thumb-home-thumb-width", data.homeThumb.width),
setVar("--tm-thumb-home-thumb-height", data.homeThumb.height),
].every(Boolean);
} else if (name === "followingList" && isFollowingListPage() && isRect(data.followingListCard) && isRect(data.followingListThumb)) {
moduleReady.followingList = [
setVar("--tm-thumb-following-list-width", data.followingListCard.width),
setCardHeightVar("--tm-thumb-following-list-card-height", data.followingListCard.height),
setVar("--tm-thumb-following-list-thumb-width", data.followingListThumb.width),
setVar("--tm-thumb-following-list-thumb-height", data.followingListThumb.height),
].every(Boolean);
} else if (name === "related" && isRect(data.relatedCard) && isRect(data.relatedThumb)) {
const widthReady = setVar("--tm-thumb-related-width", data.relatedCard.width);
const cardHeightReady = setCardHeightVar("--tm-thumb-related-card-height", data.relatedCard.height);
const thumbWidthReady = setVar("--tm-thumb-related-thumb-width", data.relatedThumb.width);
const heightReady = setVar("--tm-thumb-related-height", data.relatedThumb.height);
moduleReady.related = widthReady && cardHeightReady && thumbWidthReady && heightReady;
} else if (name === "follow" && isRect(data.followCard) && isRect(data.followThumb)) {
const followReady = [
setVar("--tm-thumb-follow-card-width", data.followCard.width),
setCardHeightFromThumbVar("--tm-thumb-follow-card-height", data.followCard.height, data.followThumb.height),
setVar("--tm-thumb-follow-thumb-width", data.followThumb.width),
setVar("--tm-thumb-follow-thumb-height", data.followThumb.height),
].every(Boolean);
const style = rootStyle();
if (followReady && style) {
style.setProperty("--tm-thumb-follow-width", `calc(${Math.round(data.followCard.width * THUMBNAIL_SCALE * 2)}px + 2em)`);
}
moduleReady.follow = followReady;
} else if (name === "tooltip" && isRect(data.tooltipThumb)) {
moduleReady.tooltip = [
setVar("--tm-thumb-tooltip-width", data.tooltipThumb.width),
setVar("--tm-thumb-tooltip-height", data.tooltipThumb.height),
].every(Boolean);
} else if (
name === "discover"
&& isRect(data.discoverCard)
&& isRect(data.discoverThumb)
&& isRect(data.discoverTripleUl)
&& isRect(data.discoverTripleArrow)
&& isRect(data.discoverDoubleUl)
&& isRect(data.discoverDoubleArrow)
&& isRect(data.discoverSingleUl)
&& isRect(data.discoverSingleArrow)
) {
const ready = [
setVar("--tm-thumb-discover-width", data.discoverCard.width),
setCardHeightVar("--tm-thumb-discover-card-height", data.discoverCard.height),
setVar("--tm-thumb-discover-thumb-width", data.discoverThumb.width),
setVar("--tm-thumb-discover-height", data.discoverThumb.height),
setCardHeightVar("--tm-thumb-discover-triple-ul", data.discoverTripleUl.height),
setCardHeightVar("--tm-thumb-discover-triple-arrow", data.discoverTripleArrow.height),
setCardHeightVar("--tm-thumb-discover-double-ul", data.discoverDoubleUl.height),
setCardHeightVar("--tm-thumb-discover-double-arrow", data.discoverDoubleArrow.height),
setCardHeightVar("--tm-thumb-discover-single-ul", data.discoverSingleUl.height),
setCardHeightVar("--tm-thumb-discover-single-arrow", data.discoverSingleArrow.height),
];
moduleReady.discover = ready.every(Boolean);
}
setModuleReady(name, moduleReady[name]);
return moduleReady[name];
};
const applyCachedSizes = () => {
const cache = readCache();
if (!cache) return false;
const modules = cache.modules || {};
const applied = {
home: applyCachedModule("home", modules.home),
followingList: applyCachedModule("followingList", modules.followingList),
related: applyCachedModule("related", modules.related),
follow: applyCachedModule("follow", modules.follow),
tooltip: applyCachedModule("tooltip", modules.tooltip),
discover: applyCachedModule("discover", modules.discover),
};
if (DEBUG) log("cache applied", applied);
return Object.values(applied).some(Boolean);
};
const rectOf = (el) => {
// getBoundingClientRect 能拿到 CSS 布局后的实际渲染尺寸。
if (!el || !el.getBoundingClientRect) return null;
const rect = el.getBoundingClientRect();
const width = rect.width || el.offsetWidth || 0;
const height = rect.height || el.offsetHeight || 0;
// 太小的元素通常是图标、占位或尚未布局完成的节点,不作为缩略图基准。
if (width < 20 && height < 20) return null;
return { width, height };
};
const firstRect = (selector) => {
try {
// 同一类列表里取第一个有尺寸的节点即可,因为同模块卡片原始尺寸应一致。
if (!document.querySelectorAll) return null;
const nodes = document.querySelectorAll(selector);
for (const node of nodes) {
const rect = rectOf(node);
if (rect) return rect;
}
} catch (_) {}
return null;
};
const detectAndApplySizes = (stage = "detect") => {
/*
* 探测策略:
*
* 1) CSS 已经注入,但各模块默认关闭,所以页面先按站点原始布局渲染。
* 2) 延迟/MutationObserver 触发后,读取还没 ready 的模块尺寸。
* 3) 某个模块探测成功后,写入 CSS 变量、打开模块开关、写入缓存,并把 moduleReady 锁住。
* 4) 锁住后的模块后续不再探测,避免把已放大的尺寸再次当成原始尺寸。
*/
const followingListPage = isFollowingListPage();
const measured = {
// 首页普通房间列表:卡片宽高 + 缩略图宽高四项独立探测。
homeCard: moduleReady.home || followingListPage ? null : firstRect("#roomlist_root li.RoomCard, #roomlist_root li.roomCard"),
homeThumb: moduleReady.home || followingListPage ? null : firstRect("#roomlist_root .RoomCardThumbnail, #roomlist_root .room_thumbnail_container"),
// 关注页房间列表:选择器和首页相似,但独立探测、独立缓存、独立 CSS 变量。
followingListCard: moduleReady.followingList || !followingListPage ? null : firstRect("#roomlist_root li.RoomCard, #roomlist_root li.roomCard"),
followingListThumb: moduleReady.followingList || !followingListPage ? null : firstRect("#roomlist_root .RoomCardThumbnail, #roomlist_root .room_thumbnail_container"),
// 频道页“更多这样的房间”:卡片宽高和缩略图宽高都要探测,否则放大后行高容易不齐。
relatedCard: moduleReady.related ? null : firstRect(".BaseRoomContents ul.RoomCardGrid > li.RoomCard, #main.roomPage ul.list:has(li.roomCard) li.roomCard, .BaseRoomContents ul.list:has(li.roomCard) li.roomCard"),
relatedThumb: moduleReady.related ? null : firstRect(".BaseRoomContents ul.RoomCardGrid .RoomCardThumbnail, #main.roomPage ul.list:has(li.roomCard) .room_thumbnail_container, .BaseRoomContents ul.list:has(li.roomCard) .room_thumbnail_container"),
// 顶部“关注”弹窗:通常弹出后才有 DOM靠 mutation 触发探测。
followCard: moduleReady.follow ? null : firstRect(".FollowedDropdown__room, .FollowedDropdown__room-image"),
followThumb: moduleReady.follow ? null : firstRect(".FollowedDropdown__room-image"),
// 悬浮预览tooltip 是 portal 动态插入,也靠 mutation 捕捉。
tooltipThumb: moduleReady.tooltip ? null : firstRect('body > div[id^="react-tooltip"] img[src*="live.mmcdn.com"], body > div[id^="react-tooltip"] img[src*="thumb.live.mmcdn"], [data-floating-ui-portal] img[src*="live.mmcdn.com"]'),
// 发现页 carousel必须同时探测 card、thumb、行容器和箭头高度全部成功后才启用。
// 这样“最受欢迎”等多行轮播能保持站点原来的对齐关系,只是整体按倍率放大。
discoverCard: moduleReady.discover ? null : firstRect(".carousel-root .room-list-carousel ul.list > li, #discover_root .room-list-carousel ul.list > li"),
discoverThumb: moduleReady.discover ? null : firstRect(".carousel-root .room-list-carousel .room_thumbnail_container, #discover_root .room-list-carousel .room_thumbnail_container"),
discoverTripleUl: moduleReady.discover ? null : firstRect(".carousel-root .triple-rows ul.list, #discover_root .triple-rows ul.list"),
discoverTripleArrow: moduleReady.discover ? null : firstRect(".carousel-root .triple-rows .carousel-arrow-container, #discover_root .triple-rows .carousel-arrow-container"),
discoverDoubleUl: moduleReady.discover ? null : firstRect(".carousel-root .double-rows ul.list, #discover_root .double-rows ul.list"),
discoverDoubleArrow: moduleReady.discover ? null : firstRect(".carousel-root .double-rows .carousel-arrow-container, #discover_root .double-rows .carousel-arrow-container"),
discoverSingleUl: moduleReady.discover ? null : firstRect(".carousel-root .single-row ul.list, #discover_root .single-row ul.list"),
discoverSingleArrow: moduleReady.discover ? null : firstRect(".carousel-root .single-row .carousel-arrow-container, #discover_root .single-row .carousel-arrow-container"),
};
if (!moduleReady.home && !followingListPage) {
// home 依赖 card/thumb 各自宽高;成功后立刻缓存,后续普通列表页面可直接套用。
moduleReady.home = [
setVar("--tm-thumb-home-width", measured.homeCard && measured.homeCard.width),
setCardHeightVar("--tm-thumb-home-card-height", measured.homeCard && measured.homeCard.height),
setVar("--tm-thumb-home-thumb-width", measured.homeThumb && measured.homeThumb.width),
setVar("--tm-thumb-home-thumb-height", measured.homeThumb && measured.homeThumb.height),
].every(Boolean);
setModuleReady("home", moduleReady.home);
if (moduleReady.home) {
updateCacheModule("home", {
homeCard: cloneRect(measured.homeCard),
homeThumb: cloneRect(measured.homeThumb),
});
}
}
if (!moduleReady.followingList && followingListPage) {
// followingList 单独缓存,避免关注页拿到首页的原始宽度。
moduleReady.followingList = [
setVar("--tm-thumb-following-list-width", measured.followingListCard && measured.followingListCard.width),
setCardHeightVar("--tm-thumb-following-list-card-height", measured.followingListCard && measured.followingListCard.height),
setVar("--tm-thumb-following-list-thumb-width", measured.followingListThumb && measured.followingListThumb.width),
setVar("--tm-thumb-following-list-thumb-height", measured.followingListThumb && measured.followingListThumb.height),
].every(Boolean);
setModuleReady("followingList", moduleReady.followingList);
if (moduleReady.followingList) {
updateCacheModule("followingList", {
followingListCard: cloneRect(measured.followingListCard),
followingListThumb: cloneRect(measured.followingListThumb),
});
}
}
if (!moduleReady.related) {
// related 同时依赖卡片宽度、卡片高度、缩略图宽度和缩略图高度;四者缺一不可。
const relatedWidthReady = setVar("--tm-thumb-related-width", measured.relatedCard && measured.relatedCard.width);
const relatedCardHeightReady = setCardHeightVar("--tm-thumb-related-card-height", measured.relatedCard && measured.relatedCard.height);
const relatedThumbWidthReady = setVar("--tm-thumb-related-thumb-width", measured.relatedThumb && measured.relatedThumb.width);
const relatedHeightReady = setVar("--tm-thumb-related-height", measured.relatedThumb && measured.relatedThumb.height);
moduleReady.related = relatedWidthReady && relatedCardHeightReady && relatedThumbWidthReady && relatedHeightReady;
setModuleReady("related", moduleReady.related);
if (moduleReady.related) {
updateCacheModule("related", {
relatedCard: cloneRect(measured.relatedCard),
relatedThumb: cloneRect(measured.relatedThumb),
});
}
}
if (!moduleReady.follow) {
// follow 的总弹窗宽度按两列计算:单卡原始宽度 * 倍率 * 2 + 2em 间距余量。
const followReady = [
setVar("--tm-thumb-follow-card-width", measured.followCard && measured.followCard.width),
setCardHeightFromThumbVar("--tm-thumb-follow-card-height", measured.followCard && measured.followCard.height, measured.followThumb && measured.followThumb.height),
setVar("--tm-thumb-follow-thumb-width", measured.followThumb && measured.followThumb.width),
setVar("--tm-thumb-follow-thumb-height", measured.followThumb && measured.followThumb.height),
].every(Boolean);
moduleReady.follow = followReady;
const style = rootStyle();
if (followReady && style) {
style.setProperty("--tm-thumb-follow-width", `calc(${Math.round(measured.followCard.width * THUMBNAIL_SCALE * 2)}px + 2em)`);
}
setModuleReady("follow", moduleReady.follow);
if (moduleReady.follow) {
updateCacheModule("follow", {
followCard: cloneRect(measured.followCard),
followThumb: cloneRect(measured.followThumb),
});
}
}
if (!moduleReady.tooltip) {
// tooltip 出现时机不固定,所以通常由 body mutation 触发探测。
moduleReady.tooltip = [
setVar("--tm-thumb-tooltip-width", measured.tooltipThumb && measured.tooltipThumb.width),
setVar("--tm-thumb-tooltip-height", measured.tooltipThumb && measured.tooltipThumb.height),
].every(Boolean);
setModuleReady("tooltip", moduleReady.tooltip);
if (moduleReady.tooltip) {
updateCacheModule("tooltip", { tooltipThumb: cloneRect(measured.tooltipThumb) });
}
}
if (!moduleReady.discover) {
// 发现页要求更严格:少一个高度都不开启,宁可不放大,也不要错位。
const discoverWidthReady = setVar("--tm-thumb-discover-width", measured.discoverCard && measured.discoverCard.width);
const discoverCardHeightReady = setCardHeightVar("--tm-thumb-discover-card-height", measured.discoverCard && measured.discoverCard.height);
const discoverThumbWidthReady = setVar("--tm-thumb-discover-thumb-width", measured.discoverThumb && measured.discoverThumb.width);
const discoverHeightReady = setVar("--tm-thumb-discover-height", measured.discoverThumb && measured.discoverThumb.height);
const discoverTripleUlReady = setCardHeightVar("--tm-thumb-discover-triple-ul", measured.discoverTripleUl && measured.discoverTripleUl.height);
const discoverTripleArrowReady = setCardHeightVar("--tm-thumb-discover-triple-arrow", measured.discoverTripleArrow && measured.discoverTripleArrow.height);
const discoverDoubleUlReady = setCardHeightVar("--tm-thumb-discover-double-ul", measured.discoverDoubleUl && measured.discoverDoubleUl.height);
const discoverDoubleArrowReady = setCardHeightVar("--tm-thumb-discover-double-arrow", measured.discoverDoubleArrow && measured.discoverDoubleArrow.height);
const discoverSingleUlReady = setCardHeightVar("--tm-thumb-discover-single-ul", measured.discoverSingleUl && measured.discoverSingleUl.height);
const discoverSingleArrowReady = setCardHeightVar("--tm-thumb-discover-single-arrow", measured.discoverSingleArrow && measured.discoverSingleArrow.height);
moduleReady.discover = discoverWidthReady && discoverCardHeightReady && discoverThumbWidthReady && discoverHeightReady
&& discoverTripleUlReady && discoverTripleArrowReady
&& discoverDoubleUlReady && discoverDoubleArrowReady
&& discoverSingleUlReady && discoverSingleArrowReady;
setModuleReady("discover", moduleReady.discover);
if (moduleReady.discover) {
updateCacheModule("discover", {
discoverCard: cloneRect(measured.discoverCard),
discoverThumb: cloneRect(measured.discoverThumb),
discoverTripleUl: cloneRect(measured.discoverTripleUl),
discoverTripleArrow: cloneRect(measured.discoverTripleArrow),
discoverDoubleUl: cloneRect(measured.discoverDoubleUl),
discoverDoubleArrow: cloneRect(measured.discoverDoubleArrow),
discoverSingleUl: cloneRect(measured.discoverSingleUl),
discoverSingleArrow: cloneRect(measured.discoverSingleArrow),
});
}
}
lastMeasure = { stage, moduleReady: { ...moduleReady }, measured };
if (DEBUG) log("measured original sizes", lastMeasure);
return lastMeasure;
};
const scheduleMeasure = (stage, delay = 0) => {
// 所有探测都通过这里排队,避免 React 连续插入节点时频繁测量布局。
if (typeof setTimeout !== "function") {
detectAndApplySizes(stage);
return;
}
if (measureTimer) clearTimeout(measureTimer);
measureTimer = setTimeout(() => {
measureTimer = null;
detectAndApplySizes(stage);
}, delay);
};
const selectorCount = (selector) => {
try {
// 诊断用计数selector 不被浏览器支持时返回错误字符串而不是中断脚本。
if (!document.querySelectorAll) return -1;
return document.querySelectorAll(selector).length;
} catch (e) {
return `ERR: ${e && e.message ? e.message : e}`;
}
};
const computedInfo = (selector) => {
try {
// 诊断用:输出关键容器的 display/grid/width方便在控制台判断 CSS 是否命中。
if (!document.querySelector || typeof getComputedStyle !== "function") return null;
const el = document.querySelector(selector);
if (!el) return null;
const style = getComputedStyle(el);
return {
selector,
tag: el.tagName,
className: el.className,
display: style.display,
gridTemplateColumns: style.gridTemplateColumns,
width: style.width,
minWidth: style.minWidth,
maxWidth: style.maxWidth,
};
} catch (e) {
return { selector, error: e && e.message ? e.message : String(e) };
}
};
const reportDiagnostics = (stage) => {
// 统一收集调试信息,手动执行 window.tmThumbScaleDebug() 也会走这里。
const styleNodePresent = !!document.getElementById(STYLE_ID);
const stylePresent = styleInjected || styleNodePresent;
const counts = {
roomlistRoot: selectorCount("#roomlist_root"),
homepageList: selectorCount("#roomlist_root ul.RoomCardGrid, #roomlist_root ul.list:has(li.roomCard)"),
homepageCards: selectorCount("#roomlist_root li.RoomCard, #roomlist_root li.roomCard"),
homepageThumbs: selectorCount("#roomlist_root .RoomCardThumbnail, #roomlist_root .room_thumbnail_container"),
followedDropdown: selectorCount(".FollowedDropdown"),
followedRooms: selectorCount(".FollowedDropdown__room"),
tooltipThumbs: selectorCount('body > div[id^="react-tooltip"] img[src*="live.mmcdn.com"], body > div[id^="react-tooltip"] img[src*="thumb.live.mmcdn"], [data-floating-ui-portal] img[src*="live.mmcdn.com"]'),
};
const computed = [
computedInfo("#roomlist_root ul.RoomCardGrid"),
computedInfo("#roomlist_root ul.list:has(li.roomCard)"),
computedInfo(".FollowedDropdown__rooms"),
computedInfo(".FollowedDropdown__room-image"),
computedInfo("#discover_root .room-list-carousel ul.list"),
].filter(Boolean);
const report = {
stage,
ver: VER,
debug: DEBUG,
href: typeof location === "undefined" ? "" : location.href,
readyState: document.readyState,
stylePresent,
styleInjected,
styleNodePresent,
injectCount,
lastInjectMethod,
cssLength: css.length,
cache: (() => {
const cache = readCache();
return cache ? {
schema: cache.schema,
scale: cache.scale,
cardHeightScale: cache.cardHeightScale,
updatedAt: cache.updatedAt,
modules: Object.keys(cache.modules || {}),
} : null;
})(),
cssVars: (() => {
try {
const style = getComputedStyle(document.documentElement);
return {
homeWidth: style.getPropertyValue("--tm-thumb-home-width").trim(),
homeCardHeight: style.getPropertyValue("--tm-thumb-home-card-height").trim(),
homeThumbWidth: style.getPropertyValue("--tm-thumb-home-thumb-width").trim(),
homeThumbHeight: style.getPropertyValue("--tm-thumb-home-thumb-height").trim(),
followingListWidth: style.getPropertyValue("--tm-thumb-following-list-width").trim(),
followingListCardHeight: style.getPropertyValue("--tm-thumb-following-list-card-height").trim(),
followingListThumbWidth: style.getPropertyValue("--tm-thumb-following-list-thumb-width").trim(),
followingListThumbHeight: style.getPropertyValue("--tm-thumb-following-list-thumb-height").trim(),
relatedWidth: style.getPropertyValue("--tm-thumb-related-width").trim(),
relatedCardHeight: style.getPropertyValue("--tm-thumb-related-card-height").trim(),
relatedThumbWidth: style.getPropertyValue("--tm-thumb-related-thumb-width").trim(),
relatedHeight: style.getPropertyValue("--tm-thumb-related-height").trim(),
followWidth: style.getPropertyValue("--tm-thumb-follow-width").trim(),
followCardHeight: style.getPropertyValue("--tm-thumb-follow-card-height").trim(),
followThumbWidth: style.getPropertyValue("--tm-thumb-follow-thumb-width").trim(),
followThumbHeight: style.getPropertyValue("--tm-thumb-follow-thumb-height").trim(),
discoverWidth: style.getPropertyValue("--tm-thumb-discover-width").trim(),
discoverCardHeight: style.getPropertyValue("--tm-thumb-discover-card-height").trim(),
discoverThumbWidth: style.getPropertyValue("--tm-thumb-discover-thumb-width").trim(),
discoverHeight: style.getPropertyValue("--tm-thumb-discover-height").trim(),
};
} catch (_) {
return {};
}
})(),
lastMeasure,
counts,
computed,
};
lastReport = report;
if (typeof window !== "undefined") {
// 暴露在页面 window方便控制台直接查看最近一次报告。
try {
window.tmThumbScaleLastReport = report;
} catch (_) {}
}
if (typeof unsafeWindow !== "undefined") {
// Tampermonkey 隔离环境下unsafeWindow 能把 API 暴露给页面上下文。
try {
unsafeWindow.tmThumbScaleLastReport = report;
} catch (_) {}
}
if (!DEBUG) return report;
setDebugAttr("ver", VER);
setDebugAttr("style-present", stylePresent ? "1" : "0");
setDebugAttr("style-node-present", styleNodePresent ? "1" : "0");
setDebugAttr("inject-count", injectCount);
setDebugAttr("inject-method", lastInjectMethod);
setDebugAttr("homepage-list-count", counts.homepageList);
console.log("[tm-thumb-scale]", report);
return report;
};
const exposeDebugApi = () => {
// 手动调试入口:控制台执行 tmThumbScaleDebug() 可立即生成报告。
const api = (stage = "manual") => reportDiagnostics(stage);
const targets = [];
if (typeof window !== "undefined") targets.push(window);
if (typeof unsafeWindow !== "undefined" && unsafeWindow !== window) targets.push(unsafeWindow);
targets.forEach((target) => {
try {
target.tmThumbScaleDebug = api;
target.tmThumbScaleLastReport = lastReport;
} catch (_) {}
});
};
const inject = () => {
// 注入 CSS 前先处理旧版本 style避免更新脚本后新旧规则同时存在。
const existingStyle = document.getElementById(STYLE_ID);
if (existingStyle) {
const existingVer = existingStyle.getAttribute("data-tm-thumb-scale-ver");
if (existingVer === VER) {
styleInjected = true;
log("style already present, skip");
reportDiagnostics("inject-skip-current-style");
return;
}
try {
existingStyle.remove();
log("removed stale style", { existingVer });
} catch (e) {
log("remove stale style failed", e);
}
}
try {
if (typeof GM_addStyle === "function") {
// Tampermonkey 推荐使用 GM_addStyle比手动 append style 更稳定。
const style = GM_addStyle(css);
if (style) {
style.id = STYLE_ID;
style.setAttribute("data-tm-thumb-scale-ver", VER);
}
injectCount += 1;
styleInjected = true;
lastInjectMethod = style ? "GM_addStyle-with-node" : "GM_addStyle-no-node";
log("injected via GM_addStyle", { returnedStyleNode: !!style });
reportDiagnostics("inject-gm-add-style");
return;
}
const style = document.createElement("style");
// GM_addStyle 不可用时才走手动 style 兜底。
style.id = STYLE_ID;
style.setAttribute("data-tm-thumb-scale-ver", VER);
style.textContent = css;
(document.head || document.documentElement).appendChild(style);
injectCount += 1;
styleInjected = true;
lastInjectMethod = "manual-style";
log("appended <style id=" + STYLE_ID + ">");
reportDiagnostics("inject-manual-style");
} catch (e) {
lastInjectMethod = "failed";
log("append <style> failed", e);
reportDiagnostics("inject-failed");
}
if (DEBUG) {
const el = document.getElementById(STYLE_ID);
log("post-inject style node:", el ? "ok" : "missing");
}
};
const startHeadObserver = () => {
// 有些 SPA/扩展会移除 head 里的 style这里只负责把本脚本样式补回去。
if (!document.head || observerStarted) return;
observerStarted = true;
const mo = new MutationObserver(() => {
if (!document.getElementById(STYLE_ID)) {
log("style removed, reinject");
inject();
}
});
mo.observe(document.head, { childList: true });
};
const startPageObserver = () => {
// Chaturbate 很多内容是 React 动态插入的,尤其关注下拉和 tooltip。
// 观察 body 后,每次 DOM 变化都延迟探测一次尚未 ready 的模块。
if (!document.body || pageObserverStarted || typeof MutationObserver !== "function") return;
pageObserverStarted = true;
const mo = new MutationObserver(() => scheduleMeasure("mutation", 300));
mo.observe(document.body, { childList: true, subtree: true });
};
log("start v" + VER);
// 注册油猴菜单;用户需要重测布局时,可从菜单清除 localStorage 缓存。
registerMenu();
// 尽早尝试应用缓存。缓存命中的模块会立即设置 CSS 变量和 data 开关。
applyCachedSizes();
// 暴露调试 API方便后续在控制台手动检查。
exposeDebugApi();
// 第一次报告记录启动时是否有缓存、CSS 变量是否已经设置。
reportDiagnostics("start-before-inject");
// 注入 CSS。因为模块默认关闭即使 CSS 很早注入,也不会改变原始布局。
inject();
// GM_addStyle 后再暴露一次 API保证 lastReport / style 状态更新。
exposeDebugApi();
// 观察 head防止 style 被站点或扩展移除。
startHeadObserver();
// 观察 body用于动态内容出现后触发尚未 ready 模块的探测。
startPageObserver();
if (!document.head && document.addEventListener) {
// 极早 document-start 时 head 可能还不存在DOMContentLoaded 再补一次注入和探测。
document.addEventListener("DOMContentLoaded", () => {
inject();
exposeDebugApi();
// DOMContentLoaded 后再等 600ms让 React/图片容器有时间完成初始布局。
scheduleMeasure("domcontentloaded", 600);
startHeadObserver();
startPageObserver();
reportDiagnostics("domcontentloaded");
}, { once: true });
}
if (typeof setTimeout === "function") {
// 固定时间点探测用于覆盖缓存未命中、React 首屏慢、图片容器稍后才出现等情况。
setTimeout(() => { scheduleMeasure("after-800ms"); reportDiagnostics("after-800ms"); startPageObserver(); }, 800);
// 第二次探测给慢一点的页面留余量。
setTimeout(() => { scheduleMeasure("after-2000ms"); reportDiagnostics("after-2000ms"); startPageObserver(); }, 2000);
// 最后一次兜底探测;成功的模块会被 moduleReady 锁住,不会重复相乘。
setTimeout(() => { scheduleMeasure("after-5000ms"); reportDiagnostics("after-5000ms"); startPageObserver(); }, 5000);
}
})();