Timerhooker

Fixed, robust start/stop timer/video speed UI with 16x as default speed up.Blocks page visibility detection.

// ==UserScript==
// @name         Timerhooker
// @namespace    https://greasyfork.org/users/1356925
// @version      4.1.2
// @description  Fixed, robust start/stop timer/video speed UI with 16x as default speed up.Blocks page visibility detection.
// @author       Cangshi, Tiger 27, Perplexity, Me
// @match        *://*/*
// @license      MIT
// @grant        none
// @run-at       document-start
// ==/UserScript==

(function() {
  // --- Feature/main parameters ---
  const SPEED_FAST = 16;
  const SPEED_NORMAL = 1;
  const UI_SIZE = 62;        // Diameter of UI button in pixels
  const DRAG_MARGIN = 7;     // Minimum distance from window edge
  const AUTOEDGE = 3000;     // ms to auto half-hide after idle
  const STORAGE_KEY = 'tm_ui_pos_final';

  let speed = SPEED_NORMAL, started = false, hiddenEdge = null, observer = null;
  let uiPos = (() => {
    try {
      return JSON.parse(localStorage.getItem(STORAGE_KEY)) || { top: '22%', left: 14 };
    } catch {
      return { top: '22%', left: 14 };
    }
  })();

  // Clamp position for visible bounds
  function clampUIPos(x, y) {
    x = Math.max(DRAG_MARGIN, Math.min(window.innerWidth - UI_SIZE - DRAG_MARGIN, x));
    y = Math.max(DRAG_MARGIN, Math.min(window.innerHeight - UI_SIZE - DRAG_MARGIN, y));
    return { left: x, top: y };
  }
  function savePos(pos) {
    try { localStorage.setItem(STORAGE_KEY, JSON.stringify(pos)); } catch {}
  }

  // Render main SVG icon for UI
  function iconSVG(type, active) {
    return type === 'play'
      ? `<svg width="38" height="38" viewBox="0 0 38 38"><ellipse cx="19" cy="19" rx="16" ry="16" fill="${active ? 'rgba(30,195,230,.18)' : 'rgba(255,255,255,.15)'}" stroke="${active ? '#16b1ea' : '#8dcfe0'}" stroke-width="1.4"/><polygon points="15,12 27,19 15,26" fill="${active ? '#179aba' : '#2a354a'}" opacity=".94"/></svg>`
      : `<svg width="38" height="38" viewBox="0 0 38 38"><ellipse cx="19" cy="19" rx="16" ry="16" fill="${active ? 'rgba(34,220,198,.20)' : 'rgba(255,255,255,.13)'}" stroke="${active ? '#1dc4c4' : '#98bdd2'}" stroke-width="1.2"/><rect x="13.5" y="13.5" width="11" height="11" fill="${active ? '#09c39a' : '#404040'}" rx="2.5" opacity=".93"/></svg>`;
  }

  // Patch window timers and all video speeds to use custom rate
  function patchTimers(getSpeed) {
    if (window.__tm_timerPatched) return;
    window.__tm_timerPatched = true;
    const sI = window.setInterval, sT = window.setTimeout;
    window.setInterval = (fn, ms, ...a) => sI(fn, ms / getSpeed(), ...a);
    window.setTimeout = (fn, ms, ...a) => sT(fn, ms / getSpeed(), ...a);
  }
  function setAllVideos(rate) {
    try {
      document.querySelectorAll('video').forEach(v => v.playbackRate = rate);
      // Find videos in any shadow roots
      (function f(n, a = []) {
        if (!n) return a;
        if (n.shadowRoot) a.push(...n.shadowRoot.querySelectorAll('video'));
        for (const c of n.children || []) f(c, a);
        return a;
      })(document.body).forEach(v => v.playbackRate = rate);
    } catch {}
  }
  function applySpeed() {
    patchTimers(() => speed);
    setAllVideos(speed);
  }

  // Block page visibility/focus detection for privacy & anti-site tricks
  function blockPageVisibilityDetection() {
    const eventsToBlock = [
      "visibilitychange", "webkitvisibilitychange", "mozvisibilitychange", "blur", "focus", "mouseleave"
    ];
    for (const eventName of eventsToBlock) {
      try {
        document.addEventListener(eventName, stopEvt, true);
        window.addEventListener(eventName, stopEvt, true);
      } catch (e) {}
    }
    // Stop event and propagation
    function stopEvt(e) {
      try {
        e.preventDefault();
        e.stopPropagation();
        e.stopImmediatePropagation();
      } catch (e) {}
    }
    // Override doc property to always report focused/visible
    function overrideDocProp(prop, value) {
      try {
        Object.defineProperty(document, prop, {
          get: () => value,
          set: () => {},
          configurable: false,
          enumerable: true
        });
      } catch (e) {}
    }
    try {
      overrideDocProp('hasFocus', function() { return true; });
      overrideDocProp('visibilityState', 'visible');
      overrideDocProp('hidden', false);
      overrideDocProp('mozHidden', false);
      overrideDocProp('webkitHidden', false);
      overrideDocProp('webkitVisibilityState', 'visible');
      document.onvisibilitychange = null;
    } catch (e) {}
  }

  // Core UI logic and drag/half-hide
  function createUI() {
    if (document.getElementById('tm-ui')) return;
    const ui = document.createElement('div');
    ui.id = 'tm-ui';
    ui.tabIndex = 0;
    ui.setAttribute('aria-label', 'Timer/Video Speed Toggle');
    ui.style.cssText =
      `position:fixed;z-index:2147483647;width:${UI_SIZE}px;height:${UI_SIZE}px;` +
      `border-radius:50%;display:flex;align-items:center;justify-content:center;user-select:none;` +
      `cursor:grab;transition:background .23s,box-shadow .18s,transform .12s,left .28s,top .28s;` +
      `background:rgba(255,255,255,0.12);box-shadow:0 2px 18px rgba(0,0,0,0.12);backdrop-filter:blur(10px);` +
      `webkit-backdrop-filter:blur(10px);border:1.1px solid rgba(98,168,210,0.12);will-change:top,left,transform;`;
    setPos(uiPos);

    function setPos(pos) {
      ui.style.top = typeof pos.top === 'string' ? pos.top : (pos.top + 'px');
      ui.style.left = typeof pos.left === 'string' ? pos.left : (pos.left + 'px');
    }
    function updateIcon() {
      ui.innerHTML = started ? iconSVG('stop', true) : iconSVG('play', false);
    }
    updateIcon();

    // Adaptive theme on OS/browser light/dark change
    function themeUpdate() {
      const dark = window.matchMedia && window.matchMedia('(prefers-color-scheme:dark)').matches;
      ui.style.background = dark ? 'rgba(27,42,58,0.17)' : 'rgba(255,255,255,0.12)';
      ui.style.borderColor = dark ? 'rgba(22,180,240,0.20)' : 'rgba(98,168,210,0.12)';
      ui.style.boxShadow = dark ? '0 4px 22px rgba(19,48,64,0.14)' : '0 2px 18px rgba(40,70,100,0.09)';
    }
    themeUpdate();
    window.matchMedia && window.matchMedia('(prefers-color-scheme:dark)').addEventListener('change', themeUpdate);

    // --- Drag to move UI ---
    let dragging = false, dragStart = null;
    ui.addEventListener('mousedown', e => {
      dragging = true;
      dragStart = { x: e.clientX - ui.offsetLeft, y: e.clientY - ui.offsetTop };
      document.body.style.userSelect = 'none';
      ui.style.cursor = 'grabbing';
      edgeIdle.cancel();
    });
    window.addEventListener('mousemove', e => {
      if (!dragging) return;
      let pos = clampUIPos(e.clientX - dragStart.x, e.clientY - dragStart.y);
      uiPos = pos; setPos(pos);
    });
    window.addEventListener('mouseup', () => {
      if (!dragging) return;
      dragging = false; ui.style.cursor = 'grab'; document.body.style.userSelect = '';
      savePos(uiPos); edgeIdle.reset();
    });
    ui.addEventListener('touchstart', e => {
      if (e.touches.length !== 1) return;
      dragging = true;
      const t = e.touches[0];
      dragStart = { x: t.clientX - ui.offsetLeft, y: t.clientY - ui.offsetTop };
      document.body.style.userSelect = 'none';
      ui.style.cursor = 'grabbing';
      edgeIdle.cancel();
    }, { passive: false });
    window.addEventListener('touchmove', e => {
      if (!dragging || e.touches.length !== 1) return;
      const t = e.touches[0];
      let pos = clampUIPos(t.clientX - dragStart.x, t.clientY - dragStart.y);
      uiPos = pos; setPos(pos); e.preventDefault();
    }, { passive: false });
    window.addEventListener('touchend', () => {
      if (!dragging) return;
      dragging = false; ui.style.cursor = 'grab'; document.body.style.userSelect = '';
      savePos(uiPos); edgeIdle.reset();
    });

    // --- Toggle start/stop on click/tap ---
    function toggle() {
      started = !started;
      speed = started ? SPEED_FAST : SPEED_NORMAL;
      updateIcon(); applySpeed(); pulse(); edgeIdle.reset();
    }
    ui.addEventListener('click', e => { if (!dragging) toggle(); });

    // --- Hide (half-slide) after 3s UI-only idle. Only UI events reset timer. ---
    let hideTO = null;
    const edgeIdle = {
      reset: function () {
        ui.style.transform = 'none';
        clearTimeout(hideTO);
        hideTO = setTimeout(() => {
          let left = typeof uiPos.left === 'number' ? uiPos.left : parseFloat(uiPos.left) || 0,
              side = left < (window.innerWidth - UI_SIZE) / 2 ? 'left' : 'right',
              shift = UI_SIZE * 0.5;
          if (side === 'left') {
            ui.style.left = (-shift) + 'px';
            hiddenEdge = 'left';
          } else {
            ui.style.left = (window.innerWidth - shift) + 'px';
            hiddenEdge = 'right';
          }
        }, AUTOEDGE);
      },
      cancel: function () {
        clearTimeout(hideTO);
        if (hiddenEdge) { setPos(uiPos); ui.style.transform = 'none'; hiddenEdge = null; }
      }
    };
    // Attach only to UI's own events
    ['mouseenter', 'mousedown', 'touchstart', 'mouseup', 'touchend'].forEach(evt =>
      ui.addEventListener(evt, edgeIdle.cancel)
    );
    ['mouseleave'].forEach(evt =>
      ui.addEventListener(evt, edgeIdle.reset)
    );
    window.addEventListener('resize', () => {
      let pos = clampUIPos(parseFloat(ui.style.left) || 0, parseFloat(ui.style.top) || 0);
      uiPos = pos; setPos(pos);
    });

    // Quick animation on toggle
    function pulse() { ui.style.transform = "scale(1.13)"; setTimeout(() => ui.style.transform = "", 120); }

    setPos(uiPos); ui.style.opacity = 1; edgeIdle.reset();
    (document.body || document.documentElement).appendChild(ui);

    // Keep UI alive if DOM changes
    if (observer) observer.disconnect();
    observer = new MutationObserver(() => {
      if (!document.getElementById('tm-ui')) setTimeout(createUI, 40);
    });
    observer.observe(document.documentElement, { childList: true, subtree: true });
  }

  // Main page visibility defense
  function blockPageVisibilityDetection() {
    const eventsToBlock = ["visibilitychange", "webkitvisibilitychange", "mozvisibilitychange", "blur", "focus", "mouseleave"];
    for (const eventName of eventsToBlock) {
      try {
        document.addEventListener(eventName, stopEvt, true);
        window.addEventListener(eventName, stopEvt, true);
      } catch (e) {}
    }
    function stopEvt(e) {
      try {
        e.preventDefault();
        e.stopPropagation();
        e.stopImmediatePropagation();
      } catch (e) {}
    }
    function overrideProp(prop, value) {
      try {
        Object.defineProperty(document, prop, {
          get: () => value,
          set: () => {},
          configurable: false,
          enumerable: true
        });
      } catch (e) {}
    }
    try {
      overrideProp('hasFocus', function() { return true; });
      overrideProp('visibilityState', 'visible');
      overrideProp('hidden', false);
      overrideProp('mozHidden', false);
      overrideProp('webkitHidden', false);
      overrideProp('webkitVisibilityState', 'visible');
      document.onvisibilitychange = null;
    } catch (e) {}
  }

  // Ensure UI is created no matter DOM/body timing/order
  function robustInit() {
    if (window.top !== window.self) return;
    let ready = false;
    function tryInit() {
      if (ready) return;
      if (document.body) {
        ready = true;
        patchTimers(() => speed);
        setAllVideos(speed);
        createUI();
        setTimeout(() => { blockPageVisibilityDetection(); }, 180);
      } else {
        setTimeout(tryInit, 40);
      }
    }
    tryInit();
  }

  if (document.getElementById('tm-ui')) return;
  if (document.readyState !== "complete" && document.readyState !== "interactive")
    document.addEventListener('DOMContentLoaded', robustInit);
  else robustInit();
})();