fix(userscript): videoTK 事件驅動與可拖動劇院按鈕
Docs/ChangeHistory.mdDocs/TEST_FILES_REFERENCE.mdDocs/USERSCRIPT_SOURCES.mdTest/userscript_source.test.mjsTest/video_tk_ui.cjsUserScript/videoTK.user.js
commit a99f96710c55c282d1ee9133bd7029d10f12177aAuthor: Nuclear0709 <agp05432@gmail.com>Date: Sun Nov 8 00:02:30 2026 +0800 fix(userscript): videoTK 事件驅動與可拖動劇院按鈕 - 移除永久 rAF loop,改用 ResizeObserver/媒體事件與低頻維護 - Theater 模式保存並精確還原原始 inline style - 禮物只在 ON 時隱藏,移除自動 unmute - 右下改成可拖曳且位置持久化的緊湊按鈕 - 補 videoTK UI 測試與文件 diff --git a/Docs/ChangeHistory.md b/Docs/ChangeHistory.md--- a/Docs/ChangeHistory.md+++ b/Docs/ChangeHistory.md@@ -23,6 +23,35 @@ --- +## 2026.11.08 00:01 videoTK 事件驅動與可拖動劇院按鈕++**類型**: 改善 · **檔案**: `UserScript/videoTK.user.js`、`Test/video_tk_ui.cjs`、`Test/userscript_source.test.mjs`、`Docs/TEST_FILES_REFERENCE.md`++### 問題++- `requestAnimationFrame(loop)` 永久執行,每幀查詢 video/container、讀取 rect 並寫樣式。+- `removeMeta` 的 MutationObserver 每次 DOM 變動都查詢整份文件。+- 每幀設定 `video.muted = false`,會覆蓋使用者靜音。+- 禮物在啟動時無條件隱藏;OFF 使用硬編碼 `flex`/`calc(100% - 327px)` 還原,容易壞。+- 右下 OFF/ON 圓形按鈕不可拖動。++### 修改++- 移除永久 rAF loop;改用 ResizeObserver、loadedmetadata、resize、scroll 與低頻 maintenance 更新。+- 快取文字與位置,只有變更才寫 DOM。+- Theater 模式保存每個元素原始 inline style,關閉時精確還原;禮物只在 ON 時隱藏。+- 移除自動 unmute 與死程式碼 `updateInfo`。+- 右下改成緊湊的 `⛶` 按鈕:pointer 拖曳、位置存 localStorage、resize clamp、拖曳不會觸發切換。+- 新增 `Test/video_tk_ui.cjs` 驗證 Theater 還原與拖曳不誤觸。++### 驗證++- `node Test/video_tk_ui.cjs`+- `node --test Test/userscript_source.test.mjs`+- `npm run lint`++---+ ## 2026.11.07 23:54 TikTokChat observer 生命週期 **類型**: 改善 · **檔案**: `UserScript/TikTokChat.user.js`、`Test/userscript_source.test.mjs`、`Docs/USERSCRIPT_SOURCES.md`diff --git a/Docs/TEST_FILES_REFERENCE.md b/Docs/TEST_FILES_REFERENCE.md--- a/Docs/TEST_FILES_REFERENCE.md+++ b/Docs/TEST_FILES_REFERENCE.md@@ -61,6 +61,7 @@ node Test/xxx.mjs | `Test/monitor_history.test.cjs` | 監控歷史 SQLite 的場次、分頁、搜尋、保留與測試訊息排除 | | `Test/monitor_routes.test.cjs` | 監控歷史 API 的登入、場次、訊息與缺少場次 | | `Test/navigation_guard.test.mjs` | Signer TikTok 導航最小間隔與 WAF 冷卻 |+| `Test/video_tk_ui.cjs` | videoTK 劇院樣式還原、可拖動按鈕與拖曳不誤觸 | | `Test/notify.test.mjs` | 通知格式化與 Bark/ntfy 逐通道結果 | | `Test/traffic_planner_ui.cjs` | 人流分類、批次補分類、預覽/套用/復原與手機寬度 | | `Test/traffic_planner_collecting_ui.cjs` | 資料累積建議、時段分組提醒與一週計畫 UI |diff --git a/Docs/USERSCRIPT_SOURCES.md b/Docs/USERSCRIPT_SOURCES.md--- a/Docs/USERSCRIPT_SOURCES.md+++ b/Docs/USERSCRIPT_SOURCES.md@@ -29,6 +29,7 @@ API+Userscript 是同一個平台的兩個入口,不拆成兩份平台分數 | 檔案 | 新版 | | --- | --- | | TikTokChat.user.js | 2.13 |+| videoTK.user.js | 2.5 | | liveCenter.user.js | 1.18 | | youtube-chat-userscript.user.js | 1.7 | | TestCenter.user.js | 1.5 |diff --git a/Test/userscript_source.test.mjs b/Test/userscript_source.test.mjs--- a/Test/userscript_source.test.mjs+++ b/Test/userscript_source.test.mjs@@ -172,6 +172,19 @@ test('TikTokChat 只在目標直播頁且可見時掛 observer,離開就拆除 assert.doesNotMatch(source, /const viewers = getViewerCount\(\);\s*console\.log\('觀眾人數:'/); }); +test('videoTK 使用事件驅動尺寸更新與可拖動劇院按鈕', () => {+ const source = fs.readFileSync(new URL('../UserScript/videoTK.user.js', import.meta.url), 'utf8');+ assert.match(source, /@version\s+2\.5/);+ assert.match(source, /ResizeObserver/);+ assert.match(source, /function applyTheater\(/);+ assert.match(source, /function restoreTheater\(/);+ assert.match(source, /pointerdown/);+ assert.match(source, /ttw-videotk-button-pos/);+ assert.doesNotMatch(source, /video\.muted\s*=\s*false/);+ assert.doesNotMatch(source, /function loop\(\)/);+ assert.doesNotMatch(source, /requestAnimationFrame\(loop\)/);+});+ test('轉送種類判斷:有禮物圖=送禮、isMain=false=加入、其餘聊天', () => { const source = fs.readFileSync(new URL('../UserScript/TikTokChat.user.js', import.meta.url), 'utf8'); const a = source.indexOf('function sentKind(');diff --git a/Test/video_tk_ui.cjs b/Test/video_tk_ui.cjs--- /dev/null+++ b/Test/video_tk_ui.cjs@@ -0,0 +1,94 @@+/** videoTK 劇院模式與可拖動按鈕的隔離 UI 測試。 */+/* global document -- 僅在瀏覽器回呼中使用。 */+const assert = require('node:assert/strict');+const http = require('node:http');+const fs = require('node:fs');+const path = require('node:path');+const { browserPath } = require('./browser_path.cjs');++const fixture = `<!doctype html><html><head><meta charset="utf-8"><title>live</title></head>+<body>+ <div class="tiktok-1w5o2is" id="extra" style="display:block">extra</div>+ <div data-e2e="live-room-content" id="live" style="position:relative">+ <video id="video"></video>+ </div>+ <div id="chat-parent" style="display:flex">+ <div data-e2e="live-chat-container" id="chat">chat</div>+ </div>+ <div data-e2e="live-content-container" id="content" style="width:calc(100% - 327px)">content</div>+ <div data-e2e="live-header-container" id="header" style="display:block">header</div>+ <div data-e2e="gift-container" id="gift" style="display:block">gift</div>+</body></html>`;++async function main() {+ const server = http.createServer((req, res) => {+ if (req.url.split('?')[0] === '/@demo/live') {+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });+ res.end(fixture);+ return;+ }+ res.writeHead(404);+ res.end();+ });+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));++ let browser;+ try {+ const { default: puppeteer } = await import('puppeteer-core');+ browser = await puppeteer.launch({ executablePath: browserPath(), headless: true });+ const page = await browser.newPage();+ const errors = [];+ page.on('pageerror', error => errors.push(error.message));+ await page.goto(`http://127.0.0.1:${server.address().port}/@demo/live`, { waitUntil: 'load' });+ const script = fs.readFileSync(path.join(__dirname, '../UserScript/videoTK.user.js'), 'utf8');+ await page.addScriptTag({ content: script });+ await page.waitForSelector('#ttw-videotk-toggle');++ await page.click('#ttw-videotk-toggle');+ await page.waitForFunction(() => document.querySelector('#ttw-videotk-toggle').getAttribute('aria-pressed') === 'true');+ const enabled = await page.evaluate(() => ({+ chat: document.querySelector('#chat-parent').style.display,+ header: document.querySelector('#header').style.display,+ gift: document.querySelector('#gift').style.display,+ extra: document.querySelector('#extra').style.display,+ width: document.querySelector('#content').style.width+ }));+ assert.deepEqual(enabled, { chat: 'none', header: 'none', gift: 'none', extra: 'none', width: '100%' });++ await page.click('#ttw-videotk-toggle');+ await page.waitForFunction(() => document.querySelector('#ttw-videotk-toggle').getAttribute('aria-pressed') === 'false');+ const restored = await page.evaluate(() => ({+ chat: document.querySelector('#chat-parent').style.display,+ header: document.querySelector('#header').style.display,+ gift: document.querySelector('#gift').style.display,+ extra: document.querySelector('#extra').style.display,+ width: document.querySelector('#content').style.width+ }));+ assert.deepEqual(restored, {+ chat: 'flex', header: 'block', gift: 'block', extra: 'block', width: 'calc(100% - 327px)'+ });++ const before = await page.$eval('#ttw-videotk-toggle', el => el.getBoundingClientRect().toJSON());+ await page.mouse.move(before.x + 10, before.y + 10);+ await page.mouse.down();+ await page.mouse.move(before.x + 90, before.y - 20, { steps: 5 });+ await page.mouse.up();+ const after = await page.$eval('#ttw-videotk-toggle', el => ({+ left: el.getBoundingClientRect().left,+ top: el.getBoundingClientRect().top,+ pressed: el.getAttribute('aria-pressed'),+ saved: localStorage.getItem('ttw-videotk-button-pos')+ }));+ assert.notEqual(after.left, before.x);+ assert.notEqual(after.top, before.y);+ assert.equal(after.pressed, 'false');+ assert.ok(after.saved);+ assert.deepEqual(errors, []);+ console.log('videoTK passed: theater styles restore exactly, button drags and does not toggle.');+ } finally {+ await browser?.close();+ await new Promise(resolve => server.close(resolve));+ }+}++main().catch(error => { console.error(error); process.exitCode = 1; });diff --git a/UserScript/videoTK.user.js b/UserScript/videoTK.user.js--- a/UserScript/videoTK.user.js+++ b/UserScript/videoTK.user.js@@ -1,12 +1,11 @@ // ==UserScript==-// @name TikTok Video Info Overlay (Dual)+// @name TikTok Video Info Overlay // @namespace pip-chat-test-// @version 2.4-// @description 顯示 video 原始解析度和頁面顯示尺寸,既掛 body 也掛 live-room-content+// @version 2.5+// @description 顯示 video 原始解析度與頁面顯示尺寸;提供可拖動的劇院模式切換 // @match https://www.tiktok.com/* // @grant none - // @updateURL https://raw.githubusercontent.com/TwhomeGH/TTWChatMessageServer/main/UserScript/videoTK.user.js // @downloadURL https://raw.githubusercontent.com/TwhomeGH/TTWChatMessageServer/main/UserScript/videoTK.user.js @@ -14,338 +13,317 @@ // ==/UserScript== -- (function () { 'use strict'; --- var LastText = ""-- var LastTextDiv = ""-- const removeMeta = () => {- document- .querySelectorAll('meta[name="apple-itunes-app"]')- .forEach(m => m.remove());- };-- removeMeta();-- new MutationObserver(removeMeta).observe(document.documentElement, {- childList: true,- subtree: true- });-+ const BUTTON_ID = 'ttw-videotk-toggle';+ const POSITION_KEY = 'ttw-videotk-button-pos';+ const MAINTENANCE_MS = 300;+ const theater = { enabled: false, styles: new Map(), timer: null };+ let maintenanceTimer = null; let infoDiv = null;- // 原本掛 body 的- let infoDivLive = null;- // 新增掛 live-room-content 的 let lastVideo = null;-- function createInfoDiv() {- const div = document.createElement("div");- div.style.position = "fixed";- div.style.backgroundColor = "rgba(0,0,0,0.6)";- div.style.color = "white";- div.style.fontSize = "12px";- div.style.padding = "4px 6px";- div.style.borderRadius = "4px";- div.style.zIndex = 9999;- div.style.pointerEvents = "none";- return div;+ let lastContainer = null;+ let overlayContainer = null;+ let lastText = '';+ let lastTop = null;+ let lastLeft = null;+ let overlayFrame = 0;+ let buttonDrag = null;+ let suppressClickUntil = 0;+ let resizeObserver = null;++ const isLivePath = () => /^\/@[^/]+\/live(?:\/|$)/i.test(location.pathname);++ function removeMeta() {+ document.querySelectorAll('meta[name="apple-itunes-app"]').forEach(meta => meta.remove()); } + function removeOpenAppBanner() {+ document.querySelector('[data-e2e="open-app-banner"]')?.remove();+ } -- // eslint-disable-next-line no-unused-vars -- 目前程式碼中無呼叫端;保留待用- function updateInfo(video, container) {- if (!infoDiv) {- infoDiv = createInfoDiv();- document.body.appendChild(infoDiv);+ /** 保存元素原始 inline style,關閉劇院模式時精確還原。 */+ function setTheaterStyle(element, property, value) {+ if (!element) return;+ let original = theater.styles.get(element);+ if (!original) {+ original = {};+ theater.styles.set(element, original); }- - // 如果要跟著 video 位置才更新座標- const rect = video.getBoundingClientRect();- const renderedWidth = video.clientWidth;- const renderedHeight = video.clientHeight;- const originalWidth = video.videoWidth;- const originalHeight = video.videoHeight;- const containerRect = container.getBoundingClientRect();-- const newText = `Render: ${renderedWidth}×${renderedHeight} | Original: ${originalWidth}×${originalHeight}`;-+ if (!(property in original)) original[property] = element.style[property];+ if (element.style[property] !== value) element.style[property] = value;+ } - if (LastText !== newText) {- LastText = newText;- infoDiv.textContent = LastText;- console.log("Not same",LastText,newText)+ function pruneDetachedStyles() {+ for (const element of theater.styles.keys()) {+ if (!element.isConnected) theater.styles.delete(element); }--- - -- infoDiv.style.top = `${rect.top - containerRect.top + 55}px`;- infoDiv.style.left = `${rect.right - containerRect.left - infoDiv.offsetWidth - 8}px`; } - function createInfoDivLive(container) {- const div = document.createElement("div");- div.style.position = "absolute"; // 相對於 container- div.style.top = "0px";- div.style.left = "0px";- div.style.backgroundColor = "rgba(0,0,0,0.6)";- div.style.color = "white";- div.style.fontSize = "12px";- div.style.padding = "4px 6px";- div.style.borderRadius = "4px";- div.style.zIndex = 9999;- div.style.pointerEvents = "none"; // 不阻塞點擊- div.style.width = "max-content";- div.style.transform = "translateZ(0)"; // 提升渲染層- container.appendChild(div);- return div;+ function applyTheater() {+ if (!theater.enabled) return;+ pruneDetachedStyles();+ document.querySelectorAll('.tiktok-1w5o2is').forEach(element => {+ setTheaterStyle(element, 'display', 'none');+ });+ const chat = document.querySelector('[data-e2e="live-chat-container"]');+ if (chat) setTheaterStyle(chat.parentElement || chat, 'display', 'none');+ const liveContent = document.querySelector('[data-e2e="live-content-container"]');+ if (liveContent) setTheaterStyle(liveContent, 'width', '100%');+ const header = document.querySelector('[data-e2e="live-header-container"]');+ if (header) setTheaterStyle(header, 'display', 'none');+ const gift = document.querySelector('[data-e2e="gift-container"]');+ if (gift) setTheaterStyle(gift, 'display', 'none'); } --- function updateInfoLive(video, container) {- if (!infoDivLive) {- infoDivLive = createInfoDivLive(container);- }--- // 如果要跟著 video 位置才更新座標- const rect = video.getBoundingClientRect();-- const renderedWidth = video.clientWidth;- const renderedHeight = video.clientHeight;- const originalWidth = video.videoWidth;- const originalHeight = video.videoHeight;- const containerRect = container.getBoundingClientRect();--- const newText = `Render: ${renderedWidth}×${renderedHeight} | Original: ${originalWidth}×${originalHeight}`;-- if (LastTextDiv !== newText) {- LastTextDiv = newText;- infoDivLive.textContent = LastTextDiv;- console.log("Not same",LastTextDiv,newText)+ function restoreTheater() {+ for (const [element, properties] of theater.styles) {+ if (!element.isConnected) continue;+ for (const [property, value] of Object.entries(properties)) {+ element.style[property] = value;+ } }---- infoDivLive.style.top = `${rect.top - containerRect.top + 42}px`;- infoDivLive.style.left = `${rect.right - containerRect.left - infoDivLive.offsetWidth - 8}px`;-+ theater.styles.clear(); } + function updateButton() {+ const button = document.getElementById(BUTTON_ID);+ if (!button) return;+ button.setAttribute('aria-pressed', String(theater.enabled));+ button.title = theater.enabled ? '劇院模式:開(可拖曳)' : '劇院模式:關(可拖曳)';+ button.setAttribute('aria-label', theater.enabled ? '關閉劇院模式' : '開啟劇院模式');+ button.style.background = theater.enabled ? 'rgba(22,163,74,.9)' : 'rgba(0,0,0,.62)';+ } - function checkVideo() {- const container = document.querySelector('[data-e2e="live-room-content"]');- const video = document.querySelector("video");- if (!video) return;-- if (video !== lastVideo) {- if (infoDiv) infoDiv.remove();- if (infoDivLive) infoDivLive.remove();- infoDiv = null;- infoDivLive = null;- lastVideo = video;-+ function setTheater(enabled) {+ if (theater.enabled === enabled) return;+ theater.enabled = enabled;+ if (enabled && !isLivePath()) {+ theater.enabled = false;+ updateButton();+ return; }-- if (video.videoWidth && video.videoHeight) {- video.muted = false;- - // MRender備用顯示- //updateInfo(video, container);-- if (container) {--- updateInfoLive(video, container);-- }+ if (enabled) {+ applyTheater();+ theater.timer = setInterval(applyTheater, 1000);+ } else {+ if (theater.timer) clearInterval(theater.timer);+ theater.timer = null;+ restoreTheater(); }+ updateButton(); } + function clampButton(button) {+ if (!button.style.left || !button.style.top) return;+ const rect = button.getBoundingClientRect();+ const left = Math.min(Math.max(8, rect.left), Math.max(8, innerWidth - rect.width - 8));+ const top = Math.min(Math.max(8, rect.top), Math.max(8, innerHeight - rect.height - 8));+ button.style.left = left + 'px';+ button.style.top = top + 'px';+ } + function loadButtonPosition(button) {+ try {+ const saved = JSON.parse(localStorage.getItem(POSITION_KEY) || 'null');+ if (Number.isFinite(saved?.left) && Number.isFinite(saved?.top)) {+ button.style.left = saved.left + 'px';+ button.style.top = saved.top + 'px';+ button.style.right = 'auto';+ button.style.bottom = 'auto';+ clampButton(button);+ }+ } catch { /* ignore */ }+ } - function createFloatingButton() {- if (document.getElementById('my-floating-btn')) return;-- const btn = document.createElement('div');- btn.id = 'my-floating-btn';- btn.textContent = 'OFF'; // 預設狀態文字-- let enabled = false; // 👉 toggle 狀態-- // 樣式- btn.style.position = 'fixed';- btn.style.right = '16px';- btn.style.bottom = '16px';- btn.style.width = '48px';- btn.style.height = '48px';- btn.style.borderRadius = '50%';- btn.style.background = 'rgba(0,0,0,0.6)';- btn.style.color = '#fff';- btn.style.display = 'flex';- btn.style.alignItems = 'center';- btn.style.justifyContent = 'center';- btn.style.fontSize = '14px';- btn.style.cursor = 'pointer';- btn.style.zIndex = 99999;- btn.style.userSelect = 'none';- btn.style.backdropFilter = 'blur(4px)';-- btn.addEventListener('click', () => {- enabled = !enabled; // 🔁 切換狀態-- // UI 顯示狀態(可改)- btn.textContent = enabled ? 'ON' : 'OFF';- btn.style.background = enabled- ? 'rgba(0,150,0,0.7)'- : 'rgba(0,0,0,0.6)';-- if (enabled) {- // =========================- // 👉 ON 狀態邏輯放這裡- // 例如:- // expandLiveContent();- // showInfoDivLive();- // =========================--- //隱藏- document.querySelectorAll('.tiktok-1w5o2is').forEach(el => {- el.style.display = 'none';- });--- // 選取 live-chat-container 元素- const chatContainer = document.querySelector('[data-e2e="live-chat-container"]');-- if (chatContainer) {- // 取得父元素- const parent = chatContainer.parentElement;-- if (parent) {- // 隱藏父元素(包含 chatContainer 和它下面所有內容)- parent.style.display = 'none';- } else {- // 沒有父元素就隱藏自己- chatContainer.style.display = 'none';- }- }-- const liveContent = document.querySelector('[data-e2e="live-content-container"]');-- if (liveContent) {- liveContent.style.width = '100%';- }--- //live-header-container- //data-e2e="live-header-container"- //隱藏用戶名關注分享常駐橫幅(全螢幕左右排版移除後(右下on/off開關)- const UserContent = document.querySelector('[data-e2e="live-header-container"]');-- if (UserContent) {- UserContent.style.display = "none"- }-- } else {- // =========================- // 👉 OFF 狀態邏輯放這裡- // 例如:- // restoreLiveContent();- // hideInfoDivLive();- // =========================--- //復原- document.querySelectorAll('.tiktok-1w5o2is').forEach(el => {- el.style.display = 'block';- });-- // 選取 live-chat-container 元素 復原- const chatContainer = document.querySelector('[data-e2e="live-chat-container"]');-- if (chatContainer) {- // 取得父元素- const parent = chatContainer.parentElement;-- if (parent) {- // 隱藏父元素(包含 chatContainer 和它下面所有內容)- parent.style.display = 'flex';- } else {- // 沒有父元素就隱藏自己- chatContainer.style.display = 'flex';- }- }- //復原右邊- const liveContent = document.querySelector('[data-e2e="live-content-container"]');-- if (liveContent) {- liveContent.style.width = 'calc(100% - 327px)';- }-- //live-header-container- //data-e2e="live-header-container"- //隱藏用戶名關注分享常駐橫幅(全螢幕左右排版移除後(右下on/off開關)- const UserContent = document.querySelector('[data-e2e="live-header-container"]');-- if (UserContent) {- UserContent.style.display = "block"- }+ function saveButtonPosition(button) {+ try {+ const rect = button.getBoundingClientRect();+ localStorage.setItem(POSITION_KEY, JSON.stringify({ left: Math.round(rect.left), top: Math.round(rect.top) }));+ } catch { /* ignore */ }+ } + function createButton() {+ if (document.getElementById(BUTTON_ID) || !document.body) return;+ const button = document.createElement('button');+ button.id = BUTTON_ID;+ button.type = 'button';+ button.textContent = '⛶';+ button.style.cssText = [+ 'position:fixed', 'right:16px', 'bottom:16px', 'width:36px', 'height:36px',+ 'border:1px solid rgba(255,255,255,.28)', 'border-radius:10px',+ 'background:rgba(0,0,0,.62)', 'color:#fff', 'font:18px/1 system-ui',+ 'display:flex', 'align-items:center', 'justify-content:center',+ 'cursor:pointer', 'z-index:99999', 'user-select:none', 'touch-action:none',+ 'backdrop-filter:blur(4px)', 'padding:0'+ ].join(';');+ button.addEventListener('pointerdown', event => {+ if (event.button !== 0) return;+ const rect = button.getBoundingClientRect();+ buttonDrag = {+ pointerId: event.pointerId, startX: event.clientX, startY: event.clientY,+ left: rect.left, top: rect.top, moved: false+ };+ button.setPointerCapture(event.pointerId);+ });+ button.addEventListener('pointermove', event => {+ if (!buttonDrag || buttonDrag.pointerId !== event.pointerId) return;+ const dx = event.clientX - buttonDrag.startX;+ const dy = event.clientY - buttonDrag.startY;+ if (Math.hypot(dx, dy) > 4) buttonDrag.moved = true;+ if (!buttonDrag.moved) return;+ button.style.left = (buttonDrag.left + dx) + 'px';+ button.style.top = (buttonDrag.top + dy) + 'px';+ button.style.right = 'auto';+ button.style.bottom = 'auto';+ clampButton(button);+ });+ button.addEventListener('pointerup', event => {+ if (!buttonDrag || buttonDrag.pointerId !== event.pointerId) return;+ const moved = buttonDrag.moved;+ buttonDrag = null;+ try { button.releasePointerCapture(event.pointerId); } catch { /* ignore */ }+ if (moved) {+ suppressClickUntil = Date.now() + 400;+ saveButtonPosition(button); } });-- document.body.appendChild(btn);+ button.addEventListener('pointercancel', () => { buttonDrag = null; });+ button.addEventListener('click', event => {+ if (Date.now() < suppressClickUntil) {+ suppressClickUntil = 0;+ event.preventDefault();+ return;+ }+ setTheater(!theater.enabled);+ });+ document.body.append(button);+ loadButtonPosition(button);+ updateButton(); } + function createInfoDiv(container) {+ const div = document.createElement('div');+ div.style.cssText = [+ 'position:absolute', 'top:0', 'left:0', 'background:rgba(0,0,0,.6)',+ 'color:#fff', 'font:12px/1.4 system-ui', 'padding:4px 6px',+ 'border-radius:4px', 'z-index:9999', 'pointer-events:none',+ 'width:max-content', 'transform:translateZ(0)'+ ].join(';');+ container.append(div);+ return div;+ } - function hiddenGift() {-- //data-e2e="gift-container"- //隱藏禮物- const GiftContent = document.querySelector('[data-e2e="gift-container"]');+ function scheduleOverlayUpdate() {+ if (overlayFrame) return;+ overlayFrame = requestAnimationFrame(() => {+ overlayFrame = 0;+ updateOverlay();+ });+ } - if (GiftContent) {- GiftContent.style.display = "none"- console.log("隱藏禮物🎁")+ function updateOverlay() {+ const video = lastVideo;+ const container = lastContainer;+ if (!video || !container || !container.isConnected || !video.isConnected) return;+ if (!infoDiv || !infoDiv.isConnected) infoDiv = createInfoDiv(container);+ const rect = video.getBoundingClientRect();+ const containerRect = container.getBoundingClientRect();+ const text = `Render: ${video.clientWidth}×${video.clientHeight} | Original: ${video.videoWidth}×${video.videoHeight}`;+ if (text !== lastText) {+ lastText = text;+ infoDiv.textContent = text; }-+ const top = Math.round(rect.top - containerRect.top + 42);+ const left = Math.round(rect.right - containerRect.left - infoDiv.offsetWidth - 8);+ if (top !== lastTop) { lastTop = top; infoDiv.style.top = top + 'px'; }+ if (left !== lastLeft) { lastLeft = left; infoDiv.style.left = left + 'px'; } } - function removeOpenAppBanner() {- const banner = document.querySelector('[data-e2e="open-app-banner"]');- if (banner) {- banner.remove();+ function detachVideo() {+ if (resizeObserver) resizeObserver.disconnect();+ if (lastVideo) {+ lastVideo.removeEventListener('loadedmetadata', scheduleOverlayUpdate);+ lastVideo.removeEventListener('resize', scheduleOverlayUpdate);+ }+ if (overlayContainer) {+ overlayContainer.removeEventListener('scroll', scheduleOverlayUpdate); }+ if (infoDiv) infoDiv.remove();+ overlayContainer = null;+ infoDiv = null;+ lastVideo = null;+ lastContainer = null;+ lastText = '';+ lastTop = null;+ lastLeft = null; } - function loop() {- checkVideo();- requestAnimationFrame(loop);+ function findVideo() {+ const container = document.querySelector('[data-e2e="live-room-content"]');+ const video = container?.querySelector('video');+ if (!video || !container) {+ if (lastVideo || infoDiv) detachVideo();+ return;+ }+ if (video !== lastVideo || container !== lastContainer) {+ detachVideo();+ lastVideo = video;+ lastContainer = container;+ overlayContainer = container;+ if (typeof ResizeObserver === 'function') {+ resizeObserver = new ResizeObserver(scheduleOverlayUpdate);+ resizeObserver.observe(video);+ resizeObserver.observe(container);+ }+ video.addEventListener('loadedmetadata', scheduleOverlayUpdate);+ video.addEventListener('resize', scheduleOverlayUpdate);+ container.addEventListener('scroll', scheduleOverlayUpdate, { passive: true });+ }+ scheduleOverlayUpdate(); } + function runMaintenance() {+ maintenanceTimer = null;+ removeMeta();+ removeOpenAppBanner();+ createButton();+ if (theater.enabled && !isLivePath()) setTheater(false);+ findVideo();+ } - removeOpenAppBanner()+ function scheduleMaintenance(delay = MAINTENANCE_MS) {+ if (maintenanceTimer) return;+ maintenanceTimer = setTimeout(runMaintenance, delay);+ } - // 啟動- requestAnimationFrame(loop)+ new MutationObserver(() => scheduleMaintenance()).observe(document.documentElement, {+ childList: true,+ subtree: true+ }); - setTimeout(() => {- createFloatingButton()- hiddenGift()- removeOpenAppBanner()+ function init() {+ removeMeta();+ removeOpenAppBanner();+ createButton();+ findVideo();+ // 低頻補齊:SPA 換頁或元素被替換時才重新檢查,不使用永久 rAF。+ setInterval(() => {+ if (theater.enabled && !isLivePath()) {+ setTheater(false);+ return;+ }+ findVideo();+ }, 2000);+ window.addEventListener('resize', () => {+ const button = document.getElementById(BUTTON_ID);+ if (button) clampButton(button);+ scheduleOverlayUpdate();+ }, { passive: true }); }- , 1000); + if (document.body) init();+ else document.addEventListener('DOMContentLoaded', init, { once: true }); -})();+})();