On-hover Steam media, description, ratings, tags, AppID, SteamDB, Open on Steam, and Open Latest Page for cs.rin.ru forum topics
// ==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) => ({
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
}[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}">↗</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">►</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">►</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">‹</button>
<button type="button" class="steamMediaNavBtn steamMediaNextBtn" aria-label="Next Steam media" title="Next media">›</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">►</button>
<button type="button" class="csrinruSteamTheatreVideoControlBtn csrinruSteamTheatreVideoMuteToggle" aria-label="Mute video" title="Mute">🔊</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">⛶</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}">⛶</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">‹</button>
<button type="button" class="csrinruSteamTheatreNavBtn csrinruSteamTheatreNextBtn" aria-label="Next Steam media" title="Next media">›</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 & Screenshots</div>
<div class="csrinruSteamTheatreHeaderControls">
<button type="button" class="csrinruSteamTheatreBtn csrinruSteamTheatreCloseBtn" aria-label="Close theatre" title="Close">×</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 ? '►' : '❚❚';
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 ? '🔇' : '🔊';
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.');
})();