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.

As of 12.07.2025. See апошняя версія.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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);