Twitch Offchat Stream Viewer

Watch another stream while chilling in an offline chat

目前為 2025-11-26 提交的版本,檢視 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Twitch Offchat Stream Viewer
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  Watch another stream while chilling in an offline chat
// @author       benno2503
// @match        https://www.twitch.tv/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    const style = document.createElement('style');
    style.textContent = `
        #offchat-embed-wrapper {
            box-sizing: border-box !important;
        }
        #offchat-embed-wrapper iframe,
        #offchat-twitch-iframe {
            box-sizing: border-box !important;
            border: none !important;
        }
    `;
    document.head.appendChild(style);

    const IGNORED_PATHS = [
        'directory', 'following', 'settings', 'subscriptions',
        'inventory', 'wallet', 'drops', 'videos', 'p', 'search',
        'downloads', 'turbo', 'jobs', 'about', 'prime', 'bits'
    ];

    let config = { lastStreams: GM_getValue('lastStreams', []) };
    let isEmbedded = false;
    let currentChannel = '';
    let controlPanel = null;
    let lastChannel = null;
    let buttonCheckInterval = null;

    function isChannelPage() {
        const path = window.location.pathname.split('/')[1]?.toLowerCase();
        if (!path) return false;
        if (IGNORED_PATHS.includes(path)) return false;
        return /^[a-z0-9_]+$/.test(path);
    }

    function getCurrentChannel() {
        const path = window.location.pathname.split('/')[1];
        if (path && isChannelPage()) return path.toLowerCase();
        return null;
    }

    function extractChannelName(input) {
        if (!input) return null;
        input = input.trim();
        const urlMatch = input.match(/twitch\.tv\/([a-zA-Z0-9_]+)/);
        if (urlMatch) return urlMatch[1].toLowerCase();
        return input.toLowerCase().replace(/[^a-z0-9_]/g, '');
    }

    function makeDraggable(element, handle) {
        let isDragging = false;
        let startX, startY, startLeft, startTop;

        handle.style.cursor = 'grab';

        handle.addEventListener('mousedown', (e) => {
            if (e.target.tagName === 'INPUT' || e.target.tagName === 'BUTTON') return;
            isDragging = true;
            handle.style.cursor = 'grabbing';
            startX = e.clientX;
            startY = e.clientY;
            const rect = element.getBoundingClientRect();
            startLeft = rect.left;
            startTop = rect.top;
            e.preventDefault();
        });

        document.addEventListener('mousemove', (e) => {
            if (!isDragging) return;
            const dx = e.clientX - startX;
            const dy = e.clientY - startY;
            element.style.left = (startLeft + dx) + 'px';
            element.style.top = (startTop + dy) + 'px';
            element.style.right = 'auto';
            element.style.bottom = 'auto';
        });

        document.addEventListener('mouseup', () => {
            isDragging = false;
            handle.style.cursor = 'grab';
        });
    }

    function createEmbedPlayer(channelInput) {
        if (!channelInput) return;

        const channel = extractChannelName(channelInput);
        if (!channel) return;

        if (channel === currentChannel) {
            alert("That's the channel you're already in!");
            return;
        }

        config.lastStreams = config.lastStreams.filter(s => s !== channel);
        config.lastStreams.unshift(channel);
        if (config.lastStreams.length > 10) config.lastStreams.pop();
        GM_setValue('lastStreams', config.lastStreams);

        const existing = document.getElementById('offchat-embed-wrapper');
        if (existing) existing.remove();

        const playerContainer = document.querySelector('[data-a-target="video-player"]') ||
                               document.querySelector('.video-player__container') ||
                               document.querySelector('.video-player') ||
                               document.querySelector('.persistent-player');

        const wrapper = document.createElement('div');
        wrapper.id = 'offchat-embed-wrapper';
        
        if (playerContainer) {
            const rect = playerContainer.getBoundingClientRect();
            wrapper.style.cssText = `
                position: fixed !important;
                top: ${rect.top}px !important;
                left: ${rect.left}px !important;
                width: ${rect.width}px !important;
                height: ${rect.height}px !important;
                max-width: ${rect.width}px !important;
                max-height: ${rect.height}px !important;
                min-width: ${rect.width}px !important;
                min-height: ${rect.height}px !important;
                z-index: 1000 !important;
                background: #0e0e10 !important;
                overflow: hidden !important;
                box-sizing: border-box !important;
                display: block !important;
                margin: 0 !important;
                padding: 0 !important;
            `;
        } else {
            const chatWidth = 340;
            const navHeight = 50;
            wrapper.style.cssText = `
                position: fixed;
                top: ${navHeight}px;
                left: 0;
                width: calc(100vw - ${chatWidth}px);
                height: calc(100vh - ${navHeight}px);
                z-index: 999;
                background: #0e0e10;
                overflow: hidden;
            `;
        }
        
        document.body.appendChild(wrapper);
        
        const iframe = document.createElement('iframe');
        iframe.id = 'offchat-twitch-iframe';
        iframe.src = `https://player.twitch.tv/?channel=${channel}&parent=www.twitch.tv&muted=false&autoplay=true`;
        
        let iframeWidth, iframeHeight;
        if (playerContainer) {
            const pRect = playerContainer.getBoundingClientRect();
            iframeWidth = pRect.width;
            iframeHeight = pRect.height;
        } else {
            iframeWidth = window.innerWidth - 340;
            iframeHeight = window.innerHeight - 50;
        }
        
        iframe.style.cssText = `
            width: ${iframeWidth}px !important;
            height: ${iframeHeight}px !important;
            max-width: ${iframeWidth}px !important;
            max-height: ${iframeHeight}px !important;
            min-width: ${iframeWidth}px !important;
            min-height: ${iframeHeight}px !important;
            border: none !important;
            position: absolute !important;
            top: 0 !important;
            left: 0 !important;
            display: block !important;
            margin: 0 !important;
            padding: 0 !important;
            box-sizing: border-box !important;
        `;
        iframe.width = iframeWidth;
        iframe.height = iframeHeight;
        iframe.setAttribute('allowfullscreen', 'true');
        iframe.setAttribute('allow', 'autoplay; fullscreen');
        wrapper.appendChild(iframe);
        
        if (playerContainer) {
            const updatePosition = () => {
                const rect = playerContainer.getBoundingClientRect();
                wrapper.style.top = rect.top + 'px';
                wrapper.style.left = rect.left + 'px';
                wrapper.style.width = rect.width + 'px';
                wrapper.style.height = rect.height + 'px';
                iframe.style.width = rect.width + 'px';
                iframe.style.height = rect.height + 'px';
                iframe.style.maxWidth = rect.width + 'px';
                iframe.style.maxHeight = rect.height + 'px';
            };
            window.addEventListener('resize', updatePosition);
            window.addEventListener('scroll', updatePosition);
        }

        isEmbedded = true;
        updateControlPanel();
    }

    function removeEmbedPlayer() {
        const wrapper = document.getElementById('offchat-embed-wrapper');
        if (wrapper) wrapper.remove();
        isEmbedded = false;
        updateControlPanel();
    }

    function createControlPanel() {
        if (controlPanel) controlPanel.remove();

        controlPanel = document.createElement('div');
        controlPanel.id = 'offchat-control-panel';
        controlPanel.style.cssText = `
            position: fixed;
            bottom: 100px;
            right: 370px;
            background: #18181b;
            border: 1px solid #2f2f35;
            border-radius: 6px;
            z-index: 10001;
            font-family: 'Inter', 'Roobert', sans-serif;
            color: #efeff1;
            width: 240px;
            box-shadow: 0 8px 24px rgba(0,0,0,0.5);
            overflow: hidden;
        `;

        document.body.appendChild(controlPanel);
        updateControlPanel();
        
        const header = controlPanel.querySelector('#offchat-header');
        if (header) makeDraggable(controlPanel, header);
    }

    function updateControlPanel() {
        if (!controlPanel) return;

        controlPanel.innerHTML = `
            <div id="offchat-header" style="
                padding: 10px 12px;
                background: #1f1f23;
                border-bottom: 1px solid #2f2f35;
                display: flex;
                align-items: center;
                justify-content: space-between;
                user-select: none;
                cursor: move;
            ">
                <div style="display: flex; align-items: center; gap: 8px;">
                    <span style="font-size: 14px;">🎬</span>
                    <span style="font-weight: 600; font-size: 13px; color: #dedee3;">Offchat Viewer</span>
                </div>
                <button id="offchat-close-panel" style="
                    background: none;
                    border: none;
                    color: #898395;
                    cursor: pointer;
                    font-size: 16px;
                    padding: 0;
                    line-height: 1;
                " title="Minimize">−</button>
            </div>
            <div style="padding: 12px;">
                <div style="font-size: 11px; color: #898395; margin-bottom: 12px;">
                    In: <span style="color: #bf94ff;">${currentChannel}</span>
                </div>
                ${isEmbedded ? `
                    <button id="offchat-stop-btn" style="
                        width: 100%;
                        padding: 8px 12px;
                        background: #3d3d3d;
                        border: none;
                        border-radius: 4px;
                        color: #efeff1;
                        cursor: pointer;
                        font-size: 13px;
                        font-weight: 500;
                    ">✕ Close Stream</button>
                ` : `
                    <input type="text" id="offchat-channel-input" 
                        placeholder="twitch.tv/... or channel name" 
                        style="
                            width: 100%;
                            padding: 8px 10px;
                            background: #0e0e10;
                            border: 1px solid #2f2f35;
                            border-radius: 4px;
                            color: #efeff1;
                            margin-bottom: 8px;
                            box-sizing: border-box;
                            font-size: 13px;
                            outline: none;
                        ">
                    <button id="offchat-embed-btn" style="
                        width: 100%;
                        padding: 8px 12px;
                        background: #9147ff;
                        border: none;
                        border-radius: 4px;
                        color: white;
                        cursor: pointer;
                        font-weight: 600;
                        font-size: 13px;
                    ">Watch</button>
                    ${config.lastStreams.length > 0 ? `
                        <div style="margin-top: 12px; padding-top: 12px; border-top: 1px solid #2f2f35;">
                            <div style="font-size: 11px; color: #898395; margin-bottom: 6px;">Recent</div>
                            <div style="display: flex; flex-wrap: wrap; gap: 4px;">
                                ${config.lastStreams.slice(0, 6).map(s => `
                                    <button class="offchat-recent-btn" data-channel="${s}" style="
                                        padding: 4px 8px;
                                        background: #2f2f35;
                                        border: none;
                                        border-radius: 4px;
                                        color: #dedee3;
                                        cursor: pointer;
                                        font-size: 11px;
                                    ">${s}</button>
                                `).join('')}
                            </div>
                        </div>
                    ` : ''}
                `}
            </div>
        `;

        const closePanel = document.getElementById('offchat-close-panel');
        const embedBtn = document.getElementById('offchat-embed-btn');
        const channelInput = document.getElementById('offchat-channel-input');
        const stopBtn = document.getElementById('offchat-stop-btn');
        const recentBtns = document.querySelectorAll('.offchat-recent-btn');
        const header = document.getElementById('offchat-header');

        if (closePanel) {
            closePanel.onclick = () => {
                controlPanel.style.display = 'none';
                createShowButton();
            };
        }

        if (embedBtn && channelInput) {
            embedBtn.onclick = () => {
                const value = channelInput.value.trim();
                if (value) createEmbedPlayer(value);
                else {
                    channelInput.style.borderColor = '#9147ff';
                    channelInput.focus();
                }
            };
            channelInput.onkeydown = (e) => { if (e.key === 'Enter') embedBtn.click(); };
            channelInput.onfocus = () => channelInput.style.borderColor = '#9147ff';
            channelInput.onblur = () => channelInput.style.borderColor = '#2f2f35';
        }

        if (stopBtn) {
            stopBtn.onclick = () => {
                removeEmbedPlayer();
                createControlPanel();
            };
        }

        recentBtns.forEach(btn => {
            btn.onclick = () => createEmbedPlayer(btn.dataset.channel);
            btn.onmouseenter = () => btn.style.background = '#3d3d3d';
            btn.onmouseleave = () => btn.style.background = '#2f2f35';
        });

        if (header) makeDraggable(controlPanel, header);
    }

    function createShowButton() {
        let btn = document.getElementById('offchat-show-btn');
        if (btn && document.body.contains(btn)) {
            btn.style.display = 'inline-flex';
            return;
        }
        
        if (btn) btn.remove();
        
        btn = document.createElement('button');
        btn.id = 'offchat-show-btn';
        btn.title = 'Offchat Viewer - Watch another stream here';
        btn.innerHTML = `
            <svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
                <path d="M5 3a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2V5a2 2 0 00-2-2H5zm0 1.5h10a.5.5 0 01.5.5v10a.5.5 0 01-.5.5H5a.5.5 0 01-.5-.5V5a.5.5 0 01.5-.5z"/>
                <path d="M8 7l5 3-5 3V7z"/>
            </svg>
        `;
        
        btn.style.cssText = `
            display: inline-flex;
            align-items: center;
            justify-content: center;
            width: 30px;
            height: 30px;
            background: transparent;
            border: none;
            border-radius: 4px;
            color: #dedee3;
            cursor: pointer;
            transition: background-color 0.1s, color 0.1s;
            padding: 0;
            margin-left: 5px;
            vertical-align: middle;
        `;
        btn.onmouseenter = () => {
            btn.style.background = 'rgba(83, 83, 95, 0.48)';
            btn.style.color = '#fff';
        };
        btn.onmouseleave = () => {
            btn.style.background = 'transparent';
            btn.style.color = '#dedee3';
        };
        btn.onclick = () => {
            if (controlPanel) {
                controlPanel.style.display = 'block';
                btn.style.display = 'none';
            }
        };
        
        const insertButton = () => {
            const shareBtn = document.querySelector('[data-a-target="share-button"]') ||
                            document.querySelector('[aria-label="Share"]') ||
                            document.querySelector('button[aria-label*="Share"]');
            
            if (shareBtn && shareBtn.parentNode) {
                shareBtn.parentNode.insertBefore(btn, shareBtn.nextSibling);
                return true;
            }
            
            const actionsArea = document.querySelector('.metadata-layout__secondary-button-spacing') ||
                               document.querySelector('[data-target="channel-header-right"]') ||
                               document.querySelector('.channel-info-content');
            
            if (actionsArea) {
                actionsArea.appendChild(btn);
                return true;
            }
            
            return false;
        };
        
        if (!insertButton()) {
            let attempts = 0;
            const retryInterval = setInterval(() => {
                attempts++;
                if (insertButton() || attempts > 10) {
                    clearInterval(retryInterval);
                    if (!btn.parentNode) {
                        btn.style.cssText += `
                            position: fixed;
                            top: 10px;
                            left: 240px;
                            z-index: 10001;
                            background: rgba(24, 24, 27, 0.8);
                        `;
                        document.body.appendChild(btn);
                    }
                }
            }, 500);
        }
    }

    function cleanup() {
        removeEmbedPlayer();
        if (controlPanel) { controlPanel.remove(); controlPanel = null; }
        const showBtn = document.getElementById('offchat-show-btn');
        if (showBtn) showBtn.remove();
        if (buttonCheckInterval) { clearInterval(buttonCheckInterval); buttonCheckInterval = null; }
    }

    function init() {
        if (!isChannelPage()) return;
        currentChannel = getCurrentChannel();
        if (!currentChannel) return;
        lastChannel = currentChannel;

        setTimeout(() => {
            createControlPanel();
            controlPanel.style.display = 'none';
            createShowButton();
            
            buttonCheckInterval = setInterval(() => {
                const btn = document.getElementById('offchat-show-btn');
                if (!btn || !document.body.contains(btn)) {
                    createShowButton();
                }
            }, 2000);
        }, 2000);
    }

    let lastUrl = location.href;
    
    new MutationObserver(() => {
        const url = location.href;
        if (url !== lastUrl) {
            lastUrl = url;
            const newChannel = getCurrentChannel();
            
            if (newChannel !== lastChannel) {
                cleanup();
                lastChannel = newChannel;
                setTimeout(init, 1500);
            } else if (newChannel) {
                setTimeout(() => {
                    const btn = document.getElementById('offchat-show-btn');
                    if (!btn || !document.body.contains(btn)) {
                        createShowButton();
                    }
                }, 1000);
            }
        }
    }).observe(document, { subtree: true, childList: true });

    GM_registerMenuCommand('Clear Recent Streams', () => {
        config.lastStreams = [];
        GM_setValue('lastStreams', []);
        updateControlPanel();
    });

    GM_registerMenuCommand('Show Panel', () => {
        if (controlPanel) {
            controlPanel.style.display = 'block';
            const showBtn = document.getElementById('offchat-show-btn');
            if (showBtn) showBtn.style.display = 'none';
        }
    });

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

})();