Twitch Watch Time Tracker

Ultra-light Twitch watch time tracker with real-time stats, IndexedDB persistence, automatic backup recovery, pause support, and zero telemetry.

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!)

Advertisement:

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!)

Advertisement:

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

})();