// ==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 = [ '', '', '', '', ].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