Ultra-light Twitch watch time tracker with real-time stats, IndexedDB persistence, automatic backup recovery, pause support, and zero telemetry.
// ==UserScript==
// @name Twitch Watch Time Tracker
// @namespace twitch.watchtime
// @version 8.0-UltraLean-Safe
// @description Ultra-light Twitch watch time tracker with real-time stats, IndexedDB persistence, automatic backup recovery, pause support, and zero telemetry.
// @author Michael Stutesman
// @license MIT
// @match *://www.twitch.tv/*
// @run-at document-idle
// @grant none
// ==/UserScript==
(function(){
'use strict';
/* ---------------- SINGLETON GUARD ---------------- */
const KEY = "__twitch_watchtime_tracker__";
if (window[KEY]) return;
window[KEY] = true;
/* ---------------- PATH FILTER ---------------- */
const path = window.location.pathname.split('/').filter(Boolean);
if (!path[0] || ['directory','drops','videos','popout','settings','collections','clips','search','u','downloads','jobs','turbo','wallet','subscriptions','inventory','friends','michaelstutesman'].includes(path[0].toLowerCase())) {
return console.log('Quantum Hop inactive');
}
// ---- Constants ----
const SAVE_INTERVAL = 60000;
const DB_NAME = 'twitchWatchTimeDB';
const STORE_NAME = 'watchTimeStore';
const PAUSE_KEY = 'twitchWatchTimePaused';
const BACKUP_KEY = 'twitchWatchTimeBackup';
const EMOJI_TODAY=' <span style="font-size:0.1em;">🔴</span> ';
const EMOJI_WEEK=' <span style="font-size:0.1em;">🟢</span> ';
const EMOJI_MONTH=' <span style="font-size:0.1em;">🔵</span> ';
// ---- IndexedDB ----
let db=null;
const getDB=()=>db?Promise.resolve(db):new Promise((r,rej)=>{
const req=indexedDB.open(DB_NAME,1);
req.onupgradeneeded=e=>e.target.result.createObjectStore(STORE_NAME);
req.onsuccess=e=>{db=e.target.result;r(db)};
req.onerror=e=>rej(e.target.error);
});
const getDBItem=k=>getDB().then(d=>new Promise(r=>d.transaction(STORE_NAME,'readonly').objectStore(STORE_NAME).get(k).onsuccess=e=>r(e.target.result||null)));
const setDBItem=(k,v)=>getDB().then(d=>d.transaction(STORE_NAME,'readwrite').objectStore(STORE_NAME).put(v,k));
// ---- State ----
const state={watchTime:{},currentChannel:null,channelEnter:0,display:null,pauseBtn:null,container:null,_lastSave:0};
// ---- Helpers ----
const now=()=>Date.now();
const isPaused=()=>localStorage.getItem(PAUSE_KEY)==='1';
const setPaused=v=>localStorage.setItem(PAUSE_KEY,v?'1':'0');
const channelFromURL=url=>new URL(url,location.origin).pathname.split('/').filter(Boolean)[0]||null;
let chatEl = null;
const getChatEl = () => {
if (chatEl && document.contains(chatEl)) return chatEl;
chatEl =
document.querySelector('[data-test-selector="chat-shell"]') ||
document.querySelector('.chat-shell') ||
document.querySelector('#chat');
return chatEl;
};
// date keys
const pad2=n=>n<10?'0'+n:n;
const dayKey=t=>{const d=new Date(t);return d.getFullYear()+'-'+pad2(d.getMonth()+1)+'-'+pad2(d.getDate());};
const weekKey=t=>{const d=new Date(t);d.setHours(0,0,0,0);d.setDate(d.getDate()-d.getDay());return d.getFullYear()+'-'+pad2(d.getMonth()+1)+'-'+pad2(d.getDate());};
const monthKey=t=>{const d=new Date(t);return d.getFullYear()+'-'+pad2(d.getMonth()+1);};
// ---- Format ----
const fmt=(ms,mode)=>{
let m=ms/60000|0,h=m/60|0,d=h/24|0,y=d/365|0;
if(mode==='YDH')return y?y+'y '+(d%365)+'d':d?d+'d '+(h%24)+'h':h?h+'h '+(m%60)+'m':m+'m';
return d?d+'d '+(h%24)+'h':h?h+'h '+(m%60)+'m':m+'m';
};
// ---- CHAT DETECTION (NEW) ----
const isChatOpen=()=>{
const el =
document.querySelector('[data-test-selector="chat-shell"]') ||
document.querySelector('.chat-shell') ||
document.querySelector('#chat');
if(!el) return false;
const r = el.getBoundingClientRect();
return r.width > 80 && r.height > 200;
};
// ---- POSITION UPDATE (NEW) ----
const updatePosition=()=>{
if(!state.container) return;
const open = isChatOpen();
const targetLeft = open ? '42%' : '50%';
if(state.container.style.left !== targetLeft){
state.container.style.left = targetLeft;
}
};
// ---- Display ----
const createDisplay=()=>{
if(state.container)return;
const c=document.createElement('div'),b=document.createElement('span'),t=document.createElement('span');
Object.assign(c.style,{
position:'fixed',
bottom:'112px',
left:'42%',
transform:'translateX(-50%)',
display:'flex',
gap:'8px',
alignItems:'center',
color:'#eee',
fontSize:'21px',
fontWeight:'900',
fontFamily:'"Comic Sans MS",impact,sans-serif',
zIndex:'1000000',
pointerEvents:'auto',
opacity:0,
transition:'opacity .4s',
textShadow:'-3px -3px 0 #000,3px -3px 0 #000,-3px 3px 0 #000,3px 3px 0 #000'
});
b.style.cursor='pointer';
b.textContent=isPaused()?'⏸':'▶';
b.onclick=()=>{
flush();
setPaused(!isPaused());
state.channelEnter=now();
b.textContent=isPaused()?'⏸':'▶';
};
c.append(b,t);
document.body.appendChild(c);
state.display=t;
state.pauseBtn=b;
state.container=c;
c.style.opacity='1';
};
// ---- Flush ----
const flush=force=>{
const t=now(),delta=isPaused()?0:t-state.channelEnter;
state.channelEnter=t;
if(!state.currentChannel)return;
const ch=state.currentChannel;
let w=state.watchTime[ch];
if(!w)state.watchTime[ch]=w={
daily:{},weekly:{},monthly:{},total:0,
lastUpdated:t,lastDay:'',lastWeek:'',lastMonth:'',displayText:''
};
const dK=dayKey(t),wK=weekKey(t),mK=monthKey(t);
w.total+=delta;
w.daily[dK]=(w.daily[dK]||0)+delta;
w.weekly[wK]=(w.weekly[wK]||0)+delta;
w.monthly[mK]=(w.monthly[mK]||0)+delta;
w.lastUpdated=t;
w.lastDay=dK;w.lastWeek=wK;w.lastMonth=mK;
const newDisplay =
'⏱ Today: ' + fmt(w.daily[dK]) + EMOJI_TODAY +
'This Week: ' + fmt(w.weekly[wK]) + EMOJI_WEEK +
'This Month: ' + fmt(w.monthly[mK]) + EMOJI_MONTH +
'Total: ' + fmt(w.total,'YDH');
if(state.display && state.display.innerHTML !== newDisplay)
state.display.innerHTML = newDisplay;
// 🔒 SAFE SAVE
if(force || t - state._lastSave > SAVE_INTERVAL){
if(Object.keys(state.watchTime).length === 0){
console.warn('Prevented saving empty watchTime (DB protection)');
return;
}
setDBItem('data',state.watchTime);
try {
localStorage.setItem(BACKUP_KEY, JSON.stringify(state.watchTime));
} catch(e){
console.warn('Backup save failed', e);
}
state._lastSave = t;
}
};
// ---- Channel Change ----
const onChannelChange=ch=>{
ch=ch&&ch.toLowerCase();
if(!ch||state.currentChannel===ch)return;
state.currentChannel=ch;
state.channelEnter=now();
flush(true);
};
// ---- SPA hooks ----
history.pushState=((f)=>(...a)=>{f.apply(history,a);onChannelChange(channelFromURL(a[2]));})(history.pushState);
history.replaceState=((f)=>(...a)=>{f.apply(history,a);onChannelChange(channelFromURL(a[2]));})(history.replaceState);
window.addEventListener('popstate',()=>onChannelChange(channelFromURL(location.href)));
window.addEventListener('beforeunload',()=>flush(true));
// ---- Init ----
getDBItem('data').then(d=>{
const backup = localStorage.getItem(BACKUP_KEY);
if(d && typeof d === 'object' && Object.keys(d).length > 0){
state.watchTime = d;
} else if(backup){
try{
state.watchTime = JSON.parse(backup);
console.warn('Recovered watchTime from backup');
}catch(e){
state.watchTime = {};
}
} else {
state.watchTime = {};
}
createDisplay();
onChannelChange(channelFromURL(location.href));
const tick=()=>{
updatePosition(); // ✅ NEW
flush();
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
});
})();