Twitch Streamer Quick View

Preview streamer's "About" panels and follower count by hovering over their name. Avoid clickbait and find the right vibe instantly.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Twitch Streamer Quick View
// @name:ja      Twitch Streamer Quick View
// @namespace    http://tampermonkey.net/
// @version      17.0
// @description  Preview streamer's "About" panels and follower count by hovering over their name. Avoid clickbait and find the right vibe instantly.
// @description:ja 配信者の名前をマウスホバーするだけで、自己紹介パネルとフォロワー数をプレビューします。配信を開く前に雰囲気を知ることができます。
// @author       anon00 & Gemini
// @match        https://www.twitch.tv/*
// @grant        GM_xmlhttpRequest
// @connect      gql.twitch.tv
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    const cache = new Map();
    let hoverTimer;

    const msg = {
        loading: "Loading...",
        noDesc: "No description available.",
        error: "Error occurred!"
    };

    const style = document.createElement('style');
    style.innerHTML = `
        #tm-panel-popup {
            position: fixed; z-index: 2147483647; background: #18181b; color: #efeff1;
            border: 1px solid #464649; border-radius: 8px; padding: 12px;
            width: 300px; max-height: 400px; overflow-y: auto;
            box-shadow: 0 10px 30px rgba(0,0,0,0.9); font-size: 13px; line-height: 1.5;
            display: none; pointer-events: none;
        }
        .tm-header { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #35353b; margin: 0 0 8px 0; padding: 0 0 6px 0; }
        .tm-title { font-weight: bold; color: #bf94ff; font-size: 14px; margin: 0; padding: 0; line-height: 1; }
        .tm-followers { color: #adadb8; font-size: 11px; font-weight: normal; margin: 0; padding: 0; line-height: 1; }
        .tm-content { margin: 0; padding: 0; white-space: pre-wrap; word-break: break-word; }
    `;
    document.head.appendChild(style);

    const popup = document.createElement('div');
    popup.id = 'tm-panel-popup';
    document.body.appendChild(popup);

    document.addEventListener('mouseover', (e) => {
        const link = e.target.closest('a');
        if (!link || link.querySelector('img')) return;
        const href = link.getAttribute('href');
        if (!href || !href.startsWith('/') || href.includes('?') || href.split('/').filter(p=>p).length !== 1) return;
        
        const username = href.split('/')[1];
        const ignored = ['directory', 'videos', 'u', 'settings', 'p', 'search', 'friends', 'moderator', 'common', 'legal', 'about'];
        
        if (username && !ignored.includes(username)) {
            clearTimeout(hoverTimer);
            hoverTimer = setTimeout(() => {
                const rect = link.getBoundingClientRect();
                showPopup(username, rect);
                fetchUserData(username);
            }, 600);
        }
    }, true);

    document.addEventListener('mouseout', (e) => {
        if (!e.target.closest('a')) {
            clearTimeout(hoverTimer);
            popup.style.display = 'none';
        }
    });

    function showPopup(username, rect) {
        const data = cache.get(username);
        if (data) {
            updateContent(username, data.description, data.followers);
        } else {
            popup.innerHTML = `<div class="tm-header"><span class="tm-title">@${username}</span></div><div class="tm-content">${msg.loading}</div>`;
        }

        popup.style.display = 'block';
        updatePosition(rect);
    }

    function updatePosition(rect) {
        const margin = 10;
        const popupHeight = popup.offsetHeight;
        const windowHeight = window.innerHeight;
        
        // 横位置の設定(画面外にはみ出さないように調整)
        let left = rect.left;
        if (left + 320 > window.innerWidth) {
            left = window.innerWidth - 320;
        }
        popup.style.left = `${left}px`;

        // 判定:下側に十分なスペースがあるか?(リンクの下端 + ポップアップの高さ + 余裕)
        const spaceBelow = windowHeight - rect.bottom;
        
        if (spaceBelow > popupHeight + margin + 20) {
            // 十分なスペースがあるなら「下」に表示
            popup.style.top = `${rect.bottom + window.scrollY + margin}px`;
        } else {
            // スペースがないなら「上」に表示
            popup.style.top = `${rect.top + window.scrollY - popupHeight - margin}px`;
        }
    }

    function updateContent(username, desc, followers) {
        const followerText = followers !== null ? `${followers.toLocaleString()} followers` : "";
        const cleanDesc = desc ? desc.trim() : msg.noDesc;
        popup.innerHTML = `<div class="tm-header"><span class="tm-title">@${username}</span><span class="tm-followers">${followerText}</span></div><div class="tm-content">${cleanDesc}</div>`;
        
        // コンテンツ更新後に高さが変わるため、位置を再計算するにゃ
        const activeLink = document.querySelector(`a[href="/${username}"]`);
        if (activeLink && popup.style.display === 'block') {
            updatePosition(activeLink.getBoundingClientRect());
        }
    }

    function fetchUserData(username) {
        if (cache.has(username)) return;
        const CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; 
        const query = JSON.stringify({
            query: `query($login: String!) { user(login: $login) { description followers { totalCount } } }`,
            variables: { login: username.toLowerCase() }
        });

        GM_xmlhttpRequest({
            method: "POST",
            url: "https://gql.twitch.tv/gql",
            headers: { "Client-ID": CLIENT_ID, "Content-Type": "application/json" },
            data: query,
            onload: function(response) {
                try {
                    const res = JSON.parse(response.responseText);
                    const user = res.data.user;
                    cache.set(username, {
                        description: user?.description || "",
                        followers: user?.followers?.totalCount ?? null
                    });
                    if (popup.style.display === 'block') {
                        const data = cache.get(username);
                        updateContent(username, data.description, data.followers);
                    }
                } catch (e) {}
            }
        });
    }
})();