Twitch Watch Time Tracker

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

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey, Greasemonkey или Violentmonkey.

Для установки этого скрипта вам необходимо установить расширение, такое как Tampermonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Violentmonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Userscripts.

Чтобы установить этот скрипт, сначала вы должны установить расширение браузера, например Tampermonkey.

Чтобы установить этот скрипт, вы должны установить расширение — менеджер скриптов.

(у меня уже есть менеджер скриптов, дайте мне установить скрипт!)

Advertisement:

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

(у меня уже есть менеджер стилей, дайте мне установить скрипт!)

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

})();