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