TimerHooker (English, Modern UI)

Toggle 1x/3x timer speed. UI docks at edge, undocks on interaction, draggable, 5S code, fully adaptive to light/dark mode.

当前为 2025-07-12 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         TimerHooker (English, Modern UI)
// @version      4.1.1
// @description  Toggle 1x/3x timer speed. UI docks at edge, undocks on interaction, draggable, 5S code, fully adaptive to light/dark mode.
// @include      *
// @match        http://*/*
// @match        https://*/*
// @require      https://greasyfork.org/scripts/372672-everything-hook/code/Everything-Hook.js?version=881251
// @author       Tiger 27, Perplexity AI
// @run-at       document-start
// @grant        none
// @license      MIT
// @namespace https://greasyfork.org/users/1356925
// ==/UserScript==

(function (global) {
    'use strict';

    /*** 5S: SORT - Group related functions and variables together ***/

    // --- UI Constants ---
    const UI = {
        BTN_SIZE: 64,
        CIRCLE_SIZE: 56,
        ICON_SIZE: 36,
        DOCK_OPACITY: 0.6,
        UNDOCK_OPACITY: 1,
        DOCK_TIMEOUT: 3000, // ms
        DOCK_MARGIN: 10,
        INIT_TOP: 0.2, // 20% from top
    };

    // --- Timer Constants ---
    const SPEED_NORMAL = 1.0;
    const SPEED_FAST = 1 / 3; // 3x faster (intervals are 1/3 original)

    /*** 5S: SET IN ORDER - Clear naming, logical order, modularity ***/

    // --- Timer Context ---
    const timerContext = {
        _intervalIds: {},
        _timeoutIds: {},
        _uniqueId: 1,
        __percentage: SPEED_NORMAL,
        _setInterval: window.setInterval,
        _clearInterval: window.clearInterval,
        _setTimeout: window.setTimeout,
        _clearTimeout: window.clearTimeout,
        _Date: window.Date,
        __lastDatetime: Date.now(),
        __lastMDatetime: Date.now(),
        genUniqueId() { return this._uniqueId++; },
        notifyExec(uniqueId) {
            if (!uniqueId) return;
            Object.values(this._timeoutIds)
                .filter(info => info.uniqueId === uniqueId)
                .forEach(info => {
                    this._clearTimeout.call(window, info.nowId);
                    delete this._timeoutIds[info.originId];
                });
        },
        get _percentage() { return this.__percentage; },
        set _percentage(val) {
            if (val === this.__percentage) return;
            percentageChangeHandler(val, this);
            this.__percentage = val;
        }
    };

    // --- Global Timer API ---
    global.timer = {
        change(percentage) {
            timerContext.__lastMDatetime = timerContext._mDate.now();
            timerContext.__lastDatetime = timerContext._Date.now();
            timerContext._percentage = percentage;
        }
    };

    /*** 5S: SHINE - Keep code clean, readable, and well-commented ***/

    // --- UI Creation ---
    function createStyles() {
        const style = `
        :root {
            --th-bg-light: rgba(245,245,245,0.95);
            --th-bg-dark: rgba(30,30,30,0.95);
            --th-fg-light: #222;
            --th-fg-dark: #fafafa;
            --th-shadow: 0 2px 12px 0 rgba(0,0,0,0.20);
            --th-accent: #4e91ff;
        }
        @media (prefers-color-scheme: dark) {
            :root {
                --th-bg: var(--th-bg-dark);
                --th-fg: var(--th-fg-dark);
            }
        }
        @media (prefers-color-scheme: light) {
            :root {
                --th-bg: var(--th-bg-light);
                --th-fg: var(--th-fg-light);
            }
        }
        .th-move-btn {
            position: fixed;
            left: 0; top: 20%;
            z-index: 100000;
            background: none;
            border: none;
            outline: none;
            box-shadow: none;
            cursor: grab;
            padding: 0;
            margin: 0;
            width: ${UI.BTN_SIZE}px; height: ${UI.BTN_SIZE}px;
            display: flex;
            align-items: center;
            justify-content: center;
            opacity: ${UI.DOCK_OPACITY};
            border-radius: 50%;
            user-select: none;
            transition: left 0.4s cubic-bezier(.4,2,.6,1), right 0.4s cubic-bezier(.4,2,.6,1), opacity 0.2s, transform 0.4s cubic-bezier(.4,2,.6,1);
            transform: translateX(-50%);
        }
        .th-move-btn.undocked {
            opacity: ${UI.UNDOCK_OPACITY} !important;
            transform: translateX(0) !important;
        }
        .th-move-btn:active {
            cursor: grabbing;
            filter: brightness(0.85);
        }
        .th-circle {
            width: ${UI.CIRCLE_SIZE}px; height: ${UI.CIRCLE_SIZE}px;
            border-radius: 50%;
            background: var(--th-bg, #eee);
            box-shadow: var(--th-shadow);
            display: flex;
            align-items: center;
            justify-content: center;
            pointer-events: none;
            position: absolute;
            left: 4px; top: 4px;
            transition: background 0.3s;
        }
        .th-icon {
            width: ${UI.ICON_SIZE}px; height: ${UI.ICON_SIZE}px;
            display: block;
            fill: var(--th-fg, #222);
            pointer-events: none;
            user-select: none;
            position: relative;
            transition: fill 0.3s;
        }
        `;
        const stylenode = document.createElement('style');
        stylenode.type = "text/css";
        stylenode.appendChild(document.createTextNode(style));
        document.head.appendChild(stylenode);
    }

    function getIconSVG(isFast) {
        // Play icon for 1x, lightning for 3x
        return isFast
            ? `<svg class="th-icon" viewBox="0 0 48 48"><polygon points="20,7 42,24 28,24 28,41 6,24 20,24" style="fill:var(--th-accent,#4e91ff)"/><polygon points="20,7 42,24 28,24 28,41 6,24 20,24" style="fill-opacity:0.3;fill:var(--th-fg,#fafafa)"/></svg>`
            : `<svg class="th-icon" viewBox="0 0 48 48"><polygon points="15,10 39,24 15,38"/></svg>`;
    }

    function createUI() {
        createStyles();
        const html = `
            <button class="th-move-btn" id="th_move_btn" type="button">
                <span class="th-circle"></span>
                <span id="th_icon_container"></span>
            </button>
        `;
        const node = document.createElement('div');
        node.innerHTML = html;
        document.body.appendChild(node);

        // --- UI State ---
        const moveBtn = document.getElementById('th_move_btn');
        const iconContainer = document.getElementById('th_icon_container');
        let isFast = false;
        let isDragging = false;
        let dragStartX = 0, dragStartY = 0;
        let origLeft = 0, origTop = 0;
        let dockedSide = 'left'; // or 'right'
        let docked = true;
        let hideTimeout;

        // --- UI Functions ---
        function dockUI() {
            docked = true;
            moveBtn.classList.remove('undocked');
            moveBtn.style.opacity = UI.DOCK_OPACITY;
            moveBtn.style.top = (parseFloat(moveBtn.style.top) || window.innerHeight * UI.INIT_TOP) + 'px';
            if (dockedSide === 'left') {
                moveBtn.style.left = '0px';
                moveBtn.style.right = 'auto';
                moveBtn.style.transform = 'translateX(-50%)';
            } else {
                moveBtn.style.right = '0px';
                moveBtn.style.left = 'auto';
                moveBtn.style.transform = 'translateX(50%)';
            }
        }
        function undockUI() {
            docked = false;
            moveBtn.classList.add('undocked');
            moveBtn.style.opacity = UI.UNDOCK_OPACITY;
            moveBtn.style.transform = 'translateX(0)';
            moveBtn.style.top = (parseFloat(moveBtn.style.top) || window.innerHeight * UI.INIT_TOP) + 'px';
            if (dockedSide === 'left') {
                moveBtn.style.left = '0px';
                moveBtn.style.right = 'auto';
            } else {
                moveBtn.style.right = '0px';
                moveBtn.style.left = 'auto';
            }
        }
        function scheduleDock() {
            clearTimeout(hideTimeout);
            hideTimeout = setTimeout(() => {
                // Find closest edge
                const rect = moveBtn.getBoundingClientRect();
                const centerX = rect.left + rect.width / 2;
                dockedSide = (centerX < window.innerWidth / 2) ? 'left' : 'right';
                dockUI();
            }, UI.DOCK_TIMEOUT);
        }
        function onInteraction() {
            if (docked) undockUI();
            scheduleDock();
        }
        function setSpeed(fast) {
            isFast = fast;
            iconContainer.innerHTML = getIconSVG(isFast);
            global.timer.change(isFast ? SPEED_FAST : SPEED_NORMAL);
            onInteraction();
        }

        // --- UI Event Listeners ---
        moveBtn.addEventListener('mousedown', e => {
            if (e.button !== 0) return;
            isDragging = true;
            dragStartX = e.clientX;
            dragStartY = e.clientY;
            origLeft = parseFloat(moveBtn.style.left) || 0;
            origTop = parseFloat(moveBtn.style.top) || window.innerHeight * UI.INIT_TOP;
            document.body.style.userSelect = "none";
            onInteraction();
        });
        document.addEventListener('mousemove', e => {
            if (!isDragging) return;
            const dx = e.clientX - dragStartX;
            const dy = e.clientY - dragStartY;
            const maxLeft = window.innerWidth - moveBtn.offsetWidth - UI.DOCK_MARGIN;
            const maxTop = window.innerHeight - moveBtn.offsetHeight - UI.DOCK_MARGIN;
            let newLeft = origLeft + dx;
            let newTop = origTop + dy;
            newLeft = Math.min(Math.max(newLeft, UI.DOCK_MARGIN), maxLeft);
            newTop = Math.min(Math.max(newTop, UI.DOCK_MARGIN), maxTop);
            moveBtn.classList.add('undocked');
            moveBtn.style.opacity = UI.UNDOCK_OPACITY;
            moveBtn.style.transform = 'translateX(0)';
            moveBtn.style.left = newLeft + 'px';
            moveBtn.style.right = 'auto';
            moveBtn.style.top = newTop + 'px';
            docked = false;
            scheduleDock();
        });
        document.addEventListener('mouseup', () => {
            if (!isDragging) return;
            isDragging = false;
            document.body.style.userSelect = "";
            scheduleDock();
        });
        moveBtn.addEventListener('touchstart', e => {
            isDragging = true;
            const touch = e.touches[0];
            dragStartX = touch.clientX;
            dragStartY = touch.clientY;
            origLeft = parseFloat(moveBtn.style.left) || 0;
            origTop = parseFloat(moveBtn.style.top) || window.innerHeight * UI.INIT_TOP;
            document.body.style.userSelect = "none";
            onInteraction();
        });
        document.addEventListener('touchmove', e => {
            if (!isDragging) return;
            const touch = e.touches[0];
            const dx = touch.clientX - dragStartX;
            const dy = touch.clientY - dragStartY;
            const maxLeft = window.innerWidth - moveBtn.offsetWidth - UI.DOCK_MARGIN;
            const maxTop = window.innerHeight - moveBtn.offsetHeight - UI.DOCK_MARGIN;
            let newLeft = origLeft + dx;
            let newTop = origTop + dy;
            newLeft = Math.min(Math.max(newLeft, UI.DOCK_MARGIN), maxLeft);
            newTop = Math.min(Math.max(newTop, UI.DOCK_MARGIN), maxTop);
            moveBtn.classList.add('undocked');
            moveBtn.style.opacity = UI.UNDOCK_OPACITY;
            moveBtn.style.transform = 'translateX(0)';
            moveBtn.style.left = newLeft + 'px';
            moveBtn.style.right = 'auto';
            moveBtn.style.top = newTop + 'px';
            docked = false;
            scheduleDock();
        }, { passive: false });
        document.addEventListener('touchend', () => {
            if (!isDragging) return;
            isDragging = false;
            document.body.style.userSelect = "";
            scheduleDock();
        });

        moveBtn.addEventListener('click', () => {
            if (isDragging) return;
            setSpeed(!isFast);
        });

        ['mouseenter', 'touchstart', 'mousedown'].forEach(ev => {
            moveBtn.addEventListener(ev, onInteraction);
        });

        // --- UI Initial State ---
        dockedSide = 'left';
        moveBtn.style.left = '0px';
        moveBtn.style.right = 'auto';
        moveBtn.style.top = window.innerHeight * UI.INIT_TOP + 'px';
        moveBtn.style.transform = 'translateX(-50%)';
        moveBtn.style.opacity = UI.DOCK_OPACITY;
        docked = true;
        scheduleDock();
        setTimeout(() => setSpeed(false), 100);
    }

    /*** 5S: STANDARDIZE - Use clear patterns for hooking and timer management ***/

    function applyHooking(ctx) {
        const eHookContext = global.eHook;
        eHookContext.hookReplace(window, 'setInterval', setInterval => getHookedTimerFunction('interval', setInterval, ctx));
        eHookContext.hookReplace(window, 'setTimeout', setTimeout => getHookedTimerFunction('timeout', setTimeout, ctx));
        eHookContext.hookBefore(window, 'clearInterval', (method, args) => redirectNewestId(args, ctx));
        eHookContext.hookBefore(window, 'clearTimeout', (method, args) => redirectNewestId(args, ctx));
        eHookContext.hookClass(window, 'Date', getHookedDateConstructor(ctx), '_innerDate', ['now']);
        Date.now = () => new Date().getTime();
        ctx._mDate = window.Date;
    }

    function getHookedDateConstructor(ctx) {
        return function (...args) {
            if (args.length === 1) {
                Object.defineProperty(this, '_innerDate', {
                    configurable: false, enumerable: false,
                    value: new ctx._Date(args[0]), writable: false
                });
                return;
            } else if (args.length > 1) {
                let definedValue;
                switch (args.length) {
                    case 2: definedValue = new ctx._Date(args[0], args[1]); break;
                    case 3: definedValue = new ctx._Date(args[0], args[1], args[2]); break;
                    case 4: definedValue = new ctx._Date(args[0], args[1], args[2], args[3]); break;
                    case 5: definedValue = new ctx._Date(args[0], args[1], args[2], args[3], args[4]); break;
                    case 6: definedValue = new ctx._Date(args[0], args[1], args[2], args[3], args[4], args[5]); break;
                    default: definedValue = new ctx._Date(args[0], args[1], args[2], args[3], args[4], args[5], args[6]);
                }
                Object.defineProperty(this, '_innerDate', {
                    configurable: false, enumerable: false,
                    value: definedValue, writable: false
                });
                return;
            }
            const now = ctx._Date.now();
            const passTime = now - ctx.__lastDatetime;
            const hookPassTime = passTime * (1 / ctx._percentage);
            Object.defineProperty(this, '_innerDate', {
                configurable: false, enumerable: false,
                value: new ctx._Date(ctx.__lastMDatetime + hookPassTime), writable: false
            });
        };
    }

    function getHookedTimerFunction(type, timer, ctx) {
        const property = '_' + type + 'Ids';
        return function (...args) {
            const uniqueId = ctx.genUniqueId();
            let callback = args[0];
            if (typeof callback === 'string') {
                callback += `;timer.notifyExec(${uniqueId})`;
                args[0] = callback;
            }
            if (typeof callback === 'function') {
                args[0] = function () {
                    const returnValue = callback.apply(this, arguments);
                    ctx.notifyExec(uniqueId);
                    return returnValue;
                };
            }
            const originMS = args[1];
            args[1] *= ctx._percentage;
            const resultId = timer.apply(window, args);
            ctx[property][resultId] = {
                args,
                originMS,
                originId: resultId,
                nowId: resultId,
                uniqueId,
                oldPercentage: ctx._percentage,
                exceptNextFireTime: ctx._Date.now() + originMS,
            };
            return resultId;
        };
    }

    function redirectNewestId(args, ctx) {
        const id = args[0];
        if (ctx._intervalIds[id]) {
            args[0] = ctx._intervalIds[id].nowId;
            delete ctx._intervalIds[id];
        }
        if (ctx._timeoutIds[id]) {
            args[0] = ctx._timeoutIds[id].nowId;
            delete ctx._timeoutIds[id];
        }
    }

    function percentageChangeHandler(percentage, ctx) {
        Object.values(ctx._intervalIds).forEach(idObj => {
            idObj.args[1] = Math.floor((idObj.originMS || 1) * percentage);
            ctx._clearInterval.call(window, idObj.nowId);
            idObj.nowId = ctx._setInterval.apply(window, idObj.args);
        });
        Object.values(ctx._timeoutIds).forEach(idObj => {
            const now = ctx._Date.now();
            let time = idObj.exceptNextFireTime - now;
            if (time < 0) time = 0;
            const changedTime = Math.floor((percentage / idObj.oldPercentage) * time);
            idObj.args[1] = changedTime;
            idObj.exceptNextFireTime = now + changedTime;
            idObj.oldPercentage = percentage;
            ctx._clearTimeout.call(window, idObj.nowId);
            idObj.nowId = ctx._setTimeout.apply(window, idObj.args);
        });
    }

    /*** 5S: SUSTAIN - Keep code maintainable, modular, and documented ***/

    function main() {
        applyHooking(timerContext);
        if (document.readyState === 'complete' || document.readyState === 'interactive') {
            createUI();
        } else {
            document.addEventListener('DOMContentLoaded', createUI);
        }
    }

    if (global.eHook) main();
})(window);