CS.RIN.RU - Steam Hover Preview

On-hover Steam media, description, ratings, tags, AppID, SteamDB, Open on Steam, and Open Latest Page for cs.rin.ru forum topics

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

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

(Tôi đã có Trình quản lý tập lệnh người dùng, hãy cài đặt nó!)

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.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         CS.RIN.RU - Steam Hover Preview
// @namespace    https://greasyfork.org/en/users/1340389-deonholo
// @version      3.2
// @description  On-hover Steam media, description, ratings, tags, AppID, SteamDB, Open on Steam, and Open Latest Page for cs.rin.ru forum topics
// @author       DeonHolo
// @license      MIT
// @icon         https://raw.githubusercontent.com/SubZeroPL/cs-rin-ru-enhanced-mod/master/image.png
// @iconURL      https://raw.githubusercontent.com/SubZeroPL/cs-rin-ru-enhanced-mod/master/image.png
// @defaulticon  https://raw.githubusercontent.com/SubZeroPL/cs-rin-ru-enhanced-mod/master/image.png
// @match        *://cs.rin.ru/forum/*
// @match        *://*.cs.rin.ru/forum/*
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// @connect      store.steampowered.com
// @run-at       document-idle
// ==/UserScript==

(() => {
    'use strict';

    const TOPIC_SELECTOR = 'a.topictitle, a[href*="viewtopic.php"][href*="t="]';
    const MIN_INTERVAL = 50;
    const MAX_CACHE = 120;
    const CACHE_TTL = 24 * 60 * 60 * 1000;
    const MEMORY_CACHE_TTL = 15 * 60 * 1000;
    const HIDE_DELAY = 100;
    const FADE_DURATION = 180;
    const API_TIMEOUT = 8000;
    const STORAGE_KEY = 'csrinruSteamHoverCache_v1';
    const CONCURRENT_VISIBLE = 3;
    const CONCURRENT_HIDDEN = 4;
    const PRIORITY_PRELOAD_COUNT = 15;
    const CONCURRENT_TAG_VISIBLE = 1;
    const CONCURRENT_TAG_HIDDEN = 3;
    const MAX_SEARCH_CANDIDATES = 5;
    const DEBUG_MODE = false;

    const tip = document.createElement('div');
    tip.className = 'csrinruSteamHoverTip';
    const theatre = document.createElement('div');
    theatre.className = 'csrinruSteamTheatre';
    theatre.setAttribute('role', 'dialog');
    theatre.setAttribute('aria-modal', 'true');
    theatre.setAttribute('aria-hidden', 'true');

    let lastRequest = 0;
    let requestGate = Promise.resolve();
    let hoverId = 0;
    let showTimeout = null;
    let hideTimeout = null;
    let displayTimeout = null;
    let trackingMove = false;
    let lastMoveEvent = null;
    let currentHoveredLink = null;
    let userHovering = false;
    let isPageHidden = document.hidden || false;
    let currentMedia = [];
    let currentMediaIndex = 0;
    let currentMediaTitle = '';
    let currentStoreUrl = '';
    let currentLatestUrl = '';
    let theatreMedia = [];
    let theatreMediaIndex = 0;
    let theatreMediaTitle = '';
    let theatreMode = 'screenshots';
    let theatreAllMedia = [];
    let previousDocumentOverflow = '';

    const apiCache = new Map();
    const inFlightFetches = new Map();
    const inFlightTagFetches = new Map();

    function debugLog(...args) {
        if (DEBUG_MODE) console.log('[CS.RIN.RU Steam Hover]', ...args);
    }

    function escapeHtml(value) {
        return String(value ?? '').replace(/[&<>"']/g, (ch) => ({
            '&': '&amp;',
            '<': '&lt;',
            '>': '&gt;',
            '"': '&quot;',
            "'": '&#39;'
        }[ch]));
    }

    function absoluteUrl(href) {
        try {
            return new URL(href, window.location.href).href;
        } catch (_) {
            return href || '';
        }
    }

    function delay(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }

    async function copyText(text) {
        if (!text) return false;

        try {
            if (navigator.clipboard?.writeText) {
                await navigator.clipboard.writeText(text);
                return true;
            }
        } catch (_) {
            // Fall through to the textarea copy path.
        }

        const textarea = document.createElement('textarea');
        textarea.value = text;
        textarea.setAttribute('readonly', '');
        textarea.style.position = 'fixed';
        textarea.style.left = '-9999px';
        document.body.appendChild(textarea);
        textarea.select();

        let copied = false;
        try {
            copied = document.execCommand('copy');
        } catch (_) {
            copied = false;
        }

        textarea.remove();
        return copied;
    }

    function getTopicId(href) {
        try {
            return new URL(href, window.location.href).searchParams.get('t');
        } catch (_) {
            return null;
        }
    }

    function loadPersistentCache() {
        try {
            const stored = GM_getValue(STORAGE_KEY, null);
            if (!stored) return;

            const parsed = JSON.parse(stored);
            const now = Date.now();
            let loaded = 0;
            for (const [key, value] of Object.entries(parsed)) {
                if (value.data && value.ts && (now - value.ts) < CACHE_TTL) {
                    apiCache.set(key, value);
                    loaded++;
                }
            }
            debugLog(`Loaded ${loaded} cached games from storage`);
        } catch (e) {
            console.warn('[CS.RIN.RU Steam Hover] Failed to load cache:', e);
        }
    }

    let saveTimeout = null;
    function savePersistentCache() {
        clearTimeout(saveTimeout);
        saveTimeout = setTimeout(() => {
            try {
                const obj = {};
                const now = Date.now();
                for (const [key, value] of apiCache.entries()) {
                    if (value.data && value.ts && (now - value.ts) < CACHE_TTL) {
                        obj[key] = value;
                    }
                }
                GM_setValue(STORAGE_KEY, JSON.stringify(obj));
            } catch (e) {
                console.warn('[CS.RIN.RU Steam Hover] Failed to save cache:', e);
            }
        }, 1000);
    }

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

    function getFreshCacheEntry(name, now = Date.now()) {
        const hit = apiCache.get(name);
        if (!hit) return null;

        if (now - hit.ts < (hit.data ? CACHE_TTL : MEMORY_CACHE_TTL)) {
            return hit;
        }

        apiCache.delete(name);
        return null;
    }

    window.clearCsrinruSteamHoverCache = function () {
        apiCache.clear();
        inFlightFetches.clear();
        inFlightTagFetches.clear();
        GM_setValue(STORAGE_KEY, '{}');
        console.log('[CS.RIN.RU Steam Hover] Cache cleared. Refresh the page to re-fetch games.');
    };

    GM_addStyle(`
        .csrinruSteamHoverTip {
            position: absolute;
            display: none;
            opacity: 0;
            max-width: 420px;
            padding: 8px;
            background: rgb(28, 28, 28);
            border: 1px solid #5c5c5c;
            border-radius: 4px;
            box-shadow: 0 2px 8px rgba(0, 0, 0, 0.45);
            color: #c1cccc;
            font-size: 12px;
            line-height: 1.45;
            white-space: normal !important;
            overflow-wrap: break-word;
            z-index: 2147483647;
            pointer-events: none;
            transition: opacity ${FADE_DURATION}ms ease-in-out;
        }
        .csrinruSteamHoverTip p {
            margin: 0 0 5px 0;
            padding: 0;
        }
        .csrinruSteamHoverTip p:last-child {
            margin-bottom: 0;
        }
        .csrinruSteamHoverTip img {
            display: block;
            width: 100%;
            margin-bottom: 8px;
            border-radius: 2px;
        }
        .csrinruSteamHoverTip .steamMediaShell {
            margin-bottom: 8px;
        }
        .csrinruSteamHoverTip .steamMediaFrame {
            position: relative;
            width: 100%;
            aspect-ratio: 460 / 215;
            overflow: hidden;
            background: #111;
            border-radius: 2px;
        }
        .csrinruSteamHoverTip .steamMediaViewport {
            width: 100%;
            height: 100%;
            background: #111;
        }
        .csrinruSteamHoverTip .steamMediaImage,
        .csrinruSteamHoverTip .steamMediaPoster,
        .csrinruSteamHoverTip .steamMediaVideo {
            display: block;
            width: 100%;
            height: 100%;
            margin: 0;
            border-radius: 0;
            object-fit: cover;
        }
        .csrinruSteamHoverTip .steamMediaVideo {
            background: #000;
        }
        .csrinruSteamHoverTip .steamMediaError {
            display: grid;
            place-items: center;
            gap: 6px;
            width: 100%;
            height: 100%;
            padding: 12px;
            background: #151515;
            color: #d6e8ee;
            font-size: 12px;
            text-align: center;
            box-sizing: border-box;
        }
        .csrinruSteamHoverTip .steamMediaError a,
        .csrinruSteamTheatreError a {
            color: #99d2f7 !important;
            font-weight: bold;
            text-decoration: underline;
        }
        .csrinruSteamHoverTip .steamMediaNavBtn {
            position: absolute;
            top: 50%;
            display: grid;
            place-items: center;
            width: 26px;
            height: 36px;
            margin: 0;
            padding: 0;
            border: 1px solid rgba(255, 255, 255, 0.22);
            background: rgba(0, 0, 0, 0.58);
            color: #f2f2f2;
            font: bold 19px/1 Arial, sans-serif;
            cursor: pointer;
            opacity: 0.82;
            touch-action: manipulation;
            transform: translateY(-50%);
        }
        .csrinruSteamHoverTip .steamMediaNavBtn:hover,
        .csrinruSteamHoverTip .steamMediaNavBtn:focus {
            opacity: 1;
            background: rgba(0, 0, 0, 0.74);
            outline: 1px solid #99d2f7;
        }
        .csrinruSteamHoverTip .steamMediaPrevBtn {
            left: 5px;
        }
        .csrinruSteamHoverTip .steamMediaNextBtn {
            right: 5px;
        }
        .csrinruSteamHoverTip .steamMediaCounter {
            position: absolute;
            top: 6px;
            right: 6px;
            padding: 1px 5px;
            background: rgba(0, 0, 0, 0.68);
            border-radius: 3px;
            color: #d6e8ee;
            font-size: 10px;
            line-height: 1.35;
        }
        .csrinruSteamHoverTip .steamMediaPlayBtn {
            position: relative;
            display: block;
            width: 100%;
            height: 100%;
            margin: 0;
            padding: 0;
            border: 0;
            background: #111;
            cursor: pointer;
            touch-action: manipulation;
        }
        .csrinruSteamHoverTip .steamMediaPlayBtn:hover .steamMediaPlayIcon,
        .csrinruSteamHoverTip .steamMediaPlayBtn:focus .steamMediaPlayIcon {
            background: rgba(0, 0, 0, 0.78);
            outline: 1px solid #99d2f7;
        }
        .csrinruSteamHoverTip .steamMediaPlayIcon {
            position: absolute;
            top: 50%;
            left: 50%;
            display: grid;
            place-items: center;
            width: 42px;
            height: 42px;
            border-radius: 50%;
            background: rgba(0, 0, 0, 0.62);
            color: #f2f2f2;
            font-size: 24px;
            line-height: 1;
            transform: translate(-50%, -50%);
        }
        .csrinruSteamHoverTip .steamMediaThumbStrip {
            display: flex;
            gap: 3px;
            margin-top: 4px;
            overflow-x: auto;
            scrollbar-width: thin;
            scrollbar-color: #40566a #151515;
        }
        .csrinruSteamHoverTip .steamMediaThumbStrip::-webkit-scrollbar {
            height: 6px;
        }
        .csrinruSteamHoverTip .steamMediaThumbStrip::-webkit-scrollbar-track {
            background: #151515;
        }
        .csrinruSteamHoverTip .steamMediaThumbStrip::-webkit-scrollbar-thumb {
            background: #40566a;
            border-radius: 999px;
        }
        .csrinruSteamHoverTip .steamMediaThumbBtn {
            position: relative;
            flex: 0 0 52px;
            width: 52px;
            height: 32px;
            margin: 0;
            padding: 0;
            overflow: hidden;
            background: #111;
            border: 1px solid #333;
            border-radius: 2px;
            cursor: pointer;
            opacity: 0.74;
            touch-action: manipulation;
        }
        .csrinruSteamHoverTip .steamMediaThumbStripFit {
            overflow-x: hidden;
        }
        .csrinruSteamHoverTip .steamMediaThumbStripFit .steamMediaThumbBtn {
            flex: 1 1 0;
            width: auto;
            min-width: 0;
        }
        .csrinruSteamHoverTip .steamMediaThumbBtn:hover,
        .csrinruSteamHoverTip .steamMediaThumbBtn:focus {
            opacity: 1;
            border-color: #99d2f7;
            outline: 1px solid rgba(153, 210, 247, 0.62);
        }
        .csrinruSteamHoverTip .steamMediaThumbActive {
            opacity: 1;
            border-color: #99d2f7;
            box-shadow: 0 0 0 1px rgba(153, 210, 247, 0.45);
        }
        .csrinruSteamHoverTip .steamMediaThumbImage {
            display: block;
            width: 100%;
            height: 100%;
            margin: 0;
            border-radius: 0;
            object-fit: cover;
        }
        .csrinruSteamHoverTip .steamMediaThumbPlayIcon {
            position: absolute;
            right: 3px;
            bottom: 3px;
            display: grid;
            place-items: center;
            width: 16px;
            height: 16px;
            border-radius: 50%;
            background: rgba(0, 0, 0, 0.72);
            color: #f2f2f2;
            font-size: 10px;
            line-height: 1;
        }
        .csrinruSteamHoverTip strong {
            color: #f2f2f2;
        }
        .csrinruSteamHoverTip a,
        .csrinruSteamHoverTip a:visited {
            color: #99d2f7 !important;
            text-decoration: underline;
            cursor: pointer;
        }
        .csrinruSteamHoverTip a:hover,
        .csrinruSteamHoverTip a:visited:hover {
            color: #c7ebff !important;
        }
        .csrinruSteamHoverTip .steamRating,
        .csrinruSteamHoverTip .steamTags,
        .csrinruSteamHoverTip .steamMetaRow,
        .csrinruSteamHoverTip .steamReleaseDate {
            margin-top: 8px;
            font-size: 12px;
            color: #c1cccc;
        }
        .csrinruSteamHoverTip .steamReleaseDate,
        .csrinruSteamHoverTip .steamMetaRow {
            margin-top: 2px;
            font-size: 11px;
            color: #a8b4b8;
        }
        .csrinruSteamHoverTip .steamMetaRow {
            display: flex;
            justify-content: space-between;
            align-items: baseline;
            flex-wrap: nowrap;
            gap: 3px 10px;
        }
        .csrinruSteamHoverTip .steamAppIdControl {
            display: inline-flex;
            align-items: baseline;
            gap: 4px;
            flex-shrink: 0;
        }
        .csrinruSteamHoverTip .copyAppIdBtn {
            display: inline;
            margin: 0;
            padding: 0;
            border: 0;
            background: transparent;
            color: #99d2f7;
            font: inherit;
            text-decoration: underline;
            cursor: pointer;
        }
        .csrinruSteamHoverTip .copyAppIdBtn:hover {
            color: #c7ebff;
        }
        .csrinruSteamHoverTip .steamDbLink,
        .csrinruSteamHoverTip .steamDbLink:visited {
            display: inline-block;
            padding: 0 2px;
            color: #99d2f7 !important;
            font-weight: bold;
            line-height: 1;
            text-decoration: none;
        }
        .csrinruSteamHoverTip .steamDbLink:hover,
        .csrinruSteamHoverTip .steamDbLink:visited:hover {
            color: #c7ebff !important;
            text-decoration: underline;
        }
        .csrinruSteamHoverTip .threadTagList {
            display: flex;
            justify-content: flex-end;
            flex-wrap: nowrap;
            flex: 1 1 auto;
            gap: 0 4px;
            margin-left: auto;
            min-width: 0;
            overflow: hidden;
        }
        .csrinruSteamHoverTip .steamMetaRowTagsOnly .threadTagList {
            justify-content: flex-start;
            margin-left: 0;
        }
        .csrinruSteamHoverTip .threadTag {
            white-space: nowrap;
            overflow: hidden;
            text-overflow: ellipsis;
            flex: 0 1 auto;
            font-weight: bold;
            min-width: 0;
            max-width: 100%;
        }
        .csrinruSteamHoverTip .threadTagText {
            font-size: 1em;
        }
        .csrinruSteamHoverTip .ratingStars {
            color: #f5c518;
            margin-right: 6px;
            letter-spacing: 1px;
            font-size: 14px;
            display: inline-block;
            vertical-align: middle;
        }
        .csrinruSteamHoverTip .ratingText {
            vertical-align: middle;
        }
        .csrinruSteamHoverTip .loadingContainer {
            display: flex;
            align-items: center;
            gap: 8px;
        }
        .csrinruSteamHoverTip .spinner {
            width: 18px;
            height: 18px;
            border: 2px solid #3a3a3a;
            border-top-color: #99d2f7;
            border-radius: 50%;
            animation: csrinruSteamSpinner 0.8s linear infinite;
            flex-shrink: 0;
        }
        @keyframes csrinruSteamSpinner {
            to { transform: rotate(360deg); }
        }
        .csrinruSteamHoverTip .tipActions {
            display: flex;
            justify-content: space-between;
            align-items: center;
            gap: 14px;
            margin-top: 8px;
            padding-top: 2px;
            border-top: none;
        }
        .csrinruSteamHoverTip .steamMediaExpandBtn {
            position: absolute;
            top: 6px;
            left: 6px;
            display: grid;
            place-items: center;
            width: 24px;
            height: 24px;
            margin: 0;
            padding: 0;
            border: 1px solid rgba(255, 255, 255, 0.22);
            background: rgba(0, 0, 0, 0.58);
            color: #f2f2f2;
            font: bold 16px/1 Arial, sans-serif;
            cursor: pointer;
            opacity: 0.82;
            touch-action: manipulation;
        }
        .csrinruSteamHoverTip .steamMediaExpandBtn:hover,
        .csrinruSteamHoverTip .steamMediaExpandBtn:focus {
            opacity: 1;
            background: rgba(0, 0, 0, 0.74);
            outline: 1px solid #99d2f7;
        }
        .csrinruSteamHoverTip .steamTheatreIcon,
        .csrinruSteamTheatre .steamTheatreIcon {
            position: relative;
            display: block;
            width: 17px;
            height: 11px;
            border: 2px solid currentColor;
            border-radius: 1px;
            box-sizing: border-box;
        }
        .csrinruSteamHoverTip .steamTheatreIcon::after,
        .csrinruSteamTheatre .steamTheatreIcon::after {
            content: '';
            position: absolute;
            left: 4px;
            right: 4px;
            bottom: -5px;
            height: 2px;
            background: currentColor;
            opacity: 0.72;
        }
        .csrinruSteamHoverTip .steamMediaExpandHidden {
            display: none;
        }
        .csrinruSteamTheatre {
            position: fixed;
            inset: 0;
            display: none;
            place-items: center;
            padding: 48px;
            background: rgba(0, 0, 0, 0.78);
            color: #c7d5e0;
            font: 12px/1.45 Arial, Helvetica, sans-serif;
            z-index: 2147483647;
            box-sizing: border-box;
        }
        .csrinruSteamTheatreOpen {
            display: grid;
        }
        .csrinruSteamTheatreShell {
            display: grid;
            grid-template-rows: 34px minmax(0, 1fr) auto;
            width: min(1416px, calc(100vw - 96px));
            height: min(866px, calc(100vh - 96px));
            overflow: hidden;
            background: #050505;
            box-shadow: 0 8px 36px rgba(0, 0, 0, 0.72);
        }
        .csrinruSteamTheatreVideoMode .csrinruSteamTheatreShell {
            grid-template-rows: 34px minmax(0, 1fr) auto 33px;
        }
        .csrinruSteamTheatre:fullscreen {
            padding: 0;
            background: #000;
        }
        .csrinruSteamTheatre:fullscreen .csrinruSteamTheatreShell {
            width: 100vw;
            height: 100vh;
        }
        .csrinruSteamTheatreHeader {
            position: relative;
            display: flex;
            align-items: center;
            justify-content: center;
            min-width: 0;
            padding: 0 96px;
            background: #20262e;
            box-sizing: border-box;
        }
        .csrinruSteamTheatreTitle {
            overflow: hidden;
            color: #b8c6d4;
            font-size: 13px;
            text-overflow: ellipsis;
            white-space: nowrap;
        }
        .csrinruSteamTheatreHeaderControls {
            position: absolute;
            top: 5px;
            right: 8px;
            display: flex;
            align-items: center;
            gap: 6px;
        }
        .csrinruSteamTheatreBtn,
        .csrinruSteamTheatreNavBtn {
            margin: 0;
            border: 0;
            color: #f2f2f2;
            cursor: pointer;
            touch-action: manipulation;
        }
        .csrinruSteamTheatreBtn {
            display: grid;
            place-items: center;
            width: 24px;
            height: 24px;
            padding: 0;
            background: transparent;
            font: bold 21px/1 Arial, sans-serif;
            opacity: 0.82;
        }
        .csrinruSteamTheatreBtn:hover,
        .csrinruSteamTheatreBtn:focus {
            opacity: 1;
            outline: 1px solid #99d2f7;
        }
        .csrinruSteamTheatreStage {
            position: relative;
            min-width: 0;
            min-height: 0;
            overflow: hidden;
            background: #050505;
        }
        .csrinruSteamTheatreViewport {
            display: grid;
            place-items: center;
            width: 100%;
            height: 100%;
        }
        .csrinruSteamTheatreImage,
        .csrinruSteamTheatrePoster,
        .csrinruSteamTheatreVideo {
            display: block;
            width: 100%;
            height: 100%;
            max-width: 100%;
            max-height: 100%;
            margin: 0;
            object-fit: contain;
            background: #000;
        }
        .csrinruSteamTheatreVideoWrap {
            position: relative;
            width: 100%;
            height: 100%;
            background: #000;
        }
        .csrinruSteamTheatreVideoWrap:fullscreen {
            width: 100vw;
            height: 100vh;
        }
        .csrinruSteamTheatreVideoControls {
            display: grid;
            gap: 6px;
            align-content: end;
            min-height: 72px;
            padding: 18px 20px 12px;
            background: linear-gradient(to top, rgba(0, 0, 0, 0.76) 0%, rgba(0, 0, 0, 0.54) 48%, rgba(0, 0, 0, 0.16) 82%, rgba(0, 0, 0, 0) 100%);
            color: #f2f2f2;
            box-sizing: border-box;
        }
        .csrinruSteamTheatreVideoControlsFullscreen {
            display: none;
        }
        .csrinruSteamTheatreVideoWrap:fullscreen .csrinruSteamTheatreVideoControlsFullscreen {
            position: absolute;
            left: 0;
            right: 0;
            bottom: 0;
            display: grid;
            min-height: 96px;
            padding: 34px 20px max(16px, env(safe-area-inset-bottom));
            z-index: 3;
        }
        .csrinruSteamTheatre:fullscreen .csrinruSteamTheatreVideoControls {
            min-height: 72px;
            padding: 18px 20px 12px;
        }
        .csrinruSteamTheatreVideoSeek {
            width: 100%;
            height: 10px;
            margin: 0;
            accent-color: #1a9fff;
            cursor: pointer;
        }
        .csrinruSteamTheatreVideoControlRow {
            display: flex;
            align-items: center;
            gap: 8px;
            min-width: 0;
        }
        .csrinruSteamTheatreVideoControlSpacer {
            flex: 1 1 auto;
        }
        .csrinruSteamTheatreVideoControlBtn {
            display: grid;
            place-items: center;
            width: 28px;
            height: 24px;
            margin: 0;
            padding: 0;
            background: transparent;
            border: 0;
            color: #f2f2f2;
            font: bold 18px/1 Arial, Helvetica, sans-serif;
            cursor: pointer;
            opacity: 0.86;
            touch-action: manipulation;
        }
        .csrinruSteamTheatreVideoControlBtn:hover,
        .csrinruSteamTheatreVideoControlBtn:focus {
            opacity: 1;
            outline: 1px solid #99d2f7;
        }
        .csrinruSteamTheatreVideoTime {
            flex: 0 0 auto;
            color: #d6e8ee;
            font-size: 13px;
            white-space: nowrap;
        }
        .csrinruSteamTheatreNavBtn {
            position: absolute;
            top: 50%;
            display: grid;
            place-items: center;
            width: 48px;
            height: 72px;
            padding: 0;
            background: rgba(0, 0, 0, 0.26);
            font: bold 52px/1 Arial, sans-serif;
            opacity: 0.62;
            transform: translateY(-50%);
        }
        .csrinruSteamTheatreNavBtn:hover,
        .csrinruSteamTheatreNavBtn:focus {
            opacity: 1;
            background: rgba(0, 0, 0, 0.42);
            outline: 1px solid #99d2f7;
        }
        .csrinruSteamTheatrePrevBtn {
            left: 0;
        }
        .csrinruSteamTheatreNextBtn {
            right: 0;
        }
        .csrinruSteamTheatreFooter {
            position: relative;
            display: grid;
            place-items: center;
            min-height: 33px;
            padding: 7px 72px 8px;
            background: #20262e;
            box-sizing: border-box;
        }
        .csrinruSteamTheatreVideoMode .csrinruSteamTheatreFooter {
            padding: 7px 72px 8px;
        }
        .csrinruSteamTheatreFooterActions {
            position: absolute;
            top: 50%;
            left: 18px;
            display: flex;
            align-items: center;
            gap: 14px;
            max-width: calc(50% - 72px);
            min-width: 0;
            overflow: hidden;
            transform: translateY(-50%);
        }
        .csrinruSteamTheatreFooterActions a {
            display: inline-flex;
            align-items: center;
            gap: 4px;
            min-width: 0;
            overflow: hidden;
            color: #99d2f7 !important;
            font: 12px/1.35 Arial, Helvetica, sans-serif;
            text-decoration: underline;
            text-overflow: ellipsis;
            white-space: nowrap;
            opacity: 0.92;
            touch-action: manipulation;
        }
        .csrinruSteamTheatreFooterActions a:hover,
        .csrinruSteamTheatreFooterActions a:focus {
            color: #c7ebff !important;
            opacity: 1;
            outline: none;
        }
        .csrinruSteamTheatreCounter {
            color: #9fb0bf;
            font-size: 12px;
            text-align: center;
        }
        .csrinruSteamTheatreFooterControlsHost {
            position: absolute;
            top: 50%;
            right: 18px;
            display: flex;
            align-items: center;
            gap: 8px;
            transform: translateY(-50%);
        }
        .csrinruSteamTheatreError {
            display: grid;
            place-items: center;
            gap: 8px;
            width: 100%;
            height: 100%;
            padding: 24px;
            color: #d6e8ee;
            text-align: center;
            box-sizing: border-box;
        }
    `);

    loadPersistentCache();
    document.body.appendChild(tip);
    document.body.appendChild(theatre);

    document.addEventListener('visibilitychange', () => {
        isPageHidden = document.hidden;
    });

    function isProbablyTopicTitleLink(link) {
        if (!link) return false;

        const href = link.getAttribute('href') || '';
        if (!/viewtopic\.php/i.test(href)) return false;

        const text = link.textContent.replace(/\s+/g, ' ').trim();
        if (!text || text.length < 2) return false;
        if (/^\d+$/.test(text)) return false;
        if (/^(next|previous|last|first)$/i.test(text)) return false;

        const topicId = getTopicId(href);
        if (!topicId) return false;

        if (link.closest('.pagination')) return false;
        if (/\b(go to page|page)\b/i.test(link.parentElement?.textContent || '') && /^\d+$/.test(text)) return false;

        return true;
    }

    function getTopicRow(link) {
        return link.closest('tr') || link.closest('li') || link.closest('.row') || link.parentElement;
    }

    let hasTopicsSectionCache = null;
    function rowSectionName(row) {
        const text = row?.textContent?.replace(/\s+/g, ' ').trim().toLowerCase() || '';
        if (text === 'topics') return 'topics';
        if (text === 'announcements') return 'announcements';
        if (text === 'stickies') return 'stickies';
        return null;
    }

    function pageHasTopicsSection() {
        if (hasTopicsSectionCache !== null) return hasTopicsSectionCache;

        hasTopicsSectionCache = Array.from(document.querySelectorAll('tr, .row'))
            .some(row => rowSectionName(row) === 'topics');
        return hasTopicsSectionCache;
    }

    function getPreviousSectionName(row) {
        let current = row;
        while (current && current.previousElementSibling) {
            current = current.previousElementSibling;
            const sectionName = rowSectionName(current);
            if (sectionName) return sectionName;
        }
        return null;
    }

    function isInTopicsSection(row) {
        if (!pageHasTopicsSection()) return true;
        return getPreviousSectionName(row) === 'topics';
    }

    function getLatestPageUrl(link, row) {
        const topicUrl = absoluteUrl(link.getAttribute('href'));
        const topicId = getTopicId(topicUrl);
        if (!row || !topicId) return topicUrl;

        const rowLinks = Array.from(row.querySelectorAll('a[href*="viewtopic.php"]'));
        const pageLinks = rowLinks.filter((a) => {
            const text = a.textContent.trim();
            return /^\d+$/.test(text) && getTopicId(a.getAttribute('href')) === topicId;
        });

        if (pageLinks.length) {
            return absoluteUrl(pageLinks[pageLinks.length - 1].getAttribute('href'));
        }

        const latestPostLinks = rowLinks.filter((a) => {
            const href = absoluteUrl(a.getAttribute('href'));
            return /[?&]p=\d+|#p\d+/i.test(href);
        });

        if (latestPostLinks.length) {
            return absoluteUrl(latestPostLinks[latestPostLinks.length - 1].getAttribute('href'));
        }

        return topicUrl;
    }

    function extractThreadTags(rawTitle) {
        const matches = rawTitle?.match(/\[([^\]]+)]/g) || [];
        const seen = new Set();

        return matches.filter((tag) => {
            const key = tag.toLowerCase();
            if (key === '[info]') return false;
            if (seen.has(key)) return false;
            seen.add(key);
            return true;
        });
    }

    function hexToRgb(hex) {
        return [
            parseInt(hex.substring(0, 2), 16),
            parseInt(hex.substring(2, 4), 16),
            parseInt(hex.substring(4, 6), 16)
        ];
    }

    function getBackgroundRgb(sourceParent) {
        let parent = sourceParent;

        while (parent) {
            const bg = getComputedStyle(parent).getPropertyValue('background-color');
            const matches = bg.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/);
            if (matches) {
                return [parseInt(matches[1], 10), parseInt(matches[2], 10), parseInt(matches[3], 10)];
            }
            parent = parent.parentElement;
        }

        return [28, 28, 28];
    }

    // Mirrors CS.RIN.RU Enhanced's deterministic custom-tag color algorithm.
    function colorizeThreadTag(tag, sourceParent) {
        let hash = 0;
        const lowerTag = tag.toLowerCase();
        for (let i = 0; i < lowerTag.length; i++) {
            hash = lowerTag.charCodeAt(i) + ((hash << 5) - hash);
        }

        let color = Math.floor(Math.abs((Math.sin(hash) * 10000) % 1 * 16777216)).toString(16);
        let rgb = hexToRgb(color);
        const bgRgb = getBackgroundRgb(sourceParent);

        while (Math.abs(rgb[0] + rgb[1] + rgb[2] - (bgRgb[0] + bgRgb[1] + bgRgb[2])) < 300) {
            hash = (hash << 5) - hash;
            color = Math.floor(Math.abs((Math.sin(hash) * 10000) % 1 * 16777216)).toString(16);
            rgb = hexToRgb(color);
        }

        return `#${color.padStart(6, '0')}`;
    }

    function renderThreadTags(tags, sourceParent) {
        if (!tags?.length) return '';

        return `<span class="threadTagList" aria-label="Thread tags">${tags.map((tag) => {
            const color = escapeHtml(colorizeThreadTag(tag, sourceParent));
            const label = escapeHtml(tag.replace(/[\[\]]/g, ''));
            return `<span class="threadTag" style="color:${color};"><span>[</span><span class="threadTagText">${label}</span><span>]</span></span>`;
        }).join(' ')}</span>`;
    }

    function getSteamDbUrl(appId) {
        return appId ? `https://steamdb.info/app/${encodeURIComponent(appId)}/` : '';
    }

    function renderMetaRow(rawAppId, topicInfo) {
        const appId = escapeHtml(rawAppId || '');
        const steamDbUrl = rawAppId ? escapeHtml(getSteamDbUrl(rawAppId)) : '';
        const appIdHtml = rawAppId ?
            `<span class="steamAppIdControl"><strong>AppID:</strong> <button type="button" class="copyAppIdBtn" data-app-id="${appId}" title="Copy Steam AppID">${appId}</button><a class="steamDbLink" href="${steamDbUrl}" target="_blank" rel="noopener noreferrer" title="Open SteamDB" aria-label="Open SteamDB for Steam AppID ${appId}">&#8599;</a></span>` :
            '';
        const threadTagsHtml = renderThreadTags(topicInfo.threadTags, topicInfo.tagSourceParent);
        const rowClass = appIdHtml ? 'steamMetaRow' : 'steamMetaRow steamMetaRowTagsOnly';

        return appIdHtml || threadTagsHtml ?
            `<div class="${rowClass}">${appIdHtml}${threadTagsHtml}</div>` :
            '';
    }

    function isUsableUrl(value) {
        try {
            const url = new URL(value);
            return url.protocol === 'http:' || url.protocol === 'https:';
        } catch (_) {
            return false;
        }
    }

    function getUsableMediaUrl(value) {
        try {
            const url = new URL(value);
            if (url.protocol !== 'http:' && url.protocol !== 'https:') return '';
            if (url.protocol === 'http:' && /(^|\.)steamstatic\.com$|(^|\.)akamai\.steamstatic\.com$|(^|\.)akamaihd\.net$/.test(url.hostname)) {
                url.protocol = 'https:';
            }
            return url.href;
        } catch (_) {
            return '';
        }
    }

    function getPreferredVideoUrl(source) {
        if (!source) return '';
        if (typeof source === 'string') return getUsableMediaUrl(source);

        for (const key of ['max', '720', '720p', '480', '480p']) {
            const url = getUsableMediaUrl(source[key]);
            if (url) return url;
        }

        return Object.values(source)
            .map(getUsableMediaUrl)
            .find(Boolean) || '';
    }

    function getVideoQualityValue(key) {
        const normalized = String(key || '').toLowerCase();
        if (normalized === 'max' || normalized === 'hd') return 10000;

        const numeric = parseInt(normalized.replace(/[^\d]/g, ''), 10);
        return isNaN(numeric) ? 0 : numeric;
    }

    function getVideoSourceLabel(format, key) {
        const upperFormat = String(format || 'Video').toUpperCase();
        const normalized = String(key || '').toLowerCase();

        if (normalized === 'max') return `${upperFormat} HD`;
        if (/^\d+p?$/.test(normalized)) return `${upperFormat} ${parseInt(normalized, 10)}p`;
        if (normalized) return `${upperFormat} ${normalized.toUpperCase()}`;
        return upperFormat;
    }

    function collectVideoSources(source, format) {
        if (!source) return [];

        if (typeof source === 'string') {
            const url = getUsableMediaUrl(source);
            return url ? [{
                url,
                label: getVideoSourceLabel(format, ''),
                format: String(format || 'video').toLowerCase(),
                quality: 0
            }] : [];
        }

        return Object.entries(source)
            .map(([key, value]) => {
                const url = getUsableMediaUrl(value);
                if (!url) return null;

                return {
                    url,
                    label: getVideoSourceLabel(format, key),
                    format: String(format || 'video').toLowerCase(),
                    quality: getVideoQualityValue(key)
                };
            })
            .filter(Boolean);
    }

    function getMovieVideoSources(movie) {
        const sources = [
            ...collectVideoSources(movie?.mp4, 'MP4'),
            ...collectVideoSources(movie?.webm, 'WebM'),
            ...collectVideoSources(movie?.video_url, 'MP4')
        ];
        const seen = new Set();

        return sources
            .filter((source) => {
                if (!source.url || seen.has(source.url)) return false;
                seen.add(source.url);
                return true;
            })
            .sort((a, b) => {
                const qualityDiff = b.quality - a.quality;
                if (qualityDiff) return qualityDiff;
                if (a.format === b.format) return 0;
                if (a.format === 'mp4') return -1;
                if (b.format === 'mp4') return 1;
                return a.format.localeCompare(b.format);
            });
    }

    function getMovieSources(movie) {
        const videoSources = getMovieVideoSources(movie);
        const videoUrl = getPreferredVideoUrl(movie?.mp4) ||
            getPreferredVideoUrl(movie?.webm) ||
            getUsableMediaUrl(movie?.video_url);

        const hlsUrl = getUsableMediaUrl(movie?.hls_h264) ||
            getUsableMediaUrl(movie?.hlsManifest) ||
            getUsableMediaUrl(movie?.hls);

        const dashUrl = getUsableMediaUrl(movie?.dash_h264) ||
            getUsableMediaUrl(movie?.dash_av1);

        return { videoUrl: videoSources[0]?.url || videoUrl, hlsUrl, dashUrl, videoSources };
    }

    function normalizeSteamMedia(appData) {
        const media = [];
        const seen = new Set();
        const title = appData?.name || 'Steam media';

        function addMedia(item) {
            const key = item.url || item.videoUrl || item.posterUrl;
            if (!key || seen.has(key)) return;
            seen.add(key);
            media.push(item);
        }

        if (isUsableUrl(appData?.header_image)) {
            addMedia({
                type: 'image',
                source: 'header',
                url: appData.header_image,
                alt: `${title} Steam header image`
            });
        }

        (appData?.movies || []).forEach((movie, index) => {
            const posterUrl = getUsableMediaUrl(movie?.thumbnail);
            const { videoUrl, hlsUrl, dashUrl, videoSources } = getMovieSources(movie);
            if (!isUsableUrl(posterUrl) || (!isUsableUrl(videoUrl) && !isUsableUrl(hlsUrl))) return;

            addMedia({
                type: 'video',
                source: 'movie',
                posterUrl,
                videoUrl,
                videoSources,
                hlsUrl,
                dashUrl,
                alt: movie?.name || `${title} video ${index + 1}`
            });
        });

        (appData?.screenshots || []).forEach((screenshot, index) => {
            const url = getUsableMediaUrl(screenshot?.path_full) || getUsableMediaUrl(screenshot?.path_thumbnail);
            const thumbUrl = getUsableMediaUrl(screenshot?.path_thumbnail) || url;
            if (!isUsableUrl(url)) return;

            addMedia({
                type: 'image',
                source: 'screenshot',
                url,
                thumbUrl,
                alt: `${title} screenshot ${index + 1}`
            });
        });

        return media;
    }

    function getMediaSortRank(item) {
        if (item?.source === 'header') return 0;
        if (item?.type === 'video' || item?.source === 'movie') return 1;
        if (item?.source === 'screenshot') return 2;
        return 3;
    }

    function orderSteamMediaItems(media) {
        if (!Array.isArray(media)) return [];

        return media
            .map((item, index) => ({ item, index }))
            .sort((a, b) => {
                const rankDiff = getMediaSortRank(a.item) - getMediaSortRank(b.item);
                return rankDiff || a.index - b.index;
            })
            .map(({ item }) => item);
    }

    function cleanName(raw) {
        if (!raw) return null;

        let name = raw.replace(/\s+/g, ' ').trim();

        if (/\b(best practices|release requirements|windows security|manifest generator|dbcode generator|rules|faq|tutorial|guide|index)\b/i.test(name)) {
            return null;
        }

        name = name.replace(/^\s*(?:\[[^\]]+\]\s*)+/g, '');
        name = name.replace(/\[[^\]]*(?:info|request|steam|gog|epic|cracked|not cracked|hypervisor|early access|playable|build|update|linux|mac|windows|goldberg|rune|tenoke|flt|skidrow|codex)[^\]]*\]/gi, ' ');
        name = name.replace(/\s+MOD\s+TOPIC\b.*$/i, '');
        name = name.replace(/\((?:[^)]*(?:request and share|build|update|crack|cracked|not cracked|multi\d*|dlc|language|windows|linux|mac)[^)]*)\)/gi, ' ');
        name = name.replace(/\b(NOT\s+CRACKED|HYPERVISOR|CRACKED|EARLY\s+ACCESS|PLAYABLE)\b/gi, ' ');
        name = name.replace(/[._]/g, ' ');
        name = name.replace(/\s+(x64|x86|64bit|32bit|64-bit|32-bit)\b/gi, '');
        name = name.replace(/\s+MULTI\d*\b/gi, '');
        name = name.replace(/\s+(incl|incl\.|including)\s+.*/gi, '');
        name = name.replace(/\b(Update|Build|Hotfix|Patch)\b.*$/i, '');
        name = name.replace(/\bv\d[\d.]*.*$/i, '');
        name = name.replace(/\s{2,}/g, ' ').trim();

        return name.length >= 2 ? name : null;
    }

    function getTopicInfo(link) {
        if (!isProbablyTopicTitleLink(link)) return null;

        const rawTitle = link.textContent.replace(/\s+/g, ' ').trim();
        const gameName = cleanName(rawTitle);
        if (!gameName) return null;

        const row = getTopicRow(link);
        if (!isInTopicsSection(row)) return null;

        const topicUrl = absoluteUrl(link.getAttribute('href'));
        const latestUrl = getLatestPageUrl(link, row);
        const threadTags = extractThreadTags(rawTitle);
        const tagSourceParent = link.parentElement;

        return { link, row, rawTitle, gameName, topicUrl, latestUrl, threadTags, tagSourceParent };
    }

    async function waitForRequestSlot() {
        const wait = Math.max(0, MIN_INTERVAL - (Date.now() - lastRequest));
        if (wait) await delay(wait);
        lastRequest = Date.now();
    }

    function gmFetch(url, responseType = 'json', timeout = API_TIMEOUT) {
        const slot = requestGate.then(waitForRequestSlot, waitForRequestSlot);
        requestGate = slot.catch(() => null);

        return slot
            .then(() => new Promise((resolve, reject) => {
                lastRequest = Date.now();
                GM_xmlhttpRequest({
                    method: 'GET',
                    url,
                    responseType,
                    timeout,
                    headers: {
                        'Accept-Language': 'en-US,en;q=0.9',
                        'Cookie': 'birthtime=0; mature_content=1; wants_mature_content=1; lastagecheckage=1-0-1990'
                    },
                    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 (_) {
                                        reject(new Error(`JSON parse error for ${url}`));
                                    }
                                }
                            } else {
                                resolve(res.response || res.responseText);
                            }
                        } else {
                            reject(new Error(`HTTP ${res.status} for ${url}`));
                        }
                    },
                    onerror: () => reject(new Error(`Network error for ${url}`)),
                    ontimeout: () => reject(new Error(`Timeout ${timeout}ms for ${url}`)),
                    onabort: () => reject(new Error(`Aborted request for ${url}`))
                });
            }));
    }

    function stripEditionSuffixForSearch(name) {
        return name
            .replace(/\s*[:\-]\s*(Digital\s+Deluxe|Deluxe|Ultimate|Gold|Premium|Collector'?s)\s+Edition\b.*$/i, '')
            .replace(/\s+(Digital\s+Deluxe|Deluxe|Ultimate|Gold|Premium|Collector'?s)\s+Edition\b.*$/i, '')
            .replace(/\s{2,}/g, ' ')
            .trim();
    }

    async function fetchSteamWithFallback(originalName) {
        const words = originalName.split(/\s+/);
        const attempts = [];
        const baseName = stripEditionSuffixForSearch(originalName);

        if (baseName && baseName.toLowerCase() !== originalName.toLowerCase()) {
            attempts.push(baseName);
        }

        attempts.push(originalName);

        const maxAttempts = Math.min(words.length, 4);
        for (let i = 0; i < maxAttempts; i++) {
            const tryName = words.slice(0, words.length - i).join(' ');
            if (tryName.length >= 2) attempts.push(tryName);
        }

        for (const tryName of [...new Set(attempts)]) {
            const result = await fetchSteam(tryName);
            if (result) return result;
        }

        return null;
    }

    async function fetchAppDetailsForIds(appIds) {
        const ids = [...new Set(appIds.filter(Boolean))].slice(0, MAX_SEARCH_CANDIDATES);
        if (!ids.length) return {};

        const batchUrl = `https://store.steampowered.com/api/appdetails?appids=${ids.join(',')}&cc=us&l=en`;
        try {
            return await gmFetch(batchUrl, 'json');
        } catch (_) {
            const details = {};
            for (const id of ids) {
                const singleUrl = `https://store.steampowered.com/api/appdetails?appids=${id}&cc=us&l=en`;
                const singleRes = await gmFetch(singleUrl, 'json').catch(() => null);
                if (singleRes?.[id]) details[id] = singleRes[id];
            }
            return details;
        }
    }

    function getReviewInfo(reviewRes) {
        if (!reviewRes?.success || !reviewRes.query_summary) return null;

        const summary = reviewRes.query_summary;
        const percent = summary.total_reviews ? Math.round((summary.total_positive / summary.total_reviews) * 100) : null;
        return {
            desc: summary.review_score_desc || 'No Reviews',
            percent,
            total: summary.total_reviews || 0
        };
    }

    function getGenreTags(appData) {
        return (appData?.genres || [])
            .map(genre => genre.description)
            .filter(Boolean)
            .slice(0, 5);
    }

    function getTagSource(data) {
        if (data?.tagsSource) return data.tagsSource;
        return data?.tags?.length ? 'steam' : 'genres';
    }

    function updateCachedDataForApp(appId, updater) {
        let updated = false;
        const now = Date.now();

        for (const [key, value] of apiCache.entries()) {
            if (value.data?.appId !== appId) continue;

            apiCache.set(key, {
                ...value,
                data: updater(value.data, now),
                ts: now
            });
            updated = true;
        }

        if (updated) savePersistentCache();
    }

    async function fetchSteamTags(appId) {
        const storeHtml = await gmFetch(`https://store.steampowered.com/app/${appId}/`, 'text');
        if (!storeHtml) return [];

        const doc = new DOMParser().parseFromString(storeHtml, 'text/html');
        return Array.from(doc.querySelectorAll('a.app_tag'))
            .map(el => el.textContent.trim())
            .filter(tag => tag && tag !== '+')
            .slice(0, 5);
    }

    function warmSteamTags(data) {
        if (!data?.appId) return null;
        if (getTagSource(data) === 'steam') return null;
        if (data.steamTagsAttemptTs && Date.now() - data.steamTagsAttemptTs < MEMORY_CACHE_TTL) return null;

        const inFlight = inFlightTagFetches.get(data.appId);
        if (inFlight) return inFlight;

        updateCachedDataForApp(data.appId, (cachedData, now) => ({
            ...cachedData,
            steamTagsAttemptTs: now
        }));

        const request = fetchSteamTags(data.appId)
            .then(tags => {
                if (!tags.length) return;

                updateCachedDataForApp(data.appId, (cachedData, now) => ({
                    ...cachedData,
                    tags,
                    tagsSource: 'steam',
                    steamTagsAttemptTs: now
                }));
            })
            .catch(() => null)
            .finally(() => inFlightTagFetches.delete(data.appId));

        inFlightTagFetches.set(data.appId, request);
        return request;
    }

    function normalizeForMatch(value) {
        return String(value ?? '')
            .toLowerCase()
            .replace(/&/g, ' and ')
            .replace(/[®™©]/g, '')
            .replace(/['’]/g, '')
            .replace(/[^a-z0-9]+/g, ' ')
            .replace(/\s+/g, ' ')
            .trim();
    }

    function getBaseMatchName(value) {
        return normalizeForMatch(stripEditionSuffixForSearch(value));
    }

    function hasDlcNameMarker(item, data) {
        const type = normalizeForMatch(data?.type);
        const name = normalizeForMatch(`${item?.name || ''} ${data?.name || ''}`);

        return type === 'dlc' ||
            /\b(dlc|upgrade|season pass|expansion pass|soundtrack|ost|artbook|skin pack|costume pack|weapon pack|bonus content)\b/.test(name);
    }

    function getTokenOverlapScore(baseName, candidateName) {
        const tokens = baseName.split(' ').filter(token => token.length > 1);
        if (!tokens.length) return 0;

        const candidateTokens = new Set(candidateName.split(' '));
        const matches = tokens.filter(token => candidateTokens.has(token)).length;
        return matches / tokens.length;
    }

    function scoreSearchCandidate(searchName, candidate, index) {
        const itemName = candidate.item?.name || '';
        const dataName = candidate.data?.name || itemName;
        const candidateName = normalizeForMatch(dataName);
        const itemMatchName = normalizeForMatch(itemName);
        const searchMatchName = normalizeForMatch(searchName);
        const baseMatchName = getBaseMatchName(searchName);
        const type = normalizeForMatch(candidate.data?.type);

        let score = 100 - index;

        if (type === 'game') score += 40;
        else if (type) score -= 120;

        if (candidateName === searchMatchName || itemMatchName === searchMatchName) score += 35;
        if (baseMatchName && (candidateName === baseMatchName || itemMatchName === baseMatchName)) score += 120;
        if (baseMatchName && (candidateName.startsWith(baseMatchName) || itemMatchName.startsWith(baseMatchName))) score += 35;
        if (baseMatchName) score += Math.round(getTokenOverlapScore(baseMatchName, candidateName) * 50);
        if (hasDlcNameMarker(candidate.item, candidate.data)) score -= 170;

        return score;
    }

    async function fetchSteam(name) {
        const now = Date.now();
        const hit = getFreshCacheEntry(name, now);
        if (hit) {
            return hit.data;
        }

        const inFlight = inFlightFetches.get(name);
        if (inFlight) return inFlight;

        const request = fetchSteamUncached(name, now)
            .finally(() => inFlightFetches.delete(name));
        inFlightFetches.set(name, request);
        return request;
    }

    async function fetchSteamUncached(name, now) {
        let appId = null;
        let appData = null;
        let reviewInfo = null;

        try {
            const searchUrl = `https://store.steampowered.com/api/storesearch/?cc=us&l=en&term=${encodeURIComponent(name)}`;
            const searchRes = await gmFetch(searchUrl, 'json');
            const items = searchRes?.items || [];
            if (!items.length) throw new Error('No suitable AppID found in search results.');

            const exactMatches = items.filter(item => item.name?.toLowerCase() === name.toLowerCase());
            const orderedCandidates = [];
            const seenIds = new Set();
            for (const item of [...exactMatches, ...items]) {
                if (!item?.id || seenIds.has(item.id)) continue;
                orderedCandidates.push(item);
                seenIds.add(item.id);
                if (orderedCandidates.length >= MAX_SEARCH_CANDIDATES) break;
            }

            const detailsRes = await fetchAppDetailsForIds(orderedCandidates.map(item => item.id));
            const enriched = orderedCandidates.map(item => ({
                item,
                data: detailsRes?.[item.id]?.success ? detailsRes[item.id].data : null
            }));

            const scoredCandidates = enriched
                .map((candidate, index) => ({
                    ...candidate,
                    score: scoreSearchCandidate(name, candidate, index)
                }))
                .sort((a, b) => b.score - a.score);

            const result = scoredCandidates[0] || { item: orderedCandidates[0], data: null };
            appId = result.item?.id;
            appData = result.data;

            if (appData?.type && appData.type !== 'game') {
                const mainGame = enriched.find(candidate => candidate.data?.type === 'game');
                if (mainGame) {
                    appId = mainGame.item.id;
                    appData = mainGame.data;
                }
            }

            if (!appId) throw new Error('No suitable AppID found in search results.');
        } catch (err) {
            console.warn(`[CS.RIN.RU Steam Hover] Steam search failed for "${name}":`, err.message);
            apiCache.set(name, { data: null, ts: now });
            pruneCache(apiCache);
            return null;
        }

        try {
            const detailsUrl = `https://store.steampowered.com/api/appdetails?appids=${appId}&cc=us&l=en`;
            const reviewUrl = `https://store.steampowered.com/appreviews/${appId}?json=1&language=all&purchase_type=all&filter=summary`;

            if (appData) {
                const reviewRes = await gmFetch(reviewUrl, 'json').catch(() => null);
                reviewInfo = getReviewInfo(reviewRes);
            } else {
                const [detailsRes, reviewRes] = await Promise.all([
                    gmFetch(detailsUrl, 'json').catch(() => null),
                    gmFetch(reviewUrl, 'json').catch(() => null)
                ]);

                if (detailsRes?.[appId]?.success) {
                    appData = detailsRes[appId].data;
                } else {
                    throw new Error('Failed to fetch app details.');
                }

                reviewInfo = getReviewInfo(reviewRes);
            }
        } catch (err) {
            console.warn(`[CS.RIN.RU Steam Hover] Steam details/reviews failed for ${appId}:`, err.message);
            if (!appData) {
                apiCache.set(name, { data: null, ts: now });
                pruneCache(apiCache);
                return null;
            }
        }

        const tags = getGenreTags(appData);
        const media = normalizeSteamMedia(appData);

        const data = {
            ...appData,
            appId,
            media,
            tags,
            tagsSource: 'genres',
            reviewInfo,
            releaseDate: appData.release_date?.date || null,
            storeUrl: `https://store.steampowered.com/app/${appId}/`
        };

        apiCache.set(name, { data, ts: now });
        pruneCache(apiCache);
        savePersistentCache();
        return data;
    }

    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);
        }

        return stars ? `<span class="ratingStars">${stars}</span>` : '';
    }

    function stopActiveVideo() {
        tip.querySelectorAll('.steamMediaVideo').forEach((video) => {
            try {
                video.pause();
                video.removeAttribute('src');
                video.load();
            } catch (_) {
                // Ignore cleanup failures from browser media internals.
            }
        });
    }

    function stopTheatreVideo() {
        theatre.querySelectorAll('.csrinruSteamTheatreVideo').forEach((video) => {
            try {
                video.pause();
                video.removeAttribute('src');
                video.load();
            } catch (_) {
                // Ignore cleanup failures from browser media internals.
            }
        });
    }

    function resetMediaState() {
        stopActiveVideo();
        currentMedia = [];
        currentMediaIndex = 0;
        currentMediaTitle = '';
        currentStoreUrl = '';
        currentLatestUrl = '';
    }

    function getRenderMedia(data) {
        const normalizedMedia = normalizeSteamMedia(data);
        const media = normalizedMedia.length ? normalizedMedia : (data?.media || []);
        return orderSteamMediaItems(media);
    }

    function renderMediaItem(item, index, title) {
        if (!item) return '';

        const label = escapeHtml(item.alt || title || 'Steam media');
        if (item.type === 'video') {
            return `
                <button type="button" class="steamMediaPlayBtn" data-media-index="${index}" aria-label="Play ${label}" title="Play video">
                    <img class="steamMediaPoster" src="${escapeHtml(item.posterUrl)}" alt="${label}" loading="lazy" onerror="this.style.display='none'">
                    <span class="steamMediaPlayIcon" aria-hidden="true">&#9658;</span>
                </button>
            `;
        }

        return `<img class="steamMediaImage" src="${escapeHtml(item.url)}" alt="${label}" loading="lazy" onerror="this.style.display='none'">`;
    }

    function getCurrentSteamUrl() {
        const storeUrl = getUsableMediaUrl(currentStoreUrl);
        if (storeUrl) return storeUrl;

        const title = theatreMediaTitle || currentMediaTitle || 'Steam';
        return `https://store.steampowered.com/search/?term=${encodeURIComponent(title)}`;
    }

    function renderVideoPlaybackFallback(className) {
        const steamUrl = escapeHtml(getCurrentSteamUrl());
        return `
            <div class="${className}">
                <span>Trailer format cannot play on this forum page.</span>
                <a href="${steamUrl}" target="_blank" rel="noopener noreferrer">Open on Steam</a>
            </div>
        `;
    }

    function bindVideoPlaybackFallback(video, viewport, className, root) {
        if (!video || !viewport) return;

        video.addEventListener('error', () => {
            if (root?.contains(video)) {
                viewport.innerHTML = renderVideoPlaybackFallback(className);
            }
        }, { once: true });
    }

    function getMediaThumbUrl(item) {
        if (!item) return '';
        return item.type === 'video' ? item.posterUrl : (item.thumbUrl || item.url);
    }

    function isScreenshotMedia(item) {
        return item?.type === 'image' && item?.source === 'screenshot';
    }

    function isVideoMedia(item) {
        return item?.type === 'video';
    }

    function canOpenMediaTheatre(item) {
        return isScreenshotMedia(item) || isVideoMedia(item);
    }

    function getTheatreCounterText() {
        const item = theatreMedia[theatreMediaIndex];
        const ordinal = theatreAllMedia.indexOf(item);
        const count = theatreAllMedia.length || theatreMedia.length;
        const position = ordinal >= 0 ? ordinal + 1 : theatreMediaIndex + 1;
        return `${position} of ${count}`;
    }

    function renderMediaThumbs(media, activeIndex, title) {
        if (!media?.length || media.length <= 1) return '';

        const thumbsHtml = media.map((item, index) => {
            const thumbUrl = getMediaThumbUrl(item);
            if (!isUsableUrl(thumbUrl)) return '';

            const label = escapeHtml(item.alt || title || `Steam media ${index + 1}`);
            const activeClass = index === activeIndex ? ' steamMediaThumbActive' : '';
            const current = index === activeIndex ? 'true' : 'false';
            const videoIcon = item.type === 'video' ?
                '<span class="steamMediaThumbPlayIcon" aria-hidden="true">&#9658;</span>' :
                '';

            return `
                <button type="button" class="steamMediaThumbBtn${activeClass}" data-media-index="${index}" aria-label="Show ${label}" aria-current="${current}" title="${label}">
                    <img class="steamMediaThumbImage" src="${escapeHtml(thumbUrl)}" alt="" loading="lazy" onerror="this.style.display='none'">
                    ${videoIcon}
                </button>
            `;
        }).join('');

        const fitClass = media.length <= 7 ? ' steamMediaThumbStripFit' : '';
        return `<div class="steamMediaThumbStrip${fitClass}" aria-label="Steam media thumbnails">${thumbsHtml}</div>`;
    }

    function renderMediaCarousel(media, title) {
        if (!media?.length) return '';

        const hasMultiple = media.length > 1;
        const controlsHtml = hasMultiple ? `
            <button type="button" class="steamMediaNavBtn steamMediaPrevBtn" aria-label="Previous Steam media" title="Previous media">&#8249;</button>
            <button type="button" class="steamMediaNavBtn steamMediaNextBtn" aria-label="Next Steam media" title="Next media">&#8250;</button>
            <span class="steamMediaCounter" aria-live="polite">1 / ${escapeHtml(media.length)}</span>
        ` : '';
        const expandClass = canOpenMediaTheatre(media[0]) ? '' : ' steamMediaExpandHidden';

        return `
            <div class="steamMediaShell">
                <div class="steamMediaFrame" aria-label="Steam media preview">
                    <div class="steamMediaViewport">${renderMediaItem(media[0], 0, title)}</div>
                    <button type="button" class="steamMediaExpandBtn${expandClass}" aria-label="Open media theatre" title="Open theatre mode"><span class="steamTheatreIcon" aria-hidden="true"></span></button>
                    ${controlsHtml}
                </div>
                ${renderMediaThumbs(media, 0, title)}
            </div>
        `;
    }

    function renderTheatreMediaItem(item, index, title) {
        if (!item) return '';

        const label = escapeHtml(item.alt || title || 'Steam media');
        if (item.type === 'video') {
            return `
                <div class="csrinruSteamTheatreVideoWrap">
                    <video class="csrinruSteamTheatreVideo" data-theatre-media-index="${index}" autoplay playsinline tabindex="0" aria-label="${label}"></video>
                    ${renderTheatreVideoControls('csrinruSteamTheatreVideoControlsFullscreen')}
                </div>
            `;
        }

        return `<img class="csrinruSteamTheatreImage" src="${escapeHtml(item.url)}" alt="${label}" loading="lazy" onerror="this.style.display='none'">`;
    }

    function renderTheatreVideoControls(extraClass = '') {
        const className = `csrinruSteamTheatreVideoControls${extraClass ? ` ${extraClass}` : ''}`;
        return `
            <div class="${className}" aria-label="Video controls">
                <input class="csrinruSteamTheatreVideoSeek" type="range" min="0" max="0" step="0.1" value="0" aria-label="Seek video">
                <div class="csrinruSteamTheatreVideoControlRow">
                    <button type="button" class="csrinruSteamTheatreVideoControlBtn csrinruSteamTheatreVideoPlayToggle" aria-label="Play video" title="Play">&#9658;</button>
                    <button type="button" class="csrinruSteamTheatreVideoControlBtn csrinruSteamTheatreVideoMuteToggle" aria-label="Mute video" title="Mute">&#128266;</button>
                    <span class="csrinruSteamTheatreVideoTime">0:00 / 0:00</span>
                    <span class="csrinruSteamTheatreVideoControlSpacer"></span>
                    <button type="button" class="csrinruSteamTheatreVideoControlBtn csrinruSteamTheatreVideoFullscreenBtn" aria-label="Toggle video fullscreen" title="Video fullscreen">&#9974;</button>
                </div>
            </div>
        `;
    }

    function renderTheatreFooterControls() {
        if (!isScreenshotMedia(theatreMedia[theatreMediaIndex])) return '';

        const fullscreenLabel = document.fullscreenElement === theatre ? 'Exit fullscreen' : 'Enter fullscreen';
        return `
            <button type="button" class="csrinruSteamTheatreVideoControlBtn csrinruSteamTheatreFullscreenBtn" aria-label="${fullscreenLabel}" title="${fullscreenLabel}">&#9974;</button>
        `;
    }

    function renderTheatreFooterActions() {
        const steamUrl = getUsableMediaUrl(currentStoreUrl);
        const latestUrl = getUsableMediaUrl(currentLatestUrl);
        const steamAction = steamUrl ?
            `<a href="${escapeHtml(steamUrl)}" target="_blank" rel="noopener noreferrer">🎮 Open on Steam</a>` :
            '';
        const latestAction = latestUrl ?
            `<a href="${escapeHtml(latestUrl)}" target="_blank" rel="noopener noreferrer">↗️ Open Latest Page</a>` :
            '';

        return steamAction || latestAction ?
            `<div class="csrinruSteamTheatreFooterActions">${steamAction}${latestAction}</div>` :
            '';
    }

    function renderTheatre() {
        if (!theatreMedia.length) return;

        const hasMultiple = theatreMedia.length > 1;
        const navHtml = hasMultiple ? `
            <button type="button" class="csrinruSteamTheatreNavBtn csrinruSteamTheatrePrevBtn" aria-label="Previous Steam media" title="Previous media">&#8249;</button>
            <button type="button" class="csrinruSteamTheatreNavBtn csrinruSteamTheatreNextBtn" aria-label="Next Steam media" title="Next media">&#8250;</button>
        ` : '';
        const videoControlsHtml = isVideoMedia(theatreMedia[theatreMediaIndex]) ? renderTheatreVideoControls() : '';
        const footerControlsHtml = renderTheatreFooterControls();
        const footerActionsHtml = renderTheatreFooterActions();

        theatre.innerHTML = `
            <div class="csrinruSteamTheatreShell">
                <div class="csrinruSteamTheatreHeader">
                    <div class="csrinruSteamTheatreTitle">${escapeHtml(theatreMediaTitle)} - Trailers &amp; Screenshots</div>
                    <div class="csrinruSteamTheatreHeaderControls">
                        <button type="button" class="csrinruSteamTheatreBtn csrinruSteamTheatreCloseBtn" aria-label="Close theatre" title="Close">&times;</button>
                    </div>
                </div>
                <div class="csrinruSteamTheatreStage">
                    <div class="csrinruSteamTheatreViewport">${renderTheatreMediaItem(theatreMedia[theatreMediaIndex], theatreMediaIndex, theatreMediaTitle)}</div>
                    ${navHtml}
                </div>
                ${videoControlsHtml}
                <div class="csrinruSteamTheatreFooter">
                    ${footerActionsHtml}
                    <div class="csrinruSteamTheatreCounter" aria-live="polite">${getTheatreCounterText()}</div>
                    <div class="csrinruSteamTheatreFooterControlsHost">${footerControlsHtml}</div>
                </div>
            </div>
        `;
    }

    function setTheatreMedia(index) {
        if (!theatreMedia.length) return;

        const nextIndex = (index + theatreMedia.length) % theatreMedia.length;
        const nextItem = theatreMedia[nextIndex];

        stopTheatreVideo();

        if (theatreMode === 'video' && isScreenshotMedia(nextItem)) {
            const screenshots = currentMedia.filter(isScreenshotMedia);
            const screenshotIndex = screenshots.indexOf(nextItem);
            if (screenshotIndex >= 0) {
                theatreMode = 'screenshots';
                theatreMedia = screenshots;
                theatreMediaIndex = screenshotIndex;
                theatre.classList.remove('csrinruSteamTheatreVideoMode');
                renderTheatre();
                return;
            }
        }

        theatreMediaIndex = nextIndex;

        const viewport = theatre.querySelector('.csrinruSteamTheatreViewport');
        if (viewport) {
            viewport.innerHTML = renderTheatreMediaItem(theatreMedia[theatreMediaIndex], theatreMediaIndex, theatreMediaTitle);
        }

        const counter = theatre.querySelector('.csrinruSteamTheatreCounter');
        if (counter) {
            counter.textContent = getTheatreCounterText();
        }

        const footerControls = theatre.querySelector('.csrinruSteamTheatreFooterControlsHost');
        if (footerControls) {
            footerControls.innerHTML = renderTheatreFooterControls();
        }

        if (theatreMode === 'video') {
            playTheatreVideo();
        }
    }

    function formatVideoTime(value) {
        if (!isFinite(value) || value < 0) return '0:00';

        const total = Math.floor(value);
        const hours = Math.floor(total / 3600);
        const minutes = Math.floor((total % 3600) / 60);
        const seconds = total % 60;
        const paddedSeconds = String(seconds).padStart(2, '0');

        return hours ? `${hours}:${String(minutes).padStart(2, '0')}:${paddedSeconds}` : `${minutes}:${paddedSeconds}`;
    }

    function updateTheatreVideoControls(video) {
        if (!video) return;

        const playButtons = theatre.querySelectorAll('.csrinruSteamTheatreVideoPlayToggle');
        const muteButtons = theatre.querySelectorAll('.csrinruSteamTheatreVideoMuteToggle');
        const seekInputs = theatre.querySelectorAll('.csrinruSteamTheatreVideoSeek');
        const timeLabels = theatre.querySelectorAll('.csrinruSteamTheatreVideoTime');
        const duration = isFinite(video.duration) ? video.duration : 0;
        const currentTime = isFinite(video.currentTime) ? video.currentTime : 0;

        playButtons.forEach((playButton) => {
            playButton.innerHTML = video.paused ? '&#9658;' : '&#10074;&#10074;';
            playButton.setAttribute('aria-label', video.paused ? 'Play video' : 'Pause video');
            playButton.setAttribute('title', video.paused ? 'Play' : 'Pause');
        });

        muteButtons.forEach((muteButton) => {
            muteButton.innerHTML = video.muted || video.volume === 0 ? '&#128263;' : '&#128266;';
            muteButton.setAttribute('aria-label', video.muted ? 'Unmute video' : 'Mute video');
            muteButton.setAttribute('title', video.muted ? 'Unmute' : 'Mute');
        });

        seekInputs.forEach((seek) => {
            seek.max = String(duration || 0);
            seek.value = String(Math.min(currentTime, duration || currentTime));
        });

        timeLabels.forEach((timeLabel) => {
            timeLabel.textContent = `${formatVideoTime(currentTime)} / ${formatVideoTime(duration)}`;
        });
    }

    function bindTheatreVideoEvents(video) {
        if (!video || video.dataset.controlsBound === 'true') return;

        video.dataset.controlsBound = 'true';
        ['timeupdate', 'durationchange', 'play', 'pause', 'volumechange', 'loadedmetadata'].forEach((eventName) => {
            video.addEventListener(eventName, () => updateTheatreVideoControls(video));
        });
        updateTheatreVideoControls(video);
    }

    function loadTheatreVideoSource(video, sourceUrl, preserveState = false) {
        const playableUrl = getUsableMediaUrl(sourceUrl);
        if (!video || !playableUrl) return;

        const previousTime = preserveState && isFinite(video.currentTime) ? video.currentTime : 0;
        const shouldResume = preserveState ? !video.paused : true;

        video.src = playableUrl;
        video.load();
        video.addEventListener('loadedmetadata', () => {
            if (previousTime && isFinite(video.duration)) {
                video.currentTime = Math.min(previousTime, Math.max(0, video.duration - 0.25));
            }

            if (shouldResume) {
                video.play().catch(() => null);
            }
            updateTheatreVideoControls(video);
        }, { once: true });

        if (shouldResume) {
            video.play().catch(() => null);
        }
    }

    async function playTheatreVideo() {
        const item = theatreMedia[theatreMediaIndex];
        if (!item || item.type !== 'video' || (!isUsableUrl(item.videoUrl) && !isUsableUrl(item.hlsUrl))) return;

        const label = escapeHtml(item.alt || theatreMediaTitle || 'Steam video');
        const viewport = theatre.querySelector('.csrinruSteamTheatreViewport');
        if (!viewport) return;

        stopTheatreVideo();
        const directSrc = isUsableUrl(item.videoUrl) ? ` src="${escapeHtml(item.videoUrl)}"` : '';
        let video = viewport.querySelector('.csrinruSteamTheatreVideo');
        if (!video) {
            viewport.innerHTML = `<video class="csrinruSteamTheatreVideo"${directSrc} autoplay playsinline tabindex="0" aria-label="${label}"></video>`;
            video = viewport.querySelector('.csrinruSteamTheatreVideo');
        }

        if (!video) return;
        video.setAttribute('aria-label', label);
        bindVideoPlaybackFallback(video, viewport, 'csrinruSteamTheatreError', theatre);
        bindTheatreVideoEvents(video);

        if (isUsableUrl(item.videoUrl)) {
            loadTheatreVideoSource(video, item.videoUrl);
            return;
        }

        if (isUsableUrl(item.hlsUrl) && video.canPlayType('application/vnd.apple.mpegurl')) {
            video.src = item.hlsUrl;
            video.play().catch(() => null);
            return;
        }

        viewport.innerHTML = renderVideoPlaybackFallback('csrinruSteamTheatreError');
    }

    function isTheatreOpen() {
        return theatre.classList.contains('csrinruSteamTheatreOpen');
    }

    function getTheatreSelection(index) {
        const selectedItem = currentMedia[index];
        if (!canOpenMediaTheatre(selectedItem)) return null;

        const mode = isVideoMedia(selectedItem) ? 'video' : 'screenshots';
        const screenshots = currentMedia.filter(isScreenshotMedia);
        const allItems = currentMedia.filter(canOpenMediaTheatre);
        const items = mode === 'video' ? [selectedItem, ...screenshots] : screenshots;
        const selectedIndex = items.indexOf(selectedItem);
        if (!items.length || selectedIndex < 0) return null;

        return { mode, items, selectedIndex, allItems };
    }

    function openTheatre(index) {
        const selection = getTheatreSelection(index);
        if (!selection) return;

        theatreMode = selection.mode;
        theatreMedia = selection.items;
        theatreAllMedia = selection.allItems;
        theatreMediaIndex = selection.selectedIndex;
        theatreMediaTitle = currentMediaTitle || 'Steam media';
        stopActiveVideo();
        clearTimeout(hideTimeout);
        clearTimeout(displayTimeout);
        clearTimeout(showTimeout);

        theatre.classList.toggle('csrinruSteamTheatreVideoMode', theatreMode === 'video');
        renderTheatre();
        previousDocumentOverflow = document.documentElement.style.overflow;
        document.documentElement.style.overflow = 'hidden';
        theatre.classList.add('csrinruSteamTheatreOpen');
        theatre.setAttribute('aria-hidden', 'false');
        tip.style.opacity = '0';
        tip.style.pointerEvents = 'none';
        tip.style.display = 'none';
        trackingMove = false;

        const closeButton = theatre.querySelector('.csrinruSteamTheatreCloseBtn');
        if (closeButton) {
            try {
                closeButton.focus({ preventScroll: true });
            } catch (_) {
                closeButton.focus();
            }
        }

        if (theatreMode === 'video') {
            playTheatreVideo();
        }
    }

    function closeTheatre() {
        if (!isTheatreOpen()) return;

        const fullscreenElement = document.fullscreenElement;
        if (fullscreenElement && (fullscreenElement === theatre || theatre.contains(fullscreenElement))) {
            document.exitFullscreen().catch(() => null);
        }

        stopTheatreVideo();
        theatre.classList.remove('csrinruSteamTheatreOpen');
        theatre.setAttribute('aria-hidden', 'true');
        theatre.innerHTML = '';
        document.documentElement.style.overflow = previousDocumentOverflow;
        previousDocumentOverflow = '';
        theatreMedia = [];
        theatreAllMedia = [];
        theatreMediaIndex = 0;
        theatreMediaTitle = '';
        theatreMode = 'screenshots';
        theatre.classList.remove('csrinruSteamTheatreVideoMode');
    }

    async function toggleTheatreFullscreen() {
        if (!isTheatreOpen()) return;

        try {
            if (document.fullscreenElement === theatre) {
                await document.exitFullscreen();
            } else if (!document.fullscreenElement && theatre.requestFullscreen) {
                await theatre.requestFullscreen();
            }
        } catch (_) {
            // Ignore browser fullscreen denials.
        }

        updateTheatreFullscreenButton();
    }

    function updateTheatreFullscreenButton() {
        const theatreFullscreenButtons = theatre.querySelectorAll('.csrinruSteamTheatreFullscreenBtn');
        const videoFullscreenButton = theatre.querySelector('.csrinruSteamTheatreVideoFullscreenBtn');
        const videoWrap = theatre.querySelector('.csrinruSteamTheatreVideoWrap');

        const label = document.fullscreenElement === theatre ? 'Exit fullscreen' : 'Enter fullscreen';
        theatreFullscreenButtons.forEach((button) => {
            button.setAttribute('aria-label', label);
            button.setAttribute('title', label);
        });

        if (videoFullscreenButton) {
            const videoLabel = document.fullscreenElement === videoWrap ? 'Exit video fullscreen' : 'Enter video fullscreen';
            videoFullscreenButton.setAttribute('aria-label', videoLabel);
            videoFullscreenButton.setAttribute('title', videoLabel);
        }
    }

    function getTheatreVideoElement() {
        return theatre.querySelector('.csrinruSteamTheatreVideo');
    }

    function toggleTheatreVideoPlayback() {
        const video = getTheatreVideoElement();
        if (!video) return;

        if (video.paused) {
            video.play().catch(() => null);
        } else {
            video.pause();
        }
        updateTheatreVideoControls(video);
    }

    function toggleTheatreVideoMute() {
        const video = getTheatreVideoElement();
        if (!video) return;

        video.muted = !video.muted;
        updateTheatreVideoControls(video);
    }

    function seekTheatreVideo(deltaSeconds) {
        const video = getTheatreVideoElement();
        if (!video) return false;

        const duration = isFinite(video.duration) ? video.duration : 0;
        const currentTime = isFinite(video.currentTime) ? video.currentTime : 0;
        const nextTime = duration ?
            Math.max(0, Math.min(duration, currentTime + deltaSeconds)) :
            Math.max(0, currentTime + deltaSeconds);
        video.currentTime = nextTime;
        updateTheatreVideoControls(video);
        return true;
    }

    async function toggleTheatreVideoFullscreen() {
        const videoWrap = theatre.querySelector('.csrinruSteamTheatreVideoWrap');
        if (!videoWrap) return;

        try {
            if (document.fullscreenElement === videoWrap) {
                await document.exitFullscreen();
            } else if (!document.fullscreenElement && videoWrap.requestFullscreen) {
                await videoWrap.requestFullscreen();
            }
        } catch (_) {
            // Ignore browser fullscreen denials.
        }
    }

    function setActiveMedia(index) {
        if (!currentMedia.length) return;

        stopActiveVideo();
        currentMediaIndex = (index + currentMedia.length) % currentMedia.length;

        const viewport = tip.querySelector('.steamMediaViewport');
        if (viewport) {
            viewport.innerHTML = renderMediaItem(currentMedia[currentMediaIndex], currentMediaIndex, currentMediaTitle);
        }

        const counter = tip.querySelector('.steamMediaCounter');
        if (counter) {
            counter.textContent = `${currentMediaIndex + 1} / ${currentMedia.length}`;
        }

        const expandButton = tip.querySelector('.steamMediaExpandBtn');
        if (expandButton) {
            expandButton.classList.toggle('steamMediaExpandHidden', !canOpenMediaTheatre(currentMedia[currentMediaIndex]));
        }

        tip.querySelectorAll('.steamMediaThumbBtn').forEach((button) => {
            const buttonIndex = parseInt(button.dataset.mediaIndex, 10);
            const isActive = buttonIndex === currentMediaIndex;
            button.classList.toggle('steamMediaThumbActive', isActive);
            button.setAttribute('aria-current', isActive ? 'true' : 'false');
        });

        const activeThumb = tip.querySelector('.steamMediaThumbActive');
        if (activeThumb) {
            const strip = activeThumb.closest('.steamMediaThumbStrip');
            if (strip) {
                const stripRect = strip.getBoundingClientRect();
                const thumbRect = activeThumb.getBoundingClientRect();

                if (thumbRect.left < stripRect.left) {
                    strip.scrollLeft -= stripRect.left - thumbRect.left;
                } else if (thumbRect.right > stripRect.right) {
                    strip.scrollLeft += thumbRect.right - stripRect.right;
                }
            }
        }
    }

    async function playActiveVideo() {
        const item = currentMedia[currentMediaIndex];
        if (!item || item.type !== 'video' || (!isUsableUrl(item.videoUrl) && !isUsableUrl(item.hlsUrl))) return;

        const label = escapeHtml(item.alt || currentMediaTitle || 'Steam video');
        const poster = isUsableUrl(item.posterUrl) ? ` poster="${escapeHtml(item.posterUrl)}"` : '';
        const viewport = tip.querySelector('.steamMediaViewport');
        if (!viewport) return;

        stopActiveVideo();
        const directSrc = isUsableUrl(item.videoUrl) ? ` src="${escapeHtml(item.videoUrl)}"` : '';
        viewport.innerHTML = `<video class="steamMediaVideo"${directSrc}${poster} controls autoplay playsinline aria-label="${label}"></video>`;

        const video = viewport.querySelector('.steamMediaVideo');
        if (!video) return;
        bindVideoPlaybackFallback(video, viewport, 'steamMediaError', tip);

        if (isUsableUrl(item.videoUrl)) {
            video.play().catch(() => null);
            return;
        }

        if (isUsableUrl(item.hlsUrl) && video.canPlayType('application/vnd.apple.mpegurl')) {
            video.src = item.hlsUrl;
            video.play().catch(() => null);
            return;
        }

        viewport.innerHTML = renderVideoPlaybackFallback('steamMediaError');
    }

    function positionTip(ev) {
        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 = Math.max(scrollX + margin, ev.pageX - tipWidth - 15);
        }
        if (y + tipHeight + margin > scrollY + viewHeight) {
            const yAbove = ev.pageY - tipHeight - 15;
            y = yAbove > scrollY + margin ? yAbove : scrollY + viewHeight - tipHeight - margin;
        }

        tip.style.left = `${Math.max(scrollX + margin, x)}px`;
        tip.style.top = `${Math.max(scrollY + margin, y)}px`;
    }

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

    function scheduleHideTip() {
        clearTimeout(hideTimeout);
        clearTimeout(displayTimeout);
        hideTimeout = setTimeout(() => {
            hoverId++;
            currentHoveredLink = null;
            startHideAnimation();
        }, 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 renderLoading(event, gameName) {
        resetMediaState();
        tip.innerHTML = `<div class="loadingContainer"><div class="spinner"></div><span>Loading <strong>${escapeHtml(gameName)}</strong>…</span></div>`;
        positionTip(event);
        tip.style.display = 'block';
        void tip.offsetHeight;
        tip.style.opacity = '1';
        tip.style.pointerEvents = 'auto';
    }

    function renderNoData(gameName, topicInfo) {
        resetMediaState();
        const searchUrl = `https://store.steampowered.com/search/?term=${encodeURIComponent(gameName)}`;
        const metaRowHtml = renderMetaRow('', topicInfo);
        tip.innerHTML = `
            <p>No Steam info found for<br><strong>${escapeHtml(gameName)}</strong></p>
            ${metaRowHtml}
            <div class="tipActions">
                <a href="${escapeHtml(searchUrl)}" target="_blank" rel="noopener noreferrer">🎮 Open on Steam</a>
                <a href="${escapeHtml(topicInfo.latestUrl)}" target="_blank" rel="noopener noreferrer">↗️ Open Latest Page</a>
            </div>
        `;
    }

    function renderSteamData(data, gameName, topicInfo) {
        const title = escapeHtml(data.name || gameName);
        const shortDescription = escapeHtml(data.short_description || 'No description available.');
        const releaseDate = escapeHtml(data.releaseDate || '');
        const rawStoreUrl = data.storeUrl || `https://store.steampowered.com/search/?term=${encodeURIComponent(gameName)}`;
        const storeUrl = escapeHtml(rawStoreUrl);
        const latestUrl = escapeHtml(topicInfo.latestUrl);
        const rawAppId = data.appId || data.steam_appid || '';
        const tagLabel = getTagSource(data) === 'steam' ? 'Tags' : 'Genres';
        const tagsHtml = data.tags?.length ?
            `<p class="steamTags"><strong>${tagLabel}:</strong> ${data.tags.map(escapeHtml).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">${escapeHtml(data.reviewInfo.desc)}${data.reviewInfo.total ? `  |  ${escapeHtml(data.reviewInfo.total.toLocaleString())} reviews` : ''}</span></p>` :
            '';
        const releaseDateHtml = data.releaseDate ?
            `<p class="steamReleaseDate"><strong>Released:</strong> ${releaseDate}</p>` :
            '';
        const metaRowHtml = renderMetaRow(rawAppId, topicInfo);
        const media = getRenderMedia(data);
        const mediaHtml = renderMediaCarousel(media, data.name || gameName);

        stopActiveVideo();
        currentMedia = media;
        currentMediaIndex = 0;
        currentMediaTitle = data.name || gameName;
        currentStoreUrl = rawStoreUrl;
        currentLatestUrl = topicInfo.latestUrl || '';

        tip.innerHTML = `
            ${mediaHtml}
            <p><strong>${title}</strong></p>
            ${releaseDateHtml}
            ${metaRowHtml}
            <p>${shortDescription}</p>
            ${reviewHtml}
            ${tagsHtml}
            <div class="tipActions">
                <a href="${storeUrl}" target="_blank" rel="noopener noreferrer">🎮 Open on Steam</a>
                <a href="${latestUrl}" target="_blank" rel="noopener noreferrer">↗️ Open Latest Page</a>
            </div>
        `;
    }

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

    tip.addEventListener('mouseleave', scheduleHideTip);

    tip.addEventListener('click', async (e) => {
        const expandButton = e.target.closest('.steamMediaExpandBtn');
        if (expandButton) {
            e.preventDefault();
            e.stopPropagation();
            openTheatre(currentMediaIndex);
            return;
        }

        const prevButton = e.target.closest('.steamMediaPrevBtn');
        if (prevButton) {
            e.preventDefault();
            e.stopPropagation();
            setActiveMedia(currentMediaIndex - 1);
            return;
        }

        const nextButton = e.target.closest('.steamMediaNextBtn');
        if (nextButton) {
            e.preventDefault();
            e.stopPropagation();
            setActiveMedia(currentMediaIndex + 1);
            return;
        }

        const thumbButton = e.target.closest('.steamMediaThumbBtn');
        if (thumbButton) {
            e.preventDefault();
            e.stopPropagation();
            const index = parseInt(thumbButton.dataset.mediaIndex, 10);
            if (!isNaN(index)) {
                setActiveMedia(index);
            }
            return;
        }

        const playButton = e.target.closest('.steamMediaPlayBtn');
        if (playButton) {
            e.preventDefault();
            e.stopPropagation();
            const index = parseInt(playButton.dataset.mediaIndex, 10);
            if (!isNaN(index) && index !== currentMediaIndex) {
                setActiveMedia(index);
            }
            await playActiveVideo();
            return;
        }

        const appIdButton = e.target.closest('.copyAppIdBtn');
        if (!appIdButton) return;

        e.preventDefault();
        e.stopPropagation();

        const appId = appIdButton.dataset.appId;
        const copied = await copyText(appId);
        if (!copied) {
            window.prompt('Copy Steam AppID:', appId);
            return;
        }

        const previousText = appIdButton.textContent;
        appIdButton.textContent = 'Copied';
        setTimeout(() => {
            appIdButton.textContent = previousText;
        }, 900);
    });

    theatre.addEventListener('click', async (e) => {
        if (e.target === theatre) {
            e.preventDefault();
            e.stopPropagation();
            closeTheatre();
            return;
        }

        const closeButton = e.target.closest('.csrinruSteamTheatreCloseBtn');
        if (closeButton) {
            e.preventDefault();
            e.stopPropagation();
            closeTheatre();
            return;
        }

        const playToggle = e.target.closest('.csrinruSteamTheatreVideoPlayToggle');
        if (playToggle) {
            e.preventDefault();
            e.stopPropagation();
            toggleTheatreVideoPlayback();
            return;
        }

        const muteToggle = e.target.closest('.csrinruSteamTheatreVideoMuteToggle');
        if (muteToggle) {
            e.preventDefault();
            e.stopPropagation();
            toggleTheatreVideoMute();
            return;
        }

        const fullscreenButton = e.target.closest('.csrinruSteamTheatreFullscreenBtn');
        if (fullscreenButton) {
            e.preventDefault();
            e.stopPropagation();
            await toggleTheatreFullscreen();
            return;
        }

        const videoFullscreenButton = e.target.closest('.csrinruSteamTheatreVideoFullscreenBtn');
        if (videoFullscreenButton) {
            e.preventDefault();
            e.stopPropagation();
            await toggleTheatreVideoFullscreen();
            return;
        }

        const videoSurface = e.target.closest('.csrinruSteamTheatreVideo');
        if (videoSurface) {
            e.preventDefault();
            e.stopPropagation();
            try {
                videoSurface.focus({ preventScroll: true });
            } catch (_) {
                videoSurface.focus();
            }
            toggleTheatreVideoPlayback();
            return;
        }

        const prevButton = e.target.closest('.csrinruSteamTheatrePrevBtn');
        if (prevButton) {
            e.preventDefault();
            e.stopPropagation();
            setTheatreMedia(theatreMediaIndex - 1);
            return;
        }

        const nextButton = e.target.closest('.csrinruSteamTheatreNextBtn');
        if (nextButton) {
            e.preventDefault();
            e.stopPropagation();
            setTheatreMedia(theatreMediaIndex + 1);
            return;
        }

    });

    theatre.addEventListener('input', (e) => {
        const seek = e.target.closest('.csrinruSteamTheatreVideoSeek');
        if (!seek) return;

        const video = getTheatreVideoElement();
        if (!video) return;

        const value = parseFloat(seek.value);
        if (!isNaN(value)) {
            video.currentTime = value;
            updateTheatreVideoControls(video);
        }
    });

    document.addEventListener('keydown', (e) => {
        if (!isTheatreOpen()) return;

        if (e.key === 'Escape') {
            e.preventDefault();
            closeTheatre();
            return;
        }

        if (e.target.closest?.('.csrinruSteamTheatreVideoControls')) return;

        if (theatreMode === 'video' && isVideoMedia(theatreMedia[theatreMediaIndex])) {
            if (e.key === 'ArrowLeft') {
                e.preventDefault();
                seekTheatreVideo(-5);
                return;
            }

            if (e.key === 'ArrowRight') {
                e.preventDefault();
                seekTheatreVideo(5);
                return;
            }

            if (e.key === ' ' || e.key === 'Spacebar') {
                e.preventDefault();
                toggleTheatreVideoPlayback();
                return;
            }
        }

        if (e.key === 'ArrowLeft') {
            e.preventDefault();
            setTheatreMedia(theatreMediaIndex - 1);
            return;
        }

        if (e.key === 'ArrowRight') {
            e.preventDefault();
            setTheatreMedia(theatreMediaIndex + 1);
        }
    });

    document.addEventListener('fullscreenchange', updateTheatreFullscreenButton);

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

        if (targetLink || isOverTip) cancelHideTip();
        if (!targetLink || (targetLink === currentHoveredLink && !trackingMove)) return;

        const topicInfo = getTopicInfo(targetLink);
        if (!topicInfo) return;

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

        currentHoveredLink = targetLink;
        userHovering = true;
        trackingMove = true;
        lastMoveEvent = e;
        const thisId = ++hoverId;

        clearTimeout(showTimeout);
        renderLoading(e, topicInfo.gameName);

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

            const data = await fetchSteamWithFallback(topicInfo.gameName);
            if (hoverId !== thisId || currentHoveredLink !== targetLink) return;

            if (data) {
                renderSteamData(data, topicInfo.gameName, topicInfo);
                warmSteamTags(data);
            } else {
                renderNoData(topicInfo.gameName, topicInfo);
            }

            positionTip(lastMoveEvent);
            trackingMove = false;
            tip.style.opacity = '1';
            tip.style.pointerEvents = 'auto';
        }, 0);
    }, true);

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

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

    async function fetchBatch(names) {
        await Promise.all(names.map(name => fetchSteamWithFallback(name).catch(() => null)));
    }

    async function waitForPreloadTurn() {
        while (userHovering && !isPageHidden) {
            await delay(200);
        }
    }

    function getPreloadNames() {
        const infos = Array.from(document.querySelectorAll(TOPIC_SELECTOR))
            .map(getTopicInfo)
            .filter(Boolean);

        const seen = new Set();
        const ranked = [];
        infos.forEach((info, index) => {
            const name = info.gameName;
            if (!name || seen.has(name) || getFreshCacheEntry(name)) return;

            seen.add(name);
            const rect = info.link.getBoundingClientRect();
            ranked.push({
                name,
                index,
                inViewport: rect.bottom >= 0 && rect.top <= window.innerHeight
            });
        });

        return ranked
            .sort((a, b) => Number(b.inViewport) - Number(a.inViewport) || a.index - b.index)
            .map(item => item.name);
    }

    async function preloadNames(names) {
        let i = 0;
        while (i < names.length) {
            await waitForPreloadTurn();

            const batchSize = isPageHidden ? CONCURRENT_HIDDEN : CONCURRENT_VISIBLE;
            const batch = names.slice(i, i + batchSize);
            await fetchBatch(batch);
            i += batchSize;
            await delay(isPageHidden ? MIN_INTERVAL : MIN_INTERVAL * 2);
        }
    }

    async function preloadTagsForNames(names) {
        let i = 0;
        while (i < names.length) {
            await waitForPreloadTurn();

            const batchSize = isPageHidden ? CONCURRENT_TAG_HIDDEN : CONCURRENT_TAG_VISIBLE;
            const batch = names.slice(i, i + batchSize);
            await Promise.all(batch.map(async (name) => {
                const data = await fetchSteamWithFallback(name).catch(() => null);
                if (data) await warmSteamTags(data);
            }));
            i += batchSize;
            await delay(isPageHidden ? MIN_INTERVAL * 2 : MIN_INTERVAL * 8);
        }
    }

    async function preloadAll() {
        const names = getPreloadNames();
        debugLog(`Preloading ${names.length} topic previews`);

        await preloadNames(names.slice(0, PRIORITY_PRELOAD_COUNT));
        await preloadNames(names.slice(PRIORITY_PRELOAD_COUNT));
        debugLog(`Warming Steam tags for ${names.length} topic previews`);
        await preloadTagsForNames(names);
    }

    window.addEventListener('load', () => {
        if ('requestIdleCallback' in window) {
            requestIdleCallback(() => preloadAll(), { timeout: 3000 });
        } else {
            setTimeout(preloadAll, 2000);
        }
    });

    console.log('CS.RIN.RU Steam Hover Preview script loaded.');
})();