Twitch Stream Info Overlay v2

Display stream uptime, viewer count, quality, and delay in fullscreen/theater mode with customizable settings.

K instalaci tototo skriptu si budete muset nainstalovat rozšíření jako Tampermonkey, Greasemonkey nebo Violentmonkey.

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

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Violentmonkey.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Userscripts.

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

K instalaci tohoto skriptu si budete muset nainstalovat manažer uživatelských skriptů.

(Už mám manažer uživatelských skriptů, nechte mě ho nainstalovat!)

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.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Už mám manažer uživatelských stylů, nechte mě ho nainstalovat!)

// ==UserScript==
// @name         Twitch Stream Info Overlay v2
// @namespace    http://tampermonkey.net/
// @version      2.0
// @description  Display stream uptime, viewer count, quality, and delay in fullscreen/theater mode with customizable settings.
// @author       snook89
// @match        https://www.twitch.tv/*
// @grant        none
// @license MIT
// ==/UserScript==

(function () {
    'use strict';

    // --- UTILS & CONSTANTS ---
    const STORAGE_KEY = 'twitch-overlay-settings-v2';
    const DEFAULT_SETTINGS = {
        position: 'top-right', // top-left, top-right, bottom-left, bottom-right
        showUptime: true,
        showViewers: true,
        showQuality: false,
        showDelay: false,
        showDelay: false,
        opacity: 0.8,
        offsetX: 0,
        offsetY: 0
    };

    const POSITIONS = {
        'top-left': { top: '20px', left: '20px', bottom: 'auto', right: 'auto' },
        'top-right': { top: '20px', right: '20px', bottom: 'auto', left: 'auto' },
        'bottom-left': { bottom: '80px', left: '20px', top: 'auto', right: 'auto' }, // Adjusted for player controls space
        'bottom-right': { bottom: '80px', right: '20px', top: 'auto', left: 'auto' }
    };

    // --- SETTINGS MANAGER ---
    class SettingsManager {
        constructor() {
            this.settings = this.load();
        }

        load() {
            try {
                const stored = localStorage.getItem(STORAGE_KEY);
                return stored ? { ...DEFAULT_SETTINGS, ...JSON.parse(stored) } : { ...DEFAULT_SETTINGS };
            } catch (e) {
                console.error('Failed to load settings', e);
                return { ...DEFAULT_SETTINGS };
            }
        }

        save(newSettings) {
            this.settings = { ...this.settings, ...newSettings };
            localStorage.setItem(STORAGE_KEY, JSON.stringify(this.settings));
            window.dispatchEvent(new CustomEvent('twitch-overlay-settings-changed', { detail: this.settings }));
        }

        get() {
            return this.settings;
        }
    }

    const settingsManager = new SettingsManager();

    // --- UI MANAGER ---
    class OverlayUI {
        constructor() {
            this.element = null;
            this.checkInterval = null;
            this.videoElement = null;
            this.container = null;
            this.streamStartTime = null;
            this.init();
        }

        init() {
            this.element = document.createElement('div');
            this.element.id = 'twitch-stream-info-overlay';
            // Initial dummy style, will be updated by updateStyle
            this.element.style.display = 'none';

            // Listen for settings changes
            window.addEventListener('twitch-overlay-settings-changed', () => this.updateStyle());

            // Start loop
            this.checkInterval = setInterval(() => this.update(), 1000);
        }

        mount(container) {
            if (this.container !== container) {
                this.container = container;
                // Move element to new container
                container.appendChild(this.element);
                this.updateStyle();
            }
        }

        updateStyle() {
            const settings = settingsManager.get();
            const pos = POSITIONS[settings.position] || POSITIONS['top-right'];

            this.element.style.cssText = `
                position: absolute; /* Absolute relative to video player container */
                background: rgba(0, 0, 0, ${settings.opacity});
                color: #efeff1;
                padding: 6px 10px;
                border-radius: 4px;
                font-family: 'Inter', 'Roobert', 'Helvetica Neue', Arial, sans-serif;
                font-size: 13px;
                z-index: 100; /* Usually enough to sit above video but below controls */
                backdrop-filter: blur(4px);
                border: 1px solid rgba(255, 255, 255, 0.1);
                pointer-events: none;
                user-select: none;
                gap: 10px;
                align-items: center;
                white-space: nowrap;
                display: flex;
                top: ${pos.top !== 'auto' ? `calc(${pos.top} + ${settings.offsetY}px)` : 'auto'};
                bottom: ${pos.bottom !== 'auto' ? `calc(${pos.bottom} + ${settings.offsetY}px)` : 'auto'};
                left: ${pos.left !== 'auto' ? `calc(${pos.left} + ${settings.offsetX}px)` : 'auto'};
                right: ${pos.right !== 'auto' ? `calc(${pos.right} + ${settings.offsetX}px)` : 'auto'};
            `;

            if (!this.shouldShow()) {
                this.element.style.display = 'none';
            }
        }

        getVideoElement() {
            if (!this.videoElement || !document.contains(this.videoElement)) {
                this.videoElement = document.querySelector('video');
            }
            return this.videoElement;
        }

        // --- DATA FETCHING ---
        getReactInstance(element) {
            for (const key in element) {
                if (key.startsWith('__reactInternalInstance$') || key.startsWith('__reactFiber$')) {
                    return element[key];
                }
            }
            return null;
        }

        searchReactProps(fiber) {
            // Traverse up to find props with useful data
            let curr = fiber;
            while (curr) {
                if (curr.memoizedProps && curr.memoizedProps.viewerCount) {
                    return curr.memoizedProps;
                }
                curr = curr.return;
            }
            return null;
        }

        getStreamStartTime() {
            // 1. GQL Strategy (Most Reliable)
            const channelName = window.location.pathname.split('/').pop();

            // Only fetch if we haven't successfully fetched yet and we have a channel name
            if (!this.streamStartTime && channelName && !this._gqlFetching) {
                this._gqlFetching = true;

                const query = `
                    query StreamUptime($login: String!) {
                        user(login: $login) {
                            stream {
                                createdAt
                            }
                        }
                    }
                `;

                fetch('https://gql.twitch.tv/gql', {
                    method: 'POST',
                    headers: {
                        'Client-ID': 'kimne78kx3ncx6brgo4mv6wki5h1ko', // Public key commonly used by Twitch site
                        'Content-Type': 'application/json'
                    },
                    body: JSON.stringify({
                        query: query,
                        variables: { login: channelName }
                    })
                })
                    .then(r => r.json())
                    .then(data => {
                        if (data.data?.user?.stream?.createdAt) {
                            this.streamStartTime = new Date(data.data.user.stream.createdAt);
                        }
                    })
                    .catch(e => console.error("GQL Uptime Fetch Failed", e))
                    .finally(() => { this._gqlFetching = false; });
            }

            return this.streamStartTime;
        }

        getUptime() {
            // 1. Date Calculation Strategy
            if (!this.streamStartTime) {
                this.streamStartTime = this.getStreamStartTime();
            }

            if (this.streamStartTime) {
                const now = new Date();
                const diff = now - this.streamStartTime;
                if (diff > 0) {
                    const hours = Math.floor(diff / 3600000);
                    const minutes = Math.floor((diff % 3600000) / 60000);
                    const seconds = Math.floor((diff % 60000) / 1000);
                    return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
                }
            }

            return '--:--:--';
        }

        getViewers() {
            // 1. Sidebar/Metadata Strategy
            const el = document.querySelector('[data-a-target="animated-channel-viewers-count"]');
            if (el) return el.textContent.trim() + ' viewers';

            // 2. DOM Search for "viewers" text
            const viewerTexts = Array.from(document.querySelectorAll('p, span, div'));
            const target = viewerTexts.find(t =>
                t.textContent &&
                /^\d{1,3}(,\d{3})*(\.\d+)?([KkMm])? viewers?$/i.test(t.textContent.trim()) &&
                t.offsetParent !== null // Visible
            );
            if (target) return target.textContent.trim();

            return '--- viewers';
        }

        getQuality() {
            const video = this.getVideoElement();
            if (!video) return 'Unknown';
            return `${video.videoHeight}p`;
        }

        getDelay() {
            const video = this.getVideoElement();
            if (!video || !video.buffered.length) return '0s';
            const bufferEdge = video.buffered.end(video.buffered.length - 1);
            const delay = Math.max(0, bufferEdge - video.currentTime);
            return `${delay.toFixed(1)}s (buff)`;
        }

        // --- UPDATE LOOP ---
        update() {
            // 1. Find correct container to mount to (Reparenting)
            const newContainer = document.querySelector('.video-player__overlay') ||
                document.querySelector('.video-player__container') ||
                document.querySelector('.highwind-video-player__overlay');

            if (newContainer && this.container !== newContainer) {
                this.mount(newContainer);
            }

            // Force display check more aggressively
            if (!this.shouldShow()) {
                this.element.style.display = 'none';
                return;
            }

            this.element.style.display = 'flex';
            const settings = settingsManager.get();

            let html = '';

            if (settings.showUptime) {
                html += `<div style="display: flex; align-items: center; gap: 4px;">
                            <span style="color: #bf94ff;">⏱️</span>
                            <span style="font-weight: 600; font-variant-numeric: tabular-nums;">${this.getUptime()}</span>
                         </div>`;
            }

            if (settings.showViewers) {
                if (html) html += `<div style="width: 1px; height: 12px; background: rgba(255,255,255,0.2); margin: 0 4px;"></div>`;
                html += `<div style="display: flex; align-items: center; gap: 4px;">
                            <span style="color: #bf94ff;">👁️</span>
                            <span style="font-weight: 600;">${this.getViewers()}</span>
                         </div>`;
            }

            if (settings.showQuality) {
                if (html) html += `<div style="width: 1px; height: 12px; background: rgba(255,255,255,0.2); margin: 0 4px;"></div>`;
                html += `<div style="display: flex; align-items: center; gap: 4px;">
                            <span style="color: #bf94ff;">📺</span>
                            <span style="font-weight: 600;">${this.getQuality()}</span>
                         </div>`;
            }

            if (settings.showDelay) {
                if (html) html += `<div style="width: 1px; height: 12px; background: rgba(255,255,255,0.2); margin: 0 4px;"></div>`;
                html += `<div style="display: flex; align-items: center; gap: 4px;">
                            <span style="color: #bf94ff;">📡</span>
                            <span style="font-weight: 600;">${this.getDelay()}</span>
                         </div>`;
            }

            this.element.innerHTML = html;
        }

        shouldShow() {
            // Check theater or fullscreen or if we are just mounted in player
            const isFullscreen = !!document.fullscreenElement;
            const isTheater = document.body.classList.contains('theatre-mode') ||
                !!document.querySelector('.video-player__container--theatre') ||
                // Fallback: check if 'Exit Theatre Mode' button exists
                !!document.querySelector('button[aria-label="Exit Theatre Mode (alt+t)"]');

            const videoExists = !!this.getVideoElement();

            return (isFullscreen || isTheater) && videoExists;
        }
    }

    // --- SETTINGS UI ---
    class SettingsUI {
        constructor() {
            this.modalId = 'twitch-overlay-settings-modal';
            this.initObserver();
        }

        initObserver() {
            // Observe chat header to inject button
            const observer = new MutationObserver(() => this.tryInjectButton());
            observer.observe(document.body, { childList: true, subtree: true });
            // Initial check
            setTimeout(() => this.tryInjectButton(), 2000);
        }

        tryInjectButton() {
            // Find "Chat Settings" button (The gear icon)
            const chatSettingsBtn = document.querySelector('[data-a-target="chat-settings"]');
            if (chatSettingsBtn && !document.getElementById('twitch-overlay-settings-btn')) {
                const btn = document.createElement('button');
                btn.id = 'twitch-overlay-settings-btn';
                btn.innerHTML = `
                    <div style="display: flex; align-items: center; padding: 0 4px;">
                       <span style="font-size: 14px;">🛠️</span>
                    </div>
                `;
                // Mimic twitch button styles roughly
                btn.className = chatSettingsBtn.className;
                // Remove some classes if they cause layout issues, but typically keeping them matches theme
                btn.style.marginLeft = '4px';
                btn.style.cursor = 'pointer';
                btn.title = "Overlay Settings";

                btn.onclick = (e) => {
                    e.preventDefault();
                    e.stopPropagation();
                    this.toggleModal();
                };

                chatSettingsBtn.parentNode.insertBefore(btn, chatSettingsBtn);
            }
        }

        toggleModal() {
            let modal = document.getElementById(this.modalId);
            if (modal) {
                modal.remove();
                return;
            }
            this.createModal();
        }

        createModal() {
            const settings = settingsManager.get();

            const modal = document.createElement('div');
            modal.id = this.modalId;
            modal.style.cssText = `
                position: fixed;
                top: 50%;
                left: 50%;
                transform: translate(-50%, -50%);
                background: #18181b;
                border: 1px solid #2f2f35;
                border-radius: 8px;
                padding: 20px;
                z-index: 10001;
                width: 300px;
                color: #efeff1;
                font-family: 'Inter', sans-serif;
                box-shadow: 0 10px 20px rgba(0,0,0,0.5);
            `;

            const createToggle = (label, key) => `
                <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
                    <label>${label}</label>
                    <input type="checkbox" id="setting-${key}" ${settings[key] ? 'checked' : ''} style="cursor: pointer;">
                </div>
            `;

            modal.innerHTML = `
                <h3 style="margin: 0 0 16px 0; font-size: 18px; border-bottom: 1px solid #333; padding-bottom: 8px;">Twitch Overlay Settings</h3>

                <div style="margin-bottom: 16px;">
                    <label style="display: block; margin-bottom: 8px;">Position</label>
                    <select id="setting-position" style="width: 100%; padding: 6px; background: #2f2f35; color: white; border: none; border-radius: 4px;">
                        <option value="top-left" ${settings.position === 'top-left' ? 'selected' : ''}>Top Left</option>
                        <option value="top-right" ${settings.position === 'top-right' ? 'selected' : ''}>Top Right</option>
                        <option value="bottom-left" ${settings.position === 'bottom-left' ? 'selected' : ''}>Bottom Left</option>
                        <option value="bottom-right" ${settings.position === 'bottom-right' ? 'selected' : ''}>Bottom Right</option>
                    </select>
                </div>

                ${createToggle('Show Uptime', 'showUptime')}
                ${createToggle('Show Viewers', 'showViewers')}
                ${createToggle('Show Quality', 'showQuality')}
                ${createToggle('Show Delay (Buffer)', 'showDelay')}

                <div style="margin-bottom: 16px;">
                    <label style="display: block; margin-bottom: 8px;">Opacity: <span id="opacity-val">${settings.opacity}</span></label>
                    <input type="range" id="setting-opacity" min="0.1" max="1.0" step="0.1" value="${settings.opacity}" style="width: 100%;">
                </div>

                <div style="display: flex; gap: 10px; margin-bottom: 16px;">
                    <div style="flex: 1;">
                        <label style="display: block; margin-bottom: 8px;">Offset X (px)</label>
                        <input type="number" id="setting-offsetX" value="${settings.offsetX}" style="width: 100%; padding: 6px; background: #2f2f35; color: white; border: none; border-radius: 4px;">
                    </div>
                    <div style="flex: 1;">
                        <label style="display: block; margin-bottom: 8px;">Offset Y (px)</label>
                        <input type="number" id="setting-offsetY" value="${settings.offsetY}" style="width: 100%; padding: 6px; background: #2f2f35; color: white; border: none; border-radius: 4px;">
                    </div>
                </div>

                <div style="margin-top: 20px; text-align: right;">
                    <button id="close-settings" style="background: #9147ff; color: white; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer;">Save & Close</button>
                </div>
            `;

            // Overlay click to close
            const backdrop = document.createElement('div');
            backdrop.style.cssText = `
                position: fixed; top: 0; left: 0; right: 0; bottom: 0;
                background: rgba(0,0,0,0.5); z-index: 10000;
            `;
            backdrop.onclick = () => { modal.remove(); backdrop.remove(); };

            document.body.appendChild(backdrop);
            document.body.appendChild(modal);

            // Bind events
            document.getElementById('setting-opacity').oninput = (e) => {
                document.getElementById('opacity-val').textContent = e.target.value;
            };

            document.getElementById('close-settings').onclick = () => {
                const newSettings = {
                    position: document.getElementById('setting-position').value,
                    showUptime: document.getElementById('setting-showUptime').checked,
                    showViewers: document.getElementById('setting-showViewers').checked,
                    showQuality: document.getElementById('setting-showQuality').checked,
                    showDelay: document.getElementById('setting-showDelay').checked,
                    opacity: parseFloat(document.getElementById('setting-opacity').value),
                    offsetX: parseInt(document.getElementById('setting-offsetX').value) || 0,
                    offsetY: parseInt(document.getElementById('setting-offsetY').value) || 0
                };
                settingsManager.save(newSettings);
                modal.remove();
                backdrop.remove();
            };
        }
    }

    // --- INITIALIZE ---
    function init() {
        console.log('Twitch Overlay v2 Loading...');
        new OverlayUI();
        new SettingsUI();
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }

})();