Twitch Watch Time Tracker

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

Bu betiği kurabilmeniz için Tampermonkey, Greasemonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği yüklemek için Tampermonkey gibi bir uzantı yüklemeniz gerekir.

Bu betiği kurabilmeniz için Tampermonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği kurabilmeniz için Tampermonkey ya da Userscripts gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği indirebilmeniz için ayrıca Tampermonkey gibi bir eklenti kurmanız gerekmektedir.

Bu komut dosyasını yüklemek için bir kullanıcı komut dosyası yöneticisi uzantısı yüklemeniz gerekecek.

(Zaten bir kullanıcı komut dosyası yöneticim var, kurmama izin verin!)

Advertisement:

Bu stili yüklemek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için Stylus gibi bir uzantı kurmanız gerekir.

Bu stili yükleyebilmek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı kurmanız gerekir.

Bu stili yükleyebilmek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

(Zateb bir user-style yöneticim var, yükleyeyim!)

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

})();