Stream Watch Timer Plus v8.1

Enhanced Twitch UI: timers, ad muting, filters, dropdown settings, QoL features with fixed dropdown behavior

// ==UserScript==
// @name         Stream Watch Timer Plus v8.1
// @namespace    http://tampermonkey.net/
// @version      8.1
// @description  Enhanced Twitch UI: timers, ad muting, filters, dropdown settings, QoL features with fixed dropdown behavior
// @author       Void
// @match        https://www.twitch.tv/*
// @grant        none
// @license      All rights reserved
// ==/UserScript==


(function(){
    'use strict';

    let sessionSec = 0, totalSec = 0, timer = null;
    const channel = window.location.pathname.split('/')[1]?.toLowerCase() || 'unknown';
    let dropsEnabled = false;

    let timerEl, resetBtn, settingsBtn, dropdownBtn, dropdownList, dropdownOptions = [];
    let dropdownOpen = false;

    const posMap = { 'TL':'top-left','TM':'top-middle','TR':'top-right', 'BL':'bottom-left','BM':'bottom-middle','BR':'bottom-right' };
    const posStyles = {
        'top-left': {top:'10px', left:'10px'},
        'top-middle': {top:'10px', left:'50%', transform:'translateX(-50%)'},
        'top-right': {top:'10px', right:'10px'},
        'bottom-left': {bottom:'10px', left:'10px'},
        'bottom-middle': {bottom:'10px', left:'50%', transform:'translateX(-50%)'},
        'bottom-right': {bottom:'10px', right:'10px'}
    };

    function key(suf) { return `swt_${channel}_${suf}`; }
    function loadTotal() { return parseInt(localStorage.getItem(key('tot')) || '0', 10); }
    function saveTotal() { localStorage.setItem(key('tot'), totalSec); }
    function loadPos() {
        const pos = localStorage.getItem(key('pos'));
        return (pos && posMap[pos]) ? pos : 'BR';
    }
    function savePos(val) {
        if(val && posMap[val]) localStorage.setItem(key('pos'), val);
    }

    function fmt(ts) {
        const h = Math.floor(ts / 3600), m = Math.floor((ts % 3600) / 60), s = ts % 60;
        return h ? `${h}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}` : `${m}:${String(s).padStart(2,'0')}`;
    }

    function detectDrops() {
        dropsEnabled = Array.from(document.querySelectorAll('[data-a-target="stream-tag"]'))
            .some(el => /drops enabled/i.test(el.textContent));
    }
    function autoClaim() {
        const btn = document.querySelector('[data-a-target="claim-drop-button"]');
        if(btn) btn.click();
    }
    function muteAds(v) {
        const selectors = ['[data-test-selector="ad-banner"]','.player-ad-overlay','[class*="ad-label"]','.sponsored-pill'];
        v.muted = selectors.some(sel => document.querySelector(sel));
    }
    function removeBanners() {
        document.querySelectorAll('[data-test-selector="ad-banner"],.top-nav__prime,.video-ad__container,.player-ad-overlay').forEach(e=>e.remove());
    }
    let lastSidebarFilter = 0;
    function filterSidebar() {
        const now = performance.now();
        if(now - lastSidebarFilter < 1500) return;
        lastSidebarFilter = now;
        document.querySelectorAll('a[data-a-target="side-nav-card"]').forEach(card => {
            const vt = card.querySelector('[data-a-target="side-nav-viewer-count"]')?.textContent?.toLowerCase();
            if(!vt) return;
            let count;
            if(vt.includes('k')) {
                const v = vt.replace(/,/g,'.').match(/[\d.]+/);
                count = v ? parseFloat(v[0]) * 1000 : 0;
            } else {
                const v = vt.match(/\d+/);
                count = v ? parseInt(v[0],10) : 0;
            }
            card.style.display = count > 20 ? 'none' : '';
        });
    }
    function autoTheatre() {
        const btn = document.querySelector('[data-a-target="player-theatre-mode-button"]');
        if(btn && !document.body.classList.contains('theatre-mode')) btn.click();
    }
    function showFollowerAge() {
        const fBtnParent = document.querySelector('[data-a-target="follow-button"]')?.parentElement;
        if(!fBtnParent || fBtnParent.querySelector('.fa-age')) return;
        fetch(`https://api.ivr.fi/v2/twitch/user/followage/${channel}`)
            .then(res => res.json())
            .then(data => {
                if(data && data.followageHuman){
                    const span = document.createElement('span');
                    span.className = 'fa-age';
                    span.textContent = ` | Following for: ${data.followageHuman}`;
                    span.style.fontSize = '12px';
                    span.style.color = '#ccc';
                    fBtnParent.appendChild(span);
                }
            }).catch(() => {});
    }

    function updateTimerText() {
        if(!timerEl) return;
        let txt = `⏱ Total: ${fmt(totalSec)} | Session: ${fmt(sessionSec)}`;
        if(dropsEnabled) {
            const remain = Math.max(0, 900 - sessionSec);
            txt += ` | 🎁 Drop in ${Math.ceil(remain / 60)}m`;
        }
        const span = timerEl.querySelector('.swt-text');
        if(span) span.textContent = txt;
    }

    function applyPosition(code) {
        if(!timerEl) return;
        const style = posStyles[posMap[code]];
        timerEl.style.top = timerEl.style.bottom = timerEl.style.left = timerEl.style.right = timerEl.style.transform = '';
        Object.assign(timerEl.style, style);
    }

    function toggleDropdown(forceOpen) {
        dropdownOpen = forceOpen !== undefined ? forceOpen : !dropdownOpen;
        dropdownList.style.display = dropdownOpen ? 'block' : 'none';
        dropdownBtn.setAttribute('aria-expanded', dropdownOpen);
        if(dropdownOpen){
            // Highlight selected option
            const currentVal = loadPos();
            dropdownOptions.forEach(opt => opt.classList.toggle('selected', opt.dataset.value === currentVal));
            dropdownList.focus();
        }
    }

    function dropdownKeyHandler(e) {
        if(!dropdownOpen) return;
        const selected = dropdownList.querySelector('.selected');
        if(e.key === 'ArrowDown' || e.key === 'Tab'){
            e.preventDefault();
            let next = selected?.nextElementSibling || dropdownList.firstElementChild;
            if(selected) selected.classList.remove('selected');
            next.classList.add('selected');
            next.focus();
        } else if(e.key === 'ArrowUp'){
            e.preventDefault();
            let prev = selected?.previousElementSibling || dropdownList.lastElementChild;
            if(selected) selected.classList.remove('selected');
            prev.classList.add('selected');
            prev.focus();
        } else if(e.key === 'Enter'){
            e.preventDefault();
            if(selected){
                const val = selected.dataset.value;
                savePos(val);
                applyPosition(val);
                dropdownBtn.textContent = val;
                toggleDropdown(false);
                dropdownBtn.focus();
            }
        } else if(e.key === 'Escape'){
            e.preventDefault();
            toggleDropdown(false);
            dropdownBtn.focus();
        }
    }

    function createDropdown() {
        const container = document.createElement('div');
        container.style.position = 'relative';
        container.style.display = 'inline-block';
        container.style.marginLeft = '6px';

        dropdownBtn = document.createElement('button');
        dropdownBtn.type = 'button';
        dropdownBtn.textContent = loadPos();
        dropdownBtn.setAttribute('aria-haspopup', 'listbox');
        dropdownBtn.setAttribute('aria-expanded', 'false');
        Object.assign(dropdownBtn.style, {
            background: '#222',
            color: '#eee',
            border: '1px solid #555',
            borderRadius: '4px',
            padding: '3px 8px',
            cursor: 'pointer',
            fontSize: '12px',
            minWidth: '50px',
            userSelect: 'none'
        });
        dropdownBtn.addEventListener('click', e => {
            e.stopPropagation();
            toggleDropdown();
        });
        dropdownBtn.addEventListener('keydown', e => {
            if(['ArrowDown','ArrowUp','Enter',' '].includes(e.key)){
                e.preventDefault();
                toggleDropdown(true);
                dropdownList.focus();
            }
        });

        dropdownList = document.createElement('ul');
        dropdownList.tabIndex = -1;
        dropdownList.setAttribute('role', 'listbox');
        Object.assign(dropdownList.style, {
            position: 'absolute',
            top: '100%',
            left: '0',
            background: '#222',
            border: '1px solid #555',
            borderRadius: '4px',
            marginTop: '2px',
            padding: '2px 0',
            listStyle: 'none',
            width: '60px',
            maxHeight: '160px',
            overflowY: 'auto',
            boxShadow: '0 2px 6px rgba(0,0,0,0.5)',
            display: 'none',
            zIndex: '10000',
            userSelect: 'none'
        });

        dropdownList.addEventListener('keydown', dropdownKeyHandler);
        dropdownList.addEventListener('click', e => {
            if(e.target.tagName === 'LI'){
                const val = e.target.dataset.value;
                if(val){
                    savePos(val);
                    applyPosition(val);
                    dropdownBtn.textContent = val;
                    toggleDropdown(false);
                    dropdownBtn.focus();
                }
            }
        });

        // Prevent dropdown from closing on clicks inside
        dropdownList.addEventListener('mousedown', e => e.preventDefault());

        dropdownOptions = [];
        Object.entries(posMap).forEach(([code]) => {
            const li = document.createElement('li');
            li.textContent = code;
            li.dataset.value = code;
            li.setAttribute('role', 'option');
            li.tabIndex = -1;
            Object.assign(li.style, {
                padding: '4px 10px',
                cursor: 'pointer',
                color: '#eee',
                fontSize: '12px',
                userSelect: 'none'
            });
            li.addEventListener('mouseenter', () => {
                dropdownOptions.forEach(o => o.classList.remove('selected'));
                li.classList.add('selected');
                li.focus();
            });
            li.addEventListener('mouseleave', () => {
                li.classList.remove('selected');
            });
            dropdownList.appendChild(li);
            dropdownOptions.push(li);
        });

        container.appendChild(dropdownBtn);
        container.appendChild(dropdownList);

        return container;
    }

    // Close dropdown if clicking outside
    document.addEventListener('click', () => {
        if(dropdownOpen) toggleDropdown(false);
    });

    function createOverlay() {
        timerEl = document.createElement('div');
        timerEl.id = 'watch-time-overlay';
        Object.assign(timerEl.style, {
            position:'absolute', background:'rgba(0,0,0,0.85)', color:'#fff',
            padding:'8px', borderRadius:'8px', fontSize:'14px', fontFamily:'Segoe UI',
            fontWeight:'600', zIndex:'9999', pointerEvents:'auto', display:'flex',
            alignItems:'center', gap:'8px', whiteSpace:'nowrap', textShadow:'0 0 4px #000',
            userSelect: 'none',
            minWidth: '220px',
            boxShadow: '0 0 8px rgba(0,0,0,0.7)'
        });

        const span = document.createElement('span');
        span.className = 'swt-text';
        span.style.flex = '1';
        timerEl.appendChild(span);

        resetBtn = document.createElement('button');
        resetBtn.textContent = 'Reset';
        Object.assign(resetBtn.style,{
            cursor:'pointer',
            marginLeft:'4px',
            fontSize:'12px',
            background:'#333',
            color:'#eee',
            border:'1px solid #555',
            borderRadius:'4px',
            padding:'2px 8px',
            userSelect:'none'
        });
        resetBtn.onmouseenter = () => resetBtn.style.background = '#444';
        resetBtn.onmouseleave = () => resetBtn.style.background = '#333';
        resetBtn.onclick = () => {
            if(confirm('Reset total and session time?')){
                totalSec = sessionSec = 0;
                saveTotal();
                updateTimerText();
            }
        };
        timerEl.appendChild(resetBtn);

        settingsBtn = document.createElement('button');
        settingsBtn.textContent = '⚙️';
        Object.assign(settingsBtn.style,{
            cursor:'pointer',
            fontSize:'16px',
            marginLeft:'4px',
            background:'transparent',
            border:'none',
            color:'#eee',
            userSelect:'none'
        });
        settingsBtn.onmouseenter = () => settingsBtn.style.color = '#ddd';
        settingsBtn.onmouseleave = () => settingsBtn.style.color = '#eee';
        settingsBtn.onclick = e => {
            e.stopPropagation();
            toggleDropdown();
        };
        timerEl.appendChild(settingsBtn);

        const dropdownContainer = createDropdown();
        dropdownContainer.style.marginLeft = '8px';
        timerEl.appendChild(dropdownContainer);

        applyPosition(loadPos());
        return timerEl;
    }

    function startUI(video, container){
        if(timer) return;
        totalSec = loadTotal();
        sessionSec = 0;

        detectDrops();
        container.appendChild(createOverlay());
        updateTimerText();
        autoTheatre();
        showFollowerAge();

        timer = setInterval(() => {
            if(video.readyState >= 2 && !video.paused && !video.ended){
                totalSec++;
                sessionSec++;
                updateTimerText();
                if(totalSec % 5 === 0) saveTotal();
            }
            autoClaim();
            muteAds(video);
            removeBanners();
        }, 1000);

        setInterval(filterSidebar, 1500);
    }

    function initWatch(){
        new MutationObserver(() => {
            const v = document.querySelector('video');
            const c = document.querySelector('.video-player__container, .video-player__overlay');
            const o = document.getElementById('watch-time-overlay');
            if(v && c && !o){
                startUI(v,c);
            }
        }).observe(document.body, {childList:true, subtree:true});
    }

    window.addEventListener('load', () => setTimeout(initWatch, 1000));
})();