1337x - Steam Hover Preview

On-hover Steam thumbnail, description, Steam Ratings, Steam‐provided tags, and a direct “Open on Steam” link for 1337x torrent titles

// ==UserScript==
// @name         1337x - Steam Hover Preview 
// @namespace    https://greasyfork.org/en/users/1340389-deonholo
// @version      2.9
// @description  On-hover Steam thumbnail, description, Steam Ratings, Steam‐provided tags, and a direct “Open on Steam” link for 1337x torrent titles
// @icon         https://greasyfork.s3.us-east-2.amazonaws.com/x432yc9hx5t6o2gbe9ccr7k5l6u8
// @author       DeonHolo
// @license      MIT
// @match        *://*.1337x.to/*
// @match        *://*.1337x.ws/*
// @match        *://*.1337x.is/*
// @match        *://*.1337x.gd/*
// @match        *://*.x1337x.cc/*
// @match        *://*.1337x.st/*
// @match        *://*.x1337x.ws/*
// @match        *://*.1337x.eu/*
// @match        *://*.1337x.se/*
// @match        *://*.x1337x.eu/*
// @match        *://*.x1337x.se/*
// @match        http://l337xdarkkaqfwzntnfk5bmoaroivtl6xsbatabvlb52umg6v3ch44yd.onion/*
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @connect      store.steampowered.com
// @connect      steamcdn-a.akamaihd.net
// @run-at       document-idle
// ==/UserScript==

(() => {
    'use strict';

    const tip = document.createElement('div');
    tip.className = 'steamHoverTip';
    const SEL = 'table.torrent-list td.name a[href^="/torrent/"], table.torrents td.name a[href^="/torrent/"], table.table-list td.name a[href^="/torrent/"]';
    const MIN_INTERVAL = 50;
    const MAX_CACHE = 100;
    const CACHE_TTL = 15 * 60 * 1000;
    const HIDE_DELAY = 100;
    const FADE_DURATION = 200;
    const API_TIMEOUT = 10000;
    const TAG_TIMEOUT = 15000;
    const SHOW_DELAY = 150;

   async function preloadAll() {
      const links = Array.from(document.querySelectorAll(SEL));
      const toFetch = new Set();

      for (const link of links) {
        const name = cleanName(link.textContent);
        if (name && !apiCache.has(name)) {
          toFetch.add(name);
        }
      }

      for (const name of toFetch) {
        fetchSteam(name).catch(()=>{});
        await new Promise(r => setTimeout(r, MIN_INTERVAL));
      }
    }

    window.addEventListener('load', () => {
      setTimeout(preloadAll, 50);
    });

    GM_addStyle(`
        .steamHoverTip {
            position: absolute;
            padding: 8px;
            background: rgba(240, 240, 240, 0.97);
            border: 1px solid #555;
            border-radius: 4px;
            box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
            z-index: 2147483647;
            max-width: 310px;
            font-size: 12px;
            line-height: 1.45;
            display: none;
            white-space: normal !important;
            overflow-wrap: break-word;
            color: #111;
            opacity: 0;
            transition: opacity ${FADE_DURATION}ms ease-in-out;
            pointer-events: none;
        }

        .steamHoverTip p {
            margin: 0 0 5px 0;
            padding: 0;
        }
        .steamHoverTip p:last-child {
            margin-bottom: 0;
        }
        .steamHoverTip img {
            display: block;
            width: 100%;
            margin-bottom: 8px;
            border-radius: 2px;
        }
        .steamHoverTip strong {
            color: #000;
        }
        .steamHoverTip .steamRating,
        .steamHoverTip .steamTags {
            margin-top: 8px;
            font-size: 12px;
            color: #333;
        }
        .steamHoverTip .steamTags strong,
        .steamHoverTip .steamRating strong {
            color: #111;
            margin-right: 4px;
        }
        .steamHoverTip .ratingStars {
            color: #f5c518;
            margin-right: 6px;
            letter-spacing: 1px;
            font-size: 14px;
            display: inline-block;
            vertical-align: middle;
        }
        .steamHoverTip .ratingText {
            vertical-align: middle;
        }
        .steamHoverTip a {
            color: #0645ad;
            text-decoration: underline;
            cursor: pointer;
        }
    `);

    const apiCache = new Map();
    let lastRequest = 0;
    let hoverId = 0;
    let showTimeout = null;
    let hideTimeout = null;
    let displayTimeout = null;
    let currentFetch = null;
    let trackingMove = false;
    let lastMoveEvent = null;
    let currentHoveredLink = null;

    document.body.appendChild(tip);

    function pruneCache(map) {
        if (map.size > MAX_CACHE) {
            map.delete(map.keys().next().value);
        }
    }

    function getRatingStars(percent, desc) {
        const filled = '★';
        const empty = '☆';
        const p = parseInt(percent, 10);
        let stars = '';

        if (!isNaN(p)) {
            if (p >= 95) stars = filled.repeat(5);
            else if (p >= 80) stars = filled.repeat(4) + empty;
            else if (p >= 70) stars = filled.repeat(3) + empty.repeat(2);
            else if (p >= 40) stars = filled.repeat(2) + empty.repeat(3);
            else if (p >= 20) stars = filled + empty.repeat(4);
            else stars = empty.repeat(5);
        } else if (desc) {
            const d = desc.toLowerCase();
            if (d.includes('overwhelmingly positive')) stars = filled.repeat(5);
            else if (d.includes('very positive')) stars = filled.repeat(4) + empty;
            else if (d.includes('mostly positive')) stars = filled.repeat(4) + empty;
            else if (d.includes('positive')) stars = filled.repeat(4) + empty;
            else if (d.includes('mixed')) stars = filled.repeat(3) + empty.repeat(2);
            else if (d.includes('mostly negative')) stars = filled.repeat(2) + empty.repeat(3);
            else if (d.includes('negative')) stars = filled + empty.repeat(4);
            else if (d.includes('very negative')) stars = filled + empty.repeat(4);
            else if (d.includes('overwhelmingly negative')) stars = filled + empty.repeat(4);
        }
        return stars ? `<span class="ratingStars">${stars}</span>` : '';
    }

    function cleanName(raw) {
        if (/soundtrack|ost|demo|dlc pack|artbook|season pass|multiplayer crack/i.test(raw)) {
            return null;
        }
        let name = raw.trim();
        name = name.replace(/\(\d{4}\)/, '').replace(/S\d{1,2}(E\d{1,2})?/, '').trim();
        const delim = /(?:[.\-_/(\[]|\bUpdate\b|\bBuild\b|v[\d.]+|\bEdition\b|\bDeluxe\b|\bDirectors? Cut\b|\bComplete\b|\bGold\b|\bGOTY\b|\bRemastered\b|\bAnniversary\b|\bEnhanced\b|\bVR\b|\bUltimate\b)/i;
        name = name.split(delim)[0].trim();
        name = name.replace(/[-. ](CODEX|CPY|SKIDROW|PLAZA|HOODLUM|FLT|DOGE|DARKSiDERS|EMPRESS|RUNE|TENOKE|TiNYiSO|ElAmigos|FitGirl|DODI)$/i, '').trim();
        name = name.replace(/^(The|Sid Meier'?s|Tom Clancy'?s)\s+/i, '').trim();
        return name || null;
    }

    function gmFetch(url, responseType = 'json', timeout = API_TIMEOUT) {
        const wait = Math.max(0, MIN_INTERVAL - (Date.now() - lastRequest));
        return new Promise(resolve => setTimeout(resolve, wait))
            .then(() => new Promise((resolve, reject) => {
                lastRequest = Date.now();
                GM_xmlhttpRequest({
                    method: 'GET',
                    url: url,
                    responseType: responseType,
                    timeout: timeout,
                    headers: {
                        'Accept-Language': 'en-US,en;q=0.9'
                    },
                    onload: (res) => {
                        if (res.status >= 200 && res.status < 300) {
                            if (responseType === 'json') {
                                if (typeof res.response === 'object' && res.response !== null) {
                                    resolve(res.response);
                                } else {
                                    try {
                                        resolve(JSON.parse(res.responseText));
                                    } catch (e) {
                                        console.error(`JSON parse error for ${url}:`, e, res.responseText);
                                        reject(new Error(`JSON parse error for ${url}`));
                                    }
                                }
                            } else {
                                resolve(res.response || res.responseText);
                            }
                        } else {
                            console.warn(`HTTP ${res.status} for ${url}`);
                            reject(new Error(`HTTP ${res.status} for ${url}`));
                        }
                    },
                    onerror: (err) => {
                        console.error(`Network error for ${url}:`, err);
                        reject(new Error(`Network error for ${url}: ${err.statusText || err.error || 'Unknown'}`));
                    },
                    ontimeout: () => {
                        console.warn(`Timeout ${timeout}ms for ${url}`);
                        reject(new Error(`Timeout ${timeout}ms for ${url}`));
                    },
                    onabort: () => {
                        console.warn(`Aborted request for ${url}`);
                        reject(new Error(`Aborted request for ${url}`));
                    }
                });
            }));
    }

    async function fetchSteam(name) {
        const now = Date.now();
        const hit = apiCache.get(name);
        if (hit && now - hit.ts < CACHE_TTL) {
            return hit.data;
        }
        let appId = null;
        let appData = null;
        try {
            const searchUrl = `https://store.steampowered.com/api/storesearch/?cc=us&l=en&term=${encodeURIComponent(name)}`;
            const searchRes = await gmFetch(searchUrl, 'json');
            let result = searchRes?.items?.[0];
            if (searchRes?.items?.length > 1) {
                const exactMatch = searchRes.items.find(item => item.name.toLowerCase() === name.toLowerCase());
                if (exactMatch) {
                    result = exactMatch;
                }
            }
            appId = result?.id;
            if (!appId) {
                throw new Error('No suitable AppID found in search results.');
            }
            const detailsUrl = `https://store.steampowered.com/api/appdetails?appids=${appId}&cc=us&l=en`;
            const detailsRes = await gmFetch(detailsUrl, 'json');
            if (detailsRes?.[appId]?.success) {
                appData = detailsRes[appId].data;
            } else {
                throw new Error('Failed to fetch app details or API indicated failure.');
            }
        } catch (err) {
            console.warn(`Steam search/details fetch failed for "${name}":`, err.message);
            apiCache.set(name, { data: null, ts: now });
            pruneCache(apiCache);
            return null;
        }
        let reviewInfo = null;
        try {
            const reviewUrl = `https://store.steampowered.com/appreviews/${appId}?json=1&language=all&purchase_type=all&filter=summary`;
            const reviewRes = await gmFetch(reviewUrl, 'json');
            if (reviewRes?.success && reviewRes.query_summary) {
                const summary = reviewRes.query_summary;
                const percent = summary.total_reviews ? Math.round((summary.total_positive / summary.total_reviews) * 100) : null;
                reviewInfo = {
                    desc: summary.review_score_desc || 'No Reviews',
                    percent: percent,
                    total: summary.total_reviews || 0
                };
            }
        } catch (revErr) {
            console.warn(`Steam reviews fetch failed for AppID ${appId}:`, revErr.message);
        }
        let tags = [];
        try {
            const appPageUrl = `https://store.steampowered.com/app/${appId}/?cc=us&l=en`;
            const html = await gmFetch(appPageUrl, 'text', TAG_TIMEOUT);
            const parser = new DOMParser();
            const doc = parser.parseFromString(html, 'text/html');
            tags = Array.from(doc.querySelectorAll('.glance_tags.popular_tags a.app_tag'))
                .map(el => el.textContent.trim())
                .slice(0, 5);
        } catch (tagErr) {
            console.warn(`Steam tag scrape failed for AppID ${appId}:`, tagErr.message);
        }
        if (tags.length === 0 && appData) {
            const genreTags = (appData.genres || []).map(g => g.description);
            const categoryTags = (appData.categories || []).map(c => c.description);
            tags = [...genreTags, ...categoryTags].filter(Boolean).slice(0, 5);
        }
        const data = {
            ...appData,
            tags: tags,
            reviewInfo: reviewInfo,
            storeUrl: `https://store.steampowered.com/app/${appId}/`
        };
        apiCache.set(name, { data: data, ts: now });
        pruneCache(apiCache);
        return data;
    }

    function positionTip(ev) {
        if (!tip) return;
        let x = ev.pageX + 15;
        let y = ev.pageY + 15;
        const tipWidth = tip.offsetWidth;
        const tipHeight = tip.offsetHeight;
        const margin = 10;
        const scrollX = window.scrollX || window.pageXOffset;
        const scrollY = window.scrollY || window.pageYOffset;
        const viewWidth = window.innerWidth;
        const viewHeight = window.innerHeight;
        if (x + tipWidth + margin > scrollX + viewWidth) {
            x = ev.pageX - tipWidth - 15;
            if (x < scrollX + margin) {
                x = scrollX + margin;
            }
        }
        if (x < scrollX + margin) {
            x = scrollX + margin;
        }
        if (y + tipHeight + margin > scrollY + viewHeight) {
            let yAbove = ev.pageY - tipHeight - 15;
            if (yAbove > scrollY + margin) {
                y = yAbove;
            } else {
                 y = scrollY + viewHeight - tipHeight - margin;
                 if (y < scrollY + margin) {
                    y = scrollY + margin;
                 }
            }
        }
         if (y < scrollY + margin) {
             y = scrollY + margin;
         }
        tip.style.left = `${x}px`;
        tip.style.top = `${y}px`;
    }

    function startHideAnimation() {
        if (tip.style.display !== 'none' && tip.style.opacity !== '0') {
            tip.style.opacity = '0';
            tip.style.pointerEvents = 'none';
            trackingMove = false;
            clearTimeout(displayTimeout);
            displayTimeout = setTimeout(() => {
                tip.style.display = 'none';
            }, FADE_DURATION);
        } else if (tip.style.display !== 'none') {
            clearTimeout(displayTimeout);
            displayTimeout = setTimeout(() => { tip.style.display = 'none'; }, FADE_DURATION);
        }
    }

    function actuallyHideTip() {
        hoverId++;
        currentFetch = null;
        currentHoveredLink = null;
        clearTimeout(showTimeout);
        startHideAnimation();
    }

    function scheduleHideTip() {
        clearTimeout(hideTimeout);
        clearTimeout(displayTimeout);
        hideTimeout = setTimeout(actuallyHideTip, HIDE_DELAY);
    }

    function cancelHideTip() {
        clearTimeout(hideTimeout);
        clearTimeout(displayTimeout);
        if (tip.style.display === 'block' && tip.style.opacity === '0') {
            tip.style.opacity = '1';
            tip.style.pointerEvents = 'auto';
        }
    }

    function triggerShowAndFadeIn(event, gameName) {
        cancelHideTip();
        clearTimeout(displayTimeout);
        tip.innerHTML = `<p>Loading <strong>${gameName}</strong>…</p>`;
        positionTip(event);
        tip.style.display = 'block';
        void tip.offsetHeight;
        tip.style.opacity = '1';
        tip.style.pointerEvents = 'auto';
    }

    tip.addEventListener('mouseenter', () => {
        cancelHideTip();
        if (trackingMove) {
            trackingMove = false;
        }
    });

    tip.addEventListener('mouseleave', () => {
        scheduleHideTip();
    });

    document.addEventListener('mouseover', async (e) => {
        const targetLink = e.target.closest(SEL);
        const isOverTip = tip.contains(e.target);

        if (targetLink || isOverTip) {
            cancelHideTip();
        }

        if (!targetLink || (targetLink === currentHoveredLink && !trackingMove)) {
            return;
        }

        if (currentHoveredLink && targetLink !== currentHoveredLink && tip.style.display === 'block') {
            tip.style.opacity = '0';
            tip.style.pointerEvents = 'none';
            tip.style.display = 'none';
            hoverId++;
            trackingMove = false;
            currentFetch = null;
        }

        currentHoveredLink = targetLink;
        const rawName = targetLink.textContent;
        const gameName = cleanName(rawName);

        if (!gameName) {
            currentHoveredLink = null;
            return;
        }

        clearTimeout(showTimeout);

        const thisId = ++hoverId;
        trackingMove = true;
        lastMoveEvent = e;

        triggerShowAndFadeIn(e, gameName);

        showTimeout = setTimeout(async () => {
            if (hoverId !== thisId || !currentHoveredLink || currentHoveredLink !== targetLink) {
                 if (!currentHoveredLink || currentHoveredLink !== targetLink) {
                    trackingMove = false;
                 }
                return;
            }

            currentFetch = fetchSteam(gameName);
            const data = await currentFetch;
            currentFetch = null;

            if (hoverId !== thisId || !currentHoveredLink || currentHoveredLink !== targetLink) {
                 if (!currentHoveredLink || currentHoveredLink !== targetLink) {
                    trackingMove = false;
                 }
                return;
            }

            if (!data) {
                tip.innerHTML = `<p>No Steam info found for<br><strong>${gameName}</strong>.</p>`;
            } else {
                const tagsHtml = data.tags?.length ?
                    `<p class="steamTags"><strong>Tags:</strong> ${data.tags.join(' • ')}</p>` :
                    '';
                const reviewHtml = (data.reviewInfo && data.reviewInfo.desc !== 'N/A' && data.reviewInfo.desc !== 'No Reviews') ?
                    `<p class="steamRating"><strong>Rating:</strong> ${getRatingStars(data.reviewInfo.percent, data.reviewInfo.desc)}<span class="ratingText">${data.reviewInfo.desc}${data.reviewInfo.total ? `  |  ${data.reviewInfo.total.toLocaleString()} reviews` : ''}</span></p>` :
                    '';

                tip.innerHTML = `
                    ${data.header_image ? `<img src="${data.header_image}" alt="${data.name || gameName}" onerror="this.style.display='none'">` : ''}
                    <p><strong>${data.name || gameName}</strong></p>
                    <p>${data.short_description || 'No description available.'}</p>
                    ${reviewHtml}
                    ${tagsHtml}
                    ${data.storeUrl ? `<p><a class="steam-link-in-tip" href="${data.storeUrl}" target="_blank" rel="noopener noreferrer">Open on Steam</a></p>`: ''}
                `;
            }

            if (hoverId === thisId && currentHoveredLink === targetLink) {
                positionTip(lastMoveEvent);
                trackingMove = false;
                tip.style.opacity = '1';
                tip.style.pointerEvents = 'auto';
            } else {
                startHideAnimation();
            }

        }, SHOW_DELAY);
    }, true);


    document.addEventListener('mouseout', (e) => {
        const leavingCurrentLink = currentHoveredLink && currentHoveredLink === e.target.closest(SEL);
        const destinationIsTip = tip.contains(e.relatedTarget);
        if (leavingCurrentLink && !destinationIsTip) {
            scheduleHideTip();
            currentHoveredLink = null;
        }
    }, true);

    document.addEventListener('pointermove', (e) => {
        if (trackingMove && tip.style.display === 'block') {
            lastMoveEvent = e;
            positionTip(e);
        }
    }, { capture: true, passive: true });

    console.log("1337x Steam Hover Preview script loaded.");

})();