From ea532949c0975a3ca5bd1d8facbd6f1ee58fc0b8 Mon Sep 17 00:00:00 2001 From: jack Date: Wed, 27 May 2026 10:10:21 +0800 Subject: [PATCH] Improve Chaturbate thumbnail scaling --- Chaturbate/chaturbate-thumbnails-2x.user.js | 1132 +++++++++++++++++-- tests/followed-dropdown-css.test.js | 388 +++++++ 2 files changed, 1411 insertions(+), 109 deletions(-) create mode 100644 tests/followed-dropdown-css.test.js diff --git a/Chaturbate/chaturbate-thumbnails-2x.user.js b/Chaturbate/chaturbate-thumbnails-2x.user.js index dab1027..2ce4263 100644 --- a/Chaturbate/chaturbate-thumbnails-2x.user.js +++ b/Chaturbate/chaturbate-thumbnails-2x.user.js @@ -1,147 +1,1061 @@ // ==UserScript== // @name Chaturbate 缩略图放大 2 倍 // @namespace https://chaturbate.com/ -// @version 0.3.5 -// @description 通过放大 grid 最小列宽来放大房间列表缩略图,保留原站填满行为 +// @version 0.10.7 +// @description 放大当前 Chaturbate 房间列表、发现页轮播、关注下拉与悬停预览缩略图 // @match https://chaturbate.com/* // @match https://*.chaturbate.com/* +// @updateURL http://127.0.0.1:49234/chaturbate-thumbnails-2x.user.js +// @downloadURL http://127.0.0.1:49234/chaturbate-thumbnails-2x.user.js // @run-at document-start -// @grant none +// @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"; // ========== 配置项 ========== - // 最小列宽倍数(原站约 174px / 180px,乘以该倍数后作为新的 minmax 最小值,1fr 仍填满整行) - const SCALE = 2; - // Discover 轮播容器高度倍数(< SCALE 可减少空白,文字区未等比放大) - const CONTAINER_HEIGHT_SCALE = 1.75; + // 图片宽高和卡片宽度倍率: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 MIN_COL_MAIN = 174; // #main 下的 min - const MIN_COL_ROOT = 180; // #roomlist_root、频道页下方(180×101) 的 min - const minMain = Math.round(MIN_COL_MAIN * SCALE); - const minRoot = Math.round(MIN_COL_ROOT * SCALE); - // Discover 原高:triple 550/534, double 366/350, single 182/166 - const h = (ul, arrow) => ({ ul: Math.round(ul * CONTAINER_HEIGHT_SCALE), arrow: Math.round(arrow * CONTAINER_HEIGHT_SCALE) }); - const triple = h(550, 534), double = h(366, 350), single = h(182, 166); + const VER = "0.10.7"; + // 缓存只保存“站点原始尺寸”,不要保存乘过倍率后的尺寸。 + // 这样以后只改 THUMBNAIL_SCALE / CARD_HEIGHT_SCALE 时,可以让缓存自动失效并重新探测,避免旧倍率污染新布局。 + const CACHE_KEY = "tm-thumb-scale:size-cache:v7"; + const CACHE_SCHEMA = 7; + const log = (...args) => { + if (DEBUG) console.log("[tm-thumb-scale]", ...args); + }; - const style = document.createElement("style"); - style.id = "tm-thumb-scale-style"; - style.textContent = ` - #main #roomlist_root .roomlist_container ul.list, - #main #roomlist_root .placeholder_roomlist_container ul.list { - grid-template-columns: repeat(auto-fill, minmax(${minMain}px, 1fr)) !important; - } - #roomlist_root .roomlist_container ul.list, - #roomlist_root .placeholder_roomlist_container ul.list { - grid-template-columns: repeat(auto-fill, minmax(${minRoot}px, 1fr)) !important; - } - /* 频道页下方:列表在 .BaseRoomContents 内,统一用该祖先即可 */ - .BaseRoomContents ul.list { + if (DEBUG) { + try { + document.documentElement.setAttribute("data-tm-thumb-scale", "1"); + document.documentElement.setAttribute("data-tm-thumb-scale-ver", VER); + } catch (_) {} + } + + const css = ` + /* + * CSS 总开关说明: + * + * 下面每一组规则都挂在 html[data-tm-thumb-scale-xxx="1"] 后面。 + * JS 没有探测到对应模块的原始尺寸前,不会设置这个 data 属性。 + * 这样 CSS 可以提前注入,但不会提前改变页面原始布局,延迟探测才能读到真实原始值。 + */ + + /* 首页当前列表 */ + /* + * home 模块: + * - 适用范围:主页、分类页等普通 #roomlist_root 房间列表。 + * - 探测值:卡片原始宽高 + 缩略图原始宽高。 + * - 放大方式:卡片和缩略图分别按自己的原始宽高乘倍率。 + * - 注意:关注页列表单独用 followingList 模块,不和 home 共用缓存。 + */ + html[data-tm-thumb-scale-home="1"] #main #roomlist_root ul.RoomCardGrid, + html[data-tm-thumb-scale-home="1"] #roomlist_root ul.RoomCardGrid, + html[data-tm-thumb-scale-home="1"] #main #roomlist_root ul.list:has(li.roomCard), + html[data-tm-thumb-scale-home="1"] #roomlist_root ul.list:has(li.roomCard) { + /* 改成 grid,是为了让放大后的卡片按新宽度自然换行。 */ display: grid !important; - grid-gap: 0.6em 0.75em !important; - grid-template-columns: repeat(auto-fill, minmax(${minRoot}px, 1fr)) !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; } - .BaseRoomContents ul.list li { + 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; - max-height: 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; } - .BaseRoomContents ul.list .room_thumbnail_container { - display: block !important; - width: 100% !important; - } - .BaseRoomContents ul.list .room_thumbnail_container img { - width: 100% !important; - height: auto !important; + html[data-tm-thumb-scale-home="1"] #roomlist_root ul.RoomCardGrid .RoomCardThumbnail, + html[data-tm-thumb-scale-home="1"] #roomlist_root ul.list:has(li.roomCard) .room_thumbnail_container { + width: var(--tm-thumb-home-thumb-width) !important; + height: var(--tm-thumb-home-thumb-height) !important; display: block !important; max-width: none !important; + box-sizing: border-box !important; } - /* 关注页:只给下拉层设一次 min-width,避免与父级重复放大导致只显示一列 */ - .followedDropdown.dropdown { - min-width: calc(2 * ${minRoot}px + 2em) !important; + html[data-tm-thumb-scale-home="1"] #roomlist_root ul.RoomCardGrid .RoomCardThumbnail img, + html[data-tm-thumb-scale-home="1"] #roomlist_root ul.list:has(li.roomCard) .room_thumbnail_container img, + html[data-tm-thumb-scale-home="1"] #roomlist_root ul.list:has(li.roomCard) .room_thumbnail { + width: var(--tm-thumb-home-thumb-width) !important; + height: var(--tm-thumb-home-thumb-height) !important; + /* block 可以消掉图片 inline baseline 带来的细小空隙。 */ + display: block !important; + max-width: none !important; + box-sizing: border-box !important; } - /* 强制两列,避免 auto-fill 在窄容器里排成单列导致单格过宽(看起来像放大两次) */ - .followRecommendedContainer .followRoomTable { + + /* 关注页当前列表 */ + /* + * followingList 模块: + * - 适用范围:/followed-cams/ 关注页里的 #roomlist_root 房间列表。 + * - 为什么和 home 分开:两者 DOM 选择器很像,但站点可能给不同页面不同原始列宽。 + * - 缓存也单独存 followingList,避免首页探测值污染关注页。 + * - 探测值同样是 card 宽高 + thumb 宽高四项。 + */ + html[data-tm-thumb-scale-following-list="1"] #main #roomlist_root ul.RoomCardGrid, + html[data-tm-thumb-scale-following-list="1"] #roomlist_root ul.RoomCardGrid, + html[data-tm-thumb-scale-following-list="1"] #main #roomlist_root ul.list:has(li.roomCard), + html[data-tm-thumb-scale-following-list="1"] #roomlist_root ul.list:has(li.roomCard) { display: grid !important; - grid-template-columns: repeat(2, minmax(${minRoot}px, 1fr)) !important; + grid-template-columns: repeat(auto-fill, minmax(var(--tm-thumb-following-list-width), 1fr)) !important; + gap: 0.6em 0.75em !important; + } + html[data-tm-thumb-scale-following-list="1"] #roomlist_root ul.RoomCardGrid > li.RoomCard, + html[data-tm-thumb-scale-following-list="1"] #roomlist_root ul.list:has(li.roomCard) li.roomCard { + width: auto !important; + min-width: 0 !important; + max-width: none !important; + height: var(--tm-thumb-following-list-card-height) !important; + min-height: var(--tm-thumb-following-list-card-height) !important; + max-height: var(--tm-thumb-following-list-card-height) !important; + } + html[data-tm-thumb-scale-following-list="1"] #roomlist_root ul.RoomCardGrid .RoomCardThumbnail, + html[data-tm-thumb-scale-following-list="1"] #roomlist_root ul.list:has(li.roomCard) .room_thumbnail_container { + width: var(--tm-thumb-following-list-thumb-width) !important; + height: var(--tm-thumb-following-list-thumb-height) !important; + display: block !important; + max-width: none !important; + box-sizing: border-box !important; + } + html[data-tm-thumb-scale-following-list="1"] #roomlist_root ul.RoomCardGrid .RoomCardThumbnail img, + html[data-tm-thumb-scale-following-list="1"] #roomlist_root ul.list:has(li.roomCard) .room_thumbnail_container img, + html[data-tm-thumb-scale-following-list="1"] #roomlist_root ul.list:has(li.roomCard) .room_thumbnail { + width: var(--tm-thumb-following-list-thumb-width) !important; + height: var(--tm-thumb-following-list-thumb-height) !important; + display: block !important; + max-width: none !important; + box-sizing: border-box !important; + } + /* 直播间页底部「更多这样的直播间」 */ + /* + * related 模块: + * - 适用范围:直播间页面底部“更多这样的房间”。 + * - 探测值:卡片原始宽高 + 缩略图原始宽高。 + * - 放大方式:卡片和缩略图分别按自己的原始宽高乘倍率,避免把 card 宽度误当 thumb 宽度。 + */ + html[data-tm-thumb-scale-related="1"] .BaseRoomContents ul.RoomCardGrid, + html[data-tm-thumb-scale-related="1"] #main.roomPage ul.list:has(li.roomCard), + html[data-tm-thumb-scale-related="1"] .BaseRoomContents ul.list:has(li.roomCard) { + display: grid !important; + grid-template-columns: repeat(auto-fill, minmax(var(--tm-thumb-related-width), 1fr)) !important; + gap: 0.6em 0.75em !important; + } + html[data-tm-thumb-scale-related="1"] .BaseRoomContents ul.RoomCardGrid > li.RoomCard, + html[data-tm-thumb-scale-related="1"] #main.roomPage ul.list:has(li.roomCard) li.roomCard, + html[data-tm-thumb-scale-related="1"] .BaseRoomContents ul.list:has(li.roomCard) li.roomCard { + width: auto !important; + min-width: 0 !important; + max-width: none !important; + /* 卡片整体高度也要放大;只放大缩略图高度时,下方用户名/人数栏可能被压住。 */ + height: var(--tm-thumb-related-card-height) !important; + min-height: var(--tm-thumb-related-card-height) !important; + max-height: var(--tm-thumb-related-card-height) !important; + } + html[data-tm-thumb-scale-related="1"] .BaseRoomContents ul.RoomCardGrid .RoomCardThumbnail, + html[data-tm-thumb-scale-related="1"] #main.roomPage ul.list:has(li.roomCard) .room_thumbnail_container, + html[data-tm-thumb-scale-related="1"] .BaseRoomContents ul.list:has(li.roomCard) .room_thumbnail_container { + /* --tm-thumb-related-thumb-width 来自原始缩略图宽度,不直接复用卡片宽度。 */ + width: var(--tm-thumb-related-thumb-width) !important; + /* --tm-thumb-related-height 来自原始缩略图容器高度。 */ + height: var(--tm-thumb-related-height) !important; + display: block !important; + max-width: none !important; + box-sizing: border-box !important; + } + html[data-tm-thumb-scale-related="1"] .BaseRoomContents ul.RoomCardGrid .RoomCardThumbnail img, + html[data-tm-thumb-scale-related="1"] #main.roomPage ul.list:has(li.roomCard) .room_thumbnail_container img, + html[data-tm-thumb-scale-related="1"] #main.roomPage ul.list:has(li.roomCard) .room_thumbnail, + html[data-tm-thumb-scale-related="1"] .BaseRoomContents ul.list:has(li.roomCard) .room_thumbnail_container img, + html[data-tm-thumb-scale-related="1"] .BaseRoomContents ul.list:has(li.roomCard) .room_thumbnail { + /* 图片宽度也按原始缩略图宽度放大,避免卡片边框/间隙被算进图片宽度。 */ + width: var(--tm-thumb-related-thumb-width) !important; + /* 图片和容器使用同一个高度,避免图片溢出或下方信息栏错位。 */ + height: var(--tm-thumb-related-height) !important; + display: block !important; + max-width: none !important; + box-sizing: border-box !important; + } + + /* 顶部「关注」下拉 */ + /* + * follow 模块: + * - 适用范围:顶部“关注”菜单弹出的房间列表。 + * - 探测值:弹窗中单个房间卡片宽高 + 图片宽高。 + * - 放大方式:卡片和图片各按自己的原始宽高乘倍率;弹窗总宽度按 2 列计算。 + */ + html[data-tm-thumb-scale-follow="1"] .FollowedDropdown { + /* 96vw 防止弹窗在窄窗口里横向溢出。 */ + width: min(96vw, var(--tm-thumb-follow-width)) !important; + min-width: min(96vw, var(--tm-thumb-follow-width)) !important; + max-width: min(96vw, var(--tm-thumb-follow-width)) !important; + box-sizing: border-box !important; + } + html[data-tm-thumb-scale-follow="1"] .FollowedDropdown__container, + html[data-tm-thumb-scale-follow="1"] .FollowedDropdown__rooms { + width: 100% !important; + max-width: none !important; + box-sizing: border-box !important; + } + html[data-tm-thumb-scale-follow="1"] .FollowedDropdown__rooms { + display: grid !important; + /* 当前设计保持两列,列宽来自原始卡片宽度 * 倍率。 */ + grid-template-columns: repeat(2, minmax(var(--tm-thumb-follow-card-width), 1fr)) !important; grid-gap: 0.6em 0.75em !important; } - .followRecommendedContainer .followRoomTable > div, - .followRecommendedContainer .followRoomTable > div > div { - display: contents !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; } - /* 卡片与图片固定为 minRoot 宽(只放大一次),覆盖站点内联 width:180 height:126 */ - .followRecommendedContainer .roomElement { - width: ${minRoot}px !important; - min-width: ${minRoot}px !important; - max-width: ${minRoot}px !important; - height: auto !important; - } - .followRecommendedContainer .roomElementAnchor { + html[data-tm-thumb-scale-follow="1"] .FollowedDropdown__room-link { width: 100% !important; - height: auto !important; + height: var(--tm-thumb-follow-card-height) !important; display: block !important; } - .followRecommendedContainer .room_thumbnail { - width: 100% !important; - height: auto !important; - display: block !important; - } - /* Discover 页:轮播缩略图 + 容器高度(CONTAINER_HEIGHT_SCALE 倍,默认 1.75) */ - .carousel-root .triple-rows ul.list, - #discover_root .triple-rows ul.list { - height: ${triple.ul}px !important; - } - .carousel-root .triple-rows .carousel-arrow-container, - #discover_root .triple-rows .carousel-arrow-container { - height: ${triple.arrow}px !important; - } - .carousel-root .double-rows ul.list, - #discover_root .double-rows ul.list { - height: ${double.ul}px !important; - } - .carousel-root .double-rows .carousel-arrow-container, - #discover_root .double-rows .carousel-arrow-container { - height: ${double.arrow}px !important; - } - .carousel-root .single-row ul.list, - #discover_root .single-row ul.list { - height: ${single.ul}px !important; - } - .carousel-root .single-row .carousel-arrow-container, - #discover_root .single-row .carousel-arrow-container { - height: ${single.arrow}px !important; - } - .carousel-root .room-list-carousel ul.list > li, - #discover_root .room-list-carousel ul.list > li { - width: ${minRoot}px !important; - min-width: ${minRoot}px !important; - max-width: ${minRoot}px !important; - flex: 0 0 ${minRoot}px !important; - } - .carousel-root .room-list-carousel .room_thumbnail_container, - #discover_root .room-list-carousel .room_thumbnail_container { - display: block !important; - width: 100% !important; - } - .carousel-root .room-list-carousel .room_thumbnail_container img, - .carousel-root .room-list-carousel .room_thumbnail, - #discover_root .room-list-carousel .room_thumbnail_container img, - #discover_root .room-list-carousel .room_thumbnail { - width: 100% !important; - height: auto !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 inject = () => { - if (!document.getElementById("tm-thumb-scale-style")) - (document.head || document.documentElement).appendChild(style); + 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, }; - if (document.head) inject(); - else document.addEventListener("DOMContentLoaded", inject); + const setDebugAttr = (name, value) => { + // 调试属性写到 html 上,方便在 Elements 面板确认脚本是否运行。 + if (!DEBUG) return; + try { + document.documentElement.setAttribute(`data-tm-thumb-scale-${name}`, String(value)); + } catch (_) {} + }; + + const scaledPx = (value) => `${Math.round(value * THUMBNAIL_SCALE)}px`; + const scaledCardHeightPx = (value) => `${Math.round(value * CARD_HEIGHT_SCALE)}px`; + const scaledCardHeightFromThumbPx = (cardHeight, thumbHeight) => { + const nonThumbHeight = Math.max(0, cardHeight - thumbHeight); + return `${Math.round((thumbHeight * THUMBNAIL_SCALE) + nonThumbHeight)}px`; + }; + const rootStyle = () => document.documentElement && document.documentElement.style; + const isFollowingListPage = () => { + try { + const path = typeof location === "undefined" ? "" : location.pathname; + return /\/followed-cams\/?$/i.test(path) || /\/followed\/?$/i.test(path); + } catch (_) { + return false; + } + }; + // 所有 CSS 变量都在这里统一乘倍率。调用方传入的必须是页面原始尺寸。 + const setVar = (name, value) => { + const style = rootStyle(); + if (!style || !Number.isFinite(value) || value <= 0) return false; + style.setProperty(name, scaledPx(value)); + return true; + }; + // 只给 card 整体高度使用。图片宽高仍然走 setVar(),继续乘 THUMBNAIL_SCALE。 + const setCardHeightVar = (name, value) => { + const style = rootStyle(); + if (!style || !Number.isFinite(value) || value <= 0) return false; + style.setProperty(name, scaledCardHeightPx(value)); + return true; + }; + const setCardHeightFromThumbVar = (name, cardHeight, thumbHeight) => { + const style = rootStyle(); + if (!style || !Number.isFinite(cardHeight) || !Number.isFinite(thumbHeight) || cardHeight <= 0 || thumbHeight <= 0) return false; + style.setProperty(name, scaledCardHeightFromThumbPx(cardHeight, thumbHeight)); + return true; + }; + // 每个模块的 CSS 都由 html[data-tm-thumb-scale-模块名="1"] 控制。 + // 未探测到原始值时不设置开关,对应模块完全不放大,避免用错误尺寸猜页面。 + const setModuleReady = (name, ready) => { + try { + const attrName = name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`); + const attr = `data-tm-thumb-scale-${attrName}`; + if (ready) document.documentElement.setAttribute(attr, "1"); + else document.documentElement.removeAttribute(attr); + } catch (_) {} + }; + const isRect = (value) => ( + // 缓存读取时做最低限度校验,防止坏数据进入 CSS 变量。 + value + && Number.isFinite(value.width) + && Number.isFinite(value.height) + && value.width > 0 + && value.height > 0 + ); + const cloneRect = (value) => (isRect(value) ? { + // 缓存不需要亚像素无限精度,保留两位小数足够复现布局。 + width: Math.round(value.width * 100) / 100, + height: Math.round(value.height * 100) / 100, + } : null); + const readCache = () => { + try { + // localStorage 可能被浏览器策略禁用,所以所有缓存操作都要 try/catch。 + if (typeof localStorage === "undefined") return null; + // 缓存格式错误、倍率不同、结构版本不同,都视为无缓存。 + const parsed = JSON.parse(localStorage.getItem(CACHE_KEY) || "null"); + if ( + !parsed + || parsed.schema !== CACHE_SCHEMA + || parsed.scale !== THUMBNAIL_SCALE + || parsed.cardHeightScale !== CARD_HEIGHT_SCALE + || !parsed.modules + ) return null; + return parsed; + } catch (_) { + return null; + } + }; + const writeCache = (cache) => { + try { + // 写入失败不影响脚本运行,只是下次还会重新探测。 + if (typeof localStorage === "undefined") return false; + localStorage.setItem(CACHE_KEY, JSON.stringify(cache)); + return true; + } catch (_) { + return false; + } + }; + const updateCacheModule = (name, data) => { + // 模块级缓存:发现页、首页、关注下拉等互不影响。 + // 只更新当前模块,保留其它模块已经探测好的原始尺寸。 + const cache = readCache() || { + schema: CACHE_SCHEMA, + scale: THUMBNAIL_SCALE, + cardHeightScale: CARD_HEIGHT_SCALE, + updatedAt: 0, + modules: {}, + }; + cache.updatedAt = Date.now(); + cache.modules[name] = data; + writeCache(cache); + }; + const clearCache = () => { + try { + // 油猴菜单调用这里;清完后不强制刷新,由用户自己刷新页面重新探测。 + if (typeof localStorage !== "undefined") localStorage.removeItem(CACHE_KEY); + } catch (_) {} + log("cache cleared; refresh page to detect sizes again"); + }; + const registerMenu = () => { + try { + // GM_registerMenuCommand 需要在 header 里 @grant;没有该 API 时静默跳过。 + if (typeof GM_registerMenuCommand === "function") { + GM_registerMenuCommand("清除 Chaturbate 缩略图探测缓存", clearCache); + } + } catch (_) {} + }; + const applyCachedModule = (name, data) => { + if (!data || moduleReady[name]) return false; + + // 缓存命中时直接写 CSS 变量并打开模块开关。 + // 这里仍然通过 setVar() 乘倍率,因此缓存中始终保持原始尺寸。 + if (name === "home" && !isFollowingListPage() && isRect(data.homeCard) && isRect(data.homeThumb)) { + moduleReady.home = [ + setVar("--tm-thumb-home-width", data.homeCard.width), + setCardHeightVar("--tm-thumb-home-card-height", data.homeCard.height), + setVar("--tm-thumb-home-thumb-width", data.homeThumb.width), + setVar("--tm-thumb-home-thumb-height", data.homeThumb.height), + ].every(Boolean); + } else if (name === "followingList" && isFollowingListPage() && isRect(data.followingListCard) && isRect(data.followingListThumb)) { + moduleReady.followingList = [ + setVar("--tm-thumb-following-list-width", data.followingListCard.width), + setCardHeightVar("--tm-thumb-following-list-card-height", data.followingListCard.height), + setVar("--tm-thumb-following-list-thumb-width", data.followingListThumb.width), + setVar("--tm-thumb-following-list-thumb-height", data.followingListThumb.height), + ].every(Boolean); + } else if (name === "related" && isRect(data.relatedCard) && isRect(data.relatedThumb)) { + const widthReady = setVar("--tm-thumb-related-width", data.relatedCard.width); + const cardHeightReady = setCardHeightVar("--tm-thumb-related-card-height", data.relatedCard.height); + const thumbWidthReady = setVar("--tm-thumb-related-thumb-width", data.relatedThumb.width); + const heightReady = setVar("--tm-thumb-related-height", data.relatedThumb.height); + moduleReady.related = widthReady && cardHeightReady && thumbWidthReady && heightReady; + } else if (name === "follow" && isRect(data.followCard) && isRect(data.followThumb)) { + const followReady = [ + setVar("--tm-thumb-follow-card-width", data.followCard.width), + setCardHeightFromThumbVar("--tm-thumb-follow-card-height", data.followCard.height, data.followThumb.height), + setVar("--tm-thumb-follow-thumb-width", data.followThumb.width), + setVar("--tm-thumb-follow-thumb-height", data.followThumb.height), + ].every(Boolean); + const style = rootStyle(); + if (followReady && style) { + style.setProperty("--tm-thumb-follow-width", `calc(${Math.round(data.followCard.width * THUMBNAIL_SCALE * 2)}px + 2em)`); + } + moduleReady.follow = followReady; + } else if (name === "tooltip" && isRect(data.tooltipThumb)) { + moduleReady.tooltip = [ + setVar("--tm-thumb-tooltip-width", data.tooltipThumb.width), + setVar("--tm-thumb-tooltip-height", data.tooltipThumb.height), + ].every(Boolean); + } else if ( + name === "discover" + && isRect(data.discoverCard) + && isRect(data.discoverThumb) + && isRect(data.discoverTripleUl) + && isRect(data.discoverTripleArrow) + && isRect(data.discoverDoubleUl) + && isRect(data.discoverDoubleArrow) + && isRect(data.discoverSingleUl) + && isRect(data.discoverSingleArrow) + ) { + const ready = [ + setVar("--tm-thumb-discover-width", data.discoverCard.width), + setCardHeightVar("--tm-thumb-discover-card-height", data.discoverCard.height), + setVar("--tm-thumb-discover-thumb-width", data.discoverThumb.width), + setVar("--tm-thumb-discover-height", data.discoverThumb.height), + setCardHeightVar("--tm-thumb-discover-triple-ul", data.discoverTripleUl.height), + setCardHeightVar("--tm-thumb-discover-triple-arrow", data.discoverTripleArrow.height), + setCardHeightVar("--tm-thumb-discover-double-ul", data.discoverDoubleUl.height), + setCardHeightVar("--tm-thumb-discover-double-arrow", data.discoverDoubleArrow.height), + setCardHeightVar("--tm-thumb-discover-single-ul", data.discoverSingleUl.height), + setCardHeightVar("--tm-thumb-discover-single-arrow", data.discoverSingleArrow.height), + ]; + moduleReady.discover = ready.every(Boolean); + } + + setModuleReady(name, moduleReady[name]); + return moduleReady[name]; + }; + const applyCachedSizes = () => { + const cache = readCache(); + if (!cache) return false; + const modules = cache.modules || {}; + const applied = { + home: applyCachedModule("home", modules.home), + followingList: applyCachedModule("followingList", modules.followingList), + related: applyCachedModule("related", modules.related), + follow: applyCachedModule("follow", modules.follow), + tooltip: applyCachedModule("tooltip", modules.tooltip), + discover: applyCachedModule("discover", modules.discover), + }; + if (DEBUG) log("cache applied", applied); + return Object.values(applied).some(Boolean); + }; + const rectOf = (el) => { + // getBoundingClientRect 能拿到 CSS 布局后的实际渲染尺寸。 + if (!el || !el.getBoundingClientRect) return null; + const rect = el.getBoundingClientRect(); + const width = rect.width || el.offsetWidth || 0; + const height = rect.height || el.offsetHeight || 0; + // 太小的元素通常是图标、占位或尚未布局完成的节点,不作为缩略图基准。 + if (width < 20 && height < 20) return null; + return { width, height }; + }; + const firstRect = (selector) => { + try { + // 同一类列表里取第一个有尺寸的节点即可,因为同模块卡片原始尺寸应一致。 + if (!document.querySelectorAll) return null; + const nodes = document.querySelectorAll(selector); + for (const node of nodes) { + const rect = rectOf(node); + if (rect) return rect; + } + } catch (_) {} + return null; + }; + const detectAndApplySizes = (stage = "detect") => { + /* + * 探测策略: + * + * 1) CSS 已经注入,但各模块默认关闭,所以页面先按站点原始布局渲染。 + * 2) 延迟/MutationObserver 触发后,读取还没 ready 的模块尺寸。 + * 3) 某个模块探测成功后,写入 CSS 变量、打开模块开关、写入缓存,并把 moduleReady 锁住。 + * 4) 锁住后的模块后续不再探测,避免把已放大的尺寸再次当成原始尺寸。 + */ + const followingListPage = isFollowingListPage(); + const measured = { + // 首页普通房间列表:卡片宽高 + 缩略图宽高四项独立探测。 + homeCard: moduleReady.home || followingListPage ? null : firstRect("#roomlist_root li.RoomCard, #roomlist_root li.roomCard"), + homeThumb: moduleReady.home || followingListPage ? null : firstRect("#roomlist_root .RoomCardThumbnail, #roomlist_root .room_thumbnail_container"), + // 关注页房间列表:选择器和首页相似,但独立探测、独立缓存、独立 CSS 变量。 + followingListCard: moduleReady.followingList || !followingListPage ? null : firstRect("#roomlist_root li.RoomCard, #roomlist_root li.roomCard"), + followingListThumb: moduleReady.followingList || !followingListPage ? null : firstRect("#roomlist_root .RoomCardThumbnail, #roomlist_root .room_thumbnail_container"), + // 频道页“更多这样的房间”:卡片宽高和缩略图宽高都要探测,否则放大后行高容易不齐。 + relatedCard: moduleReady.related ? null : firstRect(".BaseRoomContents ul.RoomCardGrid > li.RoomCard, #main.roomPage ul.list:has(li.roomCard) li.roomCard, .BaseRoomContents ul.list:has(li.roomCard) li.roomCard"), + relatedThumb: moduleReady.related ? null : firstRect(".BaseRoomContents ul.RoomCardGrid .RoomCardThumbnail, #main.roomPage ul.list:has(li.roomCard) .room_thumbnail_container, .BaseRoomContents ul.list:has(li.roomCard) .room_thumbnail_container"), + // 顶部“关注”弹窗:通常弹出后才有 DOM,靠 mutation 触发探测。 + followCard: moduleReady.follow ? null : firstRect(".FollowedDropdown__room, .FollowedDropdown__room-image"), + followThumb: moduleReady.follow ? null : firstRect(".FollowedDropdown__room-image"), + // 悬浮预览:tooltip 是 portal 动态插入,也靠 mutation 捕捉。 + tooltipThumb: moduleReady.tooltip ? null : firstRect('body > div[id^="react-tooltip"] img[src*="live.mmcdn.com"], body > div[id^="react-tooltip"] img[src*="thumb.live.mmcdn"], [data-floating-ui-portal] img[src*="live.mmcdn.com"]'), + // 发现页 carousel:必须同时探测 card、thumb、行容器和箭头高度,全部成功后才启用。 + // 这样“最受欢迎”等多行轮播能保持站点原来的对齐关系,只是整体按倍率放大。 + discoverCard: moduleReady.discover ? null : firstRect(".carousel-root .room-list-carousel ul.list > li, #discover_root .room-list-carousel ul.list > li"), + discoverThumb: moduleReady.discover ? null : firstRect(".carousel-root .room-list-carousel .room_thumbnail_container, #discover_root .room-list-carousel .room_thumbnail_container"), + discoverTripleUl: moduleReady.discover ? null : firstRect(".carousel-root .triple-rows ul.list, #discover_root .triple-rows ul.list"), + discoverTripleArrow: moduleReady.discover ? null : firstRect(".carousel-root .triple-rows .carousel-arrow-container, #discover_root .triple-rows .carousel-arrow-container"), + discoverDoubleUl: moduleReady.discover ? null : firstRect(".carousel-root .double-rows ul.list, #discover_root .double-rows ul.list"), + discoverDoubleArrow: moduleReady.discover ? null : firstRect(".carousel-root .double-rows .carousel-arrow-container, #discover_root .double-rows .carousel-arrow-container"), + discoverSingleUl: moduleReady.discover ? null : firstRect(".carousel-root .single-row ul.list, #discover_root .single-row ul.list"), + discoverSingleArrow: moduleReady.discover ? null : firstRect(".carousel-root .single-row .carousel-arrow-container, #discover_root .single-row .carousel-arrow-container"), + }; + + if (!moduleReady.home && !followingListPage) { + // home 依赖 card/thumb 各自宽高;成功后立刻缓存,后续普通列表页面可直接套用。 + moduleReady.home = [ + setVar("--tm-thumb-home-width", measured.homeCard && measured.homeCard.width), + setCardHeightVar("--tm-thumb-home-card-height", measured.homeCard && measured.homeCard.height), + setVar("--tm-thumb-home-thumb-width", measured.homeThumb && measured.homeThumb.width), + setVar("--tm-thumb-home-thumb-height", measured.homeThumb && measured.homeThumb.height), + ].every(Boolean); + setModuleReady("home", moduleReady.home); + if (moduleReady.home) { + updateCacheModule("home", { + homeCard: cloneRect(measured.homeCard), + homeThumb: cloneRect(measured.homeThumb), + }); + } + } + + if (!moduleReady.followingList && followingListPage) { + // followingList 单独缓存,避免关注页拿到首页的原始宽度。 + moduleReady.followingList = [ + setVar("--tm-thumb-following-list-width", measured.followingListCard && measured.followingListCard.width), + setCardHeightVar("--tm-thumb-following-list-card-height", measured.followingListCard && measured.followingListCard.height), + setVar("--tm-thumb-following-list-thumb-width", measured.followingListThumb && measured.followingListThumb.width), + setVar("--tm-thumb-following-list-thumb-height", measured.followingListThumb && measured.followingListThumb.height), + ].every(Boolean); + setModuleReady("followingList", moduleReady.followingList); + if (moduleReady.followingList) { + updateCacheModule("followingList", { + followingListCard: cloneRect(measured.followingListCard), + followingListThumb: cloneRect(measured.followingListThumb), + }); + } + } + + if (!moduleReady.related) { + // related 同时依赖卡片宽度、卡片高度、缩略图宽度和缩略图高度;四者缺一不可。 + const relatedWidthReady = setVar("--tm-thumb-related-width", measured.relatedCard && measured.relatedCard.width); + const relatedCardHeightReady = setCardHeightVar("--tm-thumb-related-card-height", measured.relatedCard && measured.relatedCard.height); + const relatedThumbWidthReady = setVar("--tm-thumb-related-thumb-width", measured.relatedThumb && measured.relatedThumb.width); + const relatedHeightReady = setVar("--tm-thumb-related-height", measured.relatedThumb && measured.relatedThumb.height); + moduleReady.related = relatedWidthReady && relatedCardHeightReady && relatedThumbWidthReady && relatedHeightReady; + setModuleReady("related", moduleReady.related); + if (moduleReady.related) { + updateCacheModule("related", { + relatedCard: cloneRect(measured.relatedCard), + relatedThumb: cloneRect(measured.relatedThumb), + }); + } + } + + if (!moduleReady.follow) { + // follow 的总弹窗宽度按两列计算:单卡原始宽度 * 倍率 * 2 + 2em 间距余量。 + const followReady = [ + setVar("--tm-thumb-follow-card-width", measured.followCard && measured.followCard.width), + setCardHeightFromThumbVar("--tm-thumb-follow-card-height", measured.followCard && measured.followCard.height, measured.followThumb && measured.followThumb.height), + setVar("--tm-thumb-follow-thumb-width", measured.followThumb && measured.followThumb.width), + setVar("--tm-thumb-follow-thumb-height", measured.followThumb && measured.followThumb.height), + ].every(Boolean); + moduleReady.follow = followReady; + const style = rootStyle(); + if (followReady && style) { + style.setProperty("--tm-thumb-follow-width", `calc(${Math.round(measured.followCard.width * THUMBNAIL_SCALE * 2)}px + 2em)`); + } + setModuleReady("follow", moduleReady.follow); + if (moduleReady.follow) { + updateCacheModule("follow", { + followCard: cloneRect(measured.followCard), + followThumb: cloneRect(measured.followThumb), + }); + } + } + + if (!moduleReady.tooltip) { + // tooltip 出现时机不固定,所以通常由 body mutation 触发探测。 + moduleReady.tooltip = [ + setVar("--tm-thumb-tooltip-width", measured.tooltipThumb && measured.tooltipThumb.width), + setVar("--tm-thumb-tooltip-height", measured.tooltipThumb && measured.tooltipThumb.height), + ].every(Boolean); + setModuleReady("tooltip", moduleReady.tooltip); + if (moduleReady.tooltip) { + updateCacheModule("tooltip", { tooltipThumb: cloneRect(measured.tooltipThumb) }); + } + } + + if (!moduleReady.discover) { + // 发现页要求更严格:少一个高度都不开启,宁可不放大,也不要错位。 + const discoverWidthReady = setVar("--tm-thumb-discover-width", measured.discoverCard && measured.discoverCard.width); + const discoverCardHeightReady = setCardHeightVar("--tm-thumb-discover-card-height", measured.discoverCard && measured.discoverCard.height); + const discoverThumbWidthReady = setVar("--tm-thumb-discover-thumb-width", measured.discoverThumb && measured.discoverThumb.width); + const discoverHeightReady = setVar("--tm-thumb-discover-height", measured.discoverThumb && measured.discoverThumb.height); + const discoverTripleUlReady = setCardHeightVar("--tm-thumb-discover-triple-ul", measured.discoverTripleUl && measured.discoverTripleUl.height); + const discoverTripleArrowReady = setCardHeightVar("--tm-thumb-discover-triple-arrow", measured.discoverTripleArrow && measured.discoverTripleArrow.height); + const discoverDoubleUlReady = setCardHeightVar("--tm-thumb-discover-double-ul", measured.discoverDoubleUl && measured.discoverDoubleUl.height); + const discoverDoubleArrowReady = setCardHeightVar("--tm-thumb-discover-double-arrow", measured.discoverDoubleArrow && measured.discoverDoubleArrow.height); + const discoverSingleUlReady = setCardHeightVar("--tm-thumb-discover-single-ul", measured.discoverSingleUl && measured.discoverSingleUl.height); + const discoverSingleArrowReady = setCardHeightVar("--tm-thumb-discover-single-arrow", measured.discoverSingleArrow && measured.discoverSingleArrow.height); + moduleReady.discover = discoverWidthReady && discoverCardHeightReady && discoverThumbWidthReady && discoverHeightReady + && discoverTripleUlReady && discoverTripleArrowReady + && discoverDoubleUlReady && discoverDoubleArrowReady + && discoverSingleUlReady && discoverSingleArrowReady; + setModuleReady("discover", moduleReady.discover); + if (moduleReady.discover) { + updateCacheModule("discover", { + discoverCard: cloneRect(measured.discoverCard), + discoverThumb: cloneRect(measured.discoverThumb), + discoverTripleUl: cloneRect(measured.discoverTripleUl), + discoverTripleArrow: cloneRect(measured.discoverTripleArrow), + discoverDoubleUl: cloneRect(measured.discoverDoubleUl), + discoverDoubleArrow: cloneRect(measured.discoverDoubleArrow), + discoverSingleUl: cloneRect(measured.discoverSingleUl), + discoverSingleArrow: cloneRect(measured.discoverSingleArrow), + }); + } + } + + lastMeasure = { stage, moduleReady: { ...moduleReady }, measured }; + if (DEBUG) log("measured original sizes", lastMeasure); + return lastMeasure; + }; + const scheduleMeasure = (stage, delay = 0) => { + // 所有探测都通过这里排队,避免 React 连续插入节点时频繁测量布局。 + if (typeof setTimeout !== "function") { + detectAndApplySizes(stage); + return; + } + if (measureTimer) clearTimeout(measureTimer); + measureTimer = setTimeout(() => { + measureTimer = null; + detectAndApplySizes(stage); + }, delay); + }; + + const selectorCount = (selector) => { + try { + // 诊断用计数;selector 不被浏览器支持时返回错误字符串而不是中断脚本。 + if (!document.querySelectorAll) return -1; + return document.querySelectorAll(selector).length; + } catch (e) { + return `ERR: ${e && e.message ? e.message : e}`; + } + }; + + const computedInfo = (selector) => { + try { + // 诊断用:输出关键容器的 display/grid/width,方便在控制台判断 CSS 是否命中。 + if (!document.querySelector || typeof getComputedStyle !== "function") return null; + const el = document.querySelector(selector); + if (!el) return null; + const style = getComputedStyle(el); + return { + selector, + tag: el.tagName, + className: el.className, + display: style.display, + gridTemplateColumns: style.gridTemplateColumns, + width: style.width, + minWidth: style.minWidth, + maxWidth: style.maxWidth, + }; + } catch (e) { + return { selector, error: e && e.message ? e.message : String(e) }; + } + }; + + const reportDiagnostics = (stage) => { + // 统一收集调试信息,手动执行 window.tmThumbScaleDebug() 也会走这里。 + const styleNodePresent = !!document.getElementById(STYLE_ID); + const stylePresent = styleInjected || styleNodePresent; + const counts = { + roomlistRoot: selectorCount("#roomlist_root"), + homepageList: selectorCount("#roomlist_root ul.RoomCardGrid, #roomlist_root ul.list:has(li.roomCard)"), + homepageCards: selectorCount("#roomlist_root li.RoomCard, #roomlist_root li.roomCard"), + homepageThumbs: selectorCount("#roomlist_root .RoomCardThumbnail, #roomlist_root .room_thumbnail_container"), + followedDropdown: selectorCount(".FollowedDropdown"), + followedRooms: selectorCount(".FollowedDropdown__room"), + tooltipThumbs: selectorCount('body > div[id^="react-tooltip"] img[src*="live.mmcdn.com"], body > div[id^="react-tooltip"] img[src*="thumb.live.mmcdn"], [data-floating-ui-portal] img[src*="live.mmcdn.com"]'), + }; + const computed = [ + computedInfo("#roomlist_root ul.RoomCardGrid"), + computedInfo("#roomlist_root ul.list:has(li.roomCard)"), + computedInfo(".FollowedDropdown__rooms"), + computedInfo(".FollowedDropdown__room-image"), + computedInfo("#discover_root .room-list-carousel ul.list"), + ].filter(Boolean); + + const report = { + stage, + ver: VER, + debug: DEBUG, + href: typeof location === "undefined" ? "" : location.href, + readyState: document.readyState, + stylePresent, + styleInjected, + styleNodePresent, + injectCount, + lastInjectMethod, + cssLength: css.length, + cache: (() => { + const cache = readCache(); + return cache ? { + schema: cache.schema, + scale: cache.scale, + cardHeightScale: cache.cardHeightScale, + updatedAt: cache.updatedAt, + modules: Object.keys(cache.modules || {}), + } : null; + })(), + cssVars: (() => { + try { + const style = getComputedStyle(document.documentElement); + return { + homeWidth: style.getPropertyValue("--tm-thumb-home-width").trim(), + homeCardHeight: style.getPropertyValue("--tm-thumb-home-card-height").trim(), + homeThumbWidth: style.getPropertyValue("--tm-thumb-home-thumb-width").trim(), + homeThumbHeight: style.getPropertyValue("--tm-thumb-home-thumb-height").trim(), + followingListWidth: style.getPropertyValue("--tm-thumb-following-list-width").trim(), + followingListCardHeight: style.getPropertyValue("--tm-thumb-following-list-card-height").trim(), + followingListThumbWidth: style.getPropertyValue("--tm-thumb-following-list-thumb-width").trim(), + followingListThumbHeight: style.getPropertyValue("--tm-thumb-following-list-thumb-height").trim(), + relatedWidth: style.getPropertyValue("--tm-thumb-related-width").trim(), + relatedCardHeight: style.getPropertyValue("--tm-thumb-related-card-height").trim(), + relatedThumbWidth: style.getPropertyValue("--tm-thumb-related-thumb-width").trim(), + relatedHeight: style.getPropertyValue("--tm-thumb-related-height").trim(), + followWidth: style.getPropertyValue("--tm-thumb-follow-width").trim(), + followCardHeight: style.getPropertyValue("--tm-thumb-follow-card-height").trim(), + followThumbWidth: style.getPropertyValue("--tm-thumb-follow-thumb-width").trim(), + followThumbHeight: style.getPropertyValue("--tm-thumb-follow-thumb-height").trim(), + discoverWidth: style.getPropertyValue("--tm-thumb-discover-width").trim(), + discoverCardHeight: style.getPropertyValue("--tm-thumb-discover-card-height").trim(), + discoverThumbWidth: style.getPropertyValue("--tm-thumb-discover-thumb-width").trim(), + discoverHeight: style.getPropertyValue("--tm-thumb-discover-height").trim(), + }; + } catch (_) { + return {}; + } + })(), + lastMeasure, + counts, + computed, + }; + lastReport = report; + + if (typeof window !== "undefined") { + // 暴露在页面 window,方便控制台直接查看最近一次报告。 + try { + window.tmThumbScaleLastReport = report; + } catch (_) {} + } + if (typeof unsafeWindow !== "undefined") { + // Tampermonkey 隔离环境下,unsafeWindow 能把 API 暴露给页面上下文。 + try { + unsafeWindow.tmThumbScaleLastReport = report; + } catch (_) {} + } + + if (!DEBUG) return report; + + setDebugAttr("ver", VER); + setDebugAttr("style-present", stylePresent ? "1" : "0"); + setDebugAttr("style-node-present", styleNodePresent ? "1" : "0"); + setDebugAttr("inject-count", injectCount); + setDebugAttr("inject-method", lastInjectMethod); + setDebugAttr("homepage-list-count", counts.homepageList); + + console.log("[tm-thumb-scale]", report); + return report; + }; + + const exposeDebugApi = () => { + // 手动调试入口:控制台执行 tmThumbScaleDebug() 可立即生成报告。 + const api = (stage = "manual") => reportDiagnostics(stage); + const targets = []; + if (typeof window !== "undefined") targets.push(window); + if (typeof unsafeWindow !== "undefined" && unsafeWindow !== window) targets.push(unsafeWindow); + + targets.forEach((target) => { + try { + target.tmThumbScaleDebug = api; + target.tmThumbScaleLastReport = lastReport; + } catch (_) {} + }); + }; + + const inject = () => { + // 注入 CSS 前先处理旧版本 style,避免更新脚本后新旧规则同时存在。 + const existingStyle = document.getElementById(STYLE_ID); + if (existingStyle) { + const existingVer = existingStyle.getAttribute("data-tm-thumb-scale-ver"); + if (existingVer === VER) { + styleInjected = true; + log("style already present, skip"); + reportDiagnostics("inject-skip-current-style"); + return; + } + try { + existingStyle.remove(); + log("removed stale style", { existingVer }); + } catch (e) { + log("remove stale style failed", e); + } + } + + try { + if (typeof GM_addStyle === "function") { + // Tampermonkey 推荐使用 GM_addStyle,比手动 append style 更稳定。 + const style = GM_addStyle(css); + if (style) { + style.id = STYLE_ID; + style.setAttribute("data-tm-thumb-scale-ver", VER); + } + injectCount += 1; + styleInjected = true; + lastInjectMethod = style ? "GM_addStyle-with-node" : "GM_addStyle-no-node"; + log("injected via GM_addStyle", { returnedStyleNode: !!style }); + reportDiagnostics("inject-gm-add-style"); + return; + } + + const style = document.createElement("style"); + // GM_addStyle 不可用时才走手动 style 兜底。 + style.id = STYLE_ID; + style.setAttribute("data-tm-thumb-scale-ver", VER); + style.textContent = css; + (document.head || document.documentElement).appendChild(style); + injectCount += 1; + styleInjected = true; + lastInjectMethod = "manual-style"; + log("appended