2976 lines
119 KiB
JavaScript
2976 lines
119 KiB
JavaScript
// ==UserScript==
|
||
// @name Chaturbate 缩略图自定义缩放
|
||
// @namespace https://chaturbate.com/
|
||
// @version 0.14.7
|
||
// @description 自定义缩放 Chaturbate 房间列表, 发现页轮播, 关注下拉与悬停预览缩略图, 并中文化标签页
|
||
// @match https://chaturbate.com/*
|
||
// @match https://*.chaturbate.com/*
|
||
// @updateURL https://gitea.jackadam.top/jack/Tampermonkey_scripts/raw/branch/main/Chaturbate/chaturbate-thumbnails-custom.user.js
|
||
// @downloadURL https://gitea.jackadam.top/jack/Tampermonkey_scripts/raw/branch/main/Chaturbate/chaturbate-thumbnails-custom.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 / 自定义行高去替代站点布局.
|
||
* 响应式网格只放大最小列宽, 图片和卡片高度保持自动.
|
||
* 固定式模块放大卡片宽度, 图片宽高和轮播高度, 信息栏高度尽量保留原站尺寸.
|
||
*
|
||
* 2) 各模块必须自己探测原始值, 再乘倍率.
|
||
* 没有探测到原始值, 就不放大该模块; 不拿默认尺寸猜页面.
|
||
*
|
||
* 3) 延迟探测, 不临时禁用样式.
|
||
* CSS 先注入但所有模块默认关闭; 等页面原始 DOM 渲染后, 逐个模块探测原始尺寸, 成功后才打开该模块.
|
||
* 模块一旦探测成功就锁定, 避免后续 mutation 把已经放大的尺寸当成原始值再次相乘.
|
||
*
|
||
* 4) 不再缓存探测结果.
|
||
* Chaturbate 的列表宽度会随页面/窗口变化, 缓存旧尺寸容易污染新布局.
|
||
* 当前版本每次页面加载都重新探测; localStorage 只保存用户手动设置的两个倍率.
|
||
*
|
||
* 5) 标签页中文化只改页面显示文字, 不改链接地址.
|
||
* 翻译表和分词词典里没有的标签保持原 #slug, 避免半英文保底文本影响观感.
|
||
*
|
||
* 6) DEBUG 只用于控制台与 window.tmThumbScaleDebug() 诊断, 不应该弹窗或显示页面调试面板.
|
||
*/
|
||
|
||
(function () {
|
||
"use strict";
|
||
|
||
// ========== 配置项 ==========
|
||
// 响应式倍率默认值: 影响首页, 关注页列表, 频道页更多房间等 auto-fill 网格的最小列宽.
|
||
const DEFAULT_RESPONSIVE_SCALE = 2;
|
||
// 固定式倍率默认值: 影响发现页轮播, 顶部关注弹窗和悬浮预览这类固定尺寸区域.
|
||
const DEFAULT_FIXED_SCALE = 2;
|
||
// 控件步进: 页面输入框按 0.1 调整倍率.
|
||
const SCALE_STEP = 0.1;
|
||
// 恢复原始尺寸时两个倍率都回到 1.0.
|
||
const ORIGINAL_SCALE = 1;
|
||
// 调试开关: 控制控制台日志, html data-tm-* 标记和 window.tmThumbScaleDebug().
|
||
const DEBUG = true;
|
||
// ===========================
|
||
|
||
const VER = "0.14.7";
|
||
// 旧版本用过 localStorage 尺寸缓存.当前版本不再读写尺寸缓存, 避免旧探测值污染新布局.
|
||
const LEGACY_CACHE_PREFIX = "tm-thumb-scale:size-cache:";
|
||
// 当前版本只保存用户手动调节的倍率, 不保存任何页面尺寸.
|
||
const SETTINGS_KEY = "tm-thumb-scale:settings:v1";
|
||
// 页面内倍率控制条的 id, 便于重复检查时复用同一个节点.
|
||
const CONTROL_ID = "tm-thumb-scale-controls";
|
||
// 频道页悬浮聊天窗隐藏标记.勾选设置时 JS 找到聊天窗后写入这个 data 属性, CSS 负责隐藏.
|
||
const ROOM_CHAT_OVERLAY_ATTR = "data-tm-thumb-room-chat-overlay";
|
||
// 标签页中文化表: key 使用站点 tag slug 或英文显示文本归一化后的结果.
|
||
// 只在 /tags/ 页面替换显示文字, 不改变 href, 所以点击后的站点行为保持原样.
|
||
const TAG_TRANSLATIONS = Object.freeze({
|
||
"18": "18岁",
|
||
"18yo": "18岁",
|
||
"amateur": "业余",
|
||
"anal": "肛交",
|
||
"asian": "亚洲",
|
||
"ass": "臀部",
|
||
"bbw": "大码",
|
||
"bdsm": "BDSM",
|
||
"big-ass": "大臀",
|
||
"bigass": "大臀",
|
||
"big-boobs": "大胸",
|
||
"bigboobs": "大胸",
|
||
"big-tits": "大胸",
|
||
"bigtits": "大胸",
|
||
"bisexual": "双性恋",
|
||
"blonde": "金发",
|
||
"blowjob": "口交",
|
||
"bondage": "束缚",
|
||
"boobs": "胸部",
|
||
"brunette": "深发色",
|
||
"cam-to-cam": "双向视频",
|
||
"c2c": "双向视频",
|
||
"cosplay": "角色扮演",
|
||
"couple": "情侣",
|
||
"cum": "射精",
|
||
"cumshot": "射精",
|
||
"curvy": "丰满",
|
||
"cute": "可爱",
|
||
"custom": "自定义",
|
||
"daddy": "爹系",
|
||
"dance": "跳舞",
|
||
"dildo": "假阳具",
|
||
"dom": "支配",
|
||
"ebony": "黑人",
|
||
"feet": "足部",
|
||
"femdom": "女王",
|
||
"female": "女性",
|
||
"fetish": "恋物",
|
||
"fingering": "指交",
|
||
"fit": "健身",
|
||
"fitness": "健身",
|
||
"french": "法国",
|
||
"gay": "男同",
|
||
"german": "德国",
|
||
"girlfriend": "女友感",
|
||
"hairy": "毛发",
|
||
"hardcore": "重口",
|
||
"heels": "高跟鞋",
|
||
"hentai": "二次元",
|
||
"indian": "印度",
|
||
"interracial": "跨种族",
|
||
"italian": "意大利",
|
||
"japanese": "日本",
|
||
"korean": "韩国",
|
||
"latina": "拉丁裔",
|
||
"lesbian": "女同",
|
||
"lingerie": "内衣",
|
||
"lovense": "Lovense",
|
||
"male": "男性",
|
||
"mature": "成熟女性",
|
||
"milf": "熟女",
|
||
"mistress": "女主人",
|
||
"natural": "自然",
|
||
"new": "新主播",
|
||
"nylon": "尼龙",
|
||
"oil": "油光",
|
||
"orgasm": "高潮",
|
||
"panties": "内裤",
|
||
"pantyhose": "连裤袜",
|
||
"petite": "娇小",
|
||
"pov": "第一视角",
|
||
"pregnant": "孕妇",
|
||
"private": "私密",
|
||
"public": "公开",
|
||
"redhead": "红发",
|
||
"roleplay": "角色扮演",
|
||
"romantic": "浪漫",
|
||
"russian": "俄罗斯",
|
||
"saggy": "下垂",
|
||
"shaved": "剃毛",
|
||
"skinny": "纤瘦",
|
||
"small-tits": "小胸",
|
||
"smalltits": "小胸",
|
||
"smoking": "吸烟",
|
||
"squirt": "潮吹",
|
||
"stockings": "长筒袜",
|
||
"striptease": "脱衣舞",
|
||
"submissive": "服从",
|
||
"teen": "年轻",
|
||
"thick": "丰腴",
|
||
"tiny": "娇小",
|
||
"toys": "玩具",
|
||
"trans": "跨性别",
|
||
"transgender": "跨性别",
|
||
"twink": "男孩感",
|
||
"vibrator": "振动器",
|
||
"young": "年轻",
|
||
"3dxchat": "3DXChat",
|
||
"asmr": "ASMR",
|
||
"bbclover": "BBC爱好者",
|
||
"bj": "口交",
|
||
"cam2cam": "双向视频",
|
||
"cd": "异装",
|
||
"cumshow": "射精秀",
|
||
"domme": "女王",
|
||
"gfmaterial": "女友感",
|
||
"gilf": "老年熟女",
|
||
"happynewyear": "新年快乐",
|
||
"leagueoflegends": "英雄联盟",
|
||
"makemecum": "让我射",
|
||
"makemecuminpvt": "私密让我射",
|
||
"makemehard": "让我硬",
|
||
"mtf": "男跨女",
|
||
"nolush": "无 Lush",
|
||
"ohmibod": "OhMiBod",
|
||
"pm": "私信",
|
||
"prv": "私密",
|
||
"prvt": "私密",
|
||
"pvtshow": "私密秀",
|
||
"prvtshow": "私密秀",
|
||
"str8": "异性恋",
|
||
"teamfighttactics": "云顶之弈",
|
||
"tgirl": "跨性别女孩",
|
||
"ts": "跨性别",
|
||
"vseeface": "VSeeFace",
|
||
"welcomeall": "欢迎所有人",
|
||
"18to25": "18到25岁",
|
||
"19": "19岁",
|
||
"19yearsold": "19岁",
|
||
"20": "20岁",
|
||
"69": "69式",
|
||
"africa": "非洲",
|
||
"alwayshorny": "一直想要",
|
||
"analsex": "肛交",
|
||
"artist": "艺术家",
|
||
"asianbeauty": "亚洲美女",
|
||
"asstomouth": "臀到口",
|
||
"bareback": "无套",
|
||
"beauty": "美人",
|
||
"beautifulsmile": "美丽笑容",
|
||
"bigassandtits": "大臀大胸",
|
||
"bighead": "大头",
|
||
"bigsquirt": "大量潮吹",
|
||
"bikini": "比基尼",
|
||
"blackbeauty": "黑人美女",
|
||
"bouncytits": "弹跳胸部",
|
||
"blond": "金发",
|
||
"blondie": "金发女",
|
||
"blowjobs": "口交",
|
||
"bra": "胸罩",
|
||
"braoff": "脱胸罩",
|
||
"brownskin": "棕色皮肤",
|
||
"bubblebutt": "圆翘臀",
|
||
"bulging": "凸起",
|
||
"california": "加州",
|
||
"cam": "摄像头",
|
||
"cuminprivateshow": "私密秀射精",
|
||
"cuminpvt": "私密射精",
|
||
"cumatgoal": "目标射精",
|
||
"cumslut": "射精荡妇",
|
||
"cumsquirt": "射精潮吹",
|
||
"cumwithme": "和我一起射",
|
||
"curvybody": "丰满身材",
|
||
"curvygoddess": "丰满女神",
|
||
"curvypetite": "丰满娇小",
|
||
"cuckolding": "绿帽",
|
||
"cutesmile": "可爱笑容",
|
||
"dancesexy": "性感跳舞",
|
||
"doggystyle": "后入姿势",
|
||
"doll": "娃娃",
|
||
"dominate": "支配",
|
||
"doublepenetration": "双重插入",
|
||
"eyeglasses": "眼镜",
|
||
"fem": "女性化",
|
||
"firsttime": "第一次",
|
||
"flashtits": "闪露胸部",
|
||
"fleshlight": "飞机杯",
|
||
"flexing": "展示肌肉",
|
||
"flipflops": "人字拖",
|
||
"frenchkiss": "法式接吻",
|
||
"fuckshow": "性爱秀",
|
||
"funny": "有趣",
|
||
"girlnextdoor": "邻家女孩",
|
||
"gothic": "哥特",
|
||
"halfjapanese": "半日本",
|
||
"hairyman": "毛发男",
|
||
"happybirthdaytome": "祝我生日快乐",
|
||
"india": "印度",
|
||
"interactivetoy": "互动玩具",
|
||
"jackingoff": "撸管",
|
||
"jerkingoff": "撸管",
|
||
"latinosexy": "性感拉丁男",
|
||
"lbignipples": "大乳头",
|
||
"legshow": "腿部展示",
|
||
"lesbianprivateshow": "女同私密秀",
|
||
"lesbianshow": "女同秀",
|
||
"love": "爱",
|
||
"masterforslave": "奴隶的主人",
|
||
"miss": "小姐",
|
||
"moaning": "呻吟",
|
||
"naturalbeauty": "自然美",
|
||
"newzealand": "新西兰",
|
||
"newyork": "纽约",
|
||
"nicesweetpussy": "漂亮甜美私处",
|
||
"nonbinary": "非二元",
|
||
"nudist": "裸体主义",
|
||
"painting": "绘画",
|
||
"passwordshow": "密码秀",
|
||
"phonesex": "电话性爱",
|
||
"pinky": "粉色",
|
||
"prettysexyandsmart": "漂亮性感聪明",
|
||
"privateshow": "私密秀",
|
||
"privateshows": "私密秀",
|
||
"roleplaying": "角色扮演",
|
||
"ridetoy": "骑玩具",
|
||
"selfsuck": "自吸",
|
||
"sensualadventuresthegame": "感官冒险游戏",
|
||
"sexyandtall": "性感高挑",
|
||
"sexyman": "性感男",
|
||
"shaven": "剃毛",
|
||
"shave": "剃毛",
|
||
"showsoles": "展示脚底",
|
||
"shybutfreaky": "害羞但狂野",
|
||
"sissification": "伪娘化",
|
||
"sissyfication": "伪娘化",
|
||
"sissyslut": "伪娘荡妇",
|
||
"sloppyblowjobs": "湿滑口交",
|
||
"slutty": "荡妇风",
|
||
"sneakers": "运动鞋",
|
||
"spinel": "Spinel",
|
||
"sperm": "精液",
|
||
"squirtshow": "潮吹秀",
|
||
"squirty": "潮吹",
|
||
"stayhomeandchaturbate": "宅家上 Chaturbate",
|
||
"takeoffclothes": "脱衣服",
|
||
"talkative": "健谈",
|
||
"tan": "古铜肤色",
|
||
"teasing": "挑逗",
|
||
"thai": "泰国",
|
||
"ticketshow": "门票秀",
|
||
"tinydick": "小阴茎",
|
||
"toe": "脚趾",
|
||
"tipmenu": "打赏菜单",
|
||
"titjob": "乳交",
|
||
"tokenkeno": "代币基诺",
|
||
"toy": "玩具",
|
||
"transfem": "跨性别女性",
|
||
"transman": "跨性别男性",
|
||
"tsgirl": "跨性别女孩",
|
||
"twerking": "电臀舞",
|
||
"twogirls": "两个女孩",
|
||
"veiny": "青筋",
|
||
"verbal": "语言",
|
||
"vibrations": "震动",
|
||
"videogames": "电子游戏",
|
||
"welcome": "欢迎",
|
||
"whore": "荡妇",
|
||
"yourwifematerial": "你的理想妻子",
|
||
"youngbeauty": "年轻美女",
|
||
});
|
||
// slug 分词翻译表: 标签页有大量 #bigboobs, #smalltits 这类拼接 slug.
|
||
// 完整翻译表没有命中时, 会用这个词典按最长匹配拆词, 尽量把 10 页主题标签都变成中文显示.
|
||
const TAG_WORD_TRANSLATIONS = Object.freeze({
|
||
"18": "18岁",
|
||
"24": "24岁",
|
||
"3d": "3D",
|
||
"abdl": "成人婴儿",
|
||
"abs": "腹肌",
|
||
"af": "很",
|
||
"african": "非洲",
|
||
"ahegao": "阿嘿颜",
|
||
"all": "全",
|
||
"alt": "另类",
|
||
"alpha": "强势男",
|
||
"american": "美国",
|
||
"amputee": "截肢",
|
||
"alternative": "另类",
|
||
"amateur": "业余",
|
||
"anal": "肛交",
|
||
"anime": "动漫",
|
||
"arab": "阿拉伯",
|
||
"armpits": "腋下",
|
||
"asia": "亚洲",
|
||
"asian": "亚洲",
|
||
"ass": "臀",
|
||
"atm": "ATM",
|
||
"aussie": "澳洲",
|
||
"awesome": "超棒",
|
||
"baby": "宝贝",
|
||
"balloons": "气球",
|
||
"balls": "睾丸",
|
||
"bbc": "黑人大屌",
|
||
"bbw": "大码",
|
||
"bbws": "大码",
|
||
"bdsm": "BDSM",
|
||
"bear": "熊系",
|
||
"beefy": "壮硕",
|
||
"beard": "胡子",
|
||
"belly": "肚子",
|
||
"bi": "双性恋",
|
||
"biceps": "二头肌",
|
||
"big": "大",
|
||
"bimbo": "花瓶美人",
|
||
"black": "黑人",
|
||
"blonde": "金发",
|
||
"boobs": "胸",
|
||
"body": "身体",
|
||
"bodybuilder": "健美",
|
||
"boots": "靴子",
|
||
"boy": "男孩",
|
||
"boys": "男孩",
|
||
"braces": "牙套",
|
||
"british": "英国",
|
||
"brown": "棕色",
|
||
"brunette": "深发色",
|
||
"bush": "毛丛",
|
||
"busty": "大胸",
|
||
"butt": "臀",
|
||
"butts": "臀",
|
||
"cameltoe": "骆驼趾",
|
||
"cash": "现金",
|
||
"chastity": "贞操",
|
||
"chubby": "胖乎乎",
|
||
"christmas": "圣诞",
|
||
"chinese": "中国",
|
||
"chat": "聊天",
|
||
"clamps": "夹子",
|
||
"clit": "阴蒂",
|
||
"cock": "阴茎",
|
||
"college": "大学",
|
||
"colombiana": "哥伦比亚女性",
|
||
"colombiano": "哥伦比亚男性",
|
||
"colombia": "哥伦比亚",
|
||
"colombian": "哥伦比亚",
|
||
"control": "控制",
|
||
"cosplay": "角色扮演",
|
||
"country": "乡村",
|
||
"couple": "情侣",
|
||
"cougar": "熟女",
|
||
"crazy": "疯狂",
|
||
"creampie": "内射",
|
||
"crossdresser": "异装",
|
||
"cuckold": "绿帽",
|
||
"cum": "射精",
|
||
"cut": "包皮环切",
|
||
"cute": "可爱",
|
||
"custom": "自定义",
|
||
"dad": "爸爸",
|
||
"daddy": "爹系",
|
||
"dance": "跳舞",
|
||
"deep": "深",
|
||
"desi": "南亚",
|
||
"dick": "阴茎",
|
||
"dilf": "熟男",
|
||
"dirty": "挑逗",
|
||
"dildo": "假阳具",
|
||
"dom": "支配",
|
||
"domi": "支配",
|
||
"dominant": "支配",
|
||
"domination": "支配",
|
||
"dominatrix": "女施虐者",
|
||
"door": "邻家",
|
||
"dream": "梦幻",
|
||
"drink": "饮酒",
|
||
"dutch": "荷兰",
|
||
"ebony": "黑人",
|
||
"edge": "边缘",
|
||
"edging": "边缘控制",
|
||
"emo": "情绪风",
|
||
"erotic": "情色",
|
||
"exhibitionist": "露出",
|
||
"eyes": "眼睛",
|
||
"facial": "颜射",
|
||
"face": "脸",
|
||
"fantasy": "幻想",
|
||
"femboy": "伪娘",
|
||
"femdom": "女王",
|
||
"fetish": "恋物",
|
||
"findom": "金钱支配",
|
||
"fingering": "指交",
|
||
"fishnet": "网袜",
|
||
"fit": "健身",
|
||
"flex": "展示肌肉",
|
||
"flexible": "柔韧",
|
||
"foot": "足",
|
||
"feet": "足",
|
||
"foreskin": "包皮",
|
||
"france": "法国",
|
||
"free": "免费",
|
||
"friend": "朋友",
|
||
"fuck": "性爱",
|
||
"gaming": "游戏",
|
||
"gag": "堵嘴",
|
||
"gamer": "玩家",
|
||
"gay": "男同",
|
||
"genz": "Z世代",
|
||
"german": "德国",
|
||
"girl": "女孩",
|
||
"girly": "少女",
|
||
"glasses": "眼镜",
|
||
"goddess": "女神",
|
||
"goth": "哥特",
|
||
"hair": "头发",
|
||
"hairy": "毛发",
|
||
"hard": "硬",
|
||
"hd": "高清",
|
||
"heels": "高跟鞋",
|
||
"hentai": "二次元",
|
||
"hidden": "隐藏",
|
||
"hindi": "印地语",
|
||
"hornyaf": "非常欲望",
|
||
"horny": "欲望",
|
||
"hot": "火辣",
|
||
"huge": "巨大",
|
||
"humiliation": "羞辱",
|
||
"hung": "大尺寸",
|
||
"hypno": "催眠",
|
||
"indian": "印度",
|
||
"italian": "意大利",
|
||
"japan": "日本",
|
||
"joi": "指令助兴",
|
||
"kinky": "重口",
|
||
"latex": "乳胶",
|
||
"latin": "拉丁",
|
||
"latina": "拉丁裔",
|
||
"latino": "拉丁裔男",
|
||
"leather": "皮革",
|
||
"leggings": "打底裤",
|
||
"legs": "腿",
|
||
"league": "联盟",
|
||
"legends": "传奇",
|
||
"lesbian": "女同",
|
||
"lingerie": "内衣",
|
||
"long": "长",
|
||
"lips": "唇",
|
||
"lovense": "Lovense",
|
||
"lush": "Lush",
|
||
"madam": "夫人",
|
||
"machine": "机器",
|
||
"married": "已婚",
|
||
"master": "主人",
|
||
"material": "料",
|
||
"masturbation": "自慰",
|
||
"mature": "成熟",
|
||
"messy": "凌乱",
|
||
"milf": "熟女",
|
||
"milk": "乳汁",
|
||
"mistress": "女主人",
|
||
"moan": "呻吟",
|
||
"mom": "妈妈",
|
||
"mommy": "妈咪",
|
||
"monster": "巨型",
|
||
"muscle": "肌肉",
|
||
"muscles": "肌肉",
|
||
"muscular": "肌肉型",
|
||
"nails": "指甲",
|
||
"nasty": "重口",
|
||
"natural": "自然",
|
||
"naked": "裸露",
|
||
"naughty": "淘气",
|
||
"new": "新",
|
||
"nipple": "乳头",
|
||
"nipples": "乳头",
|
||
"nonude": "不露点",
|
||
"nonnude": "不露点",
|
||
"nude": "裸露",
|
||
"nudes": "裸照",
|
||
"nyc": "纽约",
|
||
"office": "办公室",
|
||
"online": "在线",
|
||
"open": "打开",
|
||
"orgasm": "高潮",
|
||
"panties": "内裤",
|
||
"pantyhose": "连裤袜",
|
||
"party": "派对",
|
||
"pawg": "丰满大臀",
|
||
"pegging": "佩戴式插入",
|
||
"petite": "娇小",
|
||
"perverted": "变态",
|
||
"piercing": "穿环",
|
||
"pinay": "菲律宾女性",
|
||
"pov": "第一视角",
|
||
"pornstar": "色情明星",
|
||
"precum": "前列腺液",
|
||
"pregnant": "孕妇",
|
||
"princess": "公主",
|
||
"private": "私密",
|
||
"pussy": "私处",
|
||
"puffy": "蓬起",
|
||
"pvt": "私密",
|
||
"red": "红",
|
||
"redhead": "红发",
|
||
"request": "点播",
|
||
"roleplay": "角色扮演",
|
||
"russian": "俄罗斯",
|
||
"saliva": "唾液",
|
||
"shaved": "剃毛",
|
||
"sex": "性爱",
|
||
"self": "自己",
|
||
"short": "短",
|
||
"show": "展示",
|
||
"shower": "淋浴",
|
||
"shy": "害羞",
|
||
"sissy": "伪娘",
|
||
"skin": "皮肤",
|
||
"skinny": "纤瘦",
|
||
"slave": "奴役",
|
||
"slim": "苗条",
|
||
"slut": "荡妇",
|
||
"small": "小",
|
||
"smoke": "吸烟",
|
||
"smoking": "吸烟",
|
||
"smoker": "吸烟者",
|
||
"snow": "雪白",
|
||
"socks": "袜子",
|
||
"soles": "脚底",
|
||
"spain": "西班牙",
|
||
"spanish": "西班牙",
|
||
"spit": "吐口水",
|
||
"sph": "尺寸羞辱",
|
||
"squirt": "潮吹",
|
||
"stockings": "长筒袜",
|
||
"straight": "异性恋",
|
||
"strapon": "假阳具",
|
||
"student": "学生",
|
||
"sub": "服从",
|
||
"submissive": "服从",
|
||
"suck": "吸吮",
|
||
"tag": "标签",
|
||
"tattoo": "纹身",
|
||
"tattoos": "纹身",
|
||
"tease": "挑逗",
|
||
"teen": "年轻",
|
||
"thick": "丰腴",
|
||
"throat": "喉",
|
||
"tight": "紧致",
|
||
"tits": "胸",
|
||
"titty": "胸",
|
||
"tomboy": "假小子",
|
||
"toys": "玩具",
|
||
"toes": "脚趾",
|
||
"tongue": "舌头",
|
||
"torso": "躯干",
|
||
"trans": "跨性别",
|
||
"transgender": "跨性别",
|
||
"twerk": "电臀舞",
|
||
"twink": "男孩感",
|
||
"ukraine": "乌克兰",
|
||
"uk": "英国",
|
||
"uncut": "未割包皮",
|
||
"vibrator": "振动器",
|
||
"voice": "声音",
|
||
"voyeur": "偷窥",
|
||
"waifu": "二次元老婆",
|
||
"wife": "妻子",
|
||
"worship": "崇拜",
|
||
"adventures": "冒险",
|
||
"alltips": "所有打赏",
|
||
"appreciated": "感谢",
|
||
"armpit": "腋下",
|
||
"asshole": "肛门",
|
||
"athletic": "运动型",
|
||
"balloon": "气球",
|
||
"bare": "裸",
|
||
"beautiful": "漂亮",
|
||
"best": "最佳",
|
||
"binary": "二元",
|
||
"bitch": "荡妇",
|
||
"blue": "蓝色",
|
||
"boob": "胸",
|
||
"boobies": "胸",
|
||
"booty": "臀",
|
||
"bottom": "底部",
|
||
"brat": "顽皮",
|
||
"brazilian": "巴西",
|
||
"breast": "胸部",
|
||
"breasts": "胸部",
|
||
"brackets": "牙套",
|
||
"bull": "公牛",
|
||
"canada": "加拿大",
|
||
"canadian": "加拿大",
|
||
"candy": "糖果",
|
||
"cat": "猫系",
|
||
"chaturbate": "Chaturbate",
|
||
"chatting": "聊天",
|
||
"chatty": "健谈",
|
||
"chest": "胸膛",
|
||
"chill": "放松",
|
||
"chilling": "放松",
|
||
"chocolate": "巧克力",
|
||
"circumcised": "已割包皮",
|
||
"classy": "优雅",
|
||
"clitoris": "阴蒂",
|
||
"clothes": "衣服",
|
||
"color": "彩色",
|
||
"come": "来",
|
||
"cowboy": "牛仔",
|
||
"cream": "奶油",
|
||
"creamy": "奶油感",
|
||
"cross": "异装",
|
||
"cumdump": "射精目标",
|
||
"curve": "曲线",
|
||
"curved": "弯曲",
|
||
"curves": "曲线",
|
||
"cutie": "可爱女孩",
|
||
"dallas": "达拉斯",
|
||
"dancing": "跳舞",
|
||
"dark": "深色",
|
||
"destroy": "摧毁",
|
||
"devilish": "小恶魔",
|
||
"dice": "骰子",
|
||
"doggy": "后入",
|
||
"double": "双重",
|
||
"dress": "裙装",
|
||
"easter": "复活节",
|
||
"elegant": "优雅",
|
||
"english": "英语",
|
||
"erotica": "情色",
|
||
"european": "欧洲",
|
||
"experience": "体验",
|
||
"fancy": "精致",
|
||
"feminine": "女性化",
|
||
"feminization": "女性化",
|
||
"femme": "女性化",
|
||
"finger": "手指",
|
||
"fingers": "手指",
|
||
"first": "首次",
|
||
"flashes": "闪露",
|
||
"flirt": "调情",
|
||
"florida": "佛罗里达",
|
||
"follow": "关注",
|
||
"fountain": "喷泉",
|
||
"freak": "狂野",
|
||
"freckles": "雀斑",
|
||
"fresh": "清新",
|
||
"friendly": "友好",
|
||
"fun": "有趣",
|
||
"game": "游戏",
|
||
"games": "游戏",
|
||
"gangbang": "多人",
|
||
"genderfluid": "性别流动",
|
||
"girlfriend": "女友",
|
||
"glamour": "魅力",
|
||
"gloves": "手套",
|
||
"goal": "目标",
|
||
"goals": "目标",
|
||
"good": "乖",
|
||
"green": "绿色",
|
||
"guitar": "吉他",
|
||
"guy": "男士",
|
||
"guys": "男士",
|
||
"gym": "健身房",
|
||
"handsome": "英俊",
|
||
"happy": "开心",
|
||
"hetero": "异性恋",
|
||
"high": "高",
|
||
"hispanic": "西语裔",
|
||
"hole": "洞",
|
||
"home": "家",
|
||
"hose": "长袜",
|
||
"hotwax": "热蜡",
|
||
"house": "家庭",
|
||
"hunk": "猛男",
|
||
"hypnosis": "催眠",
|
||
"innocent": "清纯",
|
||
"interactive": "互动",
|
||
"intimate": "亲密",
|
||
"jacking": "撸动",
|
||
"jerk": "撸",
|
||
"jerking": "撸动",
|
||
"jerkoff": "撸管",
|
||
"jersey": "泽西",
|
||
"kawaii": "可爱",
|
||
"keyholder": "钥匙持有者",
|
||
"kink": "癖好",
|
||
"kiss": "亲吻",
|
||
"large": "大",
|
||
"lactation": "哺乳",
|
||
"leg": "腿",
|
||
"lets": "一起",
|
||
"lick": "舔",
|
||
"licking": "舔",
|
||
"little": "小",
|
||
"live": "直播",
|
||
"load": "量",
|
||
"lover": "爱好者",
|
||
"madure": "成熟",
|
||
"maid": "女仆",
|
||
"masculine": "男性化",
|
||
"mask": "面具",
|
||
"massage": "按摩",
|
||
"masturbate": "自慰",
|
||
"me": "我",
|
||
"medellin": "麦德林",
|
||
"men": "男性",
|
||
"metal": "金属",
|
||
"milky": "乳白",
|
||
"model": "模特",
|
||
"motivation": "激励",
|
||
"mouth": "嘴",
|
||
"music": "音乐",
|
||
"mustache": "胡须",
|
||
"nature": "自然",
|
||
"naturale": "自然",
|
||
"nerd": "书呆子",
|
||
"nerdy": "书呆子",
|
||
"newbie": "新人",
|
||
"night": "夜晚",
|
||
"nice": "漂亮",
|
||
"nora": "Nora",
|
||
"obey": "服从",
|
||
"older": "年长",
|
||
"on": "开启",
|
||
"pakistani": "巴基斯坦",
|
||
"pale": "白皙",
|
||
"pansexual": "泛性恋",
|
||
"passion": "热情",
|
||
"password": "密码",
|
||
"paypig": "金钱奴",
|
||
"paypigs": "金钱奴",
|
||
"penis": "阴茎",
|
||
"perfect": "完美",
|
||
"perky": "挺翘",
|
||
"phat": "丰满",
|
||
"phone": "电话",
|
||
"pierced": "穿环",
|
||
"plastic": "塑料",
|
||
"play": "玩",
|
||
"playful": "爱玩",
|
||
"playing": "玩耍",
|
||
"pleasure": "愉悦",
|
||
"plug": "塞子",
|
||
"pretty": "漂亮",
|
||
"punish": "惩罚",
|
||
"quickie": "快速",
|
||
"queen": "女王",
|
||
"real": "真实",
|
||
"ride": "骑乘",
|
||
"riding": "骑乘",
|
||
"ring": "环",
|
||
"roll": "掷",
|
||
"rubber": "橡胶",
|
||
"rubia": "金发女",
|
||
"scottish": "苏格兰",
|
||
"secretary": "秘书",
|
||
"seduction": "诱惑",
|
||
"showershow": "淋浴秀",
|
||
"single": "单身",
|
||
"sixpack": "六块腹肌",
|
||
"skirt": "短裙",
|
||
"slap": "拍打",
|
||
"slender": "苗条",
|
||
"sloppy": "湿滑",
|
||
"smile": "微笑",
|
||
"smiley": "微笑",
|
||
"smooth": "光滑",
|
||
"soft": "柔软",
|
||
"solo": "单人",
|
||
"song": "歌曲",
|
||
"south": "南方",
|
||
"spank": "打屁股",
|
||
"spanking": "打屁股",
|
||
"spin": "旋转",
|
||
"spinner": "转盘",
|
||
"sport": "运动",
|
||
"sporty": "运动风",
|
||
"squirter": "潮吹者",
|
||
"squirting": "潮吹",
|
||
"stay": "待在",
|
||
"step": "继",
|
||
"stocking": "长筒袜",
|
||
"stoner": "嗨感",
|
||
"strong": "强壮",
|
||
"strip": "脱衣",
|
||
"stripper": "脱衣舞者",
|
||
"stroke": "撸动",
|
||
"stud": "猛男",
|
||
"style": "姿势",
|
||
"submission": "臣服",
|
||
"sugar": "甜心",
|
||
"surprise": "惊喜",
|
||
"sweet": "甜美",
|
||
"switch": "双向",
|
||
"take": "脱",
|
||
"tatted": "纹身",
|
||
"tattooed": "纹身",
|
||
"tender": "温柔",
|
||
"thicc": "丰厚",
|
||
"thighs": "大腿",
|
||
"thin": "纤细",
|
||
"ticket": "门票",
|
||
"tip": "打赏",
|
||
"tips": "打赏",
|
||
"token": "代币",
|
||
"tokenstrokin": "代币撸动",
|
||
"top": "上位",
|
||
"topless": "上身裸露",
|
||
"travel": "旅行",
|
||
"trimmed": "修剪",
|
||
"tummy": "肚子",
|
||
"undress": "脱衣",
|
||
"underwear": "内衣",
|
||
"university": "大学",
|
||
"usa": "美国",
|
||
"valentinesday": "情人节",
|
||
"valorant": "无畏契约",
|
||
"venezolana": "委内瑞拉女性",
|
||
"vers": "多面手",
|
||
"versatile": "多才多艺",
|
||
"very": "非常",
|
||
"vibes": "氛围",
|
||
"videos": "视频",
|
||
"viral": "热门",
|
||
"wet": "湿润",
|
||
"wheelchair": "轮椅",
|
||
"whip": "打发",
|
||
"white": "白人",
|
||
"wild": "狂野",
|
||
"women": "女性",
|
||
"workout": "健身",
|
||
"wax": "蜡",
|
||
"year": "年",
|
||
"yoga": "瑜伽",
|
||
"bunny": "兔女郎",
|
||
"bulge": "凸起",
|
||
"bwc": "白人大屌",
|
||
"cb15": "CB15",
|
||
"cei": "射精指令",
|
||
"cuck": "绿帽",
|
||
"curly": "卷发",
|
||
"dadbod": "爸爸身材",
|
||
"daddys": "爹系",
|
||
"deutsch": "德语",
|
||
"dirtytalk": "挑逗聊天",
|
||
"dp": "双插",
|
||
"fat": "胖",
|
||
"footjob": "足交",
|
||
"ftm": "女跨男",
|
||
"futa": "扶她",
|
||
"ginger": "姜色发",
|
||
"handjob": "手交",
|
||
"jeans": "牛仔裤",
|
||
"material": "料",
|
||
"pink": "粉色",
|
||
"pump": "打气",
|
||
"saggy": "下垂",
|
||
"sensual": "感性",
|
||
"sexy": "性感",
|
||
"ssbbw": "超大码",
|
||
"talk": "聊天",
|
||
"tall": "高挑",
|
||
"turkish": "土耳其",
|
||
"young": "年轻",
|
||
});
|
||
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 最小列宽 + 卡片原始高度 + 缩略图原始高度.
|
||
* - 放大方式: 只把 grid 最小列宽乘响应式倍率; 列宽仍用 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-min-width 来自原站最小列宽, JS 写入时已经乘过倍率. */
|
||
grid-template-columns: repeat(auto-fill, minmax(var(--tm-thumb-home-min-width), 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: auto !important;
|
||
min-height: 0 !important;
|
||
max-height: none !important;
|
||
}
|
||
html[data-tm-thumb-scale-home="1"] #roomlist_root ul.RoomCardGrid .RoomCardThumbnail,
|
||
html[data-tm-thumb-scale-home="1"] #roomlist_root ul.list:has(li.roomCard) .room_thumbnail_container {
|
||
width: 100% !important;
|
||
height: auto !important;
|
||
display: block !important;
|
||
max-width: none !important;
|
||
box-sizing: border-box !important;
|
||
}
|
||
html[data-tm-thumb-scale-home="1"] #roomlist_root ul.RoomCardGrid .RoomCardThumbnail img,
|
||
html[data-tm-thumb-scale-home="1"] #roomlist_root ul.list:has(li.roomCard) .room_thumbnail_container img,
|
||
html[data-tm-thumb-scale-home="1"] #roomlist_root ul.list:has(li.roomCard) .room_thumbnail {
|
||
width: 100% !important;
|
||
height: auto !important;
|
||
/* block 可以消掉图片 inline baseline 带来的细小空隙. */
|
||
display: block !important;
|
||
max-width: none !important;
|
||
box-sizing: border-box !important;
|
||
}
|
||
|
||
/* 关注页当前列表 */
|
||
/*
|
||
* followingList 模块:
|
||
* - 适用范围: /followed-cams/ 关注页里的 #roomlist_root 房间列表.
|
||
* - 为什么和 home 分开: 两者 DOM 选择器很像, 但站点可能给不同页面不同原始列宽.
|
||
* - followingList 单独探测, 避免首页探测值污染关注页.
|
||
* - 探测值: 站点 CSS 里的 grid 最小列宽 + 卡片原始高度 + 缩略图原始高度.
|
||
* - 和 home 一样, 只放大最小列宽, 列数交给 auto-fill 随窗口自动变化.
|
||
*/
|
||
html[data-tm-thumb-scale-following-list="1"] #main #roomlist_root ul.RoomCardGrid,
|
||
html[data-tm-thumb-scale-following-list="1"] #roomlist_root ul.RoomCardGrid,
|
||
html[data-tm-thumb-scale-following-list="1"] #main #roomlist_root ul.list:has(li.roomCard),
|
||
html[data-tm-thumb-scale-following-list="1"] #roomlist_root ul.list:has(li.roomCard) {
|
||
display: grid !important;
|
||
grid-template-columns: repeat(auto-fill, minmax(var(--tm-thumb-following-list-min-width), 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: auto !important;
|
||
min-height: 0 !important;
|
||
max-height: none !important;
|
||
}
|
||
html[data-tm-thumb-scale-following-list="1"] #roomlist_root ul.RoomCardGrid .RoomCardThumbnail,
|
||
html[data-tm-thumb-scale-following-list="1"] #roomlist_root ul.list:has(li.roomCard) .room_thumbnail_container {
|
||
width: 100% !important;
|
||
height: auto !important;
|
||
display: block !important;
|
||
max-width: none !important;
|
||
box-sizing: border-box !important;
|
||
}
|
||
html[data-tm-thumb-scale-following-list="1"] #roomlist_root ul.RoomCardGrid .RoomCardThumbnail img,
|
||
html[data-tm-thumb-scale-following-list="1"] #roomlist_root ul.list:has(li.roomCard) .room_thumbnail_container img,
|
||
html[data-tm-thumb-scale-following-list="1"] #roomlist_root ul.list:has(li.roomCard) .room_thumbnail {
|
||
width: 100% !important;
|
||
height: auto !important;
|
||
display: block !important;
|
||
max-width: none !important;
|
||
box-sizing: border-box !important;
|
||
}
|
||
/* 直播间页底部"更多这样的直播间" */
|
||
/*
|
||
* related 模块:
|
||
* - 适用范围: 直播间页面底部"更多这样的房间".
|
||
* - 探测值: 卡片原始宽度 + 卡片原始高度 + 缩略图原始高度.
|
||
* - 放大方式: 把卡片原始宽度乘响应式倍率作为最小列宽, 列宽仍用 1fr 均分填满.
|
||
* - 这样右侧不会留空, 窗口变窄时也会自动减列.
|
||
*/
|
||
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;
|
||
justify-content: stretch !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: auto !important;
|
||
justify-self: stretch !important;
|
||
min-width: 0 !important;
|
||
max-width: none !important;
|
||
height: auto !important;
|
||
min-height: 0 !important;
|
||
max-height: none !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;
|
||
height: auto !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: auto !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;
|
||
}
|
||
/*
|
||
* 悬停"关注"星星等出现的浮层预览(多为 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 高度.
|
||
* - 放大方式: 图片宽高和卡片宽度乘固定式倍率.
|
||
* - 卡片高度: 只把缩略图区域乘固定式倍率, 用户名/人数等信息栏保留原始高度.
|
||
* - 轮播高度: triple/double/single 按 3/2/1 行卡片高度重新计算, 保留原始行间距.
|
||
* - 箭头高度: 跟对应轮播高度同步, 避免右侧箭头与轮播内容错位.
|
||
* - 启用条件: 卡片和缩略图探测到就先启用; 某种行数的轮播不存在时跳过对应高度变量.
|
||
* 不能强制要求 triple/double/single 全部存在, 否则页面少一种轮播就会导致发现页完全不放大.
|
||
* - 关注页底部"为您推荐"也使用 .carousel-root, 但它不是发现页完整轮播排版.
|
||
* 该区域放大后可能从一行变多行, 所以下面另有 following-page 覆盖规则释放高度.
|
||
*/
|
||
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;
|
||
}
|
||
|
||
/* 关注页底部"为您推荐" */
|
||
/*
|
||
* followedPage 模块:
|
||
* - 适用范围: /followed-cams/ 页面底部"为您推荐"推荐区.
|
||
* - 这个推荐区复用 .carousel-root / .room-list-carousel, 因此会吃到发现页放大规则.
|
||
* - 和发现页不同的是, 关注页推荐区放大后常会从原来的一行变成多行.
|
||
* - 如果继续使用发现页的 fixed height / 横向 track, 第二行卡片会被裁成一条条横带.
|
||
* - 这里不硬写列数; 用 auto-fill + 探测出的放大后卡片宽度让浏览器自然计算列数.
|
||
* - 也释放外层和中间 viewport/track 的高度与裁切, 避免换行后的第二行被裁成细条.
|
||
*/
|
||
html[data-tm-thumb-scale-followed-page="1"][data-tm-thumb-scale-discover="1"] .carousel-root,
|
||
html[data-tm-thumb-scale-followed-page="1"][data-tm-thumb-scale-discover="1"] .carousel-root div,
|
||
html[data-tm-thumb-scale-followed-page="1"][data-tm-thumb-scale-discover="1"] .carousel-root > div,
|
||
html[data-tm-thumb-scale-followed-page="1"][data-tm-thumb-scale-discover="1"] .carousel-root .room-list-carousel,
|
||
html[data-tm-thumb-scale-followed-page="1"][data-tm-thumb-scale-discover="1"] .carousel-root .room-list-carousel > div,
|
||
html[data-tm-thumb-scale-followed-page="1"][data-tm-thumb-scale-discover="1"] .carousel-root .single-row,
|
||
html[data-tm-thumb-scale-followed-page="1"][data-tm-thumb-scale-discover="1"] .carousel-root .double-rows,
|
||
html[data-tm-thumb-scale-followed-page="1"][data-tm-thumb-scale-discover="1"] .carousel-root .triple-rows {
|
||
height: auto !important;
|
||
min-height: 0 !important;
|
||
max-height: none !important;
|
||
overflow: visible !important;
|
||
}
|
||
html[data-tm-thumb-scale-followed-page="1"][data-tm-thumb-scale-discover="1"] .carousel-root .single-row ul.list,
|
||
html[data-tm-thumb-scale-followed-page="1"][data-tm-thumb-scale-discover="1"] .carousel-root .double-rows ul.list,
|
||
html[data-tm-thumb-scale-followed-page="1"][data-tm-thumb-scale-discover="1"] .carousel-root .triple-rows ul.list {
|
||
display: grid !important;
|
||
grid-template-columns: repeat(auto-fill, minmax(var(--tm-thumb-discover-width), 1fr)) !important;
|
||
grid-auto-rows: auto !important;
|
||
justify-content: stretch !important;
|
||
align-items: start !important;
|
||
gap: 0.6em 0.75em !important;
|
||
width: auto !important;
|
||
height: auto !important;
|
||
min-height: 0 !important;
|
||
max-height: none !important;
|
||
overflow: visible !important;
|
||
position: relative !important;
|
||
left: auto !important;
|
||
top: auto !important;
|
||
transform: none !important;
|
||
}
|
||
html[data-tm-thumb-scale-followed-page="1"][data-tm-thumb-scale-discover="1"] .carousel-root .room-list-carousel ul.list > li {
|
||
width: auto !important;
|
||
min-width: 0 !important;
|
||
max-width: none !important;
|
||
height: auto !important;
|
||
min-height: 0 !important;
|
||
max-height: none !important;
|
||
flex: none !important;
|
||
overflow: visible !important;
|
||
justify-self: stretch !important;
|
||
position: relative !important;
|
||
left: auto !important;
|
||
top: auto !important;
|
||
right: auto !important;
|
||
bottom: auto !important;
|
||
transform: none !important;
|
||
}
|
||
html[data-tm-thumb-scale-followed-page="1"][data-tm-thumb-scale-discover="1"] .carousel-root .room-list-carousel ul.list > li > a,
|
||
html[data-tm-thumb-scale-followed-page="1"][data-tm-thumb-scale-discover="1"] .carousel-root .room-list-carousel ul.list > li > div {
|
||
height: auto !important;
|
||
min-height: 0 !important;
|
||
max-height: none !important;
|
||
overflow: visible !important;
|
||
}
|
||
html[data-tm-thumb-scale-followed-page="1"][data-tm-thumb-scale-discover="1"] .carousel-root .room-list-carousel .room_thumbnail_container {
|
||
width: 100% !important;
|
||
height: auto !important;
|
||
min-height: 0 !important;
|
||
overflow: visible !important;
|
||
}
|
||
html[data-tm-thumb-scale-followed-page="1"][data-tm-thumb-scale-discover="1"] .carousel-root .room-list-carousel .room_thumbnail_container img,
|
||
html[data-tm-thumb-scale-followed-page="1"][data-tm-thumb-scale-discover="1"] .carousel-root .room-list-carousel .room_thumbnail {
|
||
width: 100% !important;
|
||
height: auto !important;
|
||
display: block !important;
|
||
}
|
||
html[data-tm-thumb-scale-followed-page="1"] .followRecommendedContainer,
|
||
html[data-tm-thumb-scale-followed-page="1"] .followRecommendedContainer .followRoomTable,
|
||
html[data-tm-thumb-scale-followed-page="1"] .followRecommendedContainer .followRoomTable > div,
|
||
html[data-tm-thumb-scale-followed-page="1"] .followRecommendedContainer .followRoomTable > div > div {
|
||
height: auto !important;
|
||
min-height: 0 !important;
|
||
max-height: none !important;
|
||
overflow: visible !important;
|
||
}
|
||
html[data-tm-thumb-scale-followed-page="1"] .followRecommendedContainer .followRoomTable {
|
||
display: grid !important;
|
||
grid-template-columns: repeat(auto-fill, minmax(var(--tm-thumb-discover-width), 1fr)) !important;
|
||
grid-auto-rows: auto !important;
|
||
justify-content: stretch !important;
|
||
gap: 0.6em 0.75em !important;
|
||
align-items: start !important;
|
||
}
|
||
html[data-tm-thumb-scale-followed-page="1"] .followRecommendedContainer .followRoomTable > div,
|
||
html[data-tm-thumb-scale-followed-page="1"] .followRecommendedContainer .followRoomTable > div > div {
|
||
display: contents !important;
|
||
}
|
||
html[data-tm-thumb-scale-followed-page="1"] .followRecommendedContainer .roomElement {
|
||
width: auto !important;
|
||
min-width: 0 !important;
|
||
max-width: none !important;
|
||
height: auto !important;
|
||
min-height: 0 !important;
|
||
max-height: none !important;
|
||
overflow: visible !important;
|
||
position: relative !important;
|
||
left: auto !important;
|
||
top: auto !important;
|
||
right: auto !important;
|
||
bottom: auto !important;
|
||
transform: none !important;
|
||
}
|
||
html[data-tm-thumb-scale-followed-page="1"] .followRecommendedContainer .roomElementAnchor {
|
||
width: 100% !important;
|
||
height: auto !important;
|
||
min-height: 0 !important;
|
||
max-height: none !important;
|
||
overflow: visible !important;
|
||
display: block !important;
|
||
}
|
||
html[data-tm-thumb-scale-followed-page="1"] .followRecommendedContainer .room_thumbnail {
|
||
width: 100% !important;
|
||
height: auto !important;
|
||
min-height: 0 !important;
|
||
display: block !important;
|
||
}
|
||
|
||
/* 关注页"为您推荐": 标题定位版 */
|
||
/*
|
||
* followedRecommendations 模块:
|
||
* - 当前站点的关注页推荐区不再稳定使用 .carousel-root 或 .followRecommendedContainer.
|
||
* - JS 先找中文标题"为您推荐", 再从标题后面的区域里找缩略图和卡片, 打上 data 标记.
|
||
* - CSS 只作用于这些 data 标记, 不依赖站点随机 class.
|
||
* - 这里只放大 grid 的最小列宽, 列宽仍然用 1fr 让浏览器按窗口自然分配.
|
||
*/
|
||
html[data-tm-thumb-scale-followed-recommendations="1"] [data-tm-thumb-followed-recommendations="1"],
|
||
html[data-tm-thumb-scale-followed-recommendations="1"] [data-tm-thumb-followed-recommendations="1"] > *,
|
||
html[data-tm-thumb-scale-followed-recommendations="1"] [data-tm-thumb-followed-recommendations="1"] > * > * {
|
||
height: auto !important;
|
||
max-height: none !important;
|
||
overflow: visible !important;
|
||
}
|
||
html[data-tm-thumb-scale-followed-recommendations="1"] [data-tm-thumb-followed-recommendations-list="1"] {
|
||
display: grid !important;
|
||
grid-template-columns: repeat(auto-fill, minmax(var(--tm-thumb-followed-recommendations-width), 1fr)) !important;
|
||
grid-auto-rows: auto !important;
|
||
justify-content: stretch !important;
|
||
align-items: start !important;
|
||
gap: 0.6em 0.75em !important;
|
||
width: auto !important;
|
||
height: auto !important;
|
||
max-height: none !important;
|
||
overflow: visible !important;
|
||
transform: none !important;
|
||
}
|
||
html[data-tm-thumb-scale-followed-recommendations="1"] [data-tm-thumb-followed-recommendations-card="1"] {
|
||
width: auto !important;
|
||
min-width: 0 !important;
|
||
max-width: none !important;
|
||
height: auto !important;
|
||
min-height: 0 !important;
|
||
max-height: none !important;
|
||
overflow: visible !important;
|
||
position: relative !important;
|
||
left: auto !important;
|
||
top: auto !important;
|
||
right: auto !important;
|
||
bottom: auto !important;
|
||
transform: none !important;
|
||
flex: none !important;
|
||
justify-self: stretch !important;
|
||
}
|
||
html[data-tm-thumb-scale-followed-recommendations="1"] [data-tm-thumb-followed-recommendations-thumb="1"] {
|
||
width: 100% !important;
|
||
height: auto !important;
|
||
min-height: 0 !important;
|
||
display: block !important;
|
||
max-width: none !important;
|
||
box-sizing: border-box !important;
|
||
}
|
||
|
||
/*
|
||
* 页面倍率控制条:
|
||
* - 这是用户交互控件, 不是调试面板.
|
||
* - 响应式倍率和固定式倍率都通过 JS 写回 localStorage, 然后重新计算 CSS 变量.
|
||
* - 控件尽量贴边且小尺寸, 避免遮挡主内容.
|
||
*/
|
||
#tm-thumb-scale-controls {
|
||
position: fixed !important;
|
||
right: 14px !important;
|
||
bottom: 14px !important;
|
||
z-index: 2147483647 !important;
|
||
display: flex !important;
|
||
align-items: center !important;
|
||
gap: 6px !important;
|
||
padding: 6px !important;
|
||
border: 1px solid rgba(255, 116, 28, 0.34) !important;
|
||
border-radius: 8px !important;
|
||
background: rgba(255, 255, 255, 0.9) !important;
|
||
box-shadow: 0 8px 24px rgba(31, 24, 18, 0.18), 0 1px 0 rgba(255, 255, 255, 0.85) inset !important;
|
||
color: #2f2f2f !important;
|
||
font: 12px/1.2 Arial, sans-serif !important;
|
||
backdrop-filter: blur(8px) saturate(1.15) !important;
|
||
-webkit-backdrop-filter: blur(8px) saturate(1.15) !important;
|
||
}
|
||
#tm-thumb-scale-controls label {
|
||
display: inline-flex !important;
|
||
align-items: center !important;
|
||
gap: 6px !important;
|
||
margin: 0 !important;
|
||
padding: 4px 6px !important;
|
||
border: 1px solid rgba(0, 0, 0, 0.08) !important;
|
||
border-radius: 6px !important;
|
||
background: rgba(255, 255, 255, 0.72) !important;
|
||
white-space: nowrap !important;
|
||
}
|
||
#tm-thumb-scale-controls .tm-thumb-scale-label {
|
||
color: #5d5149 !important;
|
||
font-weight: 700 !important;
|
||
letter-spacing: 0 !important;
|
||
}
|
||
#tm-thumb-scale-controls input {
|
||
width: 50px !important;
|
||
min-width: 50px !important;
|
||
height: 26px !important;
|
||
margin: 0 !important;
|
||
padding: 2px 5px !important;
|
||
border: 1px solid rgba(255, 116, 28, 0.42) !important;
|
||
border-radius: 5px !important;
|
||
background: #fffdfb !important;
|
||
box-sizing: border-box !important;
|
||
color: #1f1f1f !important;
|
||
font: 700 13px/1.2 Arial, sans-serif !important;
|
||
text-align: center !important;
|
||
outline: none !important;
|
||
}
|
||
#tm-thumb-scale-controls input:focus {
|
||
border-color: #f47321 !important;
|
||
box-shadow: 0 0 0 2px rgba(244, 115, 33, 0.16) !important;
|
||
}
|
||
#tm-thumb-scale-controls button {
|
||
height: 34px !important;
|
||
margin: 0 !important;
|
||
padding: 0 10px !important;
|
||
border: 1px solid rgba(244, 115, 33, 0.38) !important;
|
||
border-radius: 6px !important;
|
||
background: rgba(255, 247, 241, 0.96) !important;
|
||
color: #b84d10 !important;
|
||
cursor: pointer !important;
|
||
font: 700 12px/1.2 Arial, sans-serif !important;
|
||
white-space: nowrap !important;
|
||
}
|
||
#tm-thumb-scale-controls button:hover {
|
||
background: #fff0e5 !important;
|
||
border-color: rgba(244, 115, 33, 0.62) !important;
|
||
}
|
||
#tm-thumb-scale-controls button:active {
|
||
transform: translateY(1px) !important;
|
||
}
|
||
#tm-thumb-scale-controls .tm-thumb-scale-check {
|
||
cursor: pointer !important;
|
||
}
|
||
#tm-thumb-scale-controls input[type="checkbox"] {
|
||
width: 15px !important;
|
||
min-width: 15px !important;
|
||
height: 15px !important;
|
||
margin: 0 !important;
|
||
padding: 0 !important;
|
||
accent-color: #f47321 !important;
|
||
cursor: pointer !important;
|
||
}
|
||
html[data-tm-thumb-scale-auto-hide-room-chat="1"] [data-tm-thumb-room-chat-overlay="1"] {
|
||
display: none !important;
|
||
visibility: hidden !important;
|
||
pointer-events: none !important;
|
||
}
|
||
@media (max-width: 560px) {
|
||
#tm-thumb-scale-controls {
|
||
right: 8px !important;
|
||
left: 8px !important;
|
||
bottom: 8px !important;
|
||
justify-content: center !important;
|
||
flex-wrap: wrap !important;
|
||
}
|
||
}
|
||
`;
|
||
|
||
const STYLE_ID = "tm-thumb-scale-style";
|
||
// 只观察一次 head, 避免重复创建 MutationObserver.
|
||
let observerStarted = false;
|
||
// 只观察一次 body, 页面内容变化时用于捕捉动态弹窗/tooltip.
|
||
let pageObserverStarted = false;
|
||
// 探测防抖计时器; mutation 很多时只执行最后一次探测.
|
||
let measureTimer = null;
|
||
// 标签页中文化防抖计时器; SPA 动态加载标签时只执行最后一次替换.
|
||
let tagsTranslateTimer = 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,
|
||
followedRecommendations: false,
|
||
related: false,
|
||
follow: false,
|
||
tooltip: false,
|
||
discover: false,
|
||
};
|
||
// 保存每个模块第一次探测到的原始尺寸.倍率变化时只用这些原始值重算 CSS 变量.
|
||
const originalMetrics = {
|
||
home: null,
|
||
followingList: null,
|
||
followedRecommendations: null,
|
||
related: null,
|
||
follow: null,
|
||
tooltip: null,
|
||
discover: null,
|
||
};
|
||
const setDebugAttr = (name, value) => {
|
||
// 调试属性写到 html 上, 方便在 Elements 面板确认脚本是否运行.
|
||
if (!DEBUG) return;
|
||
try {
|
||
document.documentElement.setAttribute(`data-tm-thumb-scale-${name}`, String(value));
|
||
} catch (_) {}
|
||
};
|
||
|
||
const normalizeScale = (value, fallback) => {
|
||
// 倍率只保留一位小数; 过小会变成缩小, 过大容易把页面挤爆, 所以限制在 1.0 到 4.0.
|
||
const number = Number.parseFloat(value);
|
||
const safe = Number.isFinite(number) && number >= ORIGINAL_SCALE ? number : fallback;
|
||
const rounded = Math.round(safe / SCALE_STEP) * SCALE_STEP;
|
||
return Math.min(4, Math.max(ORIGINAL_SCALE, Number(rounded.toFixed(1))));
|
||
};
|
||
const defaultScaleSettings = () => ({
|
||
responsive: normalizeScale(DEFAULT_RESPONSIVE_SCALE, DEFAULT_RESPONSIVE_SCALE),
|
||
fixed: normalizeScale(DEFAULT_FIXED_SCALE, DEFAULT_FIXED_SCALE),
|
||
hideRoomChat: false,
|
||
});
|
||
const loadScaleSettings = () => {
|
||
const defaults = defaultScaleSettings();
|
||
try {
|
||
if (typeof localStorage === "undefined") return defaults;
|
||
const parsed = JSON.parse(localStorage.getItem(SETTINGS_KEY) || "{}");
|
||
return {
|
||
responsive: normalizeScale(parsed.responsive, defaults.responsive),
|
||
fixed: normalizeScale(parsed.fixed, defaults.fixed),
|
||
hideRoomChat: Boolean(parsed.hideRoomChat),
|
||
};
|
||
} catch (_) {
|
||
return defaults;
|
||
}
|
||
};
|
||
const saveScaleSettings = () => {
|
||
try {
|
||
if (typeof localStorage !== "undefined") {
|
||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(scaleSettings));
|
||
}
|
||
} catch (_) {}
|
||
};
|
||
let scaleSettings = loadScaleSettings();
|
||
const responsiveScale = () => scaleSettings.responsive;
|
||
const fixedScale = () => scaleSettings.fixed;
|
||
const shouldHideRoomChat = () => Boolean(scaleSettings.hideRoomChat);
|
||
const scaledPx = (value, scale = fixedScale()) => `${Math.round(value * scale)}px`;
|
||
const scaledResponsivePx = (value) => scaledPx(value, responsiveScale());
|
||
const scaledCardHeightFromThumbPx = (cardHeight, thumbHeight, scale = fixedScale()) => {
|
||
const nonThumbHeight = Math.max(0, cardHeight - thumbHeight);
|
||
return `${Math.round((thumbHeight * scale) + nonThumbHeight)}px`;
|
||
};
|
||
const scaledCardHeightFromThumbValue = (cardHeight, thumbHeight, scale = fixedScale()) => {
|
||
const nonThumbHeight = Math.max(0, cardHeight - thumbHeight);
|
||
return Math.round((thumbHeight * 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 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;
|
||
}
|
||
};
|
||
const isTagsPage = () => {
|
||
try {
|
||
const path = typeof location === "undefined" ? "" : location.pathname;
|
||
return /^\/tags(?:\/|$)/i.test(path);
|
||
} catch (_) {
|
||
return false;
|
||
}
|
||
};
|
||
const isRoomPage = () => {
|
||
try {
|
||
const path = typeof location === "undefined" ? "" : location.pathname;
|
||
if (!/^\/[^/]+\/?$/i.test(path)) return false;
|
||
return !/^\/(?:$|discover|tags|followed-cams|followed|accounts|auth|login|support|privacy|terms|static)\b/i.test(path);
|
||
} catch (_) {
|
||
return false;
|
||
}
|
||
};
|
||
const normalizeTagKey = (value) => String(value || "")
|
||
.trim()
|
||
.replace(/^#/, "")
|
||
.replace(/\s*\([^)]*\)\s*$/, "")
|
||
.toLowerCase()
|
||
.replace(/&/g, "and")
|
||
.replace(/['’]/g, "")
|
||
.replace(/[^a-z0-9]+/g, "-")
|
||
.replace(/^-+|-+$/g, "");
|
||
const tagWordTranslation = (word) => {
|
||
if (!word) return "";
|
||
if (TAG_WORD_TRANSLATIONS[word]) return TAG_WORD_TRANSLATIONS[word];
|
||
if (word.endsWith("ies")) {
|
||
const singularY = `${word.slice(0, -3)}y`;
|
||
if (TAG_WORD_TRANSLATIONS[singularY]) return TAG_WORD_TRANSLATIONS[singularY];
|
||
}
|
||
if (word.endsWith("es") && TAG_WORD_TRANSLATIONS[word.slice(0, -2)]) {
|
||
return TAG_WORD_TRANSLATIONS[word.slice(0, -2)];
|
||
}
|
||
if (word.endsWith("s") && TAG_WORD_TRANSLATIONS[word.slice(0, -1)]) {
|
||
return TAG_WORD_TRANSLATIONS[word.slice(0, -1)];
|
||
}
|
||
return "";
|
||
};
|
||
const compactTagKey = (value) => normalizeTagKey(value).replace(/-/g, "");
|
||
const splitCompactTagSlug = (slug) => {
|
||
const compact = compactTagKey(slug);
|
||
if (!compact) return [];
|
||
const words = Object.keys(TAG_WORD_TRANSLATIONS).flatMap((word) => {
|
||
const variants = [{ match: word, word }];
|
||
if (/^[a-z0-9]+$/.test(word) && !word.endsWith("s")) {
|
||
variants.push({ match: `${word}s`, word });
|
||
variants.push({ match: `${word}es`, word });
|
||
if (word.endsWith("y")) variants.push({ match: `${word.slice(0, -1)}ies`, word });
|
||
}
|
||
return variants;
|
||
}).sort((a, b) => b.match.length - a.match.length);
|
||
const parts = [];
|
||
let index = 0;
|
||
while (index < compact.length) {
|
||
const found = words.find((item) => compact.slice(index).startsWith(item.match));
|
||
if (!found) return [];
|
||
parts.push(found.word);
|
||
index += found.match.length;
|
||
}
|
||
return parts;
|
||
};
|
||
const translateTagKey = (key, originalText = "", allowFallback = false) => {
|
||
const normalized = normalizeTagKey(key);
|
||
const compact = compactTagKey(normalized);
|
||
const textKey = normalizeTagKey(originalText);
|
||
const textCompact = compactTagKey(textKey);
|
||
const direct = TAG_TRANSLATIONS[normalized]
|
||
|| TAG_TRANSLATIONS[compact]
|
||
|| TAG_TRANSLATIONS[textKey]
|
||
|| TAG_TRANSLATIONS[textCompact];
|
||
if (direct) return direct;
|
||
|
||
const hyphenParts = normalized.split("-").filter(Boolean);
|
||
const hyphenTranslated = hyphenParts.length > 1
|
||
? hyphenParts.map((part) => tagWordTranslation(part)).filter(Boolean)
|
||
: [];
|
||
if (hyphenTranslated.length === hyphenParts.length) {
|
||
return hyphenTranslated.join("");
|
||
}
|
||
|
||
const compactParts = splitCompactTagSlug(compact);
|
||
if (compactParts.length > 0) {
|
||
return compactParts.map((part) => tagWordTranslation(part)).join("");
|
||
}
|
||
|
||
return "";
|
||
};
|
||
const tagKeyFromHref = (anchor) => {
|
||
try {
|
||
const url = new URL(anchor.href || anchor.getAttribute("href") || "", location.href);
|
||
const match = url.pathname.match(/\/tag\/([^/]+)(?:\/[^/]+)?\/?$/i);
|
||
if (!match) return "";
|
||
return normalizeTagKey(decodeURIComponent(match[1]));
|
||
} catch (_) {
|
||
return "";
|
||
}
|
||
};
|
||
const translateTagAnchor = (anchor) => {
|
||
if (!anchor || !anchor.textContent) return false;
|
||
// 标签页真正需要中文化的主题标签都以 #slug 显示.
|
||
// 用这个页面特征做第一道过滤, 防止分页, 导航或分类链接被误翻译.
|
||
if (!anchor.textContent.trim().startsWith("#")) return false;
|
||
const hrefKey = tagKeyFromHref(anchor);
|
||
if (!hrefKey) return false;
|
||
const original = anchor.dataset && anchor.dataset.tmTagOriginal
|
||
? anchor.dataset.tmTagOriginal
|
||
: anchor.textContent.trim();
|
||
const looksUntranslated = /^#/.test(original) || original.trim().toLowerCase() === hrefKey;
|
||
const translated = translateTagKey(hrefKey, original, looksUntranslated);
|
||
if (!translated || (!looksUntranslated && translated === original)) return false;
|
||
if (anchor.dataset && !anchor.dataset.tmTagOriginal) {
|
||
anchor.dataset.tmTagOriginal = original;
|
||
}
|
||
if (anchor.textContent.trim() !== translated) {
|
||
anchor.textContent = translated;
|
||
}
|
||
if (!anchor.title) {
|
||
anchor.title = original;
|
||
}
|
||
return true;
|
||
};
|
||
const translateTagsPage = (stage = "translate-tags") => {
|
||
if (!isTagsPage() || !document.querySelectorAll) return 0;
|
||
let translatedCount = 0;
|
||
try {
|
||
document.querySelectorAll('a[href*="/tag/"]').forEach((anchor) => {
|
||
if (translateTagAnchor(anchor)) translatedCount += 1;
|
||
});
|
||
} catch (_) {}
|
||
if (DEBUG && translatedCount > 0) log("translated tags", { stage, translatedCount });
|
||
return translatedCount;
|
||
};
|
||
const scheduleTagsTranslation = (stage, delay = 0) => {
|
||
if (!isTagsPage()) return;
|
||
if (typeof setTimeout !== "function") {
|
||
translateTagsPage(stage);
|
||
return;
|
||
}
|
||
if (tagsTranslateTimer) clearTimeout(tagsTranslateTimer);
|
||
tagsTranslateTimer = setTimeout(() => {
|
||
tagsTranslateTimer = null;
|
||
translateTagsPage(stage);
|
||
}, delay);
|
||
};
|
||
// 所有 CSS 变量都在这里统一乘倍率.调用方传入的必须是页面原始尺寸.
|
||
const setVar = (name, value, scale = fixedScale()) => {
|
||
const style = rootStyle();
|
||
if (!style || !Number.isFinite(value) || value <= 0) return false;
|
||
style.setProperty(name, scaledPx(value, scale));
|
||
return true;
|
||
};
|
||
const setResponsiveVar = (name, value) => setVar(name, value, responsiveScale());
|
||
const setFixedVar = (name, value) => setVar(name, value, fixedScale());
|
||
const setCardHeightFromThumbVar = (name, cardHeight, thumbHeight, scale = fixedScale()) => {
|
||
const style = rootStyle();
|
||
if (!style || !Number.isFinite(cardHeight) || !Number.isFinite(thumbHeight) || cardHeight <= 0 || thumbHeight <= 0) return false;
|
||
style.setProperty(name, scaledCardHeightFromThumbPx(cardHeight, thumbHeight, scale));
|
||
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 = (minWidthName, cardHeightName, thumbHeightName, metrics, cardWidth, cardHeight, thumbHeight) => {
|
||
const style = rootStyle();
|
||
if (
|
||
!style
|
||
|| !metrics
|
||
|| !Number.isFinite(cardWidth)
|
||
|| !Number.isFinite(cardHeight)
|
||
|| !Number.isFinite(thumbHeight)
|
||
|| cardWidth <= 0
|
||
|| cardHeight <= 0
|
||
|| thumbHeight <= 0
|
||
) return false;
|
||
const originalMinWidth = Number.isFinite(metrics.minColumn) && metrics.minColumn > 0
|
||
? metrics.minColumn
|
||
: cardWidth;
|
||
style.setProperty(minWidthName, scaledResponsivePx(originalMinWidth));
|
||
style.setProperty(cardHeightName, "auto");
|
||
style.setProperty(thumbHeightName, scaledResponsivePx(thumbHeight));
|
||
return true;
|
||
};
|
||
const clearFollowedRecommendationMarkers = () => {
|
||
try {
|
||
document.querySelectorAll(
|
||
"[data-tm-thumb-followed-recommendations],"
|
||
+ "[data-tm-thumb-followed-recommendations-list],"
|
||
+ "[data-tm-thumb-followed-recommendations-card],"
|
||
+ "[data-tm-thumb-followed-recommendations-thumb]",
|
||
).forEach((el) => {
|
||
el.removeAttribute("data-tm-thumb-followed-recommendations");
|
||
el.removeAttribute("data-tm-thumb-followed-recommendations-list");
|
||
el.removeAttribute("data-tm-thumb-followed-recommendations-card");
|
||
el.removeAttribute("data-tm-thumb-followed-recommendations-thumb");
|
||
});
|
||
} catch (_) {}
|
||
};
|
||
const directText = (el) => {
|
||
try {
|
||
return Array.from(el.childNodes || [])
|
||
.filter((node) => node.nodeType === 3)
|
||
.map((node) => node.nodeValue || "")
|
||
.join("")
|
||
.trim();
|
||
} catch (_) {
|
||
return "";
|
||
}
|
||
};
|
||
const followedRecommendationHeadings = () => {
|
||
if (!isFollowingListPage() || !document.querySelectorAll) return [];
|
||
const result = [];
|
||
try {
|
||
document.querySelectorAll("h1,h2,h3,h4,strong,b,div,span").forEach((el) => {
|
||
const text = (directText(el) || el.textContent || "").trim();
|
||
if (/^(为您推荐|Recommended)$/i.test(text)) result.push(el);
|
||
});
|
||
} catch (_) {}
|
||
return result;
|
||
};
|
||
const hasRecommendationMedia = (el) => {
|
||
try {
|
||
return !!(el && el.querySelector && el.querySelector(
|
||
'img[src*="live.mmcdn"], img[src*="thumb.live.mmcdn"], img.room_thumbnail, .room_thumbnail img, .room_thumbnail_container img',
|
||
));
|
||
} catch (_) {
|
||
return false;
|
||
}
|
||
};
|
||
const firstFollowingRecommendationRoot = () => {
|
||
const headings = followedRecommendationHeadings();
|
||
for (const heading of headings) {
|
||
const probes = [];
|
||
let node = heading;
|
||
for (let depth = 0; node && depth < 6; depth += 1) {
|
||
if (node.nextElementSibling) probes.push(node.nextElementSibling);
|
||
if (node.parentElement && node.parentElement.nextElementSibling) probes.push(node.parentElement.nextElementSibling);
|
||
probes.push(node.parentElement);
|
||
node = node.parentElement;
|
||
}
|
||
const root = probes.find((candidate) => candidate && candidate !== document.body && hasRecommendationMedia(candidate));
|
||
if (root) return root;
|
||
}
|
||
return null;
|
||
};
|
||
const markFollowedRecommendations = () => {
|
||
clearFollowedRecommendationMarkers();
|
||
const root = firstFollowingRecommendationRoot();
|
||
if (!root) return null;
|
||
root.setAttribute("data-tm-thumb-followed-recommendations", "1");
|
||
let list = null;
|
||
try {
|
||
list = Array.from(root.querySelectorAll("ul,ol,div"))
|
||
.filter(hasRecommendationMedia)
|
||
.sort((a, b) => {
|
||
const aCount = a.querySelectorAll("img").length;
|
||
const bCount = b.querySelectorAll("img").length;
|
||
return bCount - aCount;
|
||
})[0] || root;
|
||
} catch (_) {
|
||
list = root;
|
||
}
|
||
list.setAttribute("data-tm-thumb-followed-recommendations-list", "1");
|
||
const thumbs = Array.from(root.querySelectorAll(
|
||
'img[src*="live.mmcdn"], img[src*="thumb.live.mmcdn"], img.room_thumbnail, .room_thumbnail img, .room_thumbnail_container img',
|
||
));
|
||
let cards = [];
|
||
try {
|
||
cards = Array.from(root.querySelectorAll("li.RoomCard, li.roomCard, .roomElement"));
|
||
} catch (_) {
|
||
cards = [];
|
||
}
|
||
thumbs.forEach((img) => {
|
||
const thumb = img.closest(".room_thumbnail_container, .RoomCardThumbnail, .room_thumbnail") || img;
|
||
thumb.setAttribute("data-tm-thumb-followed-recommendations-thumb", "1");
|
||
const finalCard = img.closest("li.RoomCard, li.roomCard, .roomElement, li, a") || thumb.parentElement || img.parentElement;
|
||
if (finalCard && !cards.includes(finalCard)) {
|
||
cards.push(finalCard);
|
||
}
|
||
});
|
||
cards.forEach((card) => {
|
||
card.setAttribute("data-tm-thumb-followed-recommendations-card", "1");
|
||
});
|
||
return { root, list, cards, thumbs };
|
||
};
|
||
const setFollowedRecommendationVars = (marked) => {
|
||
const style = rootStyle();
|
||
if (!style || !marked || !marked.cards.length || !marked.thumbs.length) return false;
|
||
const cardRect = rectOf(marked.cards[0]);
|
||
const thumbRect = rectOf(marked.thumbs[0]);
|
||
if (!isRect(cardRect) || !isRect(thumbRect)) return false;
|
||
style.setProperty("--tm-thumb-followed-recommendations-width", scaledResponsivePx(cardRect.width));
|
||
style.setProperty("--tm-thumb-followed-recommendations-card-height", "auto");
|
||
style.setProperty("--tm-thumb-followed-recommendations-thumb-width", scaledResponsivePx(thumbRect.width));
|
||
style.setProperty("--tm-thumb-followed-recommendations-thumb-height", "auto");
|
||
return true;
|
||
};
|
||
const applyHomeMetrics = (metrics) => setListScaleVars(
|
||
"--tm-thumb-home-min-width",
|
||
"--tm-thumb-home-card-height",
|
||
"--tm-thumb-home-thumb-height",
|
||
metrics && metrics.grid,
|
||
metrics && metrics.cardWidth,
|
||
metrics && metrics.cardHeight,
|
||
metrics && metrics.thumbHeight,
|
||
);
|
||
const applyFollowingListMetrics = (metrics) => setListScaleVars(
|
||
"--tm-thumb-following-list-min-width",
|
||
"--tm-thumb-following-list-card-height",
|
||
"--tm-thumb-following-list-thumb-height",
|
||
metrics && metrics.grid,
|
||
metrics && metrics.cardWidth,
|
||
metrics && metrics.cardHeight,
|
||
metrics && metrics.thumbHeight,
|
||
);
|
||
const applyFollowedRecommendationsMetrics = (metrics) => {
|
||
const style = rootStyle();
|
||
if (!style || !metrics || !Number.isFinite(metrics.cardWidth) || metrics.cardWidth <= 0) return false;
|
||
style.setProperty("--tm-thumb-followed-recommendations-width", scaledResponsivePx(metrics.cardWidth));
|
||
style.setProperty("--tm-thumb-followed-recommendations-card-height", "auto");
|
||
style.setProperty("--tm-thumb-followed-recommendations-thumb-width", scaledResponsivePx(metrics.thumbWidth || metrics.cardWidth));
|
||
style.setProperty("--tm-thumb-followed-recommendations-thumb-height", "auto");
|
||
return true;
|
||
};
|
||
const applyRelatedMetrics = (metrics) => ([
|
||
setResponsiveVar("--tm-thumb-related-width", metrics && metrics.cardWidth),
|
||
(() => {
|
||
const style = rootStyle();
|
||
if (!style) return false;
|
||
style.setProperty("--tm-thumb-related-card-height", "auto");
|
||
return true;
|
||
})(),
|
||
setResponsiveVar("--tm-thumb-related-height", metrics && metrics.thumbHeight),
|
||
].every(Boolean));
|
||
const applyFollowMetrics = (metrics) => {
|
||
const style = rootStyle();
|
||
const ready = [
|
||
setFixedVar("--tm-thumb-follow-card-width", metrics && metrics.cardWidth),
|
||
setCardHeightFromThumbVar("--tm-thumb-follow-card-height", metrics && metrics.cardHeight, metrics && metrics.thumbHeight, fixedScale()),
|
||
setFixedVar("--tm-thumb-follow-thumb-height", metrics && metrics.thumbHeight),
|
||
].every(Boolean);
|
||
if (ready && style) {
|
||
style.setProperty("--tm-thumb-follow-width", `calc(${Math.round(metrics.cardWidth * fixedScale() * 2)}px + 2em)`);
|
||
}
|
||
return ready;
|
||
};
|
||
const adjustFollowDropdownPosition = (stage = "follow-position") => {
|
||
/*
|
||
* 原站负责计算顶部"关注"弹窗的锚点位置.
|
||
* 脚本只在放大后发现左侧越出视口时, 临时补一个 margin-left.
|
||
* 这样正常情况下完全保持原站定位, 只有越界时才向右推回屏幕内.
|
||
*/
|
||
try {
|
||
const dropdown = firstElement(".FollowedDropdown");
|
||
if (!dropdown || !dropdown.getBoundingClientRect || !dropdown.style) return false;
|
||
dropdown.style.removeProperty("margin-left");
|
||
const rect = dropdown.getBoundingClientRect();
|
||
const viewportPadding = 8;
|
||
const shift = Math.ceil(viewportPadding - rect.left);
|
||
if (shift > 0) {
|
||
dropdown.style.setProperty("margin-left", `${shift}px`, "important");
|
||
if (DEBUG) log("follow dropdown shifted to avoid left overflow", { stage, left: rect.left, shift });
|
||
return true;
|
||
}
|
||
return false;
|
||
} catch (_) {
|
||
return false;
|
||
}
|
||
};
|
||
const applyTooltipMetrics = (metrics) => ([
|
||
setFixedVar("--tm-thumb-tooltip-width", metrics && metrics.width),
|
||
setFixedVar("--tm-thumb-tooltip-height", metrics && metrics.height),
|
||
].every(Boolean));
|
||
const applyDiscoverMetrics = (metrics) => {
|
||
if (!metrics) return false;
|
||
const discoverWidthReady = setFixedVar("--tm-thumb-discover-width", metrics.cardWidth);
|
||
const discoverCardHeightReady = setCardHeightFromThumbVar("--tm-thumb-discover-card-height", metrics.cardHeight, metrics.thumbHeight, fixedScale());
|
||
const discoverThumbWidthReady = setFixedVar("--tm-thumb-discover-thumb-width", metrics.thumbWidth);
|
||
const discoverHeightReady = setFixedVar("--tm-thumb-discover-height", metrics.thumbHeight);
|
||
const discoverTripleReady = setDiscoverStackHeightVars(
|
||
"--tm-thumb-discover-triple-ul",
|
||
"--tm-thumb-discover-triple-arrow",
|
||
metrics.tripleHeight,
|
||
metrics.cardHeight,
|
||
metrics.thumbHeight,
|
||
3,
|
||
);
|
||
const discoverDoubleReady = setDiscoverStackHeightVars(
|
||
"--tm-thumb-discover-double-ul",
|
||
"--tm-thumb-discover-double-arrow",
|
||
metrics.doubleHeight,
|
||
metrics.cardHeight,
|
||
metrics.thumbHeight,
|
||
2,
|
||
);
|
||
const discoverSingleReady = setDiscoverStackHeightVars(
|
||
"--tm-thumb-discover-single-ul",
|
||
"--tm-thumb-discover-single-arrow",
|
||
metrics.singleHeight,
|
||
metrics.cardHeight,
|
||
metrics.thumbHeight,
|
||
1,
|
||
);
|
||
return discoverWidthReady && discoverCardHeightReady && discoverThumbWidthReady && discoverHeightReady
|
||
&& discoverTripleReady && discoverDoubleReady && discoverSingleReady;
|
||
};
|
||
const reapplyScaleVars = (stage = "scale-change") => {
|
||
// 倍率变化时不重新读取 DOM, 只使用第一次探测到的原始值重新写 CSS 变量.
|
||
if (originalMetrics.home) moduleReady.home = applyHomeMetrics(originalMetrics.home);
|
||
if (originalMetrics.followingList) moduleReady.followingList = applyFollowingListMetrics(originalMetrics.followingList);
|
||
if (originalMetrics.followedRecommendations) moduleReady.followedRecommendations = applyFollowedRecommendationsMetrics(originalMetrics.followedRecommendations);
|
||
if (originalMetrics.related) moduleReady.related = applyRelatedMetrics(originalMetrics.related);
|
||
if (originalMetrics.follow) moduleReady.follow = applyFollowMetrics(originalMetrics.follow);
|
||
adjustFollowDropdownPosition(stage);
|
||
if (originalMetrics.tooltip) moduleReady.tooltip = applyTooltipMetrics(originalMetrics.tooltip);
|
||
if (originalMetrics.discover) moduleReady.discover = applyDiscoverMetrics(originalMetrics.discover);
|
||
Object.keys(moduleReady).forEach((name) => setModuleReady(name, moduleReady[name]));
|
||
reportDiagnostics(stage);
|
||
};
|
||
// 每个模块的 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 clearRoomChatOverlayMarkers = () => {
|
||
try {
|
||
if (!document.querySelectorAll) return;
|
||
document.querySelectorAll(`[${ROOM_CHAT_OVERLAY_ATTR}]`).forEach((el) => {
|
||
el.removeAttribute(ROOM_CHAT_OVERLAY_ATTR);
|
||
});
|
||
} catch (_) {}
|
||
};
|
||
const isSmallRoomChatOverlayRect = (rect) => {
|
||
if (!rect) return false;
|
||
// 只允许截图里这种浮在视频上的小聊天窗; 直播主容器通常比这个范围大很多.
|
||
const maxWidth = Math.min(760, Math.max(560, window.innerWidth * 0.86));
|
||
const maxHeight = Math.min(720, Math.max(360, window.innerHeight * 0.82));
|
||
return rect.width >= 260
|
||
&& rect.height >= 160
|
||
&& rect.width <= maxWidth
|
||
&& rect.height <= maxHeight
|
||
&& (rect.width * rect.height) <= 520000;
|
||
};
|
||
const roomChatOverlayScore = (el, anchor) => {
|
||
try {
|
||
if (!el || !el.textContent || !el.getBoundingClientRect) return 0;
|
||
const rect = el.getBoundingClientRect();
|
||
if (!isSmallRoomChatOverlayRect(rect)) return 0;
|
||
if (anchor && !el.contains(anchor)) return 0;
|
||
const text = el.textContent.replace(/\s+/g, " ").trim();
|
||
let score = 0;
|
||
if (anchor) score += 3;
|
||
if (/发送消息|send message/i.test(text)) score += 3;
|
||
if (/规则[::]/.test(text)) score += 3;
|
||
if (/聊天/.test(text)) score += 2;
|
||
if (/直播间私信/.test(text)) score += 3;
|
||
if (/打赏/.test(text)) score += 2;
|
||
if (/Notice:|The Menu|Welcome/i.test(text)) score += 2;
|
||
if (/Ctrl\+S|Ctrl\+\//i.test(text)) score += 2;
|
||
if (el.querySelector && el.querySelector('button, [role="button"], input, textarea, [contenteditable="true"]')) score += 1;
|
||
return score;
|
||
} catch (_) {
|
||
return 0;
|
||
}
|
||
};
|
||
const roomChatInputs = () => {
|
||
try {
|
||
return Array.from(document.querySelectorAll('input, textarea, [contenteditable="true"]'))
|
||
.filter((el) => {
|
||
const text = `${el.getAttribute("placeholder") || ""} ${el.getAttribute("data-placeholder") || ""} ${el.getAttribute("aria-label") || ""} ${el.value || ""} ${el.textContent || ""}`;
|
||
return /发送消息|send message/i.test(text);
|
||
});
|
||
} catch (_) {
|
||
return [];
|
||
}
|
||
};
|
||
const findRoomChatOverlay = () => {
|
||
if (!isRoomPage() || !document.querySelectorAll) return null;
|
||
try {
|
||
// 保存后的 veliasai 页面显示, 频道页浮动聊天窗有稳定标识:
|
||
// #draggableCanvasChatWindow / [data-testid="chat-floating-window"].
|
||
// 优先用这个精确节点, 并仍做小窗尺寸检查, 避免误伤直播主容器.
|
||
const direct = firstElement('#draggableCanvasChatWindow, [data-testid="chat-floating-window"]');
|
||
if (direct && isSmallRoomChatOverlayRect(direct.getBoundingClientRect())) return direct;
|
||
const candidates = [];
|
||
roomChatInputs().forEach((anchor) => {
|
||
let current = anchor.parentElement;
|
||
while (current && current !== document.body && current !== document.documentElement) {
|
||
const score = roomChatOverlayScore(current, anchor);
|
||
if (score >= 8) candidates.push({ el: current, score });
|
||
current = current.parentElement;
|
||
}
|
||
});
|
||
Array.from(document.querySelectorAll("div,section,aside,form")).forEach((el) => {
|
||
const score = roomChatOverlayScore(el, null);
|
||
if (score >= 7) candidates.push({ el, score });
|
||
});
|
||
candidates.sort((a, b) => {
|
||
const ar = a.el.getBoundingClientRect();
|
||
const br = b.el.getBoundingClientRect();
|
||
const areaDiff = (ar.width * ar.height) - (br.width * br.height);
|
||
if (b.score !== a.score && Math.abs(areaDiff) < 8000) return b.score - a.score;
|
||
return areaDiff;
|
||
});
|
||
return candidates[0] && candidates[0].el;
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
};
|
||
const inspectRoomChatOverlay = () => {
|
||
const rectInfo = (el) => {
|
||
if (!el || !el.getBoundingClientRect) return null;
|
||
const rect = el.getBoundingClientRect();
|
||
return {
|
||
x: Math.round(rect.x),
|
||
y: Math.round(rect.y),
|
||
width: Math.round(rect.width),
|
||
height: Math.round(rect.height),
|
||
};
|
||
};
|
||
const nodeInfo = (el, score) => ({
|
||
score,
|
||
tag: el && el.tagName,
|
||
id: el && el.id,
|
||
className: el && String(el.className || "").slice(0, 160),
|
||
rect: rectInfo(el),
|
||
text: el && (el.textContent || "").replace(/\s+/g, " ").trim().slice(0, 240),
|
||
});
|
||
const candidates = [];
|
||
try {
|
||
roomChatInputs().forEach((anchor) => {
|
||
let current = anchor.parentElement;
|
||
while (current && current !== document.body && current !== document.documentElement) {
|
||
const score = roomChatOverlayScore(current, anchor);
|
||
if (score > 0) candidates.push(nodeInfo(current, score));
|
||
current = current.parentElement;
|
||
}
|
||
});
|
||
Array.from(document.querySelectorAll("div,section,aside,form")).forEach((el) => {
|
||
const score = roomChatOverlayScore(el, null);
|
||
if (score > 0) candidates.push(nodeInfo(el, score));
|
||
});
|
||
} catch (_) {}
|
||
const selected = findRoomChatOverlay();
|
||
const report = {
|
||
ver: VER,
|
||
href: typeof location === "undefined" ? "" : location.href,
|
||
enabled: shouldHideRoomChat(),
|
||
isRoomPage: isRoomPage(),
|
||
selected: selected ? nodeInfo(selected, roomChatOverlayScore(selected, null)) : null,
|
||
candidates: candidates
|
||
.sort((a, b) => (b.score - a.score) || ((a.rect.width * a.rect.height) - (b.rect.width * b.rect.height)))
|
||
.slice(0, 20),
|
||
};
|
||
console.log("[tm-thumb-scale room chat]", report);
|
||
return report;
|
||
};
|
||
const applyRoomChatAutoHide = (stage = "room-chat-auto-hide") => {
|
||
try {
|
||
setModuleReady("autoHideRoomChat", shouldHideRoomChat() && isRoomPage());
|
||
if (!shouldHideRoomChat() || !isRoomPage()) {
|
||
clearRoomChatOverlayMarkers();
|
||
return false;
|
||
}
|
||
const overlay = findRoomChatOverlay();
|
||
if (!overlay) return false;
|
||
overlay.setAttribute(ROOM_CHAT_OVERLAY_ATTR, "1");
|
||
if (DEBUG) log("room chat overlay hidden", { stage });
|
||
return true;
|
||
} catch (_) {
|
||
return false;
|
||
}
|
||
};
|
||
const updateControlValues = (root) => {
|
||
if (!root || !root.querySelector) return;
|
||
const responsiveInput = root.querySelector('[data-tm-thumb-control="responsive"]');
|
||
const fixedInput = root.querySelector('[data-tm-thumb-control="fixed"]');
|
||
const hideChatInput = root.querySelector('[data-tm-thumb-control="hide-room-chat"]');
|
||
if (responsiveInput) responsiveInput.value = responsiveScale().toFixed(1);
|
||
if (fixedInput) fixedInput.value = fixedScale().toFixed(1);
|
||
if (hideChatInput) hideChatInput.checked = shouldHideRoomChat();
|
||
};
|
||
const applyScaleSettings = (next, stage) => {
|
||
scaleSettings = {
|
||
responsive: normalizeScale(next && next.responsive, responsiveScale()),
|
||
fixed: normalizeScale(next && next.fixed, fixedScale()),
|
||
hideRoomChat: next && Object.prototype.hasOwnProperty.call(next, "hideRoomChat")
|
||
? Boolean(next.hideRoomChat)
|
||
: shouldHideRoomChat(),
|
||
};
|
||
saveScaleSettings();
|
||
updateControlValues(document.getElementById(CONTROL_ID));
|
||
applyRoomChatAutoHide(stage);
|
||
reapplyScaleVars(stage);
|
||
scheduleMeasure(stage, 50);
|
||
};
|
||
const ensureScaleControls = () => {
|
||
if (!document.body || !document.createElement) return;
|
||
let root = document.getElementById(CONTROL_ID);
|
||
if (!root) {
|
||
root = document.createElement("div");
|
||
root.id = CONTROL_ID;
|
||
root.innerHTML = [
|
||
'<label title="影响首页, 关注页列表, 频道页更多房间等自适应网格"><span class="tm-thumb-scale-label">响应式</span><input data-tm-thumb-control="responsive" type="number" min="1.0" max="4.0" step="0.1"></label>',
|
||
'<label title="影响发现页轮播, 顶部关注弹窗和悬浮预览"><span class="tm-thumb-scale-label">固定式</span><input data-tm-thumb-control="fixed" type="number" min="1.0" max="4.0" step="0.1"></label>',
|
||
'<label class="tm-thumb-scale-check" title="在主播频道页自动隐藏视频上方的悬浮聊天窗"><input data-tm-thumb-control="hide-room-chat" type="checkbox"><span class="tm-thumb-scale-label">隐藏聊天窗</span></label>',
|
||
'<button type="button" data-tm-thumb-control="reset" title="把两个倍率都设为 1.0">恢复原始尺寸</button>',
|
||
].join("");
|
||
if (root.addEventListener) {
|
||
root.addEventListener("input", (event) => {
|
||
const target = event && event.target;
|
||
if (!target || !target.getAttribute) return;
|
||
const kind = target.getAttribute("data-tm-thumb-control");
|
||
if (kind === "responsive") {
|
||
applyScaleSettings({ responsive: target.value, fixed: fixedScale(), hideRoomChat: shouldHideRoomChat() }, "control-responsive");
|
||
} else if (kind === "fixed") {
|
||
applyScaleSettings({ responsive: responsiveScale(), fixed: target.value, hideRoomChat: shouldHideRoomChat() }, "control-fixed");
|
||
}
|
||
});
|
||
root.addEventListener("change", (event) => {
|
||
const target = event && event.target;
|
||
if (!target || !target.getAttribute || target.getAttribute("data-tm-thumb-control") !== "hide-room-chat") return;
|
||
applyScaleSettings({ responsive: responsiveScale(), fixed: fixedScale(), hideRoomChat: target.checked }, "control-hide-room-chat");
|
||
});
|
||
root.addEventListener("click", (event) => {
|
||
const target = event && event.target;
|
||
if (!target || !target.getAttribute || target.getAttribute("data-tm-thumb-control") !== "reset") return;
|
||
applyScaleSettings({ responsive: ORIGINAL_SCALE, fixed: ORIGINAL_SCALE, hideRoomChat: shouldHideRoomChat() }, "control-reset-original");
|
||
});
|
||
}
|
||
if (document.body.appendChild) document.body.appendChild(root);
|
||
}
|
||
updateControlValues(root);
|
||
};
|
||
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,
|
||
minColumn,
|
||
};
|
||
}
|
||
const columns = computedGridColumnCount(selector);
|
||
return Number.isFinite(columns) && columns > 0 ? {
|
||
columns,
|
||
width: rect && rect.width,
|
||
columnGap,
|
||
minColumn,
|
||
} : null;
|
||
};
|
||
const detectAndApplySizes = (stage = "detect") => {
|
||
/*
|
||
* 探测策略:
|
||
*
|
||
* 1) CSS 已经注入, 但各模块默认关闭, 所以页面先按站点原始布局渲染.
|
||
* 2) 延迟/MutationObserver 触发后, 读取还没 ready 的模块尺寸.
|
||
* 3) 某个模块探测成功后, 写入 CSS 变量, 打开模块开关, 并把 moduleReady 锁住.
|
||
* 4) 锁住后的模块后续不再探测, 避免把已放大的尺寸再次当成原始尺寸.
|
||
*/
|
||
const followingListPage = isFollowingListPage();
|
||
// followedPage 只代表 URL 处在关注页, 用于限制底部"为您推荐"的轮播高度覆盖规则.
|
||
// 它不依赖尺寸探测; 具体是否放大仍由 discover 模块探测成功后决定.
|
||
setModuleReady("followedPage", followingListPage);
|
||
const followedRecommendations = followingListPage ? markFollowedRecommendations() : null;
|
||
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"),
|
||
// 关注页底部"为您推荐": 当前站点 class 不稳定, 使用标题定位后打 data 标记.
|
||
followedRecommendations: followedRecommendations ? {
|
||
root: rectOf(followedRecommendations.root),
|
||
list: rectOf(followedRecommendations.list),
|
||
card: followedRecommendations.cards[0] && rectOf(followedRecommendations.cards[0]),
|
||
thumb: followedRecommendations.thumbs[0] && rectOf(followedRecommendations.thumbs[0]),
|
||
cards: followedRecommendations.cards.length,
|
||
thumbs: followedRecommendations.thumbs.length,
|
||
} : null,
|
||
// 频道页"更多这样的房间": 使用局部推荐区自己的卡片原始宽度和高度, 不套用首页列数算法.
|
||
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 依赖当前页面的自然列数和原始高度; 每次加载重新探测.
|
||
const metrics = {
|
||
grid: measured.homeMetrics,
|
||
cardWidth: measured.homeCard && measured.homeCard.width,
|
||
cardHeight: measured.homeCard && measured.homeCard.height,
|
||
thumbHeight: measured.homeThumb && measured.homeThumb.height,
|
||
};
|
||
moduleReady.home = applyHomeMetrics(metrics);
|
||
if (moduleReady.home) originalMetrics.home = metrics;
|
||
setModuleReady("home", moduleReady.home);
|
||
}
|
||
|
||
if (!moduleReady.followingList && followingListPage) {
|
||
// followingList 单独探测, 避免关注页拿到首页的列数.
|
||
const metrics = {
|
||
grid: measured.followingListMetrics,
|
||
cardWidth: measured.followingListCard && measured.followingListCard.width,
|
||
cardHeight: measured.followingListCard && measured.followingListCard.height,
|
||
thumbHeight: measured.followingListThumb && measured.followingListThumb.height,
|
||
};
|
||
moduleReady.followingList = applyFollowingListMetrics(metrics);
|
||
if (moduleReady.followingList) originalMetrics.followingList = metrics;
|
||
setModuleReady("followingList", moduleReady.followingList);
|
||
}
|
||
|
||
if (!moduleReady.followedRecommendations && followingListPage) {
|
||
moduleReady.followedRecommendations = setFollowedRecommendationVars(followedRecommendations);
|
||
if (moduleReady.followedRecommendations) {
|
||
const cardRect = rectOf(followedRecommendations.cards[0]);
|
||
const thumbRect = rectOf(followedRecommendations.thumbs[0]);
|
||
originalMetrics.followedRecommendations = {
|
||
cardWidth: cardRect && cardRect.width,
|
||
thumbWidth: thumbRect && thumbRect.width,
|
||
};
|
||
}
|
||
setModuleReady("followedRecommendations", moduleReady.followedRecommendations);
|
||
} else if (moduleReady.followedRecommendations && followingListPage) {
|
||
setModuleReady("followedRecommendations", true);
|
||
}
|
||
|
||
if (!moduleReady.related) {
|
||
// related 同时依赖卡片宽度, 卡片高度和缩略图高度; 三者缺一不可.
|
||
const metrics = {
|
||
cardWidth: measured.relatedCard && measured.relatedCard.width,
|
||
cardHeight: measured.relatedCard && measured.relatedCard.height,
|
||
thumbHeight: measured.relatedThumb && measured.relatedThumb.height,
|
||
};
|
||
moduleReady.related = applyRelatedMetrics(metrics);
|
||
if (moduleReady.related) originalMetrics.related = metrics;
|
||
setModuleReady("related", moduleReady.related);
|
||
}
|
||
|
||
if (!moduleReady.follow) {
|
||
// follow 的总弹窗宽度按两列计算: 单卡原始宽度 * 倍率 * 2 + 2em 间距余量.
|
||
const metrics = {
|
||
cardWidth: measured.followCard && measured.followCard.width,
|
||
cardHeight: measured.followCard && measured.followCard.height,
|
||
thumbHeight: measured.followThumb && measured.followThumb.height,
|
||
};
|
||
moduleReady.follow = applyFollowMetrics(metrics);
|
||
if (moduleReady.follow) originalMetrics.follow = metrics;
|
||
setModuleReady("follow", moduleReady.follow);
|
||
if (moduleReady.follow) adjustFollowDropdownPosition(stage);
|
||
} else if (moduleReady.follow) {
|
||
adjustFollowDropdownPosition(stage);
|
||
}
|
||
|
||
if (!moduleReady.tooltip) {
|
||
// tooltip 出现时机不固定, 所以通常由 body mutation 触发探测.
|
||
const metrics = {
|
||
width: measured.tooltipThumb && measured.tooltipThumb.width,
|
||
height: measured.tooltipThumb && measured.tooltipThumb.height,
|
||
};
|
||
moduleReady.tooltip = applyTooltipMetrics(metrics);
|
||
if (moduleReady.tooltip) originalMetrics.tooltip = metrics;
|
||
setModuleReady("tooltip", moduleReady.tooltip);
|
||
}
|
||
|
||
if (!moduleReady.discover) {
|
||
// 发现页要求更严格: 少一个高度都不开启, 宁可不放大, 也不要错位.
|
||
const metrics = {
|
||
cardWidth: measured.discoverCard && measured.discoverCard.width,
|
||
cardHeight: measured.discoverCard && measured.discoverCard.height,
|
||
thumbWidth: measured.discoverThumb && measured.discoverThumb.width,
|
||
thumbHeight: measured.discoverThumb && measured.discoverThumb.height,
|
||
tripleHeight: measured.discoverTripleUl && measured.discoverTripleUl.height,
|
||
doubleHeight: measured.discoverDoubleUl && measured.discoverDoubleUl.height,
|
||
singleHeight: measured.discoverSingleUl && measured.discoverSingleUl.height,
|
||
};
|
||
moduleReady.discover = applyDiscoverMetrics(metrics);
|
||
if (moduleReady.discover) originalMetrics.discover = metrics;
|
||
setModuleReady("discover", moduleReady.discover);
|
||
}
|
||
|
||
lastMeasure = { stage, moduleReady: { ...moduleReady }, scaleSettings: { ...scaleSettings }, 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"),
|
||
computedInfo(".FollowedDropdown__rooms"),
|
||
computedInfo(".FollowedDropdown__room-image"),
|
||
computedInfo("#discover_root .room-list-carousel ul.list"),
|
||
].filter(Boolean);
|
||
|
||
const report = {
|
||
stage,
|
||
ver: VER,
|
||
debug: DEBUG,
|
||
scaleSettings: { ...scaleSettings },
|
||
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 {
|
||
homeMinWidth: style.getPropertyValue("--tm-thumb-home-min-width").trim(),
|
||
homeCardHeight: style.getPropertyValue("--tm-thumb-home-card-height").trim(),
|
||
homeThumbHeight: style.getPropertyValue("--tm-thumb-home-thumb-height").trim(),
|
||
followingListMinWidth: style.getPropertyValue("--tm-thumb-following-list-min-width").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(),
|
||
followedRecommendationsWidth: style.getPropertyValue("--tm-thumb-followed-recommendations-width").trim(),
|
||
followedRecommendationsCardHeight: style.getPropertyValue("--tm-thumb-followed-recommendations-card-height").trim(),
|
||
followedRecommendationsThumbWidth: style.getPropertyValue("--tm-thumb-followed-recommendations-thumb-width").trim(),
|
||
followedRecommendationsThumbHeight: style.getPropertyValue("--tm-thumb-followed-recommendations-thumb-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 inspectFollowedRecommendations = () => {
|
||
/*
|
||
* 专门排查关注页底部"为您推荐"被压扁的问题.
|
||
* 输出重点:
|
||
* - 哪些推荐区候选容器存在;
|
||
* - 前几张卡片的真实矩形, 用来判断第 3 张是否已经被压成横条;
|
||
* - 被压扁卡片向上的祖先层高度/overflow/position/transform, 用来找真正裁切的节点.
|
||
*/
|
||
const rectInfo = (el) => {
|
||
if (!el || !el.getBoundingClientRect) return null;
|
||
const rect = el.getBoundingClientRect();
|
||
return {
|
||
x: Math.round(rect.x),
|
||
y: Math.round(rect.y),
|
||
width: Math.round(rect.width),
|
||
height: Math.round(rect.height),
|
||
};
|
||
};
|
||
const styleInfo = (el) => {
|
||
if (!el || typeof getComputedStyle !== "function") return {};
|
||
const style = getComputedStyle(el);
|
||
return {
|
||
display: style.display,
|
||
position: style.position,
|
||
overflow: style.overflow,
|
||
overflowX: style.overflowX,
|
||
overflowY: style.overflowY,
|
||
height: style.height,
|
||
minHeight: style.minHeight,
|
||
maxHeight: style.maxHeight,
|
||
gridTemplateColumns: style.gridTemplateColumns,
|
||
gridAutoRows: style.gridAutoRows,
|
||
transform: style.transform,
|
||
};
|
||
};
|
||
const nodeInfo = (el) => ({
|
||
tag: el && el.tagName,
|
||
id: el && el.id,
|
||
className: el && String(el.className || "").slice(0, 220),
|
||
rect: rectInfo(el),
|
||
style: styleInfo(el),
|
||
});
|
||
const ancestorChain = (el, stop) => {
|
||
const chain = [];
|
||
let current = el;
|
||
while (current && chain.length < 14) {
|
||
chain.push(nodeInfo(current));
|
||
if (current === stop || current === document.body || current === document.documentElement) break;
|
||
current = current.parentElement;
|
||
}
|
||
return chain;
|
||
};
|
||
const candidates = Array.from(document.querySelectorAll(
|
||
'[data-tm-thumb-followed-recommendations="1"], .followRecommendedContainer, .carousel-root .room-list-carousel, .carousel-root',
|
||
));
|
||
const reports = candidates.map((root, index) => {
|
||
const cards = Array.from(root.querySelectorAll(
|
||
"ul.list > li, .roomElement, li.RoomCard, li.roomCard",
|
||
)).slice(0, 8);
|
||
const targetCard = cards[2] || cards[0] || root;
|
||
return {
|
||
index,
|
||
root: nodeInfo(root),
|
||
cards: cards.map(nodeInfo),
|
||
targetAncestors: ancestorChain(targetCard, root),
|
||
};
|
||
});
|
||
const report = {
|
||
ver: VER,
|
||
href: typeof location === "undefined" ? "" : location.href,
|
||
htmlAttrs: document.documentElement ? {
|
||
followedPage: document.documentElement.getAttribute("data-tm-thumb-scale-followed-page"),
|
||
followedRecommendations: document.documentElement.getAttribute("data-tm-thumb-scale-followed-recommendations"),
|
||
discover: document.documentElement.getAttribute("data-tm-thumb-scale-discover"),
|
||
ver: document.documentElement.getAttribute("data-tm-thumb-scale-ver"),
|
||
} : {},
|
||
cssVars: (() => {
|
||
try {
|
||
const style = getComputedStyle(document.documentElement);
|
||
return {
|
||
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(),
|
||
followedRecommendationsWidth: style.getPropertyValue("--tm-thumb-followed-recommendations-width").trim(),
|
||
followedRecommendationsCardHeight: style.getPropertyValue("--tm-thumb-followed-recommendations-card-height").trim(),
|
||
followedRecommendationsThumbWidth: style.getPropertyValue("--tm-thumb-followed-recommendations-thumb-width").trim(),
|
||
followedRecommendationsThumbHeight: style.getPropertyValue("--tm-thumb-followed-recommendations-thumb-height").trim(),
|
||
};
|
||
} catch (_) {
|
||
return {};
|
||
}
|
||
})(),
|
||
candidates: reports,
|
||
};
|
||
console.log("[tm-thumb-scale recommendations]", 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.tmThumbScaleInspectRecommendations = inspectFollowedRecommendations;
|
||
target.tmThumbScaleInspectRoomChat = inspectRoomChatOverlay;
|
||
target.tmThumbScaleSetScales = (responsive, fixed) => applyScaleSettings({ responsive, fixed }, "api-set-scales");
|
||
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;
|
||
ensureScaleControls();
|
||
applyRoomChatAutoHide("body-observer-start");
|
||
const mo = new MutationObserver(() => {
|
||
scheduleMeasure("mutation", 300);
|
||
scheduleTagsTranslation("mutation", 300);
|
||
ensureScaleControls();
|
||
applyRoomChatAutoHide("mutation");
|
||
});
|
||
mo.observe(document.body, { childList: true, subtree: true });
|
||
};
|
||
|
||
log("start v" + VER);
|
||
// 注册油猴菜单; 只用于清除旧版本遗留的 localStorage 缓存.
|
||
registerMenu();
|
||
// 暴露调试 API, 方便后续在控制台手动检查.
|
||
exposeDebugApi();
|
||
// 第一次报告: 记录启动时 CSS 变量是否已经设置.
|
||
reportDiagnostics("start-before-inject");
|
||
// 标签页中文化: document-start 时可能还没有标签 DOM, 后续 observer/定时器会继续补.
|
||
scheduleTagsTranslation("start", 0);
|
||
// 关注页底部"为您推荐"复用发现页轮播结构, 必须在注入 CSS 前先打页面标记.
|
||
// 否则它会短暂吃到发现页固定高度, 后续推荐卡片换行时容易被裁成横条.
|
||
setModuleReady("followedPage", isFollowingListPage());
|
||
|
||
// 注入 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();
|
||
ensureScaleControls();
|
||
applyRoomChatAutoHide("domcontentloaded");
|
||
// DOMContentLoaded 后再等 600ms, 让 React/图片容器有时间完成初始布局.
|
||
scheduleMeasure("domcontentloaded", 600);
|
||
scheduleTagsTranslation("domcontentloaded", 600);
|
||
startHeadObserver();
|
||
startPageObserver();
|
||
reportDiagnostics("domcontentloaded");
|
||
}, { once: true });
|
||
}
|
||
|
||
if (typeof setTimeout === "function") {
|
||
// 固定时间点探测用于覆盖: React 首屏慢, 图片容器稍后才出现等情况.
|
||
setTimeout(() => { ensureScaleControls(); applyRoomChatAutoHide("after-800ms"); scheduleMeasure("after-800ms"); scheduleTagsTranslation("after-800ms"); reportDiagnostics("after-800ms"); startPageObserver(); }, 800);
|
||
// 第二次探测给慢一点的页面留余量.
|
||
setTimeout(() => { ensureScaleControls(); applyRoomChatAutoHide("after-2000ms"); scheduleMeasure("after-2000ms"); scheduleTagsTranslation("after-2000ms"); reportDiagnostics("after-2000ms"); startPageObserver(); }, 2000);
|
||
// 最后一次兜底探测; 成功的模块会被 moduleReady 锁住, 不会重复相乘.
|
||
setTimeout(() => { ensureScaleControls(); applyRoomChatAutoHide("after-5000ms"); scheduleMeasure("after-5000ms"); scheduleTagsTranslation("after-5000ms"); reportDiagnostics("after-5000ms"); startPageObserver(); }, 5000);
|
||
}
|
||
|
||
})();
|