1034 lines
52 KiB
JavaScript
1034 lines
52 KiB
JavaScript
// ==UserScript==
|
||
// @name Chaturbate 缩略图放大 2 倍
|
||
// @namespace https://chaturbate.com/
|
||
// @version 0.11.0
|
||
// @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.11.0";
|
||
// 缓存只保存“站点原始尺寸”,不要保存乘过倍率后的尺寸。
|
||
// 这样以后只改 THUMBNAIL_SCALE / CARD_HEIGHT_SCALE 时,可以让缓存自动失效并重新探测,避免旧倍率污染新布局。
|
||
const CACHE_KEY = "tm-thumb-scale:size-cache:v9";
|
||
const CACHE_SCHEMA = 9;
|
||
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 房间列表。
|
||
* - 探测值:卡片原始宽度。
|
||
* - 放大方式:只放大 grid 列宽;缩略图保持 100% / auto,跟随卡片自然缩放。
|
||
* - 注意:关注页列表单独用 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;
|
||
}
|
||
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: 100% !important;
|
||
height: auto !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: 100% !important;
|
||
height: auto !important;
|
||
/* block 可以消掉图片 inline baseline 带来的细小空隙。 */
|
||
display: block !important;
|
||
max-width: none !important;
|
||
box-sizing: border-box !important;
|
||
}
|
||
|
||
/* 关注页当前列表 */
|
||
/*
|
||
* followingList 模块:
|
||
* - 适用范围:/followed-cams/ 关注页里的 #roomlist_root 房间列表。
|
||
* - 为什么和 home 分开:两者 DOM 选择器很像,但站点可能给不同页面不同原始列宽。
|
||
* - 缓存也单独存 followingList,避免首页探测值污染关注页。
|
||
* - 和 home 一样只放大 grid 最小列宽;图片跟随卡片自然缩放。
|
||
* - 注意:这里仍保留 minmax(..., 1fr),让站点原来的整行拉伸/对齐逻辑继续工作。
|
||
* 不能改成固定列宽,否则宽屏下会出现大空洞,看起来比原站更乱。
|
||
*/
|
||
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;
|
||
}
|
||
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: 100% !important;
|
||
height: auto !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: 100% !important;
|
||
height: auto !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-tooltip,portal 挂在 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)) {
|
||
moduleReady.home = setVar("--tm-thumb-home-width", data.homeCard.width);
|
||
} else if (name === "followingList" && isFollowingListPage() && isRect(data.followingListCard)) {
|
||
moduleReady.followingList = setVar("--tm-thumb-following-list-width", data.followingListCard.width);
|
||
} 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"),
|
||
// 关注页房间列表:只探测卡片宽度;独立缓存,避免和首页混用。
|
||
followingListCard: moduleReady.followingList || !followingListPage ? null : firstRect("#roomlist_root li.RoomCard, #roomlist_root li.roomCard"),
|
||
// 频道页“更多这样的房间”:卡片宽高和缩略图宽高都要探测,否则放大后行高容易不齐。
|
||
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 只依赖卡片宽度;成功后立刻缓存,后续普通列表页面可直接套用。
|
||
moduleReady.home = setVar("--tm-thumb-home-width", measured.homeCard && measured.homeCard.width);
|
||
setModuleReady("home", moduleReady.home);
|
||
if (moduleReady.home) {
|
||
updateCacheModule("home", {
|
||
homeCard: cloneRect(measured.homeCard),
|
||
});
|
||
}
|
||
}
|
||
|
||
if (!moduleReady.followingList && followingListPage) {
|
||
// followingList 单独缓存,避免关注页拿到首页的原始宽度。
|
||
moduleReady.followingList = setVar("--tm-thumb-following-list-width", measured.followingListCard && measured.followingListCard.width);
|
||
setModuleReady("followingList", moduleReady.followingList);
|
||
if (moduleReady.followingList) {
|
||
updateCacheModule("followingList", {
|
||
followingListCard: cloneRect(measured.followingListCard),
|
||
});
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
})();
|