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 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 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);