Adds how long to beat widget, as well as bunch of other tweaks.
// ==UserScript==
// @name RetroAchievements+ (HLTB + Tweaks)
// @namespace https://bento.me/thevers
// @version 9
// @description Adds how long to beat widget, as well as bunch of other tweaks.
// @author VERS
// @icon https://i.imgur.com/IYwhfMf.png
// @match https://retroachievements.org/*
// @grant GM_xmlhttpRequest
// @grant GM.getValue
// @grant GM_addStyle
// @run-at document-start
// @grant GM.setValue
// @connect www.steamgriddb.com
// @connect howlongtobeat.com
// @license MIT
// ==/UserScript==
GM_addStyle(`
#nav-brand-wrapper img {
content: url("https://i.imgur.com/7oRtPlj.png");
}
.logo {
content: url("https://i.imgur.com/Qcaqw6b.png") !important;
}
.goldimage, .goldimagebig {
border: 2px solid gold;
filter: drop-shadow(0 0 5px gold);
}
`);
(function () {
'use strict';
const AppState = {
currentGameId: null,
currentGameName: null,
lastPath: window.location.pathname,
observers: new Set(),
intervals: new Set(),
timeouts: new Set(),
initialized: false,
apiCallCache: new Map(),
lastApiCall: new Map()
};
function cleanup() {
console.log('[RA+] Cleaning up resources...');
// Clear all observers
AppState.observers.forEach(observer => {
try {
observer.disconnect();
} catch (e) {
console.error('[RA+] Error disconnecting observer:', e);
}
});
AppState.observers.clear();
AppState.intervals.forEach(intervalId => clearInterval(intervalId));
AppState.intervals.clear();
AppState.timeouts.forEach(timeoutId => clearTimeout(timeoutId));
AppState.timeouts.clear();
const bgStyle = document.getElementById('steamgriddb-bg-style');
if (bgStyle) bgStyle.remove();
if (AppState.apiCallCache.size > 100) {
AppState.apiCallCache.clear();
}
}
function createTrackedInterval(callback, delay) {
const intervalId = setInterval(callback, delay);
AppState.intervals.add(intervalId);
return intervalId;
}
function createTrackedTimeout(callback, delay) {
const timeoutId = setTimeout(() => {
callback();
AppState.timeouts.delete(timeoutId);
}, delay);
AppState.timeouts.add(timeoutId);
return timeoutId;
}
function createTrackedObserver(callback, options) {
const observer = new MutationObserver(callback);
AppState.observers.add(observer);
return observer;
}
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
function throttle(func, limit) {
let inThrottle;
return function(...args) {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
};
}
function waitForElement(selector, timeout = 10000) {
return new Promise((resolve, reject) => {
const element = document.querySelector(selector);
if (element) {
resolve(element);
return;
}
const observer = new MutationObserver(() => {
const element = document.querySelector(selector);
if (element) {
observer.disconnect();
resolve(element);
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
createTrackedTimeout(() => {
observer.disconnect();
reject(new Error(`Timeout waiting for ${selector}`));
}, timeout);
});
}
const CACHE_EXPIRATION = 7 * 24 * 60 * 60 * 1000; // 7 days
const MAX_CACHE_SIZE = 1000;
async function getCachedValue(key, maxAge = CACHE_EXPIRATION) {
try {
const cached = await GM.getValue(key, null);
if (!cached) return null;
if (cached.timestamp && (Date.now() - cached.timestamp > maxAge)) {
await GM.setValue(key, null);
return null;
}
return cached.data;
} catch (e) {
console.error(`[RA+] Error getting cached value for ${key}:`, e);
return null;
}
}
async function setCachedValue(key, data) {
try {
await GM.setValue(key, {
data,
timestamp: Date.now()
});
} catch (e) {
console.error(`[RA+] Error setting cached value for ${key}:`, e);
}
}
const API_RATE_LIMIT = 1000;
function canMakeApiCall(apiName) {
const lastCall = AppState.lastApiCall.get(apiName);
if (!lastCall) return true;
return (Date.now() - lastCall) > API_RATE_LIMIT;
}
function recordApiCall(apiName) {
AppState.lastApiCall.set(apiName, Date.now());
}
async function cleanupOldCache() {
try {
const playtimeCache = await GM.getValue('playtime_cache', {});
const backgrounds = await GM.getValue('steamgriddb_backgrounds', {});
const playtimeKeys = Object.keys(playtimeCache);
if (playtimeKeys.length > MAX_CACHE_SIZE) {
const sortedKeys = playtimeKeys.slice(0, MAX_CACHE_SIZE / 2);
const newCache = {};
sortedKeys.forEach(key => {
newCache[key] = playtimeCache[key];
});
await GM.setValue('playtime_cache', newCache);
console.log(`[RA+] Cleaned playtime cache: ${playtimeKeys.length} -> ${sortedKeys.length}`);
}
const bgKeys = Object.keys(backgrounds);
if (bgKeys.length > MAX_CACHE_SIZE) {
const sortedBgKeys = bgKeys.slice(0, MAX_CACHE_SIZE / 2);
const newBgCache = {};
sortedBgKeys.forEach(key => {
newBgCache[key] = backgrounds[key];
});
await GM.setValue('steamgriddb_backgrounds', newBgCache);
console.log(`[RA+] Cleaned background cache: ${bgKeys.length} -> ${sortedBgKeys.length}`);
}
} catch (e) {
console.error('[RA+] Error cleaning cache:', e);
}
}
// Time widget
function extractMasteredTime() {
try {
const playtimeStats = document.querySelector('[data-testid="playtime-statistics"]');
if (!playtimeStats) {
console.log('[RA+] Playtime statistics element not found');
return null;
}
const masteredSection = Array.from(playtimeStats.querySelectorAll('.flex.items-center.justify-between'))
.find(section => {
const text = section.querySelector('.text-xs');
return text && text.textContent.trim() === 'Mastered';
});
if (!masteredSection) {
console.log('[RA+] Mastered section not found');
return null;
}
const timeElement = masteredSection.querySelector('.text-sm.text-neutral-300');
if (!timeElement) {
console.log('[RA+] Time element not found in mastered section');
return null;
}
const timeText = timeElement.textContent.trim();
console.log('[RA+] Found mastered time:', timeText);
return timeText;
} catch (e) {
console.error('[RA+] Error extracting mastered time:', e);
return null;
}
}
function extractPlaytimeTimes() {
try {
const playtimeStats = document.querySelector('[data-testid="playtime-statistics"]');
if (!playtimeStats) {
console.log('[RA+] Playtime statistics element not found');
return null;
}
const sections = Array.from(playtimeStats.querySelectorAll('.flex.items-center.justify-between'));
let masteredTime = null;
let beatTime = null;
sections.forEach(section => {
const labelElement = section.querySelector('.text-xs');
if (!labelElement) return;
const label = labelElement.textContent.trim();
const timeElement = section.querySelector('.text-sm.text-neutral-300');
if (timeElement) {
const timeText = timeElement.textContent.trim();
if (label === 'Mastered') {
masteredTime = timeText;
console.log('[RA+] Found mastered time:', timeText);
} else if (label === 'Beat the game') {
beatTime = timeText;
console.log('[RA+] Found beat time:', timeText);
}
}
});
return { masteredTime, beatTime };
} catch (e) {
console.error('[RA+] Error extracting playtime:', e);
return null;
}
}
function parseTimeToHours(timeStr) {
try {
let totalHours = 0;
const hoursMatch = timeStr.match(/(\d+)h/);
if (hoursMatch) {
totalHours += parseInt(hoursMatch[1]);
}
const minutesMatch = timeStr.match(/(\d+)m/);
if (minutesMatch) {
totalHours += parseInt(minutesMatch[1]) / 60;
}
return totalHours;
} catch (e) {
console.error('[RA+] Error parsing time:', e);
return 0;
}
}
function updatePlaytimeButton(button, times) {
try {
const { masteredTime, beatTime } = times;
const primaryTime = masteredTime || beatTime;
if (!primaryTime) {
button.textContent = 'Playtime N/A';
return;
}
let dotColor = 'gray';
const totalHours = parseTimeToHours(primaryTime);
if (totalHours < 30) {
dotColor = 'green';
} else if (totalHours <= 70) {
dotColor = 'orange';
} else {
dotColor = 'red';
}
let html = '';
if (masteredTime && beatTime) {
html = `
<img src="https://i.imgur.com/c55HqhO.png" style="width:16px;height:16px;margin-left:7px;margin-right:4px;" alt="Beat">
${beatTime}
<img src="https://i.imgur.com/1OubeR9.png" style="width:16px;height:16px;margin-left:7px;margin-right:4px;" alt="Mastered">
${masteredTime}
<span style="display:inline-block;width:11px;height:11px;background-color:${dotColor};border-radius:50%;margin-left:7px;"></span>
`;
} else if (masteredTime) {
html = `
<img src="https://i.imgur.com/1OubeR9.png" style="width:16px;height:16px;margin-left:7px;margin-right:4px;" alt="Mastered">
${masteredTime}
<span style="display:inline-block;width:11px;height:11px;background-color:${dotColor};border-radius:50%;margin-left:7px;"></span>
`;
} else if (beatTime) {
html = `
<img src="https://i.imgur.com/c55HqhO.png" style="width:16px;height:16px;margin-left:7px;margin-right:7px;" alt="Beat">
${beatTime}
`;
}
button.innerHTML = html;
button.style.cursor = 'default';
} catch (e) {
console.error('[RA+] Error updating playtime button:', e);
}
}
async function fetchPlaytimeData(gameId, button) {
try {
button.textContent = '⏳ Fetching…';
button.style.cursor = 'default';
console.log('[RA+] Fetching fresh playtime data for game:', gameId);
await waitForElement('[data-testid="playtime-statistics"]', 10000);
await new Promise(resolve => setTimeout(resolve, 500));
const times = extractPlaytimeTimes();
if (times && (times.masteredTime || times.beatTime)) {
console.log('[RA+] Found playtime data for game:', gameId, '=', times);
updatePlaytimeButton(button, times);
} else {
console.log('[RA+] No playtime data found for game:', gameId);
button.textContent = 'Playtime N/A';
}
} catch (e) {
console.error('[RA+] Playtime fetch error for game', gameId, ':', e);
button.textContent = 'Playtime N/A';
}
}
// steamgriddb
async function getSteamGridDBKey() {
try {
return await GM.getValue('steamgriddb_api_key', '');
} catch (e) {
console.error('[RA+] Error getting SteamGridDB key:', e);
return '';
}
}
async function setSteamGridDBKey(key) {
try {
// Basic validation
const trimmedKey = key.trim();
if (trimmedKey && trimmedKey.length < 10) {
throw new Error('API key seems too short');
}
await GM.setValue('steamgriddb_api_key', trimmedKey);
return true;
} catch (e) {
console.error('[RA+] Error setting SteamGridDB key:', e);
return false;
}
}
async function clearAllBackgrounds() {
try {
const backgrounds = await GM.getValue('steamgriddb_backgrounds', {});
const keys = Object.keys(backgrounds);
await GM.setValue('steamgriddb_backgrounds', {});
const style = document.getElementById('steamgriddb-bg-style');
if (style) style.remove();
return keys.length;
} catch (e) {
console.error('[RA+] Error clearing backgrounds:', e);
return 0;
}
}
async function applyBackground(gameId, imageUrl) {
try {
if (!gameId || !imageUrl) return;
const backgrounds = await GM.getValue('steamgriddb_backgrounds', {});
backgrounds[gameId] = imageUrl;
await GM.setValue('steamgriddb_backgrounds', backgrounds);
let existingStyle = document.getElementById('steamgriddb-bg-style');
if (!existingStyle) {
existingStyle = document.createElement('style');
existingStyle.id = 'steamgriddb-bg-style';
document.head.appendChild(existingStyle);
}
existingStyle.textContent = `
body {
background-image: linear-gradient(rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0.3)), url('${imageUrl}');
background-repeat: no-repeat;
background-attachment: fixed;
background-position: center center;
background-size: cover;
image-rendering: auto;
}
`;
} catch (e) {
console.error('[RA+] Error applying background:', e);
}
}
async function loadSavedBackground(gameId) {
try {
if (!gameId) return;
const backgrounds = await GM.getValue('steamgriddb_backgrounds', {});
const imageUrl = backgrounds[gameId];
if (imageUrl) {
let style = document.getElementById('steamgriddb-bg-style');
if (!style) {
style = document.createElement('style');
style.id = 'steamgriddb-bg-style';
document.head.appendChild(style);
}
style.textContent = `
body {
background-image: linear-gradient(rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0.3)), url('${imageUrl}');
background-repeat: no-repeat;
background-attachment: fixed;
background-position: center center;
background-size: cover;
image-rendering: auto;
}
`;
}
} catch (e) {
console.error('[RA+] Error loading saved background:', e);
}
}
function steamGridDBApiRequest(url, apiKey) {
return new Promise((resolve, reject) => {
if (!url || !apiKey) {
reject(new Error('Invalid SteamGridDB parameters'));
return;
}
const timeoutId = setTimeout(() => reject(new Error('SteamGridDB timeout')), 15000);
GM_xmlhttpRequest({
method: 'GET',
url,
headers: { 'Authorization': `Bearer ${apiKey}` },
onload: res => {
clearTimeout(timeoutId);
try {
if (res.status === 200) {
resolve(JSON.parse(res.responseText));
} else if (res.status === 401) {
reject(new Error('Invalid API key'));
} else {
reject(new Error(`SteamGridDB error: ${res.status}`));
}
} catch (e) {
reject(new Error('Failed to parse SteamGridDB response'));
}
},
onerror: () => {
clearTimeout(timeoutId);
reject(new Error('SteamGridDB network error'));
},
ontimeout: () => {
clearTimeout(timeoutId);
reject(new Error('SteamGridDB timeout'));
}
});
});
}
async function showSteamGridDBPopup(gameId, gameName) {
try {
const apiKey = await getSteamGridDBKey();
const popup = document.createElement('div');
popup.style.cssText = 'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:#272b30;color:#fff;padding:25px;border-radius:10px;z-index:10000;max-width:500px;box-shadow:0 0 30px rgba(0,0,0,0.8);';
popup.innerHTML = `
<div style="text-align:center;margin-bottom:20px;">
<img src="https://cdn2.steamgriddb.com/logo_thumb/a478c2b6c235580960cbae4a4ca4745e.png"
style="width:150px;height:auto;margin-bottom:10px;" alt="SteamGridDB">
<div style="font-size:12px;color:#fff;">Add your API key to load backgrounds on pages using SteamGridDB. It first fetches an image url from steamgriddb and on subsequent loads it loads the url from memory. So it might be slow on the first load.</div>
</div>
<div style="margin-bottom:15px;">
<label style="display:block;margin-bottom:5px;font-weight:bold;">API Key:</label>
<input type="text" id="sgdb-api-input" placeholder="Enter your API key or leave empty to disable"
style="width:100%;padding:8px;background:#1a1f24;border:1px solid #3a3f44;color:#fff;border-radius:5px;"
value="${apiKey}">
<p style="font-size:12px;color:#aaa;margin-top:5px;">
Get your API key from
<a href="https://www.steamgriddb.com/profile/preferences/api" target="_blank" style="color:#4a9eff;">
steamgriddb.com/profile/preferences/api
</a>
</p>
</div>
<div style="display:flex;gap:10px;justify-content:space-between;flex-wrap:wrap;">
<button id="sgdb-close" class="btn" style="background:#b24953;color:#fff;font-weight:bold;padding:10px;display:flex;align-items:center;gap:5px;">
<img src="https://i.imgur.com/spBpaqo.png" style="width:16px;height:16px;" alt="Close">
Close
</button>
<div style="display:flex;gap:10px;flex-wrap:wrap;">
<button id="sgdb-clear-all" class="btn" style="background:#3473a0;color:#fff;font-weight:bold;padding:10px;display:flex;align-items:center;gap:5px;">
<img src="https://i.imgur.com/TTCjGme.png" style="width:16px;height:16px;" alt="Clear">
Remove All Backgrounds
</button>
<button id="sgdb-save-key" class="btn" style="background:#2eab6a;color:#fff;font-weight:bold;padding:10px;display:flex;align-items:center;gap:5px;">
<img src="https://i.imgur.com/MU5BOFR.png" style="width:16px;height:16px;" alt="Save">
Save API Key
</button>
</div>
</div>
<div id="sgdb-status" style="margin-top:15px;font-size:13px;"></div>
`;
document.body.appendChild(popup);
const statusDiv = popup.querySelector('#sgdb-status');
popup.querySelector('#sgdb-save-key').addEventListener('click', async () => {
const key = popup.querySelector('#sgdb-api-input').value.trim();
const success = await setSteamGridDBKey(key);
if (success) {
if (key) {
statusDiv.innerHTML = '<span style="color:#4ade80;">✓ API Key saved - Reload the page to see changes.</span>';
} else {
statusDiv.innerHTML = '<span style="color:#4ade80;">✓ API Key removed - SteamGridDB disabled.</span>';
}
} else {
statusDiv.innerHTML = '<span style="color:#f87171;">✗ Failed to save API key</span>';
}
});
popup.querySelector('#sgdb-clear-all').addEventListener('click', async () => {
if (!confirm('Remove all saved backgrounds?')) return;
try {
const count = await clearAllBackgrounds();
statusDiv.innerHTML = `<span style="color:#4ade80;">✓ Removed ${count} background(s).</span>`;
} catch (err) {
console.error('[RA+] Error removing backgrounds:', err);
statusDiv.innerHTML = `<span style="color:#f87171;">✗ Failed to remove backgrounds</span>`;
}
});
popup.querySelector('#sgdb-close').addEventListener('click', () => popup.remove());
} catch (e) {
console.error('[RA+] Error showing SteamGridDB popup:', e);
}
}
// game id
function getRetroAchievementsGameId() {
try {
const app = document.querySelector('#app');
if (!app) return null;
const data = app.getAttribute('data-page');
if (!data) return null;
const parsed = JSON.parse(data);
const gameId =
parsed?.props?.game?.id ||
parsed?.props?.backingGame?.id ||
parsed?.props?.ziggy?.location?.match(/\/game\d*\/(\d+)/)?.[1] ||
null;
return gameId ? String(gameId) : null;
} catch (e) {
console.error('[RA+] Failed to parse game ID:', e);
return null;
}
}
// page elements
async function initGamePage() {
try {
const headerRoot = document.querySelector('[data-testid="playable-header"]');
if (!headerRoot) {
console.log('[RA+] Header not found, skipping init');
return;
}
const toolbar = document.querySelector('[data-testid="game-achievement-set-toolbar"]');
// Fallback container if toolbar doesn't exist
const fallbackToolbar = document.querySelector(
'div.flex.w-full.items-center.justify-between > div.flex.flex-col.items-start.gap-0'
);
if (headerRoot.dataset.raPlusInit === 'true') {
console.log('[RA+] Already initialized, skipping');
return;
}
const gameNameElement = headerRoot.querySelector('h1 span');
if (!gameNameElement) {
console.log('[RA+] Game name element not found');
return;
}
const gameName = gameNameElement.textContent.trim();
if (!gameName) {
console.log('[RA+] Game name is empty');
return;
}
const gameId = getRetroAchievementsGameId();
console.log('[RA+] Initializing game page:', gameName, 'ID:', gameId);
headerRoot.dataset.raPlusInit = 'true';
AppState.currentGameId = gameId;
AppState.currentGameName = gameName;
if (gameId) {
await loadSavedBackground(gameId);
const apiKey = await getSteamGridDBKey();
if (apiKey && canMakeApiCall('steamgriddb')) {
recordApiCall('steamgriddb');
try {
const searchUrl = `https://www.steamgriddb.com/api/v2/search/autocomplete/${encodeURIComponent(gameName)}`;
const searchResult = await steamGridDBApiRequest(searchUrl, apiKey);
if (searchResult?.data?.length) {
const sgdbGame = searchResult.data[0];
const sgdbGameId = sgdbGame.id;
const heroUrl = `https://www.steamgriddb.com/api/v2/heroes/game/${sgdbGameId}`;
const heroResult = await steamGridDBApiRequest(heroUrl, apiKey);
if (heroResult?.data?.length) {
const topHero = heroResult.data.sort((a, b) => (b.score || 0) - (a.score || 0))[0];
await applyBackground(gameId, topHero.url);
console.log(`[RA+] Applied background from ${topHero.url}`);
}
}
} catch (error) {
console.warn('[RA+] Failed to load background:', error.message);
}
}
}
let buttonContainer = headerRoot.querySelector('.hidden.flex-wrap.gap-x-2');
if (!buttonContainer) {
buttonContainer = document.createElement('div');
buttonContainer.className = 'hidden flex-wrap gap-x-2 gap-y-1 text-neutral-300 sm:flex';
headerRoot.appendChild(buttonContainer);
}
buttonContainer.querySelectorAll('.ra-plus-btn').forEach(btn => btn.remove());
const currentUrl = window.location.href;
let urlWithoutQuery = currentUrl.split('?')[0];
urlWithoutQuery = urlWithoutQuery.replace(/\/game\d*\//, '/game/');
const hashesUrl = urlWithoutQuery.endsWith('/')
? `${urlWithoutQuery}hashes`
: `${urlWithoutQuery}/hashes`;
const gamefaqsbuttondefaultImg = "https://i.imgur.com/lrnidRF.png";
const gamefaqsbuttonhoverImg = "https://i.imgur.com/H9jfCjO.png";
const downloadBtndefaultImg = "https://i.imgur.com/HaOSEPW.png";
const downloadBtnhoverImg = "https://i.imgur.com/5utafhc.png";
const steamgriddbbuttondefaultImg = "https://i.imgur.com/GXxzUtx.png";
const steamgriddbbuttonhoverImg = "https://i.imgur.com/QQAWdZK.png";
const achievementinfoButtonhoverImg = "https://i.imgur.com/odZpnWj.png";
const achievementinfoButtonDefaultImg = "https://i.imgur.com/kpSLIQD.png";
const achievementinfoButtonUnpublishedHoverImg = "https://i.imgur.com/8d4auxy.png";
const achievementinfoButtonUnpublishedDefaultImg = "https://i.imgur.com/oOx8Gog.png";
const downloadBtn = document.createElement('button');
downloadBtn.className = 'btn flex items-center gap-1 ra-plus-btn';
downloadBtn.title = `Go to ${gameName} hashes page`;
downloadBtn.innerHTML = `<img src="https://i.imgur.com/HaOSEPW.png" style="width:16px;height:16px;" alt="Hashes"> Hashes`;
downloadBtn.style.color = '#32a852';
downloadBtn.style.borderRadius = '9999px';
downloadBtn.style.background = '#1c1c1c';
downloadBtn.style.border = '1px solid rgba(50, 168, 82)';
downloadBtn.addEventListener('click', () => window.location.href = hashesUrl);
downloadBtn.addEventListener('mouseenter', () => {
downloadBtn.style.color = '#fff';
downloadBtn.style.backgroundColor = '#2e2e2e';
downloadBtn.style.border = '1px solid #fff';
downloadBtn.querySelector('img').src = downloadBtnhoverImg;
});
downloadBtn.addEventListener('mouseleave', () => {
downloadBtn.style.color = '#32a852';
downloadBtn.style.backgroundColor = '#1c1c1c';
downloadBtn.style.border = '1px solid rgba(50, 168, 82)';
downloadBtn.querySelector('img').src = downloadBtndefaultImg;
});
const gamefaqsButton = document.createElement('button');
gamefaqsButton.className = 'btn flex items-center gap-1 ra-plus-btn';
gamefaqsButton.title = `Search ${gameName} on GameFAQs`;
gamefaqsButton.style.backgroundColor = '#1c1c1c';
gamefaqsButton.style.color = '#606bf6';
gamefaqsButton.style.borderRadius = '9999px';
gamefaqsButton.style.border = '1px solid rgba(96,107,246)';
gamefaqsButton.innerHTML = `<img src="${gamefaqsbuttondefaultImg}" style="width:16px;height:16px;" alt="GameFAQs">`;
gamefaqsButton.addEventListener('click', () => {
window.open(`https://gamefaqs.gamespot.com/search?game=${encodeURIComponent(gameName)}`, '_blank');
});
gamefaqsButton.addEventListener('mouseenter', () => {
gamefaqsButton.style.color = '#fff';
gamefaqsButton.style.backgroundColor = '#2e2e2e';
gamefaqsButton.style.border = '1px solid #fff';
gamefaqsButton.querySelector('img').src = gamefaqsbuttonhoverImg;
});
gamefaqsButton.addEventListener('mouseleave', () => {
gamefaqsButton.style.color = '#606bf6';
gamefaqsButton.style.backgroundColor = '#1c1c1c';
gamefaqsButton.style.border = '1px solid rgba(96,107,246)';
gamefaqsButton.querySelector('img').src = gamefaqsbuttondefaultImg;
});
const steamgriddbButton = document.createElement('button');
steamgriddbButton.className = 'btn flex items-center gap-1 ra-plus-btn';
steamgriddbButton.title = `SteamGridDB Settings`;
steamgriddbButton.style.backgroundColor = '#1c1c1c';
steamgriddbButton.style.borderRadius = '9999px';
steamgriddbButton.innerHTML = `<img src="https://i.imgur.com/GXxzUtx.png" style="width:16px;height:16px;" alt="SteamGridDB">`;
steamgriddbButton.addEventListener('click', async () => showSteamGridDBPopup(gameId, gameName));
steamgriddbButton.style.border = '1px solid rgba(86, 182, 235)';
steamgriddbButton.addEventListener('mouseenter', () => {
steamgriddbButton.style.backgroundColor = '#2e2e2e';
steamgriddbButton.style.border = '1px solid #fff';
steamgriddbButton.querySelector('img').src = steamgriddbbuttonhoverImg;
});
steamgriddbButton.addEventListener('mouseleave', () => {
steamgriddbButton.style.backgroundColor = '#1c1c1c';
steamgriddbButton.style.border = '1px solid rgba(86, 182, 235)';
steamgriddbButton.querySelector('img').src = steamgriddbbuttondefaultImg;
});
const playtimeButton = document.createElement('button');
playtimeButton.className = 'btn flex items-center gap-1 ra-plus-btn';
playtimeButton.textContent = '⏳ Fetching…';
playtimeButton.style.border = 'none';
playtimeButton.style.border = '1px solid rgba(62, 62, 62, 1)';
playtimeButton.style.borderRadius = '9999px';
playtimeButton.style.color = '#ffffff';
buttonContainer.appendChild(playtimeButton);
buttonContainer.appendChild(gamefaqsButton);
buttonContainer.appendChild(downloadBtn);
buttonContainer.appendChild(steamgriddbButton);
if (!document.querySelector('.ra-plus-toolbar-achievementinfo-btn')) {
const achievementinfoButton = document.createElement('button');
achievementinfoButton.className = 'btn flex items-center gap-1 ra-plus-toolbar-achievementinfo-btn';
achievementinfoButton.style.backgroundColor = '#161616';
achievementinfoButton.style.borderRadius = '.375rem';
achievementinfoButton.style.border = '1px solid rgba(42, 42, 42)';
const url = new URL(window.location.href);
const isUnpublished = url.searchParams.has('unpublished');
let defaultImg = isUnpublished ? achievementinfoButtonUnpublishedDefaultImg : achievementinfoButtonDefaultImg;
let hoverImg = isUnpublished ? achievementinfoButtonUnpublishedHoverImg : achievementinfoButtonhoverImg;
achievementinfoButton.innerHTML = `<img src="${defaultImg}" style="width:16px;height:16px;">`;
achievementinfoButton.addEventListener('mouseenter', () => {
achievementinfoButton.style.color = '#fff';
achievementinfoButton.querySelector('img').src = hoverImg;
});
achievementinfoButton.addEventListener('mouseleave', () => {
achievementinfoButton.querySelector('img').src = defaultImg;
});
achievementinfoButton.addEventListener('click', () => {
const url = new URL(window.location.href);
if (url.searchParams.has('unpublished')) {
url.searchParams.delete('unpublished');
} else {
url.searchParams.set('unpublished', 'true');
}
window.location.href = url.toString();
});
// Insert into toolbar if it exists, otherwise fallback
if (toolbar) {
const toolbarButtonsContainers = toolbar.querySelectorAll('.flex.w-full.gap-2.sm\\:w-auto');
const toolbarButtonsContainer = toolbarButtonsContainers[1];
if (toolbarButtonsContainer) {
toolbarButtonsContainer.insertBefore(achievementinfoButton, toolbarButtonsContainer.firstChild);
}
} else if (fallbackToolbar) {
fallbackToolbar.appendChild(achievementinfoButton);
}
}
if (gameId) {
fetchPlaytimeData(gameId, playtimeButton);
}
} catch (e) {
console.error('[RA+] Error in initGamePage:', e);
}
}
// Navigation
function initAllNavbarButtons() {
try {
initRAGuidesButton();
NavbarButtons.initAll();
} catch (e) {
console.error('[RA+] Error initializing navbar buttons:', e);
}
}
const handleNavigationChange = debounce(async function() {
const currentPath = window.location.pathname;
if (!currentPath.startsWith('/game/') && !currentPath.match(/\/game\d+\//)) {
const bgStyle = document.getElementById('steamgriddb-bg-style');
if (bgStyle) bgStyle.remove();
AppState.currentGameId = null;
AppState.currentGameName = null;
document.querySelectorAll('[data-testid="playable-header"]').forEach(header => {
delete header.dataset.raPlusInit;
});
return;
}
const newGameId = getRetroAchievementsGameId();
if (newGameId && newGameId !== AppState.currentGameId) {
console.log('[RA+] Navigation detected game change:', AppState.currentGameId, '->', newGameId);
document.querySelectorAll('[data-testid="playable-header"]').forEach(header => {
delete header.dataset.raPlusInit;
});
}
try {
await waitForElement('[data-testid="playable-header"]', 5000);
await initGamePage();
initAllNavbarButtons();
} catch (e) {
console.log('[RA+] Header not found on navigation');
}
}, 300);
// Game name change watcher
const watchGameNameChanges = throttle(function() {
const currentGameId = getRetroAchievementsGameId();
if (!currentGameId) return;
const headerRoot = document.querySelector('[data-testid="playable-header"]');
if (!headerRoot) return;
const span = headerRoot.querySelector('h1 span');
if (!span) return;
const currentGameName = span.textContent.trim();
if (currentGameId !== AppState.currentGameId && currentGameId) {
console.log('[RA+] Game ID changed:', AppState.currentGameId, '->', currentGameId);
AppState.currentGameId = currentGameId;
AppState.currentGameName = currentGameName;
delete headerRoot.dataset.raPlusInit;
const buttonRow = headerRoot.querySelector('.hidden.flex-wrap.gap-x-2');
if (buttonRow) {
buttonRow.querySelectorAll('.ra-plus-btn').forEach(btn => btn.remove());
}
const bgStyle = document.getElementById('steamgriddb-bg-style');
if (bgStyle) bgStyle.remove();
initGamePage();
initAllNavbarButtons();
}
}, 1000);
const NavbarButtons = {
buttons: [],
registeredIds: new Set(),
// Register a new navbar button
register(config) {
if (this.registeredIds.has(config.id)) {
console.warn(`[RA+] Button '${config.id}' already registered, skipping`);
return;
}
this.buttons.push(config);
this.registeredIds.add(config.id);
},
// Clear all registered buttons (useful for cleanup)
clear() {
this.buttons = [];
this.registeredIds.clear();
},
// Initialize all registered buttons
initAll() {
injectNavbarStyles();
processAllNavbars();
observeNavbarChanges();
}
};
// Shared navbar injection logic
function injectNavbarStyles() {
if (document.getElementById('ra-navbar-styles')) return;
const css = `
.ra-navbar-item { position: relative; }
.ra-navbar-item .nav-link.active { background: #2a2a2a; }
`;
const style = document.createElement('style');
style.id = 'ra-navbar-styles';
style.textContent = css;
document.head.appendChild(style);
}
function addButtonsToNavbar(container) {
// Add all registered buttons
NavbarButtons.buttons.forEach(config => {
// Skip if already injected
if (container.querySelector(`[data-navbar-${config.id}]`)) return;
const navItem = config.createButton();
navItem.setAttribute(`data-navbar-${config.id}`, 'true');
// Find insertion point
const downloadButton = container.querySelector('.nav-item a[href*="downloads"]')?.closest('.nav-item');
if (downloadButton) {
downloadButton.before(navItem);
} else {
const mlAuto = container.querySelector('.ml-auto');
const notifications = container.querySelector('[wire\\:id]');
if (mlAuto) mlAuto.before(navItem);
else if (notifications) notifications.before(navItem);
else container.appendChild(navItem);
}
});
}
function processAllNavbars() {
const containers = document.querySelectorAll('.flex-1.mx-2, .flex.items-center.bg-embedded');
containers.forEach(c => {
if (c.querySelector('.nav-item')) {
addButtonsToNavbar(c);
}
});
}
function observeNavbarChanges() {
const observer = createTrackedObserver((mutations) => {
mutations.forEach(mutation => {
if (!mutation.addedNodes.length) return;
mutation.addedNodes.forEach(node => {
if (node.nodeType !== Node.ELEMENT_NODE) return;
if (node.matches && (node.matches('.flex-1.mx-2') || node.matches('.flex.items-center.bg-embedded'))) {
if (node.querySelector('.nav-item')) addButtonsToNavbar(node);
}
if (node.querySelectorAll) {
node.querySelectorAll('.flex-1.mx-2, .flex.items-center.bg-embedded').forEach(navbar => {
if (navbar.querySelector('.nav-item')) addButtonsToNavbar(navbar);
});
}
});
});
});
observer.observe(document.body, { childList: true, subtree: true });
}
// BUTTONS
function initRAGuidesButton() {
const guidesData = window.RAGUIDES || [
{ "name": "Game Boy", "category": "Nintendo", "link": "https://github.com/RetroAchievements/guides/wiki/Game-Boy", "icon": "https://static.retroachievements.org/assets/images/system/gb.png" },
{ "name": "Game Boy Color", "category": "Nintendo", "link": "https://github.com/RetroAchievements/guides/wiki/Game-Boy-Color", "icon": "https://static.retroachievements.org/assets/images/system/gbc.png" },
{ "name": "Game Boy Advance", "category": "Nintendo", "link": "https://github.com/RetroAchievements/guides/wiki/Game-Boy-Advance", "icon": "https://static.retroachievements.org/assets/images/system/gba.png" },
{ "name": "NES/Famicom Disk System", "category": "Nintendo", "link": "https://github.com/RetroAchievements/guides/wiki/NES", "icon": "https://static.retroachievements.org/assets/images/system/nes.png" },
{ "name": "SNES/Super Famicom", "category": "Nintendo", "link": "https://github.com/RetroAchievements/guides/wiki/SNES", "icon": "https://static.retroachievements.org/assets/images/system/snes.png" },
{ "name": "Nintendo 64(DD)", "category": "Nintendo", "link": "https://github.com/RetroAchievements/guides/wiki/Nintendo-64", "icon": "https://static.retroachievements.org/assets/images/system/n64.png" },
{ "name": "GameCube", "category": "Nintendo", "link": "https://github.com/RetroAchievements/guides/wiki/GameCube", "icon": "https://static.retroachievements.org/assets/images/system/gc.png" },
{ "name": "Wii", "category": "Nintendo", "link": "https://github.com/RetroAchievements/guides/wiki/Wii", "icon": "" },
{ "name": "Nintendo DS", "category": "Nintendo", "link": "https://github.com/RetroAchievements/guides/wiki/Nintendo-DS", "icon": "https://static.retroachievements.org/assets/images/system/ds.png" },
{ "name": "Nintendo DSi", "category": "Nintendo", "link": "https://github.com/RetroAchievements/guides/wiki/Nintendo-DSi", "icon": "https://static.retroachievements.org/assets/images/system/dsi.png" },
{ "name": "Pokémon Mini", "category": "Nintendo", "link": "https://github.com/RetroAchievements/guides/wiki/Pokemon-Mini", "icon": "https://static.retroachievements.org/assets/images/system/mini.png" },
{ "name": "Virtual Boy", "category": "Nintendo", "link": "https://github.com/RetroAchievements/guides/wiki/Virtual-Boy", "icon": "https://static.retroachievements.org/assets/images/system/vb.png" },
{ "name": "Atari 2600", "category": "Atari", "link": "https://github.com/RetroAchievements/guides/wiki/Atari-2600", "icon": "https://static.retroachievements.org/assets/images/system/2600.png" },
{ "name": "Atari 7800", "category": "Atari", "link": "https://github.com/RetroAchievements/guides/wiki/Atari-7800", "icon": "https://static.retroachievements.org/assets/images/system/7800.png" },
{ "name": "Jaguar", "category": "Atari", "link": "https://github.com/RetroAchievements/guides/wiki/Atari-Jaguar", "icon": "https://static.retroachievements.org/assets/images/system/jag.png" },
{ "name": "Jaguar CD", "category": "Atari", "link": "https://github.com/RetroAchievements/guides/wiki/Atari-Jaguar-CD", "icon": "https://static.retroachievements.org/assets/images/system/jcd.png" },
{ "name": "Lynx", "category": "Atari", "link": "https://github.com/RetroAchievements/guides/wiki/Atari-Lynx", "icon": "https://static.retroachievements.org/assets/images/system/lynx.png" },
{ "name": "PlayStation", "category": "Sony", "link": "https://github.com/RetroAchievements/guides/wiki/PlayStation", "icon": "https://static.retroachievements.org/assets/images/system/ps1.png" },
{ "name": "PlayStation 2", "category": "Sony", "link": "https://github.com/RetroAchievements/guides/wiki/PlayStation-2", "icon": "https://static.retroachievements.org/assets/images/system/ps2.png" },
{ "name": "PlayStation Portable", "category": "Sony", "link": "https://github.com/RetroAchievements/guides/wiki/PlayStation-Portable", "icon": "https://static.retroachievements.org/assets/images/system/psp.png" },
{ "name": "SG-1000", "category": "Sega", "link": "https://github.com/RetroAchievements/guides/wiki/SG-1000", "icon": "https://static.retroachievements.org/assets/images/system/sg1k.png" },
{ "name": "Master System", "category": "Sega", "link": "https://github.com/RetroAchievements/guides/wiki/Master-System", "icon": "https://static.retroachievements.org/assets/images/system/sms.png" },
{ "name": "Game Gear", "category": "Sega", "link": "https://github.com/RetroAchievements/guides/wiki/Game-Gear", "icon": "https://static.retroachievements.org/assets/images/system/gg.png" },
{ "name": "Genesis/Mega Drive", "category": "Sega", "link": "https://github.com/RetroAchievements/guides/wiki/Mega-Drive", "icon": "https://static.retroachievements.org/assets/images/system/md.png" },
{ "name": "Sega CD", "category": "Sega", "link": "https://github.com/RetroAchievements/guides/wiki/Sega-CD", "icon": "https://static.retroachievements.org/assets/images/system/scd.png" },
{ "name": "Sega 32X", "category": "Sega", "link": "https://github.com/RetroAchievements/guides/wiki/32X", "icon": "https://static.retroachievements.org/assets/images/system/32x.png" },
{ "name": "Sega Saturn", "category": "Sega", "link": "https://github.com/RetroAchievements/guides/wiki/Saturn", "icon": "https://static.retroachievements.org/assets/images/system/sat.png" },
{ "name": "Sega Dreamcast", "category": "Sega", "link": "https://github.com/RetroAchievements/guides/wiki/Dreamcast", "icon": "https://static.retroachievements.org/assets/images/system/dc.png" },
{ "name": "PC Engine/TurboGrafx-16", "category": "NEC", "link": "https://github.com/RetroAchievements/guides/wiki/PC-Engine", "icon": "https://static.retroachievements.org/assets/images/system/pce.png" },
{ "name": "PC Engine CD/TurboGrafx-CD", "category": "NEC", "link": "https://github.com/RetroAchievements/guides/wiki/PC-Engine-CD", "icon": "https://static.retroachievements.org/assets/images/system/pccd.png" },
{ "name": "PC-8000/8800", "category": "NEC", "link": "https://github.com/RetroAchievements/guides/wiki/PC-88", "icon": "https://static.retroachievements.org/assets/images/system/8088.png" },
{ "name": "PC-FX", "category": "NEC", "link": "https://github.com/RetroAchievements/guides/wiki/PC-FX", "icon": "https://static.retroachievements.org/assets/images/system/pc-fx.png" },
{ "name": "Neo Geo CD", "category": "SNK", "link": "https://github.com/RetroAchievements/guides/wiki/Neo-Geo-CD", "icon": "https://static.retroachievements.org/assets/images/system/ngcd.png" },
{ "name": "Neo Geo Pocket", "category": "SNK", "link": "https://github.com/RetroAchievements/guides/wiki/Neo-Geo-Pocket", "icon": "https://static.retroachievements.org/assets/images/system/ngp.png" },
{ "name": "3DO", "category": "Other", "link": "https://github.com/RetroAchievements/guides/wiki/3DO-Interactive-Multiplayer", "icon": "https://static.retroachievements.org/assets/images/system/3do.png" },
{ "name": "Amstrad CPC", "category": "Other", "link": "https://github.com/RetroAchievements/guides/wiki/Amstrad-CPC", "icon": "https://static.retroachievements.org/assets/images/system/cpc.png" },
{ "name": "Apple II", "category": "Other", "link": "https://github.com/RetroAchievements/guides/wiki/Apple-II", "icon": "https://static.retroachievements.org/assets/images/system/a2.png" },
{ "name": "Arcade", "category": "Other", "link": "https://github.com/RetroAchievements/guides/wiki/Arcade", "icon": "https://static.retroachievements.org/assets/images/system/arc.png" },
{ "name": "Arcadia 2001", "category": "Other", "link": "https://github.com/RetroAchievements/guides/wiki/Arcadia-2001", "icon": "https://static.retroachievements.org/assets/images/system/a2001.png" },
{ "name": "Arduboy", "category": "Other", "link": "https://github.com/RetroAchievements/guides/wiki/Arduboy", "icon": "https://static.retroachievements.org/assets/images/system/ard.png" },
{ "name": "ColecoVision", "category": "Other", "link": "https://github.com/RetroAchievements/guides/wiki/ColecoVision", "icon": "https://static.retroachievements.org/assets/images/system/cv.png" },
{ "name": "Elektor TV Games Computer", "category": "Other", "link": "https://github.com/RetroAchievements/guides/wiki/Elektor-TV-Games-Computer", "icon": "https://static.retroachievements.org/assets/images/system/elek.png" },
{ "name": "Fairchild Channel F", "category": "Other", "link": "https://github.com/RetroAchievements/guides/wiki/Fairchild-Channel-F", "icon": "https://static.retroachievements.org/assets/images/system/chf.png" },
{ "name": "Intellivision", "category": "Other", "link": "https://github.com/RetroAchievements/guides/wiki/Intellivision", "icon": "https://static.retroachievements.org/assets/images/system/intv.png" },
{ "name": "Interton VC 4000", "category": "Other", "link": "https://github.com/RetroAchievements/guides/wiki/Interton-VC-4000", "icon": "https://static.retroachievements.org/assets/images/system/vc4000.png" },
{ "name": "Magnavox Odyssey 2", "category": "Other", "link": "https://github.com/RetroAchievements/guides/wiki/Magnavox-Odyssey-2", "icon": "https://static.retroachievements.org/assets/images/system/mo2.png" },
{ "name": "Mega Duck", "category": "Other", "link": "https://github.com/RetroAchievements/guides/wiki/Mega-Duck", "icon": "https://static.retroachievements.org/assets/images/system/duck.png" },
{ "name": "MSX(2)", "category": "Other", "link": "https://github.com/RetroAchievements/guides/wiki/MSX", "icon": "https://static.retroachievements.org/assets/images/system/msx.png" },
{ "name": "Standalone", "category": "Other", "link": "https://github.com/RetroAchievements/guides/wiki/Standalone", "icon": "https://static.retroachievements.org/assets/images/system/exe.png" },
{ "name": "Uzebox", "category": "Other", "link": "https://github.com/RetroAchievements/guides/wiki/Uzebox", "icon": "https://static.retroachievements.org/assets/images/system/uze.png" },
{ "name": "Vectrex", "category": "Other", "link": "https://github.com/RetroAchievements/guides/wiki/Vectrex", "icon": "https://static.retroachievements.org/assets/images/system/vect.png" },
{ "name": "WASM-4", "category": "Other", "link": "https://github.com/RetroAchievements/guides/wiki/WASM-4", "icon": "https://static.retroachievements.org/assets/images/system/wasm4.png" },
{ "name": "Watara Supervision", "category": "Other", "link": "https://github.com/RetroAchievements/guides/wiki/Watara-Supervision", "icon": "https://static.retroachievements.org/assets/images/system/wsv.png" },
{ "name": "WonderSwan", "category": "Other", "link": "https://github.com/RetroAchievements/guides/wiki/WonderSwan", "icon": "https://static.retroachievements.org/assets/images/system/ws.png" }
];
// Inject guides-specific styles
function injectGuidesStyles() {
if (document.getElementById('ra-guides-styles')) return;
const css = `
.ra-guides-dropdown {
display: none;
position: fixed;
top: 0;
left: 50px;
right: 50px;
width: 800px;
max-width: calc(100vw - 140px);
max-height: 80vh;
overflow-y: auto;
background: #2a2a2a;
border: 1px solid rgba(0,0,0,0.08);
box-shadow: 0 6px 24px rgba(0,0,0,0.12);
box-sizing: border-box;
z-index: 99999;
padding: 0 20px;
}
.ra-guides-dropdown.open { display: flex; }
.ra-guides-container {
display: flex;
gap: 20px;
width: 100%;
}
.dropdown-column {
flex: 1;
min-width: 100px;
}
.ra-guides-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 0px;
}
.ra-guide-item {
display: flex;
height: 20px;
align-items: center;
gap: 10px;
padding: 12px 12px 12px 0;
margin-left: -20px;
text-decoration: none;
color: #b9b9b9;
border-radius: 0px;
}
.ra-guide-item:hover {
background: #161616;
color: #cc9900;
}
.ra-guide-icon {
width: 18px;
height: 18px;
object-fit: contain;
flex: 0 0 36px;
border-radius: 0px;
}
.ra-guides-category {
color: #0991e2;
font-size: 13px;
font-weight: bold;
margin-top: 8px;
margin-bottom: 4px;
text-align: center;
}
.ra-guide-meta {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
width: 100%;
}
.ra-guide-title {
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ra-guides-dropdown::-webkit-scrollbar { width: 10px; }
.ra-guides-dropdown::-webkit-scrollbar-track { background: rgba(255,255,255,0.05); border-radius: 5px; }
.ra-guides-dropdown::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border-radius: 5px; }
.ra-guides-dropdown::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.35); }
@media (max-width: 640px) {
.ra-guides-dropdown { left: 8px; right: 8px; }
.ra-guides-grid { grid-template-columns: repeat(auto-fill, minmax(100%, 1fr)); }
}
`;
const style = document.createElement('style');
style.id = 'ra-guides-styles';
style.textContent = css;
document.head.appendChild(style);
}
function buildDropdown(guides) {
const dropdown = document.createElement('div');
dropdown.className = 'ra-guides-dropdown';
dropdown.setAttribute('role', 'menu');
const container = document.createElement('div');
container.className = 'ra-guides-container';
// Define column layout
const columns = [
['Nintendo', 'Sony', 'Atari'],
['Sega', 'NEC', 'SNK'],
['Other']
];
const grouped = {};
guides.forEach(g => {
if (!grouped[g.category]) grouped[g.category] = [];
grouped[g.category].push(g);
});
columns.forEach(columnCategories => {
const column = document.createElement('div');
column.className = 'dropdown-column';
columnCategories.forEach(category => {
if (!grouped[category]) return;
const catHeader = document.createElement('div');
catHeader.textContent = category;
catHeader.className = "ra-guides-category";
column.appendChild(catHeader);
grouped[category].forEach(g => {
const a = document.createElement('a');
a.className = 'ra-guide-item';
a.href = g.link || '#';
a.target = '_self';
a.rel = 'noopener noreferrer';
a.setAttribute('role', 'menuitem');
const img = document.createElement('img');
img.className = 'ra-guide-icon';
img.src = g.icon && g.icon.trim() !== ""
? g.icon
: "https://docs.retroachievements.org/ra-logo-big-shadow.png";
img.alt = `${g.name} icon`;
const meta = document.createElement('div');
meta.className = 'ra-guide-meta';
const title = document.createElement('div');
title.className = 'ra-guide-title';
title.textContent = g.name;
meta.appendChild(title);
a.appendChild(img);
a.appendChild(meta);
column.appendChild(a);
});
});
container.appendChild(column);
});
dropdown.appendChild(container);
return dropdown;
}
function createButton() {
const navItem = document.createElement('div');
navItem.className = 'nav-item ra-navbar-item';
const link = document.createElement('a');
link.className = 'nav-link';
link.href = 'https://github.com/RetroAchievements/guides/wiki';
link.target = '_self';
link.title = 'Guides';
link.setAttribute('aria-haspopup', 'true');
link.setAttribute('aria-expanded', 'false');
link.innerHTML = `
<img src="https://i.imgur.com/iZ9ldkB.png"
alt="RA Guides"
width="18"
height="18"
style="vertical-align:middle">
<span class="ml-1 hidden sm:inline-block" style="margin-left:8px;vertical-align:middle">
Guides
</span>
`;
navItem.appendChild(link);
const dropdown = buildDropdown(guidesData);
document.body.appendChild(dropdown);
function showDropdown() {
const rect = link.getBoundingClientRect();
dropdown.style.top = (rect.bottom + 0) + 'px';
dropdown.style.left = rect.left + 'px';
dropdown.classList.add('open');
link.setAttribute('aria-expanded', 'true');
link.classList.add('active');
}
function hideDropdown() {
dropdown.classList.remove('open');
link.setAttribute('aria-expanded', 'false');
link.classList.remove('active');
}
let hideTimer = null;
[navItem, dropdown].forEach(el => {
el.addEventListener('mouseenter', () => {
clearTimeout(hideTimer);
showDropdown();
});
el.addEventListener('mouseleave', () => {
clearTimeout(hideTimer);
hideTimer = setTimeout(hideDropdown, 150);
});
});
link.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
const open = dropdown.classList.toggle('open');
link.setAttribute('aria-expanded', open ? 'true' : 'false');
if (open) showDropdown();
} else if (ev.key === 'Escape') {
hideDropdown();
}
});
let isTouch = false;
window.addEventListener('touchstart', function onFirstTouch() {
isTouch = true;
window.removeEventListener('touchstart', onFirstTouch, false);
}, false);
link.addEventListener('click', (ev) => {
if (isTouch) {
ev.preventDefault();
const open = dropdown.classList.toggle('open');
link.setAttribute('aria-expanded', open ? 'true' : 'false');
if (open) showDropdown();
}
});
document.addEventListener('click', (ev) => {
if (!navItem.contains(ev.target) && !dropdown.contains(ev.target)) {
hideDropdown();
}
});
return navItem;
}
injectGuidesStyles();
// Register the button
NavbarButtons.register({
id: 'guides',
createButton: createButton
});
}
// beta
function injectBetaNotice() {
try {
const gameInfo = document.querySelector('div.flex.flex-col.sm\\:flex-row.sm\\:w-full.gap-x-4.gap-y-2.items-center.mb-4');
if (!gameInfo || document.querySelector('.ra-plus-beta-notice')) return;
const notice = document.createElement('p');
notice.className = 'ra-plus-beta-notice';
notice.style.cssText = 'color:red;font-weight:600;margin-top:0.5rem;';
notice.innerHTML = 'For the script to work, please check "Enable beta features" in the <a href="https://retroachievements.org/settings" style="color:#079bf3;text-decoration:underline;">settings</a>.';
const metadata = gameInfo.querySelector('div.flex.flex-col.w-full.gap-1');
if (metadata) {
metadata.after(notice);
} else {
gameInfo.appendChild(notice);
}
} catch (e) {
console.error('[RA+] Error injecting beta notice:', e);
}
}
// news
async function showWhatsNew() {
try {
const scriptVersion = '9';
const lastSeenVersion = await GM.getValue('whatsNewVersion', null);
if (lastSeenVersion === scriptVersion) return;
const headerColors = {
h1: '#FFD700',
h2: '#FFD700',
h3: '#FFFFFF'
};
const headerSizes = {
h1: '40px',
h2: '24px',
h3: '20px'
};
const headerWeights = {
h1: '700',
h2: '900',
h3: '300'
};
const textColor = '#FFFFFF';
const popupBackground = '#2a2a2a';
const lineColor = '#5a5a5a';
const popup = document.createElement('div');
popup.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: ${popupBackground};
color: ${textColor};
padding: 25px;
border-radius: 12px;
z-index: 99999;
max-width: 900px;
text-align: center;
box-shadow: 0 0 20px rgba(0,0,0,0.8);
`;
popup.innerHTML = `
<h1 style="margin-bottom: 10px;">
<img src="https://i.imgur.com/3UZpapk.png" alt="RetroAchievements+" style="height: 70px; vertical-align: middle;">
</h1>
<hr style="border:none; height:2px; background:${lineColor}; margin-bottom: 15px;">
<h3 style="margin-bottom: 10px; font-size: ${headerSizes.h1}; font-weight: ${headerWeights.h2}; color: ${headerColors.h2};">End of Retroachievements +</h3>
<ul style="text-align:left; margin-bottom: 20px; padding-left: 20px; color: ${textColor};">
<ul>Hi, It's extremely unfortunate but I won't be able to finish this retroachievements script. Around 2 weeks ago I became fully bed ridden because back issues. I really wanted to include bunch of features in this release such as profile customizations and themes. But because of my health issues I won't be updating it any further (Or at least until I am fully healed after my surgery which is in couple of months).
The rom finder was removed from this release, but it's still present in the older versions..</ul>
</ul>
<button id="whatsNewOkBtn" style="
padding: 8px 15px;
border:none;
background:#225ad9;
color:#FFFFFF;
font-weight:bold;
border-radius:6px;
cursor:pointer;
">OK</button>
`;
document.body.appendChild(popup);
document.getElementById('whatsNewOkBtn').addEventListener('click', async () => {
await GM.setValue('whatsNewVersion', scriptVersion);
popup.remove();
});
} catch (e) {
console.error('[RA+] Error showing what\'s new:', e);
}
}
// main
function init() {
console.log('[RA+] Initializing RetroAchievements+ v4.2');
cleanup();
showWhatsNew();
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', injectBetaNotice);
} else {
injectBetaNotice();
}
initAllNavbarButtons();
const navObserver = createTrackedObserver(() => {
if (window.location.pathname !== AppState.lastPath) {
AppState.lastPath = window.location.pathname;
handleNavigationChange();
}
});
navObserver.observe(document.body, { childList: true, subtree: true });
handleNavigationChange();
const nameWatchInterval = createTrackedInterval(watchGameNameChanges, 2000);
const cacheCleanupInterval = createTrackedInterval(cleanupOldCache, 5 * 60 * 1000);
window.addEventListener('beforeunload', cleanup);
console.log('[RA+] Initialization complete');
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();