On game sites, hover over a game to display a tooltip with the game's rating, summary, and related info
// ==UserScript==
// @name IGDB game hover tooltip
// @namespace igdb-game-hover-tooltip
// @version 1.4.8
// @description On game sites, hover over a game to display a tooltip with the game's rating, summary, and related info
// @license ISC
// @match *://*.humblebundle.com/*
// @match *://*.steampowered.com/*
// @match *://*.store.steampowered.com/*
// @match *://*.gog.com/*
// @match *://*.epicgames.com/*
// @match *://*.nintendo.com/*
// @match *://*.playstation.com/*
// @match *://*.xbox.com/*
// @match *://*.metacritic.com/*
// @match *://*.opencritic.com/*
// @match *://*.ign.com/*
// @match *://*.gamespot.com/*
// @match *://*.pcgamer.com/*
// @match *://*.rockpapershotgun.com/*
// @match *://*.polygon.com/*
// @match *://*.kotaku.com/*
// @match *://*.gamefaqs.gamespot.com/*
// @match *://*.giantbomb.com/*
// @match *://*.pcgamingwiki.com/*
// @match *://*.protondb.com/*
// @match *://*.lutris.net/*
// @match *://*.itch.io/*
// @match *://*.indiegameplus.com/*
// @match *://*.gamepass.com/*
// @match *://*.ubisoft.com/*
// @match *://*.ea.com/*
// @match *://*.battle.net/*
// @match *://*.greenmangaming.com/*
// @match *://*.fanatical.com/*
// @match *://*.humblegames.com/*
// @match *://*.g2a.com/*
// @match *://*.kinguin.net/*
// @match *://*.gamersgate.com/*
// @match *://*.gamesplanet.com/*
// @match *://*.bundlestars.com/*
// @match *://*.chrono.gg/*
// @match *://*.indiegala.com/*
// @match *://*.steamdb.info/*
// @match *://*.isthereanydeal.com/*
// @match *://*.gg.deals/*
// @match *://*.cheapshark.com/*
// @match *://*.bargainbin.org/*
// @match *://*.gamesradar.com/*
// @match *://*.eurogamer.net/*
// @match *://*.vg247.com/*
// @match *://*.destructoid.com/*
// @match *://*.pcmag.com/*
// @match *://*.techradar.com/*
// @match *://*.tomshardware.com/*
// @match *://*.windowscentral.com/*
// @match *://*.androidcentral.com/*
// @match *://*.imdb.com/*
// @match *://*.rawg.io/*
// @match *://*.howlongtobeat.com/*
// @match *://*.steamcommunity.com/*
// @match *://*.reddit.com/r/gaming/*
// @match *://*.gamerankings.com/*
// @match *://*.neogaf.com/*
// @match *://*.resetera.com/*
// @match *://*.gameinformer.com/*
// @match *://*.gamesindustry.biz/*
// @match *://*.venturebeat.com/*
// @match *://*.arstechnica.com/*
// @match *://*.wired.com/*
// @match *://*.theverge.com/*
// @match *://*.engadget.com/*
// @match *://*.digitaltrends.com/*
// @match *://*.makeuseof.com/*
// @match *://*.lifewire.com/*
// @match *://*.howtogeek.com/*
// @match *://*.pcgamesn.com/*
// @match *://*.gameskinny.com/*
// @match *://*.segmentnext.com/*
// @match *://*.twinfinite.net/*
// @match *://*.gamingbolt.com/*
// @match *://*.shacknews.com/*
// @match *://*.twitch.tv/*
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addStyle
// @connect id.twitch.tv
// @connect api.igdb.com
// @connect images.igdb.com
// ==/UserScript==
(function () {
"use strict";
// Twitch / IGDB API credentials
// If this doesn't work for you, get your own account here: https://api-docs.igdb.com/#account-creation
var CLIENT_ID = "yykw3oww4um4fq81z5dfpmae5qwj4n";
var CLIENT_SECRET = "sbq2hgahyqcyvrmlzeh70jqbg7ln3j";
var TOKEN_KEY = "igdb_gm_hover_token_v1";
var CACHE_PREFIX = "igdb_gm_hover_cache_v2:";
var HOVER_DELAY_MS = 180;
/** Time to leave the source and reach the tooltip before it closes (crossing empty space). */
var CLOSE_GRACE_MS = 180;
var SUMMARY_MAX_CHARS = 420;
/** Keep tooltip open until click outside. Useful for inspecting elements and CSS. */
var KEEP_OPEN_UNTIL_CLICK = false;
// Set to true to enable function call logging
var DEBUG = false;
var hoverState = {
root: null,
title: null,
/** Same game = same .content-choice node (Humble-style) or same game <a>. */
activeContentChoice: null,
activeAnchor: null,
enterTimer: null,
closeTimer: null,
seq: 0,
};
var tooltipEl = null;
var cacheMem = {};
var pointerTrackingBound = false;
var suppressedTitles = {};
function debugLog(funcName, args) {
if (DEBUG) {
console.log(`[IGDB Tooltip] ${funcName}(`, args, `)`);
}
}
function escapeHtml(s) {
if (s == null || s === "") return "";
var d = document.createElement("div");
d.textContent = String(s);
return d.innerHTML;
}
function normalizeTitleText(s) {
return (
String(s)
.replace(/\s+/g, " ")
.replace(/[\u2018\u2019]/g, "'")
.trim()
// For Steam thumbnails
.replace(/'s screenshot [0-9]*/, "")
.replace(/ Demo/, "")
);
}
var isHumbleBundle = /humblebundle\.com/i.test(location.hostname);
var isTwitch = /twitch\.tv/i.test(location.hostname);
/**
* Humble-style choice cards: innermost .content-choice, then .content-choice-title
* (avoids a parent wrapper that contains several titles returning the wrong game).
* Otherwise walk up for legacy markup; then nearest anchor text / title="".
*/
function getGameTitleFromElement(start) {
debugLog("getGameTitleFromElement", {
element: start.tagName,
className: start.className,
});
if (isTwitch) {
// Check for elements with title attributes, but skip if in StreamTitle container
var elemWithTitle = start.closest && start.closest("[title]");
// Skip if inside SteamTitle container
var isStreamTitle =
elemWithTitle &&
elemWithTitle.closest &&
(elemWithTitle.closest("[data-test-selector=StreamTitle]") ||
elemWithTitle.closest("[data-test-selector=TitleAndChannel]"));
if (isStreamTitle) {
debugLog("getGameTitleFromElement", {
skipped: "StreamTitle container found",
});
return null;
}
if (elemWithTitle) {
var title = elemWithTitle.getAttribute("title");
if (title && normalizeTitleText(title)) {
return normalizeTitleText(title);
}
}
var isGameLink =
start.closest && start.closest("[data-test-selector=GameLink]");
var gameLinkTitle =
isGameLink && normalizeTitleText(gameLinkTitle.textContent());
if (gameLinkTitle) {
return gameLinkTitle;
}
return null; // Don't detect anything else on Twitch
}
if (isHumbleBundle) {
var cc = start.closest && start.closest(".content-choice");
if (cc) {
var ct0 = cc.querySelector(".content-choice-title");
if (ct0) {
var t0 = normalizeTitleText(ct0.textContent || "");
if (t0) return t0;
}
}
var el = start;
var i;
for (i = 0; i < 12 && el; i++, el = el.parentElement) {
if (!el || !el.querySelector) continue;
var ct = el.querySelector(".content-choice-title");
if (ct) {
var t = normalizeTitleText(ct.textContent || "");
if (t) return t;
}
}
}
var a = start.closest && start.closest("a");
if (!a) {
for (
el = start;
el && el !== document.documentElement;
el = el.parentElement
) {
if (el.tagName === "A") {
a = el;
break;
}
}
}
// Steam store image thumbnails
const imgElem = a && a.querySelector("img[alt]");
if (imgElem && imgElem.alt) return normalizeTitleText(imgElem.alt);
// Steam store search results
if (
a &&
a.href &&
/store\.steampowered\.com\/app\/(\d+)(?:\/[^?]*)?/i.test(a.href)
) {
const match = a.href.match(
/store\.steampowered\.com\/app\/(\d+)(?:\/[^?]*)?/i,
);
if (match) {
// Try to find title from nearby text elements
const titleElem = a.querySelector(".title, .search_name");
if (titleElem) {
return normalizeTitleText(titleElem.textContent || "");
}
// Fallback: extract title from URL path
if (match[2]) {
const pathTitle = decodeURIComponent(match[2])
.replace(/[_-]/g, " ")
.trim();
if (pathTitle) return pathTitle;
}
// Final fallback: use app ID
return "Steam App " + match[1];
}
}
if (a && a.tagName === "A") {
var linkText = normalizeTitleText(a.textContent || "");
if (linkText && linkText.length <= 220 && !/^https?:\/\//i.test(linkText))
return linkText;
var ta = a.getAttribute("title");
if (ta && normalizeTitleText(ta)) return normalizeTitleText(ta);
}
return null;
}
function findHoverRoot(start, title) {
if (isHumbleBundle) {
var cc = start.closest && start.closest(".content-choice");
if (cc && cc.querySelector(".content-choice-title")) return cc;
}
var a = start.closest && start.closest("a");
if (
a &&
normalizeTitleText(getGameTitleFromElement(a) || "") ===
normalizeTitleText(title || "")
) {
return a;
}
return start;
}
function setActiveGameIdentityFromRoot(root) {
hoverState.activeContentChoice = null;
hoverState.activeAnchor = null;
if (!root || root.nodeType !== 1) return;
if (isHumbleBundle) {
var cc =
root.matches && root.matches(".content-choice")
? root
: root.closest && root.closest(".content-choice");
if (cc) {
hoverState.activeContentChoice = cc;
return;
}
}
var a =
root.matches && root.matches("a")
? root
: root.closest && root.closest("a");
if (a) hoverState.activeAnchor = a;
}
/** Same physical game tile: exact .content-choice or exact <a> we armed from. */
function pointerStillOnSameGame(node) {
if (!node || node.nodeType !== 1) return false;
if (isHumbleBundle && hoverState.activeContentChoice) {
var cc = node.closest && node.closest(".content-choice");
return !!cc && cc === hoverState.activeContentChoice;
}
if (hoverState.activeAnchor) {
var a = node.closest && node.closest("a");
return !!a && a === hoverState.activeAnchor;
}
if (!hoverState.root || hoverState.title == null || hoverState.title === "")
return false;
if (!hoverState.root.contains(node)) return false;
var t = getGameTitleFromElement(node);
if (!t) return false;
return normalizeTitleText(t) === normalizeTitleText(hoverState.title);
}
/** True when the pointer is on a different choice card or different game link. */
function isDifferentGameTarget(node) {
if (!node || node.nodeType !== 1) return false;
if (isHumbleBundle && hoverState.activeContentChoice) {
var cc = node.closest && node.closest(".content-choice");
return !!cc && cc !== hoverState.activeContentChoice;
}
if (hoverState.activeAnchor) {
var a = node.closest && node.closest("a");
return !!a && a !== hoverState.activeAnchor;
}
var t = getGameTitleFromElement(node);
return !!(
t &&
hoverState.title &&
normalizeTitleText(t) !== normalizeTitleText(hoverState.title)
);
}
function clearTimers() {
if (hoverState.enterTimer) {
clearTimeout(hoverState.enterTimer);
hoverState.enterTimer = null;
}
if (hoverState.closeTimer) {
clearTimeout(hoverState.closeTimer);
hoverState.closeTimer = null;
}
}
function bindTooltipPointerTracking() {
if (pointerTrackingBound) return;
document.addEventListener(
"mousemove",
onPointerMoveWhileTooltipVisible,
true,
);
document.addEventListener(
"pointermove",
onPointerMoveWhileTooltipVisible,
true,
);
pointerTrackingBound = true;
}
function unbindTooltipPointerTracking() {
if (!pointerTrackingBound) return;
document.removeEventListener(
"mousemove",
onPointerMoveWhileTooltipVisible,
true,
);
document.removeEventListener(
"pointermove",
onPointerMoveWhileTooltipVisible,
true,
);
pointerTrackingBound = false;
}
/** True if (x,y) is over the tooltip or still over the same .content-choice / game <a> we opened from. */
function pointerPositionKeepsTooltipOpen(x, y) {
if (!tooltipEl || tooltipEl.style.display === "none") return false;
var tip = tooltipEl.getBoundingClientRect();
if (x >= tip.left && x <= tip.right && y >= tip.top && y <= tip.bottom)
return true;
var hit = document.elementFromPoint(x, y);
return pointerStillOnSameGame(hit);
}
/**
* Any move off the opened source and off the tooltip should arm close — do not use
* getGameTitleFromElement here (ancestors often “see” unrelated game titles on the page).
*/
function onPointerMoveWhileTooltipVisible(ev) {
if (!tooltipEl || tooltipEl.style.display === "none" || !hoverState.root)
return;
// If KEEP_OPEN_UNTIL_CLICK is enabled, don't close on mouse move
if (KEEP_OPEN_UNTIL_CLICK) {
return;
}
var x = ev.clientX;
var y = ev.clientY;
if (pointerPositionKeepsTooltipOpen(x, y)) {
cancelCloseTimer();
return;
}
scheduleHideTooltip(false);
}
function hideTooltip() {
clearTimers();
unbindTooltipPointerTracking();
hoverState.root = null;
hoverState.title = null;
hoverState.activeContentChoice = null;
hoverState.activeAnchor = null;
hoverState.seq++;
if (tooltipEl) {
tooltipEl.style.display = "none";
tooltipEl.innerHTML = "";
}
// Remove click outside listener if KEEP_OPEN_UNTIL_CLICK is enabled
if (KEEP_OPEN_UNTIL_CLICK) {
// Remove all click outside listeners (cleanup any that might remain)
var listeners = document.querySelectorAll("[data-tooltip-listener]");
listeners.forEach(function (el) {
el.removeAttribute("data-tooltip-listener");
});
}
}
function cancelCloseTimer() {
if (hoverState.closeTimer) {
clearTimeout(hoverState.closeTimer);
hoverState.closeTimer = null;
}
}
function armDelayedHover(root, title) {
var t = title;
var r = root;
hoverState.root = root;
hoverState.title = title;
setActiveGameIdentityFromRoot(root);
if (hoverState.enterTimer) {
clearTimeout(hoverState.enterTimer);
hoverState.enterTimer = null;
}
hoverState.enterTimer = setTimeout(function () {
hoverState.enterTimer = null;
if (hoverState.root !== r || hoverState.title !== t) return;
beginHover(t);
}, HOVER_DELAY_MS);
}
/**
* @param {boolean} [restart=true] If false and a close timer is already running, leave it (avoids
* extending the grace forever while scrubbing over blank UI).
*/
function scheduleHideTooltip(restart) {
if (restart === false && hoverState.closeTimer != null) return;
cancelCloseTimer();
hoverState.closeTimer = setTimeout(function () {
hoverState.closeTimer = null;
hideTooltip();
/*
* Do not re-arm from here: getGameTitleFromElement often matches unrelated nodes
* (first title under a large ancestor), which reopened the tooltip immediately.
* A new hover is picked up by mouseover on the next game.
*/
}, CLOSE_GRACE_MS);
}
function ensureTooltip() {
if (tooltipEl) return tooltipEl;
tooltipEl = document.createElement("div");
tooltipEl.id = "igdb-gm-hover-tooltip";
tooltipEl.setAttribute("role", "tooltip");
document.body.appendChild(tooltipEl);
return tooltipEl;
}
var lastPointer = { x: 0, y: 0 };
function positionTooltip(x, y) {
if (!tooltipEl || tooltipEl.style.display === "none") return;
var pad = 14;
var margin = 8;
var vw = window.innerWidth;
var vh = window.innerHeight;
tooltipEl.style.left = "0";
tooltipEl.style.top = "0";
var rect = tooltipEl.getBoundingClientRect();
var w = rect.width;
var h = rect.height;
// Past the horizontal midpoint: anchor to the left of the pointer so the panel stays on-screen.
var left = x > vw / 2 ? x - w - pad : x + pad;
var top = y + pad;
left = Math.max(margin, Math.min(left, vw - w - margin));
if (top + h > vh - margin) top = Math.max(margin, y - h - pad);
tooltipEl.style.left = left + "px";
tooltipEl.style.top = top + "px";
}
function showTooltipLoading() {
var el = ensureTooltip();
el.style.display = "block";
el.innerHTML =
'<div class="igdb-gm-inner"><div class="igdb-gm-loading">IGDB…</div></div>';
positionTooltip(lastPointer.x, lastPointer.y);
bindTooltipPointerTracking();
}
function showTooltipHtml(html) {
debugLog("showTooltipHtml", { htmlLength: html.length });
var el = ensureTooltip();
el.style.display = "block";
el.innerHTML = html;
// Add close button functionality
var closeBtn = el.querySelector(".igdb-gm-close");
if (closeBtn) {
closeBtn.addEventListener("click", function (e) {
e.preventDefault();
e.stopPropagation();
// Suppress this title for 10 seconds
if (hoverState.title) {
var normalizedTitle = normalizeTitleText(hoverState.title);
suppressedTitles[normalizedTitle] = Date.now() + 15 * 1000; // 15 seconds
}
hideTooltip();
});
}
// Add click outside listener if KEEP_OPEN_UNTIL_CLICK is enabled
if (KEEP_OPEN_UNTIL_CLICK) {
function onClickOutside(e) {
if (
!tooltipEl.contains(e.target) &&
!pointerStillOnSameGame(e.target)
) {
e.preventDefault();
e.stopPropagation();
hideTooltip();
document.removeEventListener("click", onClickOutside, true);
}
}
document.addEventListener("click", onClickOutside, true);
}
positionTooltip(lastPointer.x, lastPointer.y);
bindTooltipPointerTracking();
}
function getAccessToken(cb) {
var raw = GM_getValue(TOKEN_KEY);
if (raw) {
try {
var c = JSON.parse(raw);
if (c.expiresAt > Date.now() + 10000) return cb(null, c.accessToken);
} catch (e) {}
}
GM_xmlhttpRequest({
method: "POST",
url: "https://id.twitch.tv/oauth2/token",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
data:
"client_id=" +
encodeURIComponent(CLIENT_ID) +
"&client_secret=" +
encodeURIComponent(CLIENT_SECRET) +
"&grant_type=client_credentials",
onload: function (res) {
if (res.status !== 200)
return cb(new Error("OAuth HTTP " + res.status));
var j = JSON.parse(res.responseText);
var expiresAt = Date.now() + j.expires_in * 1000 - 120000;
GM_setValue(
TOKEN_KEY,
JSON.stringify({ accessToken: j.access_token, expiresAt: expiresAt }),
);
cb(null, j.access_token);
},
onerror: function () {
cb(new Error("OAuth network error"));
},
});
}
function igdbPost(path, body, cb) {
getAccessToken(function (err, token) {
if (err) return cb(err);
GM_xmlhttpRequest({
method: "POST",
url: "https://api.igdb.com/v4" + path,
headers: {
"Client-ID": CLIENT_ID,
Authorization: "Bearer " + token,
Accept: "application/json",
"Content-Type": "text/plain",
},
data: body,
onload: function (res) {
if (res.status !== 200) {
return cb(
new Error(
"IGDB " +
res.status +
": " +
(res.responseText || "").slice(0, 240),
),
);
}
try {
cb(null, JSON.parse(res.responseText));
} catch (e) {
cb(e);
}
},
onerror: function () {
cb(new Error("IGDB network error"));
},
});
});
}
function formatRating(label, value, count) {
if (value == null || value === "") return "";
var n = typeof value === "number" ? Math.round(value * 10) / 10 : value;
var line = label + ": " + n + "/100";
if (count != null && count > 0) line += " (" + count + " votes)";
return line;
}
function formatDate(ts) {
if (ts == null) return "";
var d = new Date(ts * 1000);
if (isNaN(d.getTime())) return "";
return d.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
function truncate(s, n) {
s = String(s);
if (s.length <= n) return s;
return s.slice(0, n - 1).trim() + "…";
}
function coverUrl(cover) {
if (!cover || !cover.url) return "";
var u = cover.url;
if (u.indexOf("//") === 0) return "https:" + u;
return u;
}
function buildGameBody(title) {
var q = title.replace(/"/g, "").replace(/\r|\n/g, " ").trim();
return (
'search "' +
q +
'"; fields name,slug,summary,storyline,first_release_date,' +
"aggregated_rating,aggregated_rating_count,total_rating,total_rating_count," +
"url,cover.url,genres.name,platforms.name,game_modes.name,keywords.name,themes.name," +
"involved_companies.company.name,involved_companies.developer,involved_companies.publisher," +
"external_games.uid,external_games.url,external_games.external_game_source.name;" +
" limit 1;"
);
}
function pickInvolved(list, role) {
if (!list || !list.length) return "";
for (var i = 0; i < list.length; i++) {
var ic = list[i];
if (ic[role] && ic.company && ic.company.name) return ic.company.name;
}
return "";
}
function pickExternalBySourceName(list, sourceName) {
if (!list || !list.length) return null;
var i, eg;
for (i = 0; i < list.length; i++) {
eg = list[i];
if (
eg.external_game_source &&
eg.external_game_source.name === sourceName
)
return eg;
}
return null;
}
function outboundLink(label, href) {
return (
'<a class="igdb-gm-link" href="' +
escapeHtml(href) +
'" target="_blank" rel="noopener noreferrer">' +
escapeHtml(label) +
"</a>"
);
}
/**
* Outbound searches / stores to judge whether to install (YouTube, aggregators, Steam reviews, Linux).
*/
function buildReviewLinksHtml(displayName, externalGames) {
var name = displayName || "";
var chunks = [];
var mc =
"https://www.metacritic.com/search/" +
encodeURIComponent(name) +
"/?category=game";
chunks.push(outboundLink("Metacritic search", mc));
var oc =
"https://duckduckgo.com/?q=" +
encodeURIComponent(name + " game site:opencritic.com");
chunks.push(outboundLink("OpenCritic (via DDG)", oc));
var proton =
"https://www.protondb.com/search?q=" + encodeURIComponent(name);
chunks.push(outboundLink("ProtonDB (Linux)", proton));
var steam = pickExternalBySourceName(externalGames, "Steam");
if (steam) {
var steamDigits = String(steam.uid || "").replace(/\D/g, "");
var storeUrl =
steam.url ||
(steamDigits
? "https://store.steampowered.com/app/" + steamDigits + "/"
: "");
if (storeUrl) {
chunks.unshift(outboundLink("Steam store", storeUrl));
if (steamDigits) {
chunks.splice(
1,
0,
outboundLink(
"Steam user reviews",
"https://steamcommunity.com/app/" + steamDigits + "/reviews/",
),
);
}
}
}
var gog = pickExternalBySourceName(externalGames, "GOG");
if (gog && gog.url) chunks.push(outboundLink("GOG store", gog.url));
var epic = pickExternalBySourceName(externalGames, "Epic Game Store");
if (epic && epic.url) chunks.push(outboundLink("Epic store", epic.url));
var ytReviews =
"https://www.youtube.com/results?search_query=" +
encodeURIComponent("review game " + name);
chunks.push(outboundLink("YouTube: review search", ytReviews));
var ytBuy =
"https://www.youtube.com/results?search_query=" +
encodeURIComponent(name + " before you buy");
chunks.push(outboundLink("YouTube: before you buy", ytBuy));
return (
'<div class="igdb-gm-links">' +
'<div class="igdb-gm-links-h"><strong>Links</strong></div>' +
'<div class="igdb-gm-linkrow">' +
chunks.join('<span class="igdb-gm-sep"> · </span>') +
"</div></div>"
);
}
function cacheGet(key) {
if (cacheMem[key] !== undefined) return cacheMem[key];
var raw = GM_getValue(CACHE_PREFIX + key);
if (!raw) return null;
try {
var o = JSON.parse(raw);
if (o.exp && o.exp < Date.now()) {
return null;
}
cacheMem[key] = o.data;
return o.data;
} catch (e) {
return null;
}
}
function cacheSet(key, data) {
cacheMem[key] = data;
var exp = Date.now() + 86400000 * 3;
GM_setValue(CACHE_PREFIX + key, JSON.stringify({ exp: exp, data: data }));
}
function renderGameHtml(game, ttb) {
debugLog("renderGameHtml", {
gameName: game.name,
hasGenres: !!(game.genres && game.genres.length),
hasThemes: !!(game.themes && game.themes.length),
hasKeywords: !!(game.keywords && game.keywords.length),
hasTags: !!(game.tags && game.tags.length),
});
var cover = coverUrl(game.cover);
var genres = (game.genres || []).map((g) => g.name).filter(Boolean);
var plats = (game.platforms || []).map((p) => p.name).filter(Boolean);
var modes = (game.game_modes || []).map((m) => m.name).filter(Boolean);
var keywords = (game.keywords || []).map((k) => k.name).filter(Boolean);
var themes = (game.themes || []).map((t) => t.name).filter(Boolean);
var dev = pickInvolved(game.involved_companies, "developer");
var pub = pickInvolved(game.involved_companies, "publisher");
var ratings = [];
var ar = formatRating(
"Critics (aggregated)",
game.aggregated_rating,
game.aggregated_rating_count,
);
if (ar) ratings.push(ar);
var tr = formatRating(
"Users (total)",
game.total_rating,
game.total_rating_count,
);
if (tr) ratings.push(tr);
var blurb = game.summary || game.storyline || "";
if (blurb) blurb = truncate(blurb, SUMMARY_MAX_CHARS);
var ttbLine = "";
if (ttb && ttb.normally) {
var hrs = Math.round(ttb.normally / 60 / 60);
ttbLine = "<div><strong>Time to beat:</strong> ~" + hrs + " h</div>";
}
var igdbLink = game.url
? '<a class="igdb-gm-link" href="' +
escapeHtml(game.url) +
'" target="_blank" rel="noopener noreferrer">IGDB</a>'
: "";
return (
'<div class="igdb-gm-inner">' +
'<div class="igdb-gm-close-wrapper"><button class="igdb-gm-close" title="Close and suppress for 10s">×</button></div>' +
(cover
? '<img class="igdb-gm-cover" src="' +
escapeHtml(cover) +
'" width="120" height="auto" alt="" />'
: "") +
'<div class="igdb-gm-main">' +
'<div class="igdb-gm-title">' +
escapeHtml(game.name || "") +
"</div>" +
(ratings.length
? '<div class="igdb-gm-ratings">' +
ratings.map((r) => "<div>" + escapeHtml(r) + "</div>").join("") +
"</div>"
: '<div class="igdb-gm-muted">No IGDB score yet.</div>') +
'<div class="igdb-gm-meta">' +
(game.first_release_date
? "<span>Released: " +
escapeHtml(formatDate(game.first_release_date)) +
"</span>"
: "") +
"</div>" +
(dev
? '<div class="igdb-gm-dev">Developer: ' + escapeHtml(dev) + "</div>"
: "") +
(pub && pub !== dev
? '<div class="igdb-gm-pub">Publisher: ' + escapeHtml(pub) + "</div>"
: "") +
(plats.length
? '<div class="igdb-gm-platforms">' +
escapeHtml(plats.slice(0, 8).join(" · ")) +
"</div>"
: "") +
(blurb
? '<div class="igdb-gm-summary"><strong>Summary</strong><div>' +
escapeHtml(blurb) +
"</div></div>"
: "") +
"<hr />" +
'<div class="igdb-gm-flavor">' +
(genres.length
? "<div><strong>Genres:</strong> " +
escapeHtml(genres.slice(0, 4).join(", ")) +
"</div>"
: "") +
(modes.length
? "<div><strong>Modes:</strong> " +
escapeHtml(modes.join(", ")) +
"</div>"
: "") +
(themes.length
? "<div><strong>Themes:</strong> " +
escapeHtml(themes.slice(0, 5).join(", ")) +
"</div>"
: "") +
(keywords.length
? "<div><strong>Keywords:</strong> " +
escapeHtml(keywords.slice(0, 6).join(", ")) +
"</div>"
: "") +
ttbLine +
"</div>" +
"<hr />" +
buildReviewLinksHtml(game.name || "", game.external_games) +
'<div class="igdb-gm-footer">' +
igdbLink +
"</div>" +
"</hr></div>"
);
}
function fetchGameBundle(title, mySeq, done) {
var ck = cacheGet(title);
if (ck) {
if (mySeq !== hoverState.seq) return;
return done(null, ck.game, ck.ttb);
}
igdbPost("/games", buildGameBody(title), function (err, games) {
if (mySeq !== hoverState.seq) return;
if (err) return done(err);
if (!games || !games.length) return done(new Error("No match"));
var game = games[0];
igdbPost(
"/game_time_to_beats",
"where game_id = " +
game.id +
"; fields hastily,normally,completely,count; limit 1;",
function (err2, rows) {
if (mySeq !== hoverState.seq) return;
var ttb = !err2 && rows && rows[0] ? rows[0] : null;
cacheSet(title, { game: game, ttb: ttb });
done(null, game, ttb);
},
);
});
}
function beginHover(title) {
debugLog("beginHover", { title });
var mySeq = ++hoverState.seq;
showTooltipLoading();
fetchGameBundle(title, mySeq, function (err, game, ttb) {
if (mySeq !== hoverState.seq) return;
if (err) {
showTooltipHtml(
'<div class="igdb-gm-inner igdb-gm-error"><strong>' +
escapeHtml(title) +
"</strong><p>" +
escapeHtml(err.message || String(err)) +
"</p></div>",
);
return;
}
showTooltipHtml(renderGameHtml(game, ttb));
});
}
function onDocumentMouseOver(ev) {
lastPointer.x = ev.clientX;
lastPointer.y = ev.clientY;
if (tooltipEl && tooltipEl.style.display !== "none") {
if (tooltipEl.contains(ev.target)) {
cancelCloseTimer();
return;
}
if (pointerStillOnSameGame(ev.target)) cancelCloseTimer();
}
var title = getGameTitleFromElement(ev.target);
if (!title) {
if (
!KEEP_OPEN_UNTIL_CLICK &&
tooltipEl &&
tooltipEl.style.display !== "none" &&
!tooltipEl.contains(ev.target) &&
!pointerStillOnSameGame(ev.target)
) {
scheduleHideTooltip(false);
}
return;
}
// Check if this title is suppressed
var normalizedTitle = normalizeTitleText(title);
if (
suppressedTitles[normalizedTitle] &&
suppressedTitles[normalizedTitle] > Date.now()
) {
return;
}
var root = findHoverRoot(ev.target, title);
if (hoverState.root === root) return;
/*
* Grace: ignore other games while the close timer runs — unless the pointer is on a
* different .content-choice or different game <a>, then switch immediately.
*/
if (
tooltipEl &&
tooltipEl.style.display !== "none" &&
hoverState.closeTimer != null &&
!isDifferentGameTarget(ev.target)
) {
return;
}
hideTooltip();
armDelayedHover(root, title);
}
function onDocumentMouseOut(ev) {
if (!tooltipEl || tooltipEl.style.display === "none") return;
var to = ev.relatedTarget;
if (to) {
if (tooltipEl.contains(to)) return;
if (pointerStillOnSameGame(to)) return;
}
if (KEEP_OPEN_UNTIL_CLICK) return;
scheduleHideTooltip();
}
GM_addStyle(`
#igdb-gm-hover-tooltip {
position: fixed;
z-index: 2147483647;
max-width: 420px;
max-height: min(78vh, 640px);
overflow: auto;
padding: 0;
margin: 0;
font: 13px/1.45 system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, sans-serif;
color: #eee;
background: rgba(20, 22, 28, 1);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 10px;
box-shadow: 0 12px 40px rgba(0,0,0,0.45);
pointer-events: auto;
}
#igdb-gm-hover-tooltip a { color: #8cb4ff; }
.igdb-gm-inner { display: flex; gap: 12px; padding: 12px 14px; align-items: flex-start; }
.igdb-gm-cover {
flex: 0 0 auto;
border-radius: 6px;
object-fit: cover;
max-height: 160px;
}
.igdb-gm-main { flex: 1; min-width: 0; }
.igdb-gm-title { font-weight: 700; font-size: 15px; margin-bottom: 6px; color: #fff; }
.igdb-gm-ratings { margin-bottom: 6px; color: #b8d487; font-size: 12px; }
.igdb-gm-meta { font-size: 11px; color: #aaa; display: flex; flex-wrap: wrap; gap: 6px 12px; margin-bottom: 4px; }
.igdb-gm-dev, .igdb-gm-pub { font-size: 11px; color: #aaa; }
.igdb-gm-platforms { font-size: 11px; color: #aaa; margin-bottom: 4px; }
.igdb-gm-muted { font-size: 11px; color: #888; margin-bottom: 4px; }
.igdb-gm-flavor { font-size: 12px; color: #ddd; margin-bottom: 4px; }
.igdb-gm-summary { margin-top: 8px; font-size: 12px; color: #ddd; }
.igdb-gm-summary div { margin: 4px 0 0; }
.igdb-gm-links-h { font-size: 12px; font-weight: 600; color: #ddd; margin-bottom: 6px; }
.igdb-gm-linkrow { font-size: 11px; line-height: 1.55; color: #999; }
.igdb-gm-sep { color: #555; user-select: none; }
.igdb-gm-footer { margin-top: 10px; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; font-size: 10px; color: #666; }
.igdb-gm-loading { padding: 20px 28px; color: #aaa; }
.igdb-gm-error { display: block; padding: 12px 14px; color: #f88; }
.igdb-gm-error p { margin: 8px 0 0; color: #ccc; font-size: 12px; }
.igdb-gm-inner hr { margin: 8px; border: 1px solid #4444; }
.igdb-gm-close-wrapper {
float: right;
}
.igdb-gm-close {
position: absolute;
top: 8px;
right: 8px;
width: 20px;
height: 20px;
border: none;
background: rgba(255,255,255,0.1);
color: #ccc;
border-radius: 50%;
cursor: pointer;
font-size: 14px;
font-weight: bold;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
z-index: 1;
}
.igdb-gm-close:hover {
background: rgba(255,255,255,0.2);
color: #fff;
}
.igdb-gm-close:active {
background: rgba(255,255,255,0.3);
transform: scale(0.95);
}
`);
document.addEventListener("mouseover", onDocumentMouseOver, true);
document.addEventListener("mouseout", onDocumentMouseOut, true);
})();