1069 lines
51 KiB
JavaScript
1069 lines
51 KiB
JavaScript
// ==UserScript==
|
|
// @name Chaturbate 缩略图放大 2 倍
|
|
// @namespace https://chaturbate.com/
|
|
// @version 0.12.2
|
|
// @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) 不再缓存探测结果.
|
|
* Chaturbate 的列表宽度会随页面/窗口变化, 缓存旧尺寸容易污染新布局.
|
|
* 当前版本每次页面加载都重新探测; 油猴菜单只用于清除旧版本遗留的 localStorage 缓存.
|
|
*
|
|
* 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.12.2";
|
|
// 旧版本用过 localStorage 尺寸缓存.当前版本不再读写缓存, 避免旧探测值污染新布局.
|
|
const LEGACY_CACHE_PREFIX = "tm-thumb-scale:size-cache:";
|
|
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 房间列表.
|
|
* - 探测值: 站点 CSS 里的 grid 最小列宽 + 卡片原始高度 + 缩略图原始高度.
|
|
* - 放大方式: 原站列数除以 THUMBNAIL_SCALE; 每列仍用 1fr 均分, 保留原站对齐方式.
|
|
* - 注意: 关注页列表单独用 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-columns 来自原站自然列数, JS 写入时已经除过倍率. */
|
|
grid-template-columns: repeat(var(--tm-thumb-home-columns), minmax(0, 1fr)) !important;
|
|
justify-content: stretch !important;
|
|
align-items: start !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;
|
|
justify-self: stretch !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: 100% !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: 100% !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 单独探测, 避免首页探测值污染关注页.
|
|
* - 探测值: 站点 CSS 里的 grid 最小列宽 + 卡片原始高度 + 缩略图原始高度.
|
|
* - 和 home 一样, 原站列数除以 THUMBNAIL_SCALE; 每列仍用 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(var(--tm-thumb-following-list-columns), minmax(0, 1fr)) !important;
|
|
justify-content: stretch !important;
|
|
align-items: start !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;
|
|
justify-self: stretch !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: 100% !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: 100% !important;
|
|
height: var(--tm-thumb-following-list-thumb-height) !important;
|
|
display: block !important;
|
|
max-width: none !important;
|
|
box-sizing: border-box !important;
|
|
}
|
|
/* 直播间页底部"更多这样的直播间" */
|
|
/*
|
|
* related 模块:
|
|
* - 适用范围: 直播间页面底部"更多这样的房间".
|
|
* - 探测值: 卡片原始宽度 + 卡片原始高度 + 缩略图原始高度.
|
|
* - 放大方式: 卡片宽度按 THUMBNAIL_SCALE 固定放大; 缩略图宽度跟随卡片宽度.
|
|
* - 为什么不按列数: 这个区域是频道页里的局部推荐列表, 容器宽度和首页/关注页不同,
|
|
* 用"列数除倍率"会把卡片拉成容器均分宽度, 导致"更多这样的房间"宽高比例失真.
|
|
*/
|
|
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, var(--tm-thumb-related-width)) !important;
|
|
justify-content: start !important;
|
|
align-items: start !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: var(--tm-thumb-related-width) !important;
|
|
justify-self: start !important;
|
|
min-width: 0 !important;
|
|
max-width: var(--tm-thumb-related-width) !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 {
|
|
width: 100% !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: 100% !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, var(--tm-thumb-follow-card-width)) !important;
|
|
justify-content: start !important;
|
|
align-items: start !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: 100% !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 高度.
|
|
* - 放大方式: 图片宽高和卡片宽度乘 THUMBNAIL_SCALE.
|
|
* - 卡片高度: 只把缩略图区域乘 THUMBNAIL_SCALE, 用户名/人数等信息栏保留原始高度.
|
|
* - 轮播高度: triple/double/single 按 3/2/1 行卡片高度重新计算, 保留原始行间距.
|
|
* - 箭头高度: 跟对应轮播高度同步, 避免右侧箭头与轮播内容错位.
|
|
* - 启用条件: 卡片和缩略图探测到就先启用; 某种行数的轮播不存在时跳过对应高度变量.
|
|
* 不能强制要求 triple/double/single 全部存在, 否则页面少一种轮播就会导致发现页完全不放大.
|
|
*/
|
|
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 {
|
|
/* 三行轮播高度由"放大后卡片高度 * 3 + 原始行间距"推导. */
|
|
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 {
|
|
/* 箭头容器跟轮播高度一致, 否则右侧箭头会和轮播内容错位. */
|
|
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;
|
|
height: var(--tm-thumb-discover-height) !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 scaledCardHeightFromThumbValue = (cardHeight, thumbHeight) => {
|
|
const nonThumbHeight = Math.max(0, cardHeight - thumbHeight);
|
|
return Math.round((thumbHeight * THUMBNAIL_SCALE) + nonThumbHeight);
|
|
};
|
|
const scaledStackHeightFromCardValue = (stackHeight, cardHeight, thumbHeight, rows) => {
|
|
if (
|
|
!Number.isFinite(stackHeight)
|
|
|| !Number.isFinite(cardHeight)
|
|
|| !Number.isFinite(thumbHeight)
|
|
|| !Number.isFinite(rows)
|
|
|| stackHeight <= 0
|
|
|| cardHeight <= 0
|
|
|| thumbHeight <= 0
|
|
|| rows <= 0
|
|
) return null;
|
|
const originalNonCardSpace = Math.max(0, stackHeight - (cardHeight * rows));
|
|
return Math.round((scaledCardHeightFromThumbValue(cardHeight, thumbHeight) * rows) + originalNonCardSpace);
|
|
};
|
|
const scaledCardHeightFromThumbWithScalePx = (cardHeight, thumbHeight, scale) => {
|
|
const nonThumbHeight = Math.max(0, cardHeight - thumbHeight);
|
|
return `${Math.round((thumbHeight * 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;
|
|
};
|
|
const setDiscoverStackHeightVars = (ulName, arrowName, stackHeight, cardHeight, thumbHeight, rows) => {
|
|
const style = rootStyle();
|
|
// 发现页并不保证 triple/double/single 三种轮播同时存在.
|
|
// 缺少某一种时直接视为完成, 避免一个缺失行把整个发现页放大总开关卡住.
|
|
if (stackHeight == null) return true;
|
|
const height = scaledStackHeightFromCardValue(stackHeight, cardHeight, thumbHeight, rows);
|
|
if (!style || !height) return false;
|
|
const value = `${height}px`;
|
|
style.setProperty(ulName, value);
|
|
style.setProperty(arrowName, value);
|
|
return true;
|
|
};
|
|
const setListScaleVars = (columnsName, cardHeightName, thumbHeightName, metrics, cardWidth, cardHeight, thumbHeight) => {
|
|
const style = rootStyle();
|
|
if (
|
|
!style
|
|
|| !metrics
|
|
|| !Number.isFinite(metrics.columns)
|
|
|| !Number.isFinite(cardWidth)
|
|
|| !Number.isFinite(cardHeight)
|
|
|| !Number.isFinite(thumbHeight)
|
|
|| metrics.columns <= 0
|
|
|| cardWidth <= 0
|
|
|| cardHeight <= 0
|
|
|| thumbHeight <= 0
|
|
) return false;
|
|
const scaledColumns = Math.max(1, Math.floor(metrics.columns / THUMBNAIL_SCALE));
|
|
const targetCardWidth = (
|
|
Number.isFinite(metrics.width)
|
|
&& metrics.width > 0
|
|
&& Number.isFinite(metrics.columnGap)
|
|
&& metrics.columnGap >= 0
|
|
)
|
|
? (metrics.width - (metrics.columnGap * Math.max(0, scaledColumns - 1))) / scaledColumns
|
|
: null;
|
|
const effectiveScale = targetCardWidth && targetCardWidth > 0
|
|
? targetCardWidth / cardWidth
|
|
: THUMBNAIL_SCALE;
|
|
style.setProperty(columnsName, String(scaledColumns));
|
|
style.setProperty(cardHeightName, scaledCardHeightFromThumbWithScalePx(cardHeight, thumbHeight, effectiveScale));
|
|
style.setProperty(thumbHeightName, `${Math.round(thumbHeight * effectiveScale)}px`);
|
|
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) => (
|
|
value
|
|
&& Number.isFinite(value.width)
|
|
&& Number.isFinite(value.height)
|
|
&& value.width > 0
|
|
&& value.height > 0
|
|
);
|
|
const clearCache = () => {
|
|
try {
|
|
if (typeof localStorage !== "undefined") {
|
|
Object.keys(localStorage)
|
|
.filter((key) => key.indexOf(LEGACY_CACHE_PREFIX) === 0)
|
|
.forEach((key) => localStorage.removeItem(key));
|
|
}
|
|
} catch (_) {}
|
|
log("legacy cache cleared; current version always detects sizes fresh");
|
|
};
|
|
const registerMenu = () => {
|
|
try {
|
|
// GM_registerMenuCommand 需要在 header 里 @grant; 没有该 API 时静默跳过.
|
|
if (typeof GM_registerMenuCommand === "function") {
|
|
GM_registerMenuCommand("清除 Chaturbate 缩略图探测缓存", clearCache);
|
|
}
|
|
} catch (_) {}
|
|
};
|
|
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 firstElement = (selector) => {
|
|
try {
|
|
if (!document.querySelector) return null;
|
|
return document.querySelector(selector);
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
};
|
|
const parseMinmaxPx = (value) => {
|
|
if (!value) return null;
|
|
const match = String(value).match(/minmax\(\s*([0-9.]+)px\s*,\s*1fr\s*\)/i);
|
|
if (!match) return null;
|
|
const width = Number.parseFloat(match[1]);
|
|
return Number.isFinite(width) && width > 0 ? width : null;
|
|
};
|
|
const selectorMatches = (el, selectorText) => {
|
|
try {
|
|
return !!(el && selectorText && el.matches && el.matches(selectorText));
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
};
|
|
const ruleGridMinColumn = (el, rules) => {
|
|
if (!el || !rules) return null;
|
|
for (const rule of rules) {
|
|
if (rule && rule.cssRules) {
|
|
const nested = ruleGridMinColumn(el, rule.cssRules);
|
|
if (nested) return nested;
|
|
}
|
|
if (!rule || !rule.style || !rule.selectorText) continue;
|
|
if (!selectorMatches(el, rule.selectorText)) continue;
|
|
const value = rule.style.getPropertyValue("grid-template-columns");
|
|
const width = parseMinmaxPx(value);
|
|
if (width) return width;
|
|
}
|
|
return null;
|
|
};
|
|
const gridMinColumnFor = (selector) => {
|
|
const el = firstElement(selector);
|
|
if (!el || !document.styleSheets) return null;
|
|
for (const sheet of Array.from(document.styleSheets)) {
|
|
let rules = null;
|
|
try {
|
|
rules = sheet.cssRules;
|
|
} catch (_) {
|
|
continue;
|
|
}
|
|
const width = ruleGridMinColumn(el, rules);
|
|
if (width) return width;
|
|
}
|
|
return null;
|
|
};
|
|
const computedGridColumnCount = (selector) => {
|
|
const el = firstElement(selector);
|
|
if (!el) return null;
|
|
try {
|
|
const value = getComputedStyle(el).getPropertyValue("grid-template-columns");
|
|
const count = String(value).split(/\s+/).filter(Boolean).length;
|
|
return Number.isFinite(count) && count > 0 ? count : null;
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
};
|
|
const naturalGridMetrics = (selector) => {
|
|
const minColumn = gridMinColumnFor(selector);
|
|
const el = firstElement(selector);
|
|
const rect = rectOf(el);
|
|
let columnGap = 0;
|
|
try {
|
|
if (el && typeof getComputedStyle === "function") {
|
|
const styles = getComputedStyle(el);
|
|
const parsedGap = Number.parseFloat(styles.columnGap || styles.gap || "0");
|
|
columnGap = Number.isFinite(parsedGap) && parsedGap > 0 ? parsedGap : 0;
|
|
}
|
|
} catch (_) {}
|
|
if (Number.isFinite(minColumn) && minColumn > 0 && rect && rect.width > 0) {
|
|
return {
|
|
columns: Math.max(1, Math.floor((rect.width + columnGap) / (minColumn + columnGap))),
|
|
width: rect.width,
|
|
columnGap,
|
|
};
|
|
}
|
|
const columns = computedGridColumnCount(selector);
|
|
return Number.isFinite(columns) && columns > 0 ? {
|
|
columns,
|
|
width: rect && rect.width,
|
|
columnGap,
|
|
} : null;
|
|
};
|
|
const detectAndApplySizes = (stage = "detect") => {
|
|
/*
|
|
* 探测策略:
|
|
*
|
|
* 1) CSS 已经注入, 但各模块默认关闭, 所以页面先按站点原始布局渲染.
|
|
* 2) 延迟/MutationObserver 触发后, 读取还没 ready 的模块尺寸.
|
|
* 3) 某个模块探测成功后, 写入 CSS 变量, 打开模块开关, 并把 moduleReady 锁住.
|
|
* 4) 锁住后的模块后续不再探测, 避免把已放大的尺寸再次当成原始尺寸.
|
|
*/
|
|
const followingListPage = isFollowingListPage();
|
|
const homeMetrics = moduleReady.home || followingListPage ? null : naturalGridMetrics("#roomlist_root ul.RoomCardGrid, #roomlist_root ul.list:has(li.roomCard)");
|
|
const followingListMetrics = moduleReady.followingList || !followingListPage ? null : naturalGridMetrics("#roomlist_root ul.RoomCardGrid, #roomlist_root ul.list:has(li.roomCard)");
|
|
const measured = {
|
|
// 首页普通房间列表: 列宽来自站点 CSS 的 minmax 最小值, 避免读取到 1fr 拉伸后的当前渲染宽度.
|
|
homeMetrics,
|
|
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 最小列宽, 否则会把已拉伸宽度再乘倍率.
|
|
followingListMetrics,
|
|
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"),
|
|
discoverDoubleUl: moduleReady.discover ? null : firstRect(".carousel-root .double-rows ul.list, #discover_root .double-rows ul.list"),
|
|
discoverSingleUl: moduleReady.discover ? null : firstRect(".carousel-root .single-row ul.list, #discover_root .single-row ul.list"),
|
|
};
|
|
|
|
if (!moduleReady.home && !followingListPage) {
|
|
// home 依赖当前页面的自然列数和原始高度; 每次加载重新探测.
|
|
moduleReady.home = setListScaleVars(
|
|
"--tm-thumb-home-columns",
|
|
"--tm-thumb-home-card-height",
|
|
"--tm-thumb-home-thumb-height",
|
|
measured.homeMetrics,
|
|
measured.homeCard && measured.homeCard.width,
|
|
measured.homeCard && measured.homeCard.height,
|
|
measured.homeThumb && measured.homeThumb.height,
|
|
);
|
|
setModuleReady("home", moduleReady.home);
|
|
}
|
|
|
|
if (!moduleReady.followingList && followingListPage) {
|
|
// followingList 单独探测, 避免关注页拿到首页的列数.
|
|
moduleReady.followingList = setListScaleVars(
|
|
"--tm-thumb-following-list-columns",
|
|
"--tm-thumb-following-list-card-height",
|
|
"--tm-thumb-following-list-thumb-height",
|
|
measured.followingListMetrics,
|
|
measured.followingListCard && measured.followingListCard.width,
|
|
measured.followingListCard && measured.followingListCard.height,
|
|
measured.followingListThumb && measured.followingListThumb.height,
|
|
);
|
|
setModuleReady("followingList", moduleReady.followingList);
|
|
}
|
|
|
|
if (!moduleReady.related) {
|
|
// related 同时依赖卡片宽度, 卡片高度和缩略图高度; 三者缺一不可.
|
|
moduleReady.related = [
|
|
setVar("--tm-thumb-related-width", measured.relatedCard && measured.relatedCard.width),
|
|
setCardHeightFromThumbVar("--tm-thumb-related-card-height", measured.relatedCard && measured.relatedCard.height, measured.relatedThumb && measured.relatedThumb.height),
|
|
setVar("--tm-thumb-related-height", measured.relatedThumb && measured.relatedThumb.height),
|
|
].every(Boolean);
|
|
setModuleReady("related", moduleReady.related);
|
|
}
|
|
|
|
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-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.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.discover) {
|
|
// 发现页要求更严格: 少一个高度都不开启, 宁可不放大, 也不要错位.
|
|
const discoverWidthReady = setVar("--tm-thumb-discover-width", measured.discoverCard && measured.discoverCard.width);
|
|
const discoverCardHeightReady = setCardHeightFromThumbVar("--tm-thumb-discover-card-height", measured.discoverCard && measured.discoverCard.height, measured.discoverThumb && measured.discoverThumb.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 discoverTripleReady = setDiscoverStackHeightVars(
|
|
"--tm-thumb-discover-triple-ul",
|
|
"--tm-thumb-discover-triple-arrow",
|
|
measured.discoverTripleUl && measured.discoverTripleUl.height,
|
|
measured.discoverCard && measured.discoverCard.height,
|
|
measured.discoverThumb && measured.discoverThumb.height,
|
|
3,
|
|
);
|
|
const discoverDoubleReady = setDiscoverStackHeightVars(
|
|
"--tm-thumb-discover-double-ul",
|
|
"--tm-thumb-discover-double-arrow",
|
|
measured.discoverDoubleUl && measured.discoverDoubleUl.height,
|
|
measured.discoverCard && measured.discoverCard.height,
|
|
measured.discoverThumb && measured.discoverThumb.height,
|
|
2,
|
|
);
|
|
const discoverSingleReady = setDiscoverStackHeightVars(
|
|
"--tm-thumb-discover-single-ul",
|
|
"--tm-thumb-discover-single-arrow",
|
|
measured.discoverSingleUl && measured.discoverSingleUl.height,
|
|
measured.discoverCard && measured.discoverCard.height,
|
|
measured.discoverThumb && measured.discoverThumb.height,
|
|
1,
|
|
);
|
|
moduleReady.discover = discoverWidthReady && discoverCardHeightReady && discoverThumbWidthReady && discoverHeightReady
|
|
&& discoverTripleReady
|
|
&& discoverDoubleReady
|
|
&& discoverSingleReady;
|
|
setModuleReady("discover", moduleReady.discover);
|
|
}
|
|
|
|
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: "disabled",
|
|
cssVars: (() => {
|
|
try {
|
|
const style = getComputedStyle(document.documentElement);
|
|
return {
|
|
homeColumns: style.getPropertyValue("--tm-thumb-home-columns").trim(),
|
|
homeCardHeight: style.getPropertyValue("--tm-thumb-home-card-height").trim(),
|
|
homeThumbHeight: style.getPropertyValue("--tm-thumb-home-thumb-height").trim(),
|
|
followingListColumns: style.getPropertyValue("--tm-thumb-following-list-columns").trim(),
|
|
followingListCardHeight: style.getPropertyValue("--tm-thumb-following-list-card-height").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(),
|
|
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(),
|
|
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();
|
|
// 暴露调试 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);
|
|
}
|
|
|
|
})();
|