Twitch Watch Time Tracker

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

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, Greasemonkey alebo Violentmonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey alebo Userscripts.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie správcu používateľských skriptov.

(Už mám správcu používateľských skriptov, nechajte ma ho nainštalovať!)

Advertisement:

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

(Už mám správcu používateľských štýlov, nechajte ma ho nainštalovať!)

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

})();