app · a99f967

Dev index Recent changes / versions Changelog About TODO

fix(userscript): videoTK 事件驅動與可拖動劇院按鈕

a99f967 · 2026-11-08 00:02 · app
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.mdindex 147f37a..2b7496d 100644--- 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.mdindex f7ac675..dd78819 100644--- 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.mdindex aa7b02f..92ceecf 100644--- 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.mjsindex 5f9db94..4b5ca70 100644--- 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.cjsnew file mode 100644index 0000000..203cb19--- /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.jsindex 2d1109e..9728b5b 100644--- 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 }); -})();\ No newline at end of file+})();