diff --git a/Chaturbate/chaturbate-thumbnails-2x.user.js b/Chaturbate/chaturbate-thumbnails-2x.user.js index 7d886df..18b0f51 100644 --- a/Chaturbate/chaturbate-thumbnails-2x.user.js +++ b/Chaturbate/chaturbate-thumbnails-2x.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name Chaturbate 缩略图放大 2 倍 // @namespace https://chaturbate.com/ -// @version 0.13.9 +// @version 0.14.7 // @description 放大当前 Chaturbate 房间列表, 发现页轮播, 关注下拉与悬停预览缩略图, 并中文化标签页 // @match https://chaturbate.com/* // @match https://*.chaturbate.com/* @@ -26,7 +26,8 @@ * * 1) 只做"站点原始尺寸 * 倍率"的放大. * 不重写站点原有排版模型, 不用 object-fit / aspect-ratio / 自定义行高去替代站点布局. - * 图片宽高和卡片宽度使用 THUMBNAIL_SCALE; 卡片整体高度使用 CARD_HEIGHT_SCALE. + * 响应式网格只放大最小列宽, 图片和卡片高度保持自动. + * 固定式模块放大卡片宽度, 图片宽高和轮播高度, 信息栏高度尽量保留原站尺寸. * * 2) 各模块必须自己探测原始值, 再乘倍率. * 没有探测到原始值, 就不放大该模块; 不拿默认尺寸猜页面. @@ -37,33 +38,39 @@ * * 4) 不再缓存探测结果. * Chaturbate 的列表宽度会随页面/窗口变化, 缓存旧尺寸容易污染新布局. - * 当前版本每次页面加载都重新探测; 油猴菜单只用于清除旧版本遗留的 localStorage 缓存. + * 当前版本每次页面加载都重新探测; localStorage 只保存用户手动设置的两个倍率. * - * 5) 顶部"关注"弹窗的 FOLLOW_DROPDOWN_SHIFT_X 只是位置微调, 不属于缩略图倍率. - * - * 6) 标签页中文化只改页面显示文字, 不改链接地址. + * 5) 标签页中文化只改页面显示文字, 不改链接地址. * 翻译表和分词词典里没有的标签保持原 #slug, 避免半英文保底文本影响观感. * - * 7) DEBUG 只用于控制台与 window.tmThumbScaleDebug() 诊断, 不应该弹窗或显示页面调试面板. + * 6) DEBUG 只用于控制台与 window.tmThumbScaleDebug() 诊断, 不应该弹窗或显示页面调试面板. */ (function () { "use strict"; // ========== 配置项 ========== - // 图片宽高和卡片宽度倍率: 2 表示按站点原始尺寸放大到 2 倍. - const THUMBNAIL_SCALE = 2; - // 卡片整体高度倍率: 信息栏, 边距等不需要和图片一样放大到 2 倍, 过大会产生空白. - const CARD_HEIGHT_SCALE = 1.55; - // 顶部"关注"弹出层水平偏移; 只调位置, 不参与缩略图放大倍率. - const FOLLOW_DROPDOWN_SHIFT_X = 10; + // 响应式倍率默认值: 影响首页, 关注页列表, 频道页更多房间等 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.13.9"; - // 旧版本用过 localStorage 尺寸缓存.当前版本不再读写缓存, 避免旧探测值污染新布局. + 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({ @@ -946,7 +953,7 @@ * home 模块: * - 适用范围: 主页, 分类页等普通 #roomlist_root 房间列表. * - 探测值: 站点 CSS 里的 grid 最小列宽 + 卡片原始高度 + 缩略图原始高度. - * - 放大方式: 只把 grid 最小列宽乘 THUMBNAIL_SCALE; 列宽仍用 1fr 自适应. + * - 放大方式: 只把 grid 最小列宽乘响应式倍率; 列宽仍用 1fr 自适应. * - 这样窗口缩小时会自动减列, 不会把固定列数硬压窄. * - 注意: 关注页列表单独用 followingList 模块, 不和 home 共用探测结果. */ @@ -1044,7 +1051,7 @@ * related 模块: * - 适用范围: 直播间页面底部"更多这样的房间". * - 探测值: 卡片原始宽度 + 卡片原始高度 + 缩略图原始高度. - * - 放大方式: 把卡片原始宽度乘 THUMBNAIL_SCALE 作为最小列宽, 列宽仍用 1fr 均分填满. + * - 放大方式: 把卡片原始宽度乘响应式倍率作为最小列宽, 列宽仍用 1fr 均分填满. * - 这样右侧不会留空, 窗口变窄时也会自动减列. */ html[data-tm-thumb-scale-related="1"] .BaseRoomContents ul.RoomCardGrid, @@ -1136,10 +1143,6 @@ max-width: none !important; box-sizing: border-box !important; } - html[data-tm-thumb-scale-follow="1"] .HeaderNavBar__following .FollowedDropdown { - /* 只做用户要求的右移微调, 不参与缩略图尺寸计算. */ - transform: translateX(${FOLLOW_DROPDOWN_SHIFT_X}px) !important; - } /* * 悬停"关注"星星等出现的浮层预览(多为 react-tooltip, portal 挂在 body) * 仅放大直播间缩略图域名, 避免误伤图标类小图 @@ -1162,8 +1165,8 @@ * 1. li 卡片宽高; * 2. 缩略图容器高度; * 3. triple/double/single 三种轮播 ul 高度. - * - 放大方式: 图片宽高和卡片宽度乘 THUMBNAIL_SCALE. - * - 卡片高度: 只把缩略图区域乘 THUMBNAIL_SCALE, 用户名/人数等信息栏保留原始高度. + * - 放大方式: 图片宽高和卡片宽度乘固定式倍率. + * - 卡片高度: 只把缩略图区域乘固定式倍率, 用户名/人数等信息栏保留原始高度. * - 轮播高度: triple/double/single 按 3/2/1 行卡片高度重新计算, 保留原始行间距. * - 箭头高度: 跟对应轮播高度同步, 避免右侧箭头与轮播内容错位. * - 启用条件: 卡片和缩略图探测到就先启用; 某种行数的轮播不存在时跳过对应高度变量. @@ -1414,6 +1417,111 @@ 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"; @@ -1445,6 +1553,16 @@ 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; @@ -1453,15 +1571,52 @@ } catch (_) {} }; - const scaledPx = (value) => `${Math.round(value * THUMBNAIL_SCALE)}px`; - const scaledCardHeightPx = (value) => `${Math.round(value * CARD_HEIGHT_SCALE)}px`; - const scaledCardHeightFromThumbPx = (cardHeight, thumbHeight) => { - const nonThumbHeight = Math.max(0, cardHeight - thumbHeight); - return `${Math.round((thumbHeight * THUMBNAIL_SCALE) + nonThumbHeight)}px`; + const 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 scaledCardHeightFromThumbValue = (cardHeight, thumbHeight) => { + 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 * THUMBNAIL_SCALE) + nonThumbHeight); + 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 ( @@ -1477,10 +1632,6 @@ const originalNonCardSpace = Math.max(0, stackHeight - (cardHeight * rows)); return Math.round((scaledCardHeightFromThumbValue(cardHeight, thumbHeight) * rows) + originalNonCardSpace); }; - const scaledCardHeightFromThumbWithScalePx = (cardHeight, thumbHeight, scale) => { - const nonThumbHeight = Math.max(0, cardHeight - thumbHeight); - return `${Math.round((thumbHeight * scale) + nonThumbHeight)}px`; - }; const rootStyle = () => document.documentElement && document.documentElement.style; const isFollowingListPage = () => { try { @@ -1498,6 +1649,15 @@ 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(/^#/, "") @@ -1629,23 +1789,18 @@ }, delay); }; // 所有 CSS 变量都在这里统一乘倍率.调用方传入的必须是页面原始尺寸. - const setVar = (name, value) => { + const setVar = (name, value, scale = fixedScale()) => { const style = rootStyle(); if (!style || !Number.isFinite(value) || value <= 0) return false; - style.setProperty(name, scaledPx(value)); + style.setProperty(name, scaledPx(value, scale)); return true; }; - // 只给 card 整体高度使用.图片宽高仍然走 setVar(), 继续乘 THUMBNAIL_SCALE. - const setCardHeightVar = (name, value) => { - const style = rootStyle(); - if (!style || !Number.isFinite(value) || value <= 0) return false; - style.setProperty(name, scaledCardHeightPx(value)); - return true; - }; - const setCardHeightFromThumbVar = (name, cardHeight, thumbHeight) => { + const 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)); + style.setProperty(name, scaledCardHeightFromThumbPx(cardHeight, thumbHeight, scale)); return true; }; const setDiscoverStackHeightVars = (ulName, arrowName, stackHeight, cardHeight, thumbHeight, rows) => { @@ -1675,9 +1830,9 @@ const originalMinWidth = Number.isFinite(metrics.minColumn) && metrics.minColumn > 0 ? metrics.minColumn : cardWidth; - style.setProperty(minWidthName, scaledPx(originalMinWidth)); - style.setProperty(cardHeightName, scaledCardHeightFromThumbPx(cardHeight, thumbHeight)); - style.setProperty(thumbHeightName, scaledPx(thumbHeight)); + style.setProperty(minWidthName, scaledResponsivePx(originalMinWidth)); + style.setProperty(cardHeightName, "auto"); + style.setProperty(thumbHeightName, scaledResponsivePx(thumbHeight)); return true; }; const clearFollowedRecommendationMarkers = () => { @@ -1788,12 +1943,134 @@ 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", scaledPx(cardRect.width)); + 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", scaledPx(thumbRect.width)); + 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) => { @@ -1829,6 +2106,216 @@ } } 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; @@ -1995,34 +2482,40 @@ if (!moduleReady.home && !followingListPage) { // home 依赖当前页面的自然列数和原始高度; 每次加载重新探测. - moduleReady.home = setListScaleVars( - "--tm-thumb-home-min-width", - "--tm-thumb-home-card-height", - "--tm-thumb-home-thumb-height", - measured.homeMetrics, - measured.homeCard && measured.homeCard.width, - measured.homeCard && measured.homeCard.height, - measured.homeThumb && measured.homeThumb.height, - ); + 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 单独探测, 避免关注页拿到首页的列数. - moduleReady.followingList = setListScaleVars( - "--tm-thumb-following-list-min-width", - "--tm-thumb-following-list-card-height", - "--tm-thumb-following-list-thumb-height", - measured.followingListMetrics, - measured.followingListCard && measured.followingListCard.width, - measured.followingListCard && measured.followingListCard.height, - measured.followingListThumb && measured.followingListThumb.height, - ); + 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); @@ -2030,76 +2523,59 @@ if (!moduleReady.related) { // related 同时依赖卡片宽度, 卡片高度和缩略图高度; 三者缺一不可. - moduleReady.related = [ - setVar("--tm-thumb-related-width", measured.relatedCard && measured.relatedCard.width), - setCardHeightFromThumbVar("--tm-thumb-related-card-height", measured.relatedCard && measured.relatedCard.height, measured.relatedThumb && measured.relatedThumb.height), - setVar("--tm-thumb-related-height", measured.relatedThumb && measured.relatedThumb.height), - ].every(Boolean); + 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 followReady = [ - setVar("--tm-thumb-follow-card-width", measured.followCard && measured.followCard.width), - setCardHeightFromThumbVar("--tm-thumb-follow-card-height", measured.followCard && measured.followCard.height, measured.followThumb && measured.followThumb.height), - setVar("--tm-thumb-follow-thumb-height", measured.followThumb && measured.followThumb.height), - ].every(Boolean); - moduleReady.follow = followReady; - const style = rootStyle(); - if (followReady && style) { - style.setProperty("--tm-thumb-follow-width", `calc(${Math.round(measured.followCard.width * THUMBNAIL_SCALE * 2)}px + 2em)`); - } + 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 触发探测. - moduleReady.tooltip = [ - setVar("--tm-thumb-tooltip-width", measured.tooltipThumb && measured.tooltipThumb.width), - setVar("--tm-thumb-tooltip-height", measured.tooltipThumb && measured.tooltipThumb.height), - ].every(Boolean); + 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 discoverWidthReady = setVar("--tm-thumb-discover-width", measured.discoverCard && measured.discoverCard.width); - const discoverCardHeightReady = setCardHeightFromThumbVar("--tm-thumb-discover-card-height", measured.discoverCard && measured.discoverCard.height, measured.discoverThumb && measured.discoverThumb.height); - const discoverThumbWidthReady = setVar("--tm-thumb-discover-thumb-width", measured.discoverThumb && measured.discoverThumb.width); - const discoverHeightReady = setVar("--tm-thumb-discover-height", measured.discoverThumb && measured.discoverThumb.height); - const discoverTripleReady = setDiscoverStackHeightVars( - "--tm-thumb-discover-triple-ul", - "--tm-thumb-discover-triple-arrow", - measured.discoverTripleUl && measured.discoverTripleUl.height, - measured.discoverCard && measured.discoverCard.height, - measured.discoverThumb && measured.discoverThumb.height, - 3, - ); - const discoverDoubleReady = setDiscoverStackHeightVars( - "--tm-thumb-discover-double-ul", - "--tm-thumb-discover-double-arrow", - measured.discoverDoubleUl && measured.discoverDoubleUl.height, - measured.discoverCard && measured.discoverCard.height, - measured.discoverThumb && measured.discoverThumb.height, - 2, - ); - const discoverSingleReady = setDiscoverStackHeightVars( - "--tm-thumb-discover-single-ul", - "--tm-thumb-discover-single-arrow", - measured.discoverSingleUl && measured.discoverSingleUl.height, - measured.discoverCard && measured.discoverCard.height, - measured.discoverThumb && measured.discoverThumb.height, - 1, - ); - moduleReady.discover = discoverWidthReady && discoverCardHeightReady && discoverThumbWidthReady && discoverHeightReady - && discoverTripleReady - && discoverDoubleReady - && discoverSingleReady; + 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 }, measured }; + lastMeasure = { stage, moduleReady: { ...moduleReady }, scaleSettings: { ...scaleSettings }, measured }; if (DEBUG) log("measured original sizes", lastMeasure); return lastMeasure; }; @@ -2164,6 +2640,7 @@ 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"), @@ -2173,6 +2650,7 @@ stage, ver: VER, debug: DEBUG, + scaleSettings: { ...scaleSettings }, href: typeof location === "undefined" ? "" : location.href, readyState: document.readyState, stylePresent, @@ -2353,6 +2831,8 @@ try { target.tmThumbScaleDebug = api; target.tmThumbScaleInspectRecommendations = inspectFollowedRecommendations; + target.tmThumbScaleInspectRoomChat = inspectRoomChatOverlay; + target.tmThumbScaleSetScales = (responsive, fixed) => applyScaleSettings({ responsive, fixed }, "api-set-scales"); target.tmThumbScaleLastReport = lastReport; } catch (_) {} }); @@ -2434,9 +2914,13 @@ // 观察 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 }); }; @@ -2468,6 +2952,8 @@ document.addEventListener("DOMContentLoaded", () => { inject(); exposeDebugApi(); + ensureScaleControls(); + applyRoomChatAutoHide("domcontentloaded"); // DOMContentLoaded 后再等 600ms, 让 React/图片容器有时间完成初始布局. scheduleMeasure("domcontentloaded", 600); scheduleTagsTranslation("domcontentloaded", 600); @@ -2479,11 +2965,11 @@ if (typeof setTimeout === "function") { // 固定时间点探测用于覆盖: React 首屏慢, 图片容器稍后才出现等情况. - setTimeout(() => { scheduleMeasure("after-800ms"); scheduleTagsTranslation("after-800ms"); reportDiagnostics("after-800ms"); startPageObserver(); }, 800); + setTimeout(() => { ensureScaleControls(); applyRoomChatAutoHide("after-800ms"); scheduleMeasure("after-800ms"); scheduleTagsTranslation("after-800ms"); reportDiagnostics("after-800ms"); startPageObserver(); }, 800); // 第二次探测给慢一点的页面留余量. - setTimeout(() => { scheduleMeasure("after-2000ms"); scheduleTagsTranslation("after-2000ms"); reportDiagnostics("after-2000ms"); startPageObserver(); }, 2000); + setTimeout(() => { ensureScaleControls(); applyRoomChatAutoHide("after-2000ms"); scheduleMeasure("after-2000ms"); scheduleTagsTranslation("after-2000ms"); reportDiagnostics("after-2000ms"); startPageObserver(); }, 2000); // 最后一次兜底探测; 成功的模块会被 moduleReady 锁住, 不会重复相乘. - setTimeout(() => { scheduleMeasure("after-5000ms"); scheduleTagsTranslation("after-5000ms"); reportDiagnostics("after-5000ms"); startPageObserver(); }, 5000); + setTimeout(() => { ensureScaleControls(); applyRoomChatAutoHide("after-5000ms"); scheduleMeasure("after-5000ms"); scheduleTagsTranslation("after-5000ms"); reportDiagnostics("after-5000ms"); startPageObserver(); }, 5000); } })(); diff --git a/tests/followed-dropdown-css.test.js b/tests/followed-dropdown-css.test.js index 8348a3e..3cf282a 100644 --- a/tests/followed-dropdown-css.test.js +++ b/tests/followed-dropdown-css.test.js @@ -196,6 +196,9 @@ assert.match( /\.FollowedDropdown__room-image[\s\S]*width:\s*100%\s*!important[\s\S]*height:\s*var\(--tm-thumb-follow-thumb-height\)\s*!important/, "follow dropdown images should follow fixed card width and use detected height", ); +assert.doesNotMatch(capturedCss, /translateX\(/, "follow dropdown should not use a fixed horizontal offset"); +assert.match(source, /adjustFollowDropdownPosition/, "script should adjust follow dropdown position only when it overflows"); +assert.match(source, /viewportPadding - rect\.left/, "follow dropdown adjustment should be based on left viewport overflow"); assert.match( capturedCss, /react-tooltip[\s\S]*width:\s*var\(--tm-thumb-tooltip-width\)\s*!important[\s\S]*height:\s*var\(--tm-thumb-tooltip-height\)\s*!important/, @@ -209,14 +212,20 @@ assert.match( assert.doesNotMatch(source, /withOwnStyleDisabled/, "script should not disable its own style while measuring"); assert.match(source, /detectAndApplySizes/, "script should detect original sizes before applying scaled variables"); assert.match(source, /moduleReady/, "script should lock each module after detecting its original size"); -assert.match(source, /CARD_HEIGHT_SCALE/, "script should use a separate scale for card heights"); -assert.match(source, /setCardHeightVar/, "script should apply card height scaling separately from thumbnail scaling"); +assert.match(source, /DEFAULT_RESPONSIVE_SCALE/, "script should expose a default responsive scale"); +assert.match(source, /DEFAULT_FIXED_SCALE/, "script should expose a default fixed scale"); +assert.match(source, /tmThumbScaleSetScales/, "script should expose a debug API for changing both scales"); +assert.match(capturedCss, /#tm-thumb-scale-controls[\s\S]*position:\s*fixed\s*!important/, "script should render a fixed page control panel"); +assert.match(capturedCss, /data-tm-thumb-scale-auto-hide-room-chat="1"[\s\S]*data-tm-thumb-room-chat-overlay="1"[\s\S]*display:\s*none\s*!important/, "script should hide marked room chat overlays when enabled"); +assert.match(source, /hideRoomChat/, "script should persist the room chat auto-hide setting"); +assert.match(source, /applyRoomChatAutoHide/, "script should apply automatic room chat hiding"); assert.match(source, /setDiscoverStackHeightVars/, "discover carousel stack heights should be derived from scaled card rows"); assert.match(source, /setCardHeightFromThumbVar\("--tm-thumb-discover-card-height"/, "discover card height should scale the thumbnail area and keep metadata height"); assert.match(source, /stackHeight == null\) return true/, "discover should not require every carousel row type to exist before enabling"); assert.match(source, /TAG_TRANSLATIONS/, "script should include a centralized tag translation table"); assert.match(source, /translateTagsPage/, "script should translate the Chaturbate tags page"); -assert.doesNotMatch(source, /localStorage\.setItem/, "script should not write detected sizes to localStorage"); +assert.doesNotMatch(source, /localStorage\.setItem\([^,]*LEGACY_CACHE_PREFIX/, "script should not write detected sizes to localStorage"); +assert.match(source, /SETTINGS_KEY/, "script may persist user-adjustable scale settings"); assert.match(source, /LEGACY_CACHE_PREFIX/, "script should only keep legacy cache cleanup support"); assert.match(source, /scheduleMeasure\("after-800ms"\)/, "script should delay the first page measurement until original layout can render"); assert.doesNotMatch(