Files
Tampermonkey_scripts/Chaturbate/chaturbate-thumbnails-custom.user.js

2976 lines
119 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ==UserScript==
// @name Chaturbate 缩略图自定义缩放
// @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);
}
})();