Twitch Stream Info Overlay v2

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

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==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();
    }

})();