Twitch Watch Time Tracker

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

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Greasemonkey lub Violentmonkey.

You will need to install an extension such as Tampermonkey to install this script.

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Violentmonkey.

Aby zainstalować ten skrypt, wymagana będzie instalacja rozszerzenia Tampermonkey lub Userscripts.

You will need to install an extension such as Tampermonkey to install this script.

Aby zainstalować ten skrypt, musisz zainstalować rozszerzenie menedżera skryptów użytkownika.

(Mam już menedżera skryptów użytkownika, pozwól mi to zainstalować!)

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.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Musisz zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

(Mam już menedżera stylów użytkownika, pozwól mi to zainstalować!)

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

})();