Rozszerza Backloggd o więcej informacji, bogatsze UI i usprawnienia QoL
// ==UserScript==
// @name Backloggd Plus
// @name:ru Backloggd Plus
// @name:zh-CN Backloggd Plus
// @name:es Backloggd Plus
// @name:pt-BR Backloggd Plus
// @name:de Backloggd Plus
// @name:fr Backloggd Plus
// @name:ja Backloggd Plus
// @name:ko Backloggd Plus
// @name:pl Backloggd Plus
// @namespace https://github.com/NemoKing1210/backloggd-plus
// @version 0.7.21
// @description Extends Backloggd and adds a Backloggd button on Steam game pages
// @description:ru Расширяет Backloggd и добавляет кнопку Backloggd на страницах игр Steam
// @description:zh-CN 扩展 Backloggd:更多游戏信息、更丰富的界面与使用体验
// @description:es Amplía Backloggd con más información de juegos, UI enriquecida y mejoras QoL
// @description:pt-BR Amplia o Backloggd com mais info de jogos, UI enriquecida e melhorias QoL
// @description:de Erweitert Backloggd um mehr Spielinfos, reichere UI und QoL-Features
// @description:fr Enrichit Backloggd avec plus d’infos jeux, une UI améliorée et des QoL
// @description:ja Backloggdを拡張:追加のゲーム情報、UI強化、QoL機能
// @description:ko Backloggd 확장: 추가 게임 정보, 풍부한 UI, QoL 기능
// @description:pl Rozszerza Backloggd o więcej informacji, bogatsze UI i usprawnienia QoL
// @author NemoKing1210
// @tag backloggd
// @tag games
// @homepageURL https://github.com/NemoKing1210/backloggd-plus
// @supportURL https://github.com/NemoKing1210/backloggd-plus/issues
// @license MIT
// @icon https://www.backloggd.com/favicon.ico
// @match https://www.backloggd.com/*
// @match https://backloggd.com/*
// @match https://store.steampowered.com/app/*
// @match https://steamcommunity.com/app/*
// @match https://steamdb.info/app/*
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_addStyle
// @grant GM_registerMenuCommand
// @connect store.steampowered.com
// @connect api.steampowered.com
// @connect gamestatus.info
// @connect howlongtobeat.com
// @connect api.opencritic.com
// @connect opencritic.com
// @connect html.duckduckgo.com
// @connect www.protondb.com
// @connect protondb.com
// @run-at document-idle
// @noframes
// ==/UserScript==
(function () {
'use strict';
const REPO_URL = 'https://github.com/NemoKing1210/backloggd-plus';
/** Keep in sync with `@version` in the userscript header (and `.meta.js`). */
const SCRIPT_VERSION = '0.7.21';
const SETTINGS_KEY = 'blp_settings';
const CACHE_KEY = 'blp_cache_v1';
const CACHE_VERSION_KEY = 'blp_cache_script_version';
const STEAM_OVERRIDES_KEY = 'blp_steam_overrides';
const ROOT_ATTR = 'data-blp-root';
const ENRICH_ATTR = 'data-blp-enrich';
const STEAMDB_ATTR = 'data-blp-steamdb';
const CARD_ATTR = 'data-blp-card';
const CARD_STATE_ATTR = 'data-blp-card-state';
const FAVICON_URL = 'https://www.google.com/s2/favicons?domain={domain}&sz=32';
const SCAN_DEBOUNCE_MS = 400;
const CARD_CONCURRENCY = 3;
const CARD_ROOT_MARGIN = '240px 0px';
const CARD_SKIP_ANCESTOR =
'#game-profile, #game-body, #logging-sidebar-section, .blp-settings-backdrop, .blp-fix-match-backdrop';
const CACHE_HOURS_MAX = 168;
/** Soft advisory budget for the settings meter (GM storage has no fixed quota). */
const CACHE_SOFT_LIMIT_BYTES = 5 * 1024 * 1024;
const STEAM_SEARCH_URL = 'https://store.steampowered.com/api/storesearch/';
const STEAM_DETAILS_URL = 'https://store.steampowered.com/api/appdetails';
const STEAM_REVIEWS_URL = 'https://store.steampowered.com/appreviews';
const STEAM_USERDATA_URL = 'https://store.steampowered.com/dynamicstore/userdata/';
const STEAM_POPULAR_TAGS_URL = 'https://store.steampowered.com/tagdata/populartags/english';
const STEAM_STORE_ITEMS_URL = 'https://api.steampowered.com/IStoreBrowseService/GetItems/v1/';
const STEAM_PLAYERS_URL = 'https://api.steampowered.com/ISteamUserStats/GetNumberOfCurrentPlayers/v1/';
const STEAM_DECK_REPORT_URL =
'https://store.steampowered.com/saleaction/ajaxgetdeckappcompatibilityreport';
const PROTONDB_SUMMARY_URL = 'https://www.protondb.com/api/v1/reports/summaries';
const OPENCRITIC_API_BASE = 'https://api.opencritic.com/api';
const OPENCRITIC_SITE = 'https://opencritic.com';
const DDG_HTML_SEARCH = 'https://html.duckduckgo.com/html/';
const HLTB_SITE = 'https://howlongtobeat.com';
const HLTB_INIT_URL = `${HLTB_SITE}/api/bleed/init`;
const HLTB_SEARCH_URL = `${HLTB_SITE}/api/bleed`;
const ITAD_SITE = 'https://isthereanydeal.com';
const PCGW_SITE = 'https://www.pcgamingwiki.com';
const GOGDB_SITE = 'https://www.gogdb.org';
const STEAM_CDN_APPS = 'https://shared.fastly.steamstatic.com/store_item_assets/steam/apps';
const STEAM_CDN_STORE_ASSETS = 'https://shared.fastly.steamstatic.com/store_item_assets';
const STEAM_CDN_COMMUNITY_ICONS = 'https://shared.fastly.steamstatic.com/community_assets/images/apps';
const STEAMDB_APP_URL = 'https://steamdb.info/app';
const STEAM_TAGS_MAX = 12;
const PLAYERS_CACHE_TTL_MS = 10 * 60 * 1000;
const GAMESTATUS_API_BASE = 'https://gamestatus.info/back/api/gameinfo/game';
const GAMESTATUS_SITE_BASE = 'https://gamestatus.info';
const GAMESTATUS_MAX_SLUG_ATTEMPTS = 2;
const USERDATA_CACHE_KEY = 'steam:userdata';
const USERDATA_EMPTY_TTL_MS = 5 * 60 * 1000;
const USERDATA_FALLBACK_TTL_MS = 60 * 60 * 1000;
const TAG_MAP_CACHE_KEY = 'steam:tagmap:english';
const TAG_MAP_TTL_MS = 7 * 24 * 3600 * 1000;
const GS_INVALID_SLUG_RE =
/^(https?-)?(store-)?steam(powered|static)?(-[a-z0-9]+)*(-com)?$|steampowered|steamstatic|akamaihd|^(on-)?wishlist$|^gamestatus$|^(soon-)?on-game-pass$/;
const LINK_KEYS = [
'igdb',
'steam',
'steamdb',
'metacritic',
'opencritic',
'hltb',
'pcgamingwiki',
'itad',
'gogdb',
];
const LINK_DOMAINS = {
igdb: 'igdb.com',
steam: 'store.steampowered.com',
steamdb: 'steamdb.info',
metacritic: 'metacritic.com',
opencritic: 'opencritic.com',
hltb: 'howlongtobeat.com',
pcgamingwiki: 'pcgamingwiki.com',
itad: 'isthereanydeal.com',
gogdb: 'gogdb.org',
};
const DEFAULT_SETTINGS = {
cacheHours: 12,
uiLocale: 'auto',
steamCountry: 'US',
showSteam: true,
showMetacritic: true,
showOpenCritic: true,
showHltb: true,
showDeckProton: true,
showGameStatus: true,
showLinks: true,
showSteamOwned: true,
showSteamWishlist: true,
showSteamTags: true,
showSteamCategories: true,
showSteamPageLink: true,
showSteamDbPageLink: true,
showSteamDbIcon: true,
showSteamDbCover: true,
showSteamDbGallery: true,
showSteamPlayers: true,
showCardBadges: true,
debugMode: false,
links: {
igdb: true,
steam: true,
steamdb: true,
metacritic: true,
opencritic: true,
hltb: true,
pcgamingwiki: true,
itad: true,
gogdb: true,
},
};
const SUPPORTED_LOCALES = ['en', 'ru', 'zh', 'es', 'pt', 'de', 'fr', 'ja', 'ko', 'pl'];
const TRANSLATIONS = {
en: {
menuSettings: 'Backloggd Plus — Settings',
panelTitle: 'Backloggd Plus',
panelSubtitle: 'Steam · scores · Deck/Proton · HLTB · list badges · links',
close: 'Close',
cancel: 'Cancel',
save: 'Save',
saveReload: 'Save & Reload page',
repoLink: 'GitHub',
repoAbout: 'Source code, updates, and issue reports',
sectionGame: 'Game page',
sectionLists: 'Lists & cards',
sectionGeneral: 'General',
sectionCache: 'Cache',
sectionDebug: 'Debug',
debugMode: 'Debug mode',
debugModeHint:
'Show one debug panel on the game page with sources (what each URL is for) and a full response dump.',
debugReason: 'Reason',
debugResponse: 'Response',
debugPanelTitle: 'Debug',
debugSources: 'Sources',
debugDump: 'Full dump',
debugSectionPage: 'Page',
debugSectionSteam: 'Steam',
debugSectionSteamDb: 'SteamDB / players',
debugSectionGameStatus: 'GameStatus',
debugOwned: 'Owned',
debugSrcRequest: 'request',
debugSrcBackloggd: 'Current Backloggd game page',
debugSrcIgdb: 'Game slug / Links row (IGDB)',
debugSrcMetacritic: 'Links row — Metacritic URL (slug guess)',
debugSrcSteamStore: 'Steam store page — Links row + Steam row link',
debugSrcSteamDb: 'Links row — SteamDB app page',
debugSrcSteamDbCharts: 'SteamDB charts (reference; not scraped)',
debugSrcSteamDbInfo: 'SteamDB info (reference; not scraped)',
debugSrcSteamTiny: 'Tiny capsule — icon fallback',
debugSrcSteamHeader: 'Header image from appdetails',
debugSrcSteamSearch: 'storesearch — resolve Steam App ID',
debugSrcSteamDetails: 'appdetails — price, free, Metacritic, header',
debugSrcSteamReviews: 'appreviews — review summary for Steam row',
debugSrcSteamTags: 'GetItems — popular tags for Steam row',
debugSrcSteamTagMap: 'populartags — tag id → name map',
debugSrcSteamOwned: 'dynamicstore/userdata — owned library check',
debugSrcSteamDbPage: 'SteamDB HTML scrape — icon, logo, players',
debugSrcSteamPlayersApi: 'GetNumberOfCurrentPlayers — players fallback',
debugSrcIcon: 'Title icon (SteamDB / CDN)',
debugSrcLogo: 'Cover logo under Change cover',
debugSrcGameStatusApi: 'GameStatus API — status',
debugSrcGameStatusPage: 'GameStatus site page for matched slug',
uiLanguage: 'Interface language',
uiLanguageHint: 'Auto follows your browser language. Saved choice applies after reload.',
uiLanguageAuto: 'Auto (browser)',
steamCountry: 'Steam store region',
steamCountryHint: 'Affects price currency from the Steam Store API.',
showSteam: 'Show Steam price & reviews',
showMetacritic: 'Show Metacritic score',
showOpenCritic: 'Show OpenCritic score',
showOpenCriticHint: 'OpenCritic tier badge (searched by title).',
showHltb: 'Show HowLongToBeat times',
showHltbHint: 'Main / Extra / Complete hours from HowLongToBeat.',
showDeckProton: 'Show Steam Deck & ProtonDB',
showDeckProtonHint: 'Steam Deck Verified/Playable plus ProtonDB tier (needs a Steam App ID).',
showGameStatus: 'Show GameStatus',
showGameStatusHint: 'Status from GameStatus.info (needs a Steam match).',
showLinks: 'Show quick links row',
showSteamOwned: 'Show Steam owned status',
showSteamOwnedHint:
'Shows “Owned” when the game is in your Steam library. Requires being logged into Steam in this browser.',
showSteamWishlist: 'Show Steam wishlist status',
showSteamWishlistHint:
'Shows “Wishlist” when the game is on your Steam wishlist (hidden if already owned). Requires Steam login in this browser.',
showSteamTags: 'Show Steam tags',
showSteamTagsHint: 'Popular community tags from the Steam store (Open World, RPG, …).',
showSteamCategories: 'Show Steam categories',
showSteamCategoriesHint:
'Store categories from Steam app details (Single-player, Multi-player, Steam Achievements, …).',
showSteamPageLink: 'Show Backloggd button on Steam',
showSteamPageLinkHint: 'Adds a SteamDB-style button in Other site info on Steam app pages.',
showSteamDbPageLink: 'Show Backloggd button on SteamDB',
showSteamDbPageLinkHint: 'Adds a Backloggd button next to Store / IGDB in SteamDB app links.',
showSteamDbIcon: 'Show SteamDB icon before title',
showSteamDbIconHint: 'App icon from Steam store assets (community_icon), same CDN as SteamDB.',
showSteamDbCover: 'Show SteamDB logo under Change cover',
showSteamDbCoverHint: 'Steam header image under the cover / Change cover control.',
showSteamDbGallery: 'Show Steam screenshots gallery',
showSteamDbGalleryHint:
'Horizontal screenshot strip after game stats (Steam store assets, same as SteamDB). Click to enlarge.',
steamGalleryTitle: 'Screenshots',
steamGalleryOpen: 'Open screenshot',
steamGalleryClose: 'Close',
steamGalleryPrev: 'Previous',
steamGalleryNext: 'Next',
viewerZoomIn: 'Zoom in',
viewerZoomOut: 'Zoom out',
viewerZoomReset: 'Reset zoom',
viewerOf: '{current} / {total}',
viewerOpenCover: 'Open cover',
showSteamPlayers: 'Show online players',
showSteamPlayersHint: 'Current players from Steam GetNumberOfCurrentPlayers.',
showCardBadges: 'Show badges on list covers',
showCardBadgesHint:
'Price, Steam review %, Owned / Wishlist, and GameStatus chips on browse, search, journal, and other cover grids (not the game page).',
steamBackloggdTooltip: 'View on Backloggd',
steamDbBackloggdLabel: 'Backloggd',
steamOwned: 'Owned',
steamWishlist: 'Wishlist',
steamFixMatch: 'Fix match',
steamFixMatchTitle: 'Steam App ID',
steamFixMatchHint:
'Override the automatic Steam store search for this Backloggd page. Paste the numeric App ID from the Steam store URL.',
steamFixMatchPlaceholder: 'e.g. 1245620',
steamFixMatchClear: 'Use automatic match',
steamFixMatchInvalid: 'Enter a valid numeric Steam App ID',
steamMatchManual: 'manual',
steamMatchAuto: 'auto',
steamMatchLabel: 'App {id} · {source}',
sectionLinks: 'Quick links',
sectionLinksHint: 'Choose which sites appear in the Links row on game pages.',
navSettings: 'Plus',
navSettingsTitle: 'Backloggd Plus settings',
cacheHours: 'Cache duration (hours)',
cacheHoursHint: 'How long to reuse Steam / GameStatus lookups. 0 disables cache.',
clearCache: 'Clear cache',
cacheCleared: 'Cache cleared ({count})',
cacheEmpty: 'Cache is empty',
cacheBarFull: 'Full',
cacheBarPartial: 'Partial',
cacheBarFree: 'Free',
cacheBarLegend: '{label}: {count} · {size}',
cacheBarUsed: '{used} / {limit}',
cacheBarHint:
'Soft storage budget. Full = game-page Steam resolves (with tags); partial = list/card lite or expired entries.',
cacheBarAria: 'Cache usage: {full} full, {partial} partial, {free} free',
cacheClearHint: 'Removes stored Steam / GameStatus lookups from this browser profile.',
on: 'ON',
off: 'OFF',
loading: 'Loading…',
notOnSteam: 'Not found on Steam',
loadError: 'Could not load Steam data',
steam: 'Steam',
metacritic: 'Metacritic',
gameStatus: 'GameStatus',
gsReady: 'Ready',
gsPending: 'Pending',
gsPartial: 'Partial',
gsReleaseToday: 'Release today',
gsUnknown: 'Unknown',
gsNotInDatabase: 'Not in database',
reviews: 'Reviews',
price: 'Price',
free: 'Free',
discount: '-{n}%',
discountEnds: 'ends {date}',
steamUsFallback: 'Found via US store ({cc} search returned nothing)',
recommendations: '{n} recommendations',
links: 'Links',
linkIgdb: 'IGDB',
linkSteam: 'Steam',
linkSteamDb: 'SteamDB',
linkMetacritic: 'Metacritic',
linkOpencritic: 'OpenCritic',
linkHltb: 'HLTB',
linkPcgamingwiki: 'PCGamingWiki',
linkItad: 'IsThereAnyDeal',
linkGogdb: 'GOG DB',
players: 'Players',
playersOnline: '{n} playing',
openCritic: 'OpenCritic',
hltb: 'HLTB',
hltbMain: 'Main {n}h',
hltbExtra: 'Extra {n}h',
hltbComplete: 'Complete {n}h',
deckProton: 'Deck / Proton',
deckVerified: 'Verified',
deckPlayable: 'Playable',
deckUnsupported: 'Unsupported',
deckUnknown: 'Unknown',
protonTier: 'ProtonDB {tier}',
unifiedRatingTitle: 'Plus rating',
unifiedRatingHint: 'Average of Steam, Metacritic, OpenCritic & Backloggd',
unifiedRatingOutOf: 'out of 5',
unifiedRatingSources: '{n} sources',
unifiedRatingLoading: 'Gathering scores…',
unifiedRatingSteam: 'Steam',
unifiedRatingMetacritic: 'Metacritic',
unifiedRatingOpenCritic: 'OpenCritic',
unifiedRatingBackloggd: 'Backloggd',
unifiedRatingMissing: '—',
},
ru: {
menuSettings: 'Backloggd Plus — Настройки',
panelTitle: 'Backloggd Plus',
panelSubtitle: 'Steam · оценки · Deck/Proton · HLTB · бейджи · ссылки',
close: 'Закрыть',
cancel: 'Отмена',
save: 'Сохранить',
saveReload: 'Сохранить и перезагрузить',
repoLink: 'GitHub',
repoAbout: 'Исходники, обновления и баг-репорты',
sectionGame: 'Страница игры',
sectionLists: 'Списки и карточки',
sectionGeneral: 'Общие',
sectionCache: 'Кэш',
sectionDebug: 'Отладка',
debugMode: 'Режим отладки',
debugModeHint:
'Одна панель отладки на странице игры: источники (за что отвечает каждая ссылка) и полный дамп ответов.',
debugReason: 'Причина',
debugResponse: 'Ответ',
debugPanelTitle: 'Отладка',
debugSources: 'Источники',
debugDump: 'Полный дамп',
debugSectionPage: 'Страница',
debugSectionSteam: 'Steam',
debugSectionSteamDb: 'SteamDB / игроки',
debugSectionGameStatus: 'GameStatus',
debugOwned: 'Куплено',
debugSrcRequest: 'запрос',
debugSrcBackloggd: 'Текущая страница игры на Backloggd',
debugSrcIgdb: 'Слаг игры / ряд Links (IGDB)',
debugSrcMetacritic: 'Ряд Links — URL Metacritic (по слагу)',
debugSrcSteamStore: 'Страница в Steam — Links + ссылка в ряду Steam',
debugSrcSteamDb: 'Ряд Links — страница приложения SteamDB',
debugSrcSteamDbCharts: 'SteamDB charts (справочно; не скрапится)',
debugSrcSteamDbInfo: 'SteamDB info (справочно; не скрапится)',
debugSrcSteamTiny: 'Маленькая капсула — запасной icon',
debugSrcSteamHeader: 'Header-картинка из appdetails',
debugSrcSteamSearch: 'storesearch — поиск Steam App ID',
debugSrcSteamDetails: 'appdetails — цена, free, Metacritic, header',
debugSrcSteamReviews: 'appreviews — сводка отзывов для ряда Steam',
debugSrcSteamTags: 'GetItems — популярные теги для ряда Steam',
debugSrcSteamTagMap: 'populartags — карта id тега → имя',
debugSrcSteamOwned: 'dynamicstore/userdata — проверка библиотеки',
debugSrcSteamDbPage: 'HTML SteamDB — icon, logo, игроки',
debugSrcSteamPlayersApi: 'GetNumberOfCurrentPlayers — запасной онлайн',
debugSrcIcon: 'Иконка у заголовка (SteamDB / CDN)',
debugSrcLogo: 'Логотип обложки под Change cover',
debugSrcGameStatusApi: 'API GameStatus — статус',
debugSrcGameStatusPage: 'Страница GameStatus для найденного слага',
uiLanguage: 'Язык интерфейса',
uiLanguageHint: 'Авто — язык браузера. Выбор применится после перезагрузки.',
uiLanguageAuto: 'Авто (браузер)',
steamCountry: 'Регион Steam Store',
steamCountryHint: 'Влияет на валюту цены из Steam Store API.',
showSteam: 'Показывать цену и отзывы Steam',
showMetacritic: 'Показывать оценку Metacritic',
showOpenCritic: 'Показывать оценку OpenCritic',
showOpenCriticHint: 'Бейдж tier с OpenCritic (поиск по названию).',
showHltb: 'Показывать время HowLongToBeat',
showHltbHint: 'Main / Extra / Complete в часах с HowLongToBeat.',
showDeckProton: 'Показывать Steam Deck и ProtonDB',
showDeckProtonHint: 'Verified/Playable для Steam Deck и тир ProtonDB (нужен Steam App ID).',
showGameStatus: 'Показывать статус GameStatus',
showGameStatusHint: 'Статус с GameStatus.info (нужно совпадение со Steam).',
showLinks: 'Показывать ряд ссылок',
showSteamOwned: 'Показывать «Куплено» в Steam',
showSteamOwnedHint:
'Показывает «Куплено», если игра в вашей библиотеке Steam. Нужен вход в Steam в этом браузере.',
showSteamWishlist: 'Показывать «В вишлисте» Steam',
showSteamWishlistHint:
'Показывает «В вишлисте», если игра в списке желаний Steam (скрывается, если уже куплена). Нужен вход в Steam.',
showSteamTags: 'Показывать теги Steam',
showSteamTagsHint: 'Популярные пользовательские теги из Steam Store (Open World, RPG, …).',
showSteamCategories: 'Показывать категории Steam',
showSteamCategoriesHint:
'Категории из карточки Steam (Single-player, Multi-player, Steam Achievements, …).',
showSteamPageLink: 'Кнопка Backloggd на Steam',
showSteamPageLinkHint: 'Кнопка в стиле SteamDB в блоке Other site info на страницах игр Steam.',
showSteamDbPageLink: 'Кнопка Backloggd на SteamDB',
showSteamDbPageLinkHint: 'Кнопка рядом со Store / IGDB в блоке app-links на SteamDB.',
showSteamDbIcon: 'Иконка SteamDB перед названием',
showSteamDbIconHint: 'Иконка из Steam store assets (community_icon), тот же CDN, что у SteamDB.',
showSteamDbCover: 'Логотип SteamDB под Change cover',
showSteamDbCoverHint: 'Steam header под обложкой / кнопкой Change cover.',
showSteamDbGallery: 'Галерея скриншотов Steam',
showSteamDbGalleryHint:
'Горизонтальная лента скриншотов после блока статистики (ассеты Steam Store, как на SteamDB). Клик — увеличить.',
steamGalleryTitle: 'Скриншоты',
steamGalleryOpen: 'Открыть скриншот',
steamGalleryClose: 'Закрыть',
steamGalleryPrev: 'Назад',
steamGalleryNext: 'Вперёд',
viewerZoomIn: 'Увеличить',
viewerZoomOut: 'Уменьшить',
viewerZoomReset: 'Сбросить масштаб',
viewerOf: '{current} / {total}',
viewerOpenCover: 'Открыть обложку',
showSteamPlayers: 'Показывать онлайн игроков',
showSteamPlayersHint: 'Текущие игроки через Steam GetNumberOfCurrentPlayers.',
showCardBadges: 'Бейджи на обложках в списках',
showCardBadgesHint:
'Цена, рейтинг Steam %, Куплено / Вишлист и GameStatus на browse, поиске, journal и других сетках обложек (не на странице игры).',
steamBackloggdTooltip: 'Открыть на Backloggd',
steamDbBackloggdLabel: 'Backloggd',
steamOwned: 'Куплено',
steamWishlist: 'В вишлисте',
steamFixMatch: 'Исправить матч',
steamFixMatchTitle: 'Steam App ID',
steamFixMatchHint:
'Переопределить автоматический поиск в Steam Store для этой страницы Backloggd. Вставьте числовой App ID из URL магазина Steam.',
steamFixMatchPlaceholder: 'например 1245620',
steamFixMatchClear: 'Автоматический матч',
steamFixMatchInvalid: 'Введите корректный числовой Steam App ID',
steamMatchManual: 'вручную',
steamMatchAuto: 'авто',
steamMatchLabel: 'App {id} · {source}',
sectionLinks: 'Быстрые ссылки',
sectionLinksHint: 'Какие сайты показывать в ряду Links на странице игры.',
navSettings: 'Plus',
navSettingsTitle: 'Настройки Backloggd Plus',
cacheHours: 'Время кэша (часы)',
cacheHoursHint: 'Как долго переиспользовать ответы Steam / GameStatus. 0 отключает кэш.',
clearCache: 'Очистить кэш',
cacheCleared: 'Кэш очищен ({count})',
cacheEmpty: 'Кэш пуст',
cacheBarFull: 'Полный',
cacheBarPartial: 'Частичный',
cacheBarFree: 'Свободно',
cacheBarLegend: '{label}: {count} · {size}',
cacheBarUsed: '{used} / {limit}',
cacheBarHint:
'Мягкий лимит хранилища. Полный — резолвы со страницы игры (с тегами); частичный — lite с карточек или просроченные.',
cacheBarAria: 'Использование кэша: {full} полный, {partial} частичный, {free} свободно',
cacheClearHint: 'Удаляет сохранённые запросы Steam / GameStatus из этого профиля браузера.',
on: 'ВКЛ',
off: 'ВЫКЛ',
loading: 'Загрузка…',
notOnSteam: 'Не найдено в Steam',
loadError: 'Не удалось загрузить данные Steam',
steam: 'Steam',
metacritic: 'Metacritic',
gameStatus: 'GameStatus',
gsReady: 'Готово',
gsPending: 'Ожидание',
gsPartial: 'Частично',
gsReleaseToday: 'Релиз сегодня',
gsUnknown: 'Неизвестно',
gsNotInDatabase: 'Нет в базе',
reviews: 'Отзывы',
price: 'Цена',
free: 'Бесплатно',
discount: '-{n}%',
discountEnds: 'до {date}',
steamUsFallback: 'Найдено через магазин US (в {cc} ничего не нашлось)',
recommendations: '{n} рекомендаций',
links: 'Ссылки',
linkIgdb: 'IGDB',
linkSteam: 'Steam',
linkSteamDb: 'SteamDB',
linkMetacritic: 'Metacritic',
linkOpencritic: 'OpenCritic',
linkHltb: 'HLTB',
linkPcgamingwiki: 'PCGamingWiki',
linkItad: 'IsThereAnyDeal',
linkGogdb: 'GOG DB',
players: 'Игроки',
playersOnline: '{n} в игре',
openCritic: 'OpenCritic',
hltb: 'HLTB',
hltbMain: 'Main {n}ч',
hltbExtra: 'Extra {n}ч',
hltbComplete: 'Complete {n}ч',
deckProton: 'Deck / Proton',
deckVerified: 'Verified',
deckPlayable: 'Playable',
deckUnsupported: 'Unsupported',
deckUnknown: 'Unknown',
protonTier: 'ProtonDB {tier}',
unifiedRatingTitle: 'Рейтинг Plus',
unifiedRatingHint: 'Среднее Steam, Metacritic, OpenCritic и Backloggd',
unifiedRatingOutOf: 'из 5',
unifiedRatingSources: '{n} источников',
unifiedRatingLoading: 'Собираем оценки…',
unifiedRatingSteam: 'Steam',
unifiedRatingMetacritic: 'Metacritic',
unifiedRatingOpenCritic: 'OpenCritic',
unifiedRatingBackloggd: 'Backloggd',
unifiedRatingMissing: '—',
},
zh: {
menuSettings: 'Backloggd Plus — 设置',
panelTitle: 'Backloggd Plus',
panelSubtitle: 'Steam · 评分 · Deck/Proton · HLTB · 列表徽章 · 链接',
close: '关闭',
cancel: '取消',
save: '保存',
saveReload: '保存并刷新页面',
repoLink: 'GitHub',
repoAbout: '源码、更新与问题反馈',
sectionGame: '游戏页',
sectionLists: '列表与卡片',
sectionGeneral: '通用',
sectionCache: '缓存',
sectionDebug: '调试',
debugMode: '调试模式',
debugModeHint:
'在游戏页显示单个调试面板,含来源用途说明和完整响应转储。',
debugReason: '原因',
debugResponse: '响应',
debugPanelTitle: '调试',
debugSources: '来源',
debugDump: '完整转储',
debugSectionPage: '页面',
debugSectionSteam: 'Steam',
debugSectionSteamDb: 'SteamDB / 玩家',
debugSectionGameStatus: 'GameStatus',
debugOwned: '已拥有',
debugSrcRequest: '请求',
debugSrcBackloggd: '当前 Backloggd 游戏页',
debugSrcIgdb: '游戏 slug / Links 行(IGDB)',
debugSrcMetacritic: 'Links 行 — Metacritic URL(按 slug 猜测)',
debugSrcSteamStore: 'Steam 商店页 — Links + Steam 行链接',
debugSrcSteamDb: 'Links 行 — SteamDB 应用页',
debugSrcSteamDbCharts: 'SteamDB charts(参考;不抓取)',
debugSrcSteamDbInfo: 'SteamDB info(参考;不抓取)',
debugSrcSteamTiny: '小胶囊图 — 图标回退',
debugSrcSteamHeader: '来自 appdetails 的 header 图',
debugSrcSteamSearch: 'storesearch — 解析 Steam App ID',
debugSrcSteamDetails: 'appdetails — 价格、免费、Metacritic、header',
debugSrcSteamReviews: 'appreviews — Steam 行评价摘要',
debugSrcSteamTags: 'GetItems — Steam 行热门标签',
debugSrcSteamTagMap: 'populartags — 标签 id → 名称',
debugSrcSteamOwned: 'dynamicstore/userdata — 库拥有检查',
debugSrcSteamDbPage: 'SteamDB HTML — icon、logo、玩家数',
debugSrcSteamPlayersApi: 'GetNumberOfCurrentPlayers — 玩家数回退',
debugSrcIcon: '标题图标(SteamDB / CDN)',
debugSrcLogo: 'Change cover 下的封面 logo',
debugSrcGameStatusApi: 'GameStatus API — 状态',
debugSrcGameStatusPage: '匹配 slug 的 GameStatus 页面',
uiLanguage: '界面语言',
uiLanguageHint: '自动跟随浏览器语言。保存后刷新生效。',
uiLanguageAuto: '自动(浏览器)',
steamCountry: 'Steam 商店地区',
steamCountryHint: '影响 Steam Store API 返回的货币。',
showSteam: '显示 Steam 价格与评价',
showMetacritic: '显示 Metacritic 分数',
showOpenCritic: '显示 OpenCritic 分数',
showOpenCriticHint: '来自 OpenCritic 的 Tier 徽章(按标题搜索)。',
showHltb: '显示 HowLongToBeat 时长',
showHltbHint: '来自 HowLongToBeat 的 Main / Extra / Complete 小时数。',
showDeckProton: '显示 Steam Deck 与 ProtonDB',
showDeckProtonHint: 'Steam Deck Verified/Playable 与 ProtonDB 等级(需要 Steam App ID)。',
showGameStatus: '显示 GameStatus',
showGameStatusHint: '来自 GameStatus.info 的状态(需要匹配到 Steam)。',
showLinks: '显示快捷链接行',
showSteamOwned: '显示 Steam 已拥有状态',
showSteamOwnedHint: '若游戏在您的 Steam 库中则显示“已拥有”。需要在此浏览器登录 Steam。',
showSteamWishlist: '显示 Steam 愿望单状态',
showSteamWishlistHint: '若游戏在您的 Steam 愿望单中则显示“愿望单”(已拥有时隐藏)。需要在此浏览器登录 Steam。',
showSteamTags: '显示 Steam 标签',
showSteamTagsHint: '来自 Steam 商店的热门社区标签(Open World、RPG 等)。',
showSteamCategories: '显示 Steam 类别',
showSteamCategoriesHint: '来自 Steam 应用详情的商店类别(单人、多人、Steam 成就等)。',
showSteamPageLink: '在 Steam 显示 Backloggd 按钮',
showSteamPageLinkHint: '在 Steam 游戏页 Other site info 中添加类似 SteamDB 的按钮。',
showSteamDbPageLink: '在 SteamDB 显示 Backloggd 按钮',
showSteamDbPageLinkHint: '在 SteamDB app-links 中、Store / IGDB 旁添加 Backloggd 按钮。',
showSteamDbIcon: '在标题前显示 SteamDB 图标',
showSteamDbIconHint: '来自 SteamDB 页面标题的应用图标,失败时回退到 Steam 胶囊图。',
showSteamDbCover: '在 Change cover 下显示 SteamDB Logo',
showSteamDbCoverHint: '在封面 / Change cover 下方显示 SteamDB 封面图。',
showSteamDbGallery: '显示 Steam 截图库',
showSteamDbGalleryHint: '在游戏统计下方显示横向截图条(Steam 商店资源,与 SteamDB 相同)。点击放大。',
steamGalleryTitle: '截图',
steamGalleryOpen: '打开截图',
steamGalleryClose: '关闭',
steamGalleryPrev: '上一张',
steamGalleryNext: '下一张',
viewerZoomIn: '放大',
viewerZoomOut: '缩小',
viewerZoomReset: '重置缩放',
viewerOf: '{current} / {total}',
viewerOpenCover: '打开封面',
showSteamPlayers: '显示在线玩家数',
showSteamPlayersHint: '当前玩家来自 Steam GetNumberOfCurrentPlayers。',
showCardBadges: '在列表封面显示徽章',
showCardBadgesHint: '在浏览、搜索、日志等封面网格上显示价格、Steam 好%、已拥有/愿望单与 GameStatus(游戏页除外)。',
steamBackloggdTooltip: '在 Backloggd 查看',
steamDbBackloggdLabel: 'Backloggd',
steamOwned: '已拥有',
steamWishlist: '愿望单',
steamFixMatch: '修正匹配',
steamFixMatchTitle: 'Steam App ID',
steamFixMatchHint: '覆盖此 Backloggd 页面的自动 Steam 商店搜索。粘贴 Steam 商店 URL 中的数字 App ID。',
steamFixMatchPlaceholder: '例如 1245620',
steamFixMatchClear: '使用自动匹配',
steamFixMatchInvalid: '请输入有效的数字 Steam App ID',
steamMatchManual: '手动',
steamMatchAuto: '自动',
steamMatchLabel: 'App {id} · {source}',
sectionLinks: '快捷链接',
sectionLinksHint: '选择游戏页 Links 行中显示的站点。',
navSettings: 'Plus',
navSettingsTitle: 'Backloggd Plus 设置',
cacheHours: '缓存时长(小时)',
cacheHoursHint: '复用 Steam / GameStatus 查询的时间。0 禁用缓存。',
clearCache: '清除缓存',
cacheCleared: '已清除缓存({count})',
cacheEmpty: '缓存为空',
cacheBarFull: '完整',
cacheBarPartial: '部分',
cacheBarFree: '可用',
cacheBarLegend: '{label}:{count} · {size}',
cacheBarUsed: '{used} / {limit}',
cacheBarHint: '软性存储上限。完整 = 可用查询;部分 = 不完整或已过期但仍占用空间的条目。',
cacheBarAria: '缓存用量:完整 {full},部分 {partial},可用 {free}',
cacheClearHint: '删除此浏览器配置中的 Steam / GameStatus 查询缓存。',
on: '开',
off: '关',
loading: '加载中…',
notOnSteam: '在 Steam 未找到',
loadError: '无法加载 Steam 数据',
steam: 'Steam',
metacritic: 'Metacritic',
gameStatus: 'GameStatus',
gsReady: '就绪',
gsPending: '待定',
gsPartial: '部分',
gsReleaseToday: '今日发售',
gsUnknown: '未知',
gsNotInDatabase: '不在数据库',
reviews: '评价',
price: '价格',
free: '免费',
discount: '-{n}%',
steamUsFallback: '通过 US 商店找到({cc} 搜索无结果)',
recommendations: '{n} 条推荐',
links: '链接',
linkIgdb: 'IGDB',
linkSteam: 'Steam',
linkSteamDb: 'SteamDB',
linkMetacritic: 'Metacritic',
linkOpencritic: 'OpenCritic',
linkHltb: 'HLTB',
discountEnds: '至 {date}',
linkPcgamingwiki: 'PCGamingWiki',
linkItad: 'IsThereAnyDeal',
linkGogdb: 'GOG DB',
openCritic: 'OpenCritic',
hltb: 'HLTB',
deckProton: 'Deck / Proton',
deckVerified: 'Verified',
deckPlayable: 'Playable',
deckUnsupported: 'Unsupported',
deckUnknown: 'Unknown',
protonTier: 'ProtonDB {tier}',
hltbMain: 'Main {n}小时',
hltbExtra: 'Extra {n}小时',
hltbComplete: 'Complete {n}小时',
players: '玩家',
playersOnline: '{n} 在玩',
unifiedRatingTitle: 'Plus 评分',
unifiedRatingHint: 'Steam、Metacritic、OpenCritic 与 Backloggd 的平均分',
unifiedRatingOutOf: '/ 5',
unifiedRatingSources: '{n} 个来源',
unifiedRatingLoading: '正在汇总评分…',
unifiedRatingSteam: 'Steam',
unifiedRatingMetacritic: 'Metacritic',
unifiedRatingOpenCritic: 'OpenCritic',
unifiedRatingBackloggd: 'Backloggd',
unifiedRatingMissing: '—',
},
es: {
menuSettings: 'Backloggd Plus — Ajustes',
panelTitle: 'Backloggd Plus',
panelSubtitle: 'Steam · notas · Deck/Proton · HLTB · insignias · enlaces',
close: 'Cerrar',
cancel: 'Cancelar',
save: 'Guardar',
saveReload: 'Guardar y recargar',
repoLink: 'GitHub',
repoAbout: 'Código, actualizaciones e informes',
sectionGame: 'Página del juego',
sectionLists: 'Listas y tarjetas',
sectionGeneral: 'General',
sectionCache: 'Caché',
sectionDebug: 'Depuración',
debugMode: 'Modo depuración',
debugModeHint:
'Una sola panel de depuración en la página del juego con fuentes (para qué sirve cada URL) y volcado completo.',
debugReason: 'Motivo',
debugResponse: 'Respuesta',
debugPanelTitle: 'Depuración',
debugSources: 'Fuentes',
debugDump: 'Volcado completo',
debugSectionPage: 'Página',
debugSectionSteam: 'Steam',
debugSectionSteamDb: 'SteamDB / jugadores',
debugSectionGameStatus: 'GameStatus',
debugOwned: 'En biblioteca',
debugSrcRequest: 'petición',
debugSrcBackloggd: 'Página actual del juego en Backloggd',
debugSrcIgdb: 'Slug del juego / fila Links (IGDB)',
debugSrcMetacritic: 'Fila Links — URL Metacritic (por slug)',
debugSrcSteamStore: 'Página Steam — Links + enlace en la fila Steam',
debugSrcSteamDb: 'Fila Links — página de app SteamDB',
debugSrcSteamDbCharts: 'SteamDB charts (referencia; no se scrapea)',
debugSrcSteamDbInfo: 'SteamDB info (referencia; no se scrapea)',
debugSrcSteamTiny: 'Cápsula pequeña — fallback de icono',
debugSrcSteamHeader: 'Imagen header de appdetails',
debugSrcSteamSearch: 'storesearch — resolver Steam App ID',
debugSrcSteamDetails: 'appdetails — precio, free, Metacritic, header',
debugSrcSteamReviews: 'appreviews — resumen de reseñas para Steam',
debugSrcSteamTags: 'GetItems — etiquetas populares para Steam',
debugSrcSteamTagMap: 'populartags — mapa id → nombre',
debugSrcSteamOwned: 'dynamicstore/userdata — comprobar biblioteca',
debugSrcSteamDbPage: 'HTML SteamDB — icono, logo, jugadores',
debugSrcSteamPlayersApi: 'GetNumberOfCurrentPlayers — fallback de jugadores',
debugSrcIcon: 'Icono del título (SteamDB / CDN)',
debugSrcLogo: 'Logo de portada bajo Change cover',
debugSrcGameStatusApi: 'API GameStatus — estado',
debugSrcGameStatusPage: 'Página GameStatus del slug encontrado',
uiLanguage: 'Idioma de la interfaz',
uiLanguageHint: 'Auto sigue el idioma del navegador. Se aplica al recargar.',
uiLanguageAuto: 'Auto (navegador)',
steamCountry: 'Región de Steam Store',
steamCountryHint: 'Afecta la moneda del precio de la API de Steam.',
showSteam: 'Mostrar precio y reseñas de Steam',
showMetacritic: 'Mostrar puntuación de Metacritic',
showOpenCritic: 'Mostrar puntuación de OpenCritic',
showOpenCriticHint: 'Insignia de tier de OpenCritic (búsqueda por título).',
showHltb: 'Mostrar tiempos de HowLongToBeat',
showHltbHint: 'Horas Main / Extra / Complete de HowLongToBeat.',
showDeckProton: 'Mostrar Steam Deck y ProtonDB',
showDeckProtonHint: 'Verified/Playable de Steam Deck y nivel ProtonDB (hace falta un Steam App ID).',
showGameStatus: 'Mostrar estado GameStatus',
showGameStatusHint: 'Estado de GameStatus.info (requiere coincidencia con Steam).',
showLinks: 'Mostrar fila de enlaces',
showSteamOwned: 'Mostrar si está en tu biblioteca Steam',
showSteamOwnedHint:
'Muestra “En propiedad” si el juego está en tu biblioteca de Steam. Requiere estar conectado a Steam en este navegador.',
showSteamWishlist: 'Mostrar estado de wishlist de Steam',
showSteamWishlistHint:
'Muestra “Wishlist” si el juego está en tu lista de deseos de Steam (oculto si ya lo tienes). Requiere sesión de Steam.',
showSteamTags: 'Mostrar etiquetas de Steam',
showSteamTagsHint: 'Etiquetas populares de la tienda Steam (Open World, RPG, …).',
showSteamCategories: 'Mostrar categorías de Steam',
showSteamCategoriesHint:
'Categorías de la ficha de Steam (Single-player, Multi-player, Steam Achievements, …).',
showSteamPageLink: 'Botón Backloggd en Steam',
showSteamPageLinkHint: 'Añade un botón estilo SteamDB en Other site info en páginas de Steam.',
showSteamDbPageLink: 'Botón Backloggd en SteamDB',
showSteamDbPageLinkHint: 'Añade un botón junto a Store / IGDB en app-links de SteamDB.',
showSteamDbIcon: 'Icono SteamDB antes del título',
showSteamDbIconHint: 'Icono de la app desde la cabecera de SteamDB; reserva: cápsula de Steam.',
showSteamDbCover: 'Logo SteamDB bajo Change cover',
showSteamDbCoverHint: 'Portada de SteamDB debajo de la portada / Change cover.',
showSteamDbGallery: 'Galería de capturas de Steam',
showSteamDbGalleryHint:
'Franja horizontal de capturas tras las estadísticas (assets de Steam Store, como SteamDB). Clic para ampliar.',
steamGalleryTitle: 'Capturas',
steamGalleryOpen: 'Abrir captura',
steamGalleryClose: 'Cerrar',
steamGalleryPrev: 'Anterior',
steamGalleryNext: 'Siguiente',
viewerZoomIn: 'Acercar',
viewerZoomOut: 'Alejar',
viewerZoomReset: 'Restablecer zoom',
viewerOf: '{current} / {total}',
viewerOpenCover: 'Abrir portada',
showSteamPlayers: 'Mostrar jugadores en línea',
showSteamPlayersHint: 'Jugadores actuales desde Steam GetNumberOfCurrentPlayers.',
showCardBadges: 'Mostrar insignias en portadas de listas',
showCardBadgesHint:
'Precio, % de reseñas Steam, En propiedad / Wishlist y GameStatus en browse, búsqueda, journal y otras rejillas (no en la página del juego).',
steamBackloggdTooltip: 'Ver en Backloggd',
steamDbBackloggdLabel: 'Backloggd',
steamOwned: 'En propiedad',
steamWishlist: 'Wishlist',
steamFixMatch: 'Corregir coincidencia',
steamFixMatchTitle: 'Steam App ID',
steamFixMatchHint:
'Anula la búsqueda automática en Steam Store para esta página de Backloggd. Pega el App ID numérico de la URL de Steam.',
steamFixMatchPlaceholder: 'p. ej. 1245620',
steamFixMatchClear: 'Usar coincidencia automática',
steamFixMatchInvalid: 'Introduce un Steam App ID numérico válido',
steamMatchManual: 'manual',
steamMatchAuto: 'auto',
steamMatchLabel: 'App {id} · {source}',
sectionLinks: 'Enlaces rápidos',
sectionLinksHint: 'Elige qué sitios aparecen en la fila Links.',
navSettings: 'Plus',
navSettingsTitle: 'Ajustes de Backloggd Plus',
cacheHours: 'Duración de caché (horas)',
cacheHoursHint: 'Cuánto reutilizar búsquedas de Steam / GameStatus. 0 desactiva la caché.',
clearCache: 'Vaciar caché',
cacheCleared: 'Caché vaciada ({count})',
cacheEmpty: 'La caché está vacía',
cacheBarFull: 'Completo',
cacheBarPartial: 'Parcial',
cacheBarFree: 'Libre',
cacheBarLegend: '{label}: {count} · {size}',
cacheBarUsed: '{used} / {limit}',
cacheBarHint:
'Límite blando de almacenamiento. Completo = consultas útiles; parcial = incompletas o caducadas que aún ocupan espacio.',
cacheBarAria: 'Uso de caché: {full} completo, {partial} parcial, {free} libre',
cacheClearHint: 'Elimina las búsquedas de Steam / GameStatus de este perfil.',
on: 'ON',
off: 'OFF',
loading: 'Cargando…',
notOnSteam: 'No encontrado en Steam',
loadError: 'No se pudieron cargar datos de Steam',
steam: 'Steam',
metacritic: 'Metacritic',
gameStatus: 'GameStatus',
gsReady: 'Listo',
gsPending: 'Pendiente',
gsPartial: 'Parcial',
gsReleaseToday: 'Lanzamiento hoy',
gsUnknown: 'Desconocido',
gsNotInDatabase: 'No está en la base',
reviews: 'Reseñas',
price: 'Precio',
free: 'Gratis',
discount: '-{n}%',
steamUsFallback: 'Encontrado en tienda US (la búsqueda en {cc} no dio resultados)',
recommendations: '{n} recomendaciones',
links: 'Enlaces',
linkIgdb: 'IGDB',
linkSteam: 'Steam',
linkSteamDb: 'SteamDB',
linkMetacritic: 'Metacritic',
linkOpencritic: 'OpenCritic',
linkHltb: 'HLTB',
discountEnds: 'hasta {date}',
linkPcgamingwiki: 'PCGamingWiki',
linkItad: 'IsThereAnyDeal',
linkGogdb: 'GOG DB',
openCritic: 'OpenCritic',
hltb: 'HLTB',
deckProton: 'Deck / Proton',
deckVerified: 'Verified',
deckPlayable: 'Playable',
deckUnsupported: 'Unsupported',
deckUnknown: 'Unknown',
protonTier: 'ProtonDB {tier}',
hltbMain: 'Main {n}h',
hltbExtra: 'Extra {n}h',
hltbComplete: 'Complete {n}h',
players: 'Jugadores',
playersOnline: '{n} jugando',
unifiedRatingTitle: 'Nota Plus',
unifiedRatingHint: 'Media de Steam, Metacritic, OpenCritic y Backloggd',
unifiedRatingOutOf: 'de 5',
unifiedRatingSources: '{n} fuentes',
unifiedRatingLoading: 'Recopilando notas…',
unifiedRatingSteam: 'Steam',
unifiedRatingMetacritic: 'Metacritic',
unifiedRatingOpenCritic: 'OpenCritic',
unifiedRatingBackloggd: 'Backloggd',
unifiedRatingMissing: '—',
},
pt: {
menuSettings: 'Backloggd Plus — Configurações',
panelTitle: 'Backloggd Plus',
panelSubtitle: 'Steam · notas · Deck/Proton · HLTB · badges · links',
close: 'Fechar',
cancel: 'Cancelar',
save: 'Salvar',
saveReload: 'Salvar e recarregar',
repoLink: 'GitHub',
repoAbout: 'Código, atualizações e relatórios',
sectionGame: 'Página do jogo',
sectionLists: 'Listas e cartões',
sectionGeneral: 'Geral',
sectionCache: 'Cache',
sectionDebug: 'Depuração',
debugMode: 'Modo debug',
debugModeHint:
'Um único painel de debug na página do jogo com fontes (para que serve cada URL) e dump completo.',
debugReason: 'Motivo',
debugResponse: 'Resposta',
debugPanelTitle: 'Debug',
debugSources: 'Fontes',
debugDump: 'Dump completo',
debugSectionPage: 'Página',
debugSectionSteam: 'Steam',
debugSectionSteamDb: 'SteamDB / jogadores',
debugSectionGameStatus: 'GameStatus',
debugOwned: 'Na biblioteca',
debugSrcRequest: 'requisição',
debugSrcBackloggd: 'Página atual do jogo no Backloggd',
debugSrcIgdb: 'Slug do jogo / linha Links (IGDB)',
debugSrcMetacritic: 'Linha Links — URL Metacritic (por slug)',
debugSrcSteamStore: 'Página Steam — Links + link na linha Steam',
debugSrcSteamDb: 'Linha Links — página do app SteamDB',
debugSrcSteamDbCharts: 'SteamDB charts (referência; não é scrape)',
debugSrcSteamDbInfo: 'SteamDB info (referência; não é scrape)',
debugSrcSteamTiny: 'Cápsula pequena — fallback de ícone',
debugSrcSteamHeader: 'Imagem header do appdetails',
debugSrcSteamSearch: 'storesearch — resolver Steam App ID',
debugSrcSteamDetails: 'appdetails — preço, free, Metacritic, header',
debugSrcSteamReviews: 'appreviews — resumo de reviews para Steam',
debugSrcSteamTags: 'GetItems — tags populares para Steam',
debugSrcSteamTagMap: 'populartags — mapa id → nome',
debugSrcSteamOwned: 'dynamicstore/userdata — checagem da biblioteca',
debugSrcSteamDbPage: 'HTML SteamDB — ícone, logo, jogadores',
debugSrcSteamPlayersApi: 'GetNumberOfCurrentPlayers — fallback de jogadores',
debugSrcIcon: 'Ícone do título (SteamDB / CDN)',
debugSrcLogo: 'Logo da capa sob Change cover',
debugSrcGameStatusApi: 'API GameStatus — status',
debugSrcGameStatusPage: 'Página GameStatus do slug encontrado',
uiLanguage: 'Idioma da interface',
uiLanguageHint: 'Auto segue o idioma do navegador. Aplica ao recarregar.',
uiLanguageAuto: 'Auto (navegador)',
steamCountry: 'Região da Steam Store',
steamCountryHint: 'Afeta a moeda do preço da API da Steam.',
showSteam: 'Mostrar preço e avaliações Steam',
showMetacritic: 'Mostrar nota Metacritic',
showOpenCritic: 'Mostrar nota OpenCritic',
showOpenCriticHint: 'Badge de tier do OpenCritic (busca pelo título).',
showHltb: 'Mostrar tempos do HowLongToBeat',
showHltbHint: 'Horas Main / Extra / Complete do HowLongToBeat.',
showDeckProton: 'Mostrar Steam Deck e ProtonDB',
showDeckProtonHint: 'Verified/Playable do Steam Deck e nível ProtonDB (precisa de Steam App ID).',
showGameStatus: 'Mostrar status GameStatus',
showGameStatusHint: 'Status do GameStatus.info (precisa bater com a Steam).',
showLinks: 'Mostrar linha de links',
showSteamOwned: 'Mostrar se está na biblioteca Steam',
showSteamOwnedHint:
'Mostra “Possui” se o jogo estiver na sua biblioteca Steam. É preciso estar logado na Steam neste navegador.',
showSteamWishlist: 'Mostrar status da wishlist Steam',
showSteamWishlistHint:
'Mostra “Wishlist” se o jogo estiver na lista de desejos Steam (oculto se já possuir). Requer login Steam.',
showSteamTags: 'Mostrar tags da Steam',
showSteamTagsHint: 'Tags populares da Steam Store (Open World, RPG, …).',
showSteamCategories: 'Mostrar categorias da Steam',
showSteamCategoriesHint:
'Categorias da ficha Steam (Single-player, Multi-player, Steam Achievements, …).',
showSteamPageLink: 'Botão Backloggd no Steam',
showSteamPageLinkHint: 'Adiciona um botão estilo SteamDB em Other site info nas páginas da Steam.',
showSteamDbPageLink: 'Botão Backloggd no SteamDB',
showSteamDbPageLinkHint: 'Adiciona um botão ao lado de Store / IGDB em app-links do SteamDB.',
showSteamDbIcon: 'Ícone SteamDB antes do título',
showSteamDbIconHint: 'Ícone do app no cabeçalho do SteamDB; fallback: cápsula da Steam.',
showSteamDbCover: 'Logo SteamDB sob Change cover',
showSteamDbCoverHint: 'Capa do SteamDB abaixo da capa / Change cover.',
showSteamDbGallery: 'Galeria de screenshots da Steam',
showSteamDbGalleryHint:
'Faixa horizontal de screenshots após as estatísticas (assets da Steam Store, como no SteamDB). Clique para ampliar.',
steamGalleryTitle: 'Screenshots',
steamGalleryOpen: 'Abrir screenshot',
steamGalleryClose: 'Fechar',
steamGalleryPrev: 'Anterior',
steamGalleryNext: 'Próximo',
viewerZoomIn: 'Aumentar zoom',
viewerZoomOut: 'Diminuir zoom',
viewerZoomReset: 'Redefinir zoom',
viewerOf: '{current} / {total}',
viewerOpenCover: 'Abrir capa',
showSteamPlayers: 'Mostrar jogadores online',
showSteamPlayersHint: 'Jogadores atuais via Steam GetNumberOfCurrentPlayers.',
showCardBadges: 'Mostrar badges nas capas das listas',
showCardBadgesHint:
'Preço, % de avaliações Steam, Possui / Wishlist e GameStatus em browse, busca, journal e outras grades (não na página do jogo).',
steamBackloggdTooltip: 'Ver no Backloggd',
steamDbBackloggdLabel: 'Backloggd',
steamOwned: 'Possui',
steamWishlist: 'Wishlist',
steamFixMatch: 'Corrigir correspondência',
steamFixMatchTitle: 'Steam App ID',
steamFixMatchHint:
'Substitui a busca automática na Steam Store nesta página do Backloggd. Cole o App ID numérico da URL da Steam.',
steamFixMatchPlaceholder: 'ex. 1245620',
steamFixMatchClear: 'Usar correspondência automática',
steamFixMatchInvalid: 'Digite um Steam App ID numérico válido',
steamMatchManual: 'manual',
steamMatchAuto: 'auto',
steamMatchLabel: 'App {id} · {source}',
sectionLinks: 'Links rápidos',
sectionLinksHint: 'Escolha quais sites aparecem na linha Links.',
navSettings: 'Plus',
navSettingsTitle: 'Configurações do Backloggd Plus',
cacheHours: 'Duração do cache (horas)',
cacheHoursHint: 'Por quanto tempo reutilizar buscas Steam / GameStatus. 0 desativa o cache.',
clearCache: 'Limpar cache',
cacheCleared: 'Cache limpo ({count})',
cacheEmpty: 'Cache vazio',
cacheBarFull: 'Completo',
cacheBarPartial: 'Parcial',
cacheBarFree: 'Livre',
cacheBarLegend: '{label}: {count} · {size}',
cacheBarUsed: '{used} / {limit}',
cacheBarHint:
'Limite suave de armazenamento. Completo = consultas úteis; parcial = incompletas ou expiradas ainda armazenadas.',
cacheBarAria: 'Uso do cache: {full} completo, {partial} parcial, {free} livre',
cacheClearHint: 'Remove buscas Steam / GameStatus deste perfil do navegador.',
on: 'ON',
off: 'OFF',
loading: 'Carregando…',
notOnSteam: 'Não encontrado na Steam',
loadError: 'Falha ao carregar dados da Steam',
steam: 'Steam',
metacritic: 'Metacritic',
gameStatus: 'GameStatus',
gsReady: 'Pronto',
gsPending: 'Pendente',
gsPartial: 'Parcial',
gsReleaseToday: 'Lançamento hoje',
gsUnknown: 'Desconhecido',
gsNotInDatabase: 'Não está na base',
reviews: 'Avaliações',
price: 'Preço',
free: 'Grátis',
discount: '-{n}%',
steamUsFallback: 'Encontrado na loja US (busca em {cc} sem resultados)',
recommendations: '{n} recomendações',
links: 'Links',
linkIgdb: 'IGDB',
linkSteam: 'Steam',
linkSteamDb: 'SteamDB',
linkMetacritic: 'Metacritic',
linkOpencritic: 'OpenCritic',
linkHltb: 'HLTB',
discountEnds: 'até {date}',
linkPcgamingwiki: 'PCGamingWiki',
linkItad: 'IsThereAnyDeal',
linkGogdb: 'GOG DB',
openCritic: 'OpenCritic',
hltb: 'HLTB',
deckProton: 'Deck / Proton',
deckVerified: 'Verified',
deckPlayable: 'Playable',
deckUnsupported: 'Unsupported',
deckUnknown: 'Unknown',
protonTier: 'ProtonDB {tier}',
hltbMain: 'Main {n}h',
hltbExtra: 'Extra {n}h',
hltbComplete: 'Complete {n}h',
players: 'Jogadores',
playersOnline: '{n} jogando',
unifiedRatingTitle: 'Nota Plus',
unifiedRatingHint: 'Média de Steam, Metacritic, OpenCritic e Backloggd',
unifiedRatingOutOf: 'de 5',
unifiedRatingSources: '{n} fontes',
unifiedRatingLoading: 'Coletando notas…',
unifiedRatingSteam: 'Steam',
unifiedRatingMetacritic: 'Metacritic',
unifiedRatingOpenCritic: 'OpenCritic',
unifiedRatingBackloggd: 'Backloggd',
unifiedRatingMissing: '—',
},
de: {
menuSettings: 'Backloggd Plus — Einstellungen',
panelTitle: 'Backloggd Plus',
panelSubtitle: 'Steam · Wertungen · Deck/Proton · HLTB · Badges · Links',
close: 'Schließen',
cancel: 'Abbrechen',
save: 'Speichern',
saveReload: 'Speichern & neu laden',
repoLink: 'GitHub',
repoAbout: 'Quellcode, Updates und Issue-Reports',
sectionGame: 'Spieleseite',
sectionLists: 'Listen & Karten',
sectionGeneral: 'Allgemein',
sectionCache: 'Cache',
sectionDebug: 'Debug',
debugMode: 'Debug-Modus',
debugModeHint:
'Ein Debug-Panel auf der Spieleseite mit Quellen (wofür jede URL dient) und vollständigem Dump.',
debugReason: 'Grund',
debugResponse: 'Antwort',
debugPanelTitle: 'Debug',
debugSources: 'Quellen',
debugDump: 'Vollständiger Dump',
debugSectionPage: 'Seite',
debugSectionSteam: 'Steam',
debugSectionSteamDb: 'SteamDB / Spieler',
debugSectionGameStatus: 'GameStatus',
debugOwned: 'Im Besitz',
debugSrcRequest: 'Anfrage',
debugSrcBackloggd: 'Aktuelle Backloggd-Spieleseite',
debugSrcIgdb: 'Spiel-Slug / Links-Zeile (IGDB)',
debugSrcMetacritic: 'Links-Zeile — Metacritic-URL (Slug-Schätzung)',
debugSrcSteamStore: 'Steam-Store-Seite — Links + Steam-Zeilenlink',
debugSrcSteamDb: 'Links-Zeile — SteamDB-App-Seite',
debugSrcSteamDbCharts: 'SteamDB charts (Referenz; kein Scraping)',
debugSrcSteamDbInfo: 'SteamDB info (Referenz; kein Scraping)',
debugSrcSteamTiny: 'Kleine Capsule — Icon-Fallback',
debugSrcSteamHeader: 'Header-Bild aus appdetails',
debugSrcSteamSearch: 'storesearch — Steam App ID auflösen',
debugSrcSteamDetails: 'appdetails — Preis, free, Metacritic, Header',
debugSrcSteamReviews: 'appreviews — Review-Zusammenfassung für Steam',
debugSrcSteamTags: 'GetItems — beliebte Tags für Steam',
debugSrcSteamTagMap: 'populartags — Tag-id → Name',
debugSrcSteamOwned: 'dynamicstore/userdata — Bibliotheksprüfung',
debugSrcSteamDbPage: 'SteamDB-HTML — Icon, Logo, Spieler',
debugSrcSteamPlayersApi: 'GetNumberOfCurrentPlayers — Spieler-Fallback',
debugSrcIcon: 'Titel-Icon (SteamDB / CDN)',
debugSrcLogo: 'Cover-Logo unter Change cover',
debugSrcGameStatusApi: 'GameStatus-API — Status',
debugSrcGameStatusPage: 'GameStatus-Seite für gefundenen Slug',
uiLanguage: 'Oberflächensprache',
uiLanguageHint: 'Auto folgt der Browsersprache. Gilt nach dem Neuladen.',
uiLanguageAuto: 'Auto (Browser)',
steamCountry: 'Steam-Store-Region',
steamCountryHint: 'Beeinflusst die Währung der Steam-Store-API.',
showSteam: 'Steam-Preis & Bewertungen anzeigen',
showMetacritic: 'Metacritic-Wertung anzeigen',
showOpenCritic: 'OpenCritic-Wertung anzeigen',
showOpenCriticHint: 'OpenCritic-Tier-Badge (Suche nach Titel).',
showHltb: 'HowLongToBeat-Zeiten anzeigen',
showHltbHint: 'Main / Extra / Complete in Stunden von HowLongToBeat.',
showDeckProton: 'Steam Deck & ProtonDB anzeigen',
showDeckProtonHint: 'Steam Deck Verified/Playable plus ProtonDB-Stufe (braucht eine Steam App ID).',
showGameStatus: 'GameStatus-Status anzeigen',
showGameStatusHint: 'Status von GameStatus.info (Steam-Treffer erforderlich).',
showLinks: 'Link-Zeile anzeigen',
showSteamOwned: 'Steam-Besitz anzeigen',
showSteamOwnedHint:
'Zeigt „Im Besitz“, wenn das Spiel in Ihrer Steam-Bibliothek ist. Erfordert eine Steam-Anmeldung in diesem Browser.',
showSteamWishlist: 'Steam-Wunschliste anzeigen',
showSteamWishlistHint:
'Zeigt „Wunschliste“, wenn das Spiel auf Ihrer Steam-Wunschliste steht (ausgeblendet bei Besitz). Steam-Login nötig.',
showSteamTags: 'Steam-Tags anzeigen',
showSteamTagsHint: 'Beliebte Community-Tags aus dem Steam Store (Open World, RPG, …).',
showSteamCategories: 'Steam-Kategorien anzeigen',
showSteamCategoriesHint:
'Store-Kategorien aus den Steam-Appdetails (Single-player, Multi-player, Steam Achievements, …).',
showSteamPageLink: 'Backloggd-Button auf Steam',
showSteamPageLinkHint: 'SteamDB-ähnlicher Button in Other site info auf Steam-Spieleseiten.',
showSteamDbPageLink: 'Backloggd-Button auf SteamDB',
showSteamDbPageLinkHint: 'Button neben Store / IGDB in den SteamDB app-links.',
showSteamDbIcon: 'SteamDB-Icon vor dem Titel',
showSteamDbIconHint: 'App-Icon aus dem SteamDB-Seitenkopf, Fallback: Steam-Kapsel.',
showSteamDbCover: 'SteamDB-Logo unter Change cover',
showSteamDbCoverHint: 'Cover-Bild von SteamDB unter Cover / Change cover.',
showSteamDbGallery: 'Steam-Screenshot-Galerie zeigen',
showSteamDbGalleryHint:
'Horizontale Screenshot-Leiste nach den Spielstatistiken (Steam-Store-Assets wie bei SteamDB). Klick zum Vergrößern.',
steamGalleryTitle: 'Screenshots',
steamGalleryOpen: 'Screenshot öffnen',
steamGalleryClose: 'Schließen',
steamGalleryPrev: 'Zurück',
steamGalleryNext: 'Weiter',
viewerZoomIn: 'Vergrößern',
viewerZoomOut: 'Verkleinern',
viewerZoomReset: 'Zoom zurücksetzen',
viewerOf: '{current} / {total}',
viewerOpenCover: 'Cover öffnen',
showSteamPlayers: 'Online-Spieler anzeigen',
showSteamPlayersHint: 'Aktuelle Spieler über Steam GetNumberOfCurrentPlayers.',
showCardBadges: 'Badges auf Listen-Covers anzeigen',
showCardBadgesHint:
'Preis, Steam-Bewertung %, Besitz / Wunschliste und GameStatus auf Browse, Suche, Journal und anderen Grids (nicht auf der Spieleseite).',
steamBackloggdTooltip: 'Auf Backloggd ansehen',
steamDbBackloggdLabel: 'Backloggd',
steamOwned: 'Im Besitz',
steamWishlist: 'Wunschliste',
steamFixMatch: 'Treffer korrigieren',
steamFixMatchTitle: 'Steam App ID',
steamFixMatchHint:
'Überschreibt die automatische Steam-Store-Suche für diese Backloggd-Seite. Numerische App-ID aus der Steam-URL einfügen.',
steamFixMatchPlaceholder: 'z. B. 1245620',
steamFixMatchClear: 'Automatischen Treffer verwenden',
steamFixMatchInvalid: 'Gültige numerische Steam App ID eingeben',
steamMatchManual: 'manuell',
steamMatchAuto: 'auto',
steamMatchLabel: 'App {id} · {source}',
sectionLinks: 'Schnelllinks',
sectionLinksHint: 'Welche Seiten in der Links-Zeile erscheinen.',
navSettings: 'Plus',
navSettingsTitle: 'Backloggd Plus Einstellungen',
cacheHours: 'Cache-Dauer (Stunden)',
cacheHoursHint: 'Wie lange Steam-/GameStatus-Abfragen wiederverwendet werden. 0 deaktiviert den Cache.',
clearCache: 'Cache leeren',
cacheCleared: 'Cache geleert ({count})',
cacheEmpty: 'Cache ist leer',
cacheBarFull: 'Voll',
cacheBarPartial: 'Teilweise',
cacheBarFree: 'Frei',
cacheBarLegend: '{label}: {count} · {size}',
cacheBarUsed: '{used} / {limit}',
cacheBarHint:
'Weiches Speicherbudget. Voll = nutzbare Einträge; teilweise = unvollständig oder abgelaufen, aber noch gespeichert.',
cacheBarAria: 'Cache-Nutzung: {full} voll, {partial} teilweise, {free} frei',
cacheClearHint: 'Entfernt gespeicherte Steam-/GameStatus-Abfragen aus diesem Profil.',
on: 'AN',
off: 'AUS',
loading: 'Lädt…',
notOnSteam: 'Nicht auf Steam gefunden',
loadError: 'Steam-Daten konnten nicht geladen werden',
steam: 'Steam',
metacritic: 'Metacritic',
gameStatus: 'GameStatus',
gsReady: 'Bereit',
gsPending: 'Ausstehend',
gsPartial: 'Eingeschränkt',
gsReleaseToday: 'Release heute',
gsUnknown: 'Unbekannt',
gsNotInDatabase: 'Nicht in der Datenbank',
reviews: 'Bewertungen',
price: 'Preis',
free: 'Kostenlos',
discount: '-{n}%',
steamUsFallback: 'Über US-Store gefunden (Suche in {cc} ohne Treffer)',
recommendations: '{n} Empfehlungen',
links: 'Links',
linkIgdb: 'IGDB',
linkSteam: 'Steam',
linkSteamDb: 'SteamDB',
linkMetacritic: 'Metacritic',
linkOpencritic: 'OpenCritic',
linkHltb: 'HLTB',
discountEnds: 'bis {date}',
linkPcgamingwiki: 'PCGamingWiki',
linkItad: 'IsThereAnyDeal',
linkGogdb: 'GOG DB',
openCritic: 'OpenCritic',
hltb: 'HLTB',
deckProton: 'Deck / Proton',
deckVerified: 'Verified',
deckPlayable: 'Playable',
deckUnsupported: 'Unsupported',
deckUnknown: 'Unknown',
protonTier: 'ProtonDB {tier}',
hltbMain: 'Main {n}h',
hltbExtra: 'Extra {n}h',
hltbComplete: 'Complete {n}h',
players: 'Spieler',
playersOnline: '{n} spielen',
unifiedRatingTitle: 'Plus-Wertung',
unifiedRatingHint: 'Durchschnitt aus Steam, Metacritic, OpenCritic & Backloggd',
unifiedRatingOutOf: 'von 5',
unifiedRatingSources: '{n} Quellen',
unifiedRatingLoading: 'Wertungen werden geladen…',
unifiedRatingSteam: 'Steam',
unifiedRatingMetacritic: 'Metacritic',
unifiedRatingOpenCritic: 'OpenCritic',
unifiedRatingBackloggd: 'Backloggd',
unifiedRatingMissing: '—',
},
fr: {
menuSettings: 'Backloggd Plus — Réglages',
panelTitle: 'Backloggd Plus',
panelSubtitle: 'Steam · notes · Deck/Proton · HLTB · badges · liens',
close: 'Fermer',
cancel: 'Annuler',
save: 'Enregistrer',
saveReload: 'Enregistrer et recharger',
repoLink: 'GitHub',
repoAbout: 'Code source, mises à jour et signalements',
sectionGame: 'Page jeu',
sectionLists: 'Listes et cartes',
sectionGeneral: 'Général',
sectionCache: 'Cache',
sectionDebug: 'Débogage',
debugMode: 'Mode debug',
debugModeHint:
'Un seul panneau debug sur la page jeu avec sources (rôle de chaque URL) et dump complet.',
debugReason: 'Raison',
debugResponse: 'Réponse',
debugPanelTitle: 'Debug',
debugSources: 'Sources',
debugDump: 'Dump complet',
debugSectionPage: 'Page',
debugSectionSteam: 'Steam',
debugSectionSteamDb: 'SteamDB / joueurs',
debugSectionGameStatus: 'GameStatus',
debugOwned: 'Possédé',
debugSrcRequest: 'requête',
debugSrcBackloggd: 'Page jeu Backloggd actuelle',
debugSrcIgdb: 'Slug du jeu / rangée Links (IGDB)',
debugSrcMetacritic: 'Rangée Links — URL Metacritic (slug)',
debugSrcSteamStore: 'Page Steam — Links + lien rangée Steam',
debugSrcSteamDb: 'Rangée Links — page app SteamDB',
debugSrcSteamDbCharts: 'SteamDB charts (référence ; non scrapé)',
debugSrcSteamDbInfo: 'SteamDB info (référence ; non scrapé)',
debugSrcSteamTiny: 'Petite capsule — fallback icône',
debugSrcSteamHeader: 'Image header depuis appdetails',
debugSrcSteamSearch: 'storesearch — résoudre le Steam App ID',
debugSrcSteamDetails: 'appdetails — prix, free, Metacritic, header',
debugSrcSteamReviews: 'appreviews — résumé des avis pour Steam',
debugSrcSteamTags: 'GetItems — tags populaires pour Steam',
debugSrcSteamTagMap: 'populartags — carte id → nom',
debugSrcSteamOwned: 'dynamicstore/userdata — contrôle bibliothèque',
debugSrcSteamDbPage: 'HTML SteamDB — icône, logo, joueurs',
debugSrcSteamPlayersApi: 'GetNumberOfCurrentPlayers — fallback joueurs',
debugSrcIcon: 'Icône du titre (SteamDB / CDN)',
debugSrcLogo: 'Logo couverture sous Change cover',
debugSrcGameStatusApi: 'API GameStatus — statut',
debugSrcGameStatusPage: 'Page GameStatus du slug trouvé',
uiLanguage: 'Langue de l’interface',
uiLanguageHint: 'Auto suit la langue du navigateur. Appliqué après rechargement.',
uiLanguageAuto: 'Auto (navigateur)',
steamCountry: 'Région Steam Store',
steamCountryHint: 'Affecte la devise du prix via l’API Steam Store.',
showSteam: 'Afficher prix et avis Steam',
showMetacritic: 'Afficher le score Metacritic',
showOpenCritic: 'Afficher le score OpenCritic',
showOpenCriticHint: 'Badge de tier OpenCritic (recherche par titre).',
showHltb: 'Afficher les temps HowLongToBeat',
showHltbHint: 'Heures Main / Extra / Complete depuis HowLongToBeat.',
showDeckProton: 'Afficher Steam Deck et ProtonDB',
showDeckProtonHint: 'Verified/Playable Steam Deck et palier ProtonDB (nécessite un Steam App ID).',
showGameStatus: 'Afficher le statut GameStatus',
showGameStatusHint: 'Statut via GameStatus.info (correspondance Steam requise).',
showLinks: 'Afficher la rangée de liens',
showSteamOwned: 'Afficher le statut possédé Steam',
showSteamOwnedHint:
'Affiche « Possédé » si le jeu est dans votre bibliothèque Steam. Connexion Steam requise dans ce navigateur.',
showSteamWishlist: 'Afficher le statut wishlist Steam',
showSteamWishlistHint:
'Affiche « Wishlist » si le jeu est dans votre liste de souhaits Steam (masqué si déjà possédé). Connexion Steam requise.',
showSteamTags: 'Afficher les tags Steam',
showSteamTagsHint: 'Tags communautaires populaires du Steam Store (Open World, RPG, …).',
showSteamCategories: 'Afficher les catégories Steam',
showSteamCategoriesHint:
'Catégories de la fiche Steam (Single-player, Multi-player, Steam Achievements, …).',
showSteamPageLink: 'Bouton Backloggd sur Steam',
showSteamPageLinkHint: 'Ajoute un bouton style SteamDB dans Other site info sur les pages Steam.',
showSteamDbPageLink: 'Bouton Backloggd sur SteamDB',
showSteamDbPageLinkHint: 'Ajoute un bouton à côté de Store / IGDB dans app-links SteamDB.',
showSteamDbIcon: 'Icône SteamDB avant le titre',
showSteamDbIconHint: 'Icône app dans l’en-tête SteamDB, repli : capsule Steam.',
showSteamDbCover: 'Logo SteamDB sous Change cover',
showSteamDbCoverHint: 'Jaquette SteamDB sous la jaquette / Change cover.',
showSteamDbGallery: 'Galerie de captures Steam',
showSteamDbGalleryHint:
'Bandeau horizontal de captures après les stats (assets Steam Store, comme SteamDB). Clic pour agrandir.',
steamGalleryTitle: 'Captures',
steamGalleryOpen: 'Ouvrir la capture',
steamGalleryClose: 'Fermer',
steamGalleryPrev: 'Précédente',
steamGalleryNext: 'Suivante',
viewerZoomIn: 'Zoom avant',
viewerZoomOut: 'Zoom arrière',
viewerZoomReset: 'Réinitialiser le zoom',
viewerOf: '{current} / {total}',
viewerOpenCover: 'Ouvrir la jaquette',
showSteamPlayers: 'Afficher les joueurs en ligne',
showSteamPlayersHint: 'Joueurs actuels via Steam GetNumberOfCurrentPlayers.',
showCardBadges: 'Afficher les badges sur les jaquettes des listes',
showCardBadgesHint:
'Prix, % d’avis Steam, Possédé / Wishlist et GameStatus sur browse, recherche, journal et autres grilles (pas sur la page jeu).',
steamBackloggdTooltip: 'Voir sur Backloggd',
steamDbBackloggdLabel: 'Backloggd',
steamOwned: 'Possédé',
steamWishlist: 'Wishlist',
steamFixMatch: 'Corriger la correspondance',
steamFixMatchTitle: 'Steam App ID',
steamFixMatchHint:
'Remplace la recherche automatique Steam Store pour cette page Backloggd. Collez l’App ID numérique de l’URL Steam.',
steamFixMatchPlaceholder: 'ex. 1245620',
steamFixMatchClear: 'Utiliser la correspondance auto',
steamFixMatchInvalid: 'Entrez un Steam App ID numérique valide',
steamMatchManual: 'manuel',
steamMatchAuto: 'auto',
steamMatchLabel: 'App {id} · {source}',
sectionLinks: 'Liens rapides',
sectionLinksHint: 'Choisissez les sites affichés dans la rangée Links.',
navSettings: 'Plus',
navSettingsTitle: 'Réglages Backloggd Plus',
cacheHours: 'Durée du cache (heures)',
cacheHoursHint: 'Durée de réutilisation des requêtes Steam / GameStatus. 0 désactive le cache.',
clearCache: 'Vider le cache',
cacheCleared: 'Cache vidé ({count})',
cacheEmpty: 'Le cache est vide',
cacheBarFull: 'Complet',
cacheBarPartial: 'Partiel',
cacheBarFree: 'Libre',
cacheBarLegend: '{label} : {count} · {size}',
cacheBarUsed: '{used} / {limit}',
cacheBarHint:
'Budget de stockage indicatif. Complet = requêtes utiles ; partiel = incomplètes ou expirées encore stockées.',
cacheBarAria: 'Utilisation du cache : {full} complet, {partial} partiel, {free} libre',
cacheClearHint: 'Supprime les requêtes Steam / GameStatus de ce profil navigateur.',
on: 'ON',
off: 'OFF',
loading: 'Chargement…',
notOnSteam: 'Introuvable sur Steam',
loadError: 'Impossible de charger les données Steam',
steam: 'Steam',
metacritic: 'Metacritic',
gameStatus: 'GameStatus',
gsReady: 'Prêt',
gsPending: 'En attente',
gsPartial: 'Partiel',
gsReleaseToday: 'Sortie aujourd’hui',
gsUnknown: 'Inconnu',
gsNotInDatabase: 'Absent de la base',
reviews: 'Avis',
price: 'Prix',
free: 'Gratuit',
discount: '-{n}%',
steamUsFallback: 'Trouvé via le magasin US (aucune réponse pour {cc})',
recommendations: '{n} recommandations',
links: 'Liens',
linkIgdb: 'IGDB',
linkSteam: 'Steam',
linkSteamDb: 'SteamDB',
linkMetacritic: 'Metacritic',
linkOpencritic: 'OpenCritic',
linkHltb: 'HLTB',
discountEnds: 'jusqu’au {date}',
linkPcgamingwiki: 'PCGamingWiki',
linkItad: 'IsThereAnyDeal',
linkGogdb: 'GOG DB',
openCritic: 'OpenCritic',
hltb: 'HLTB',
deckProton: 'Deck / Proton',
deckVerified: 'Verified',
deckPlayable: 'Playable',
deckUnsupported: 'Unsupported',
deckUnknown: 'Unknown',
protonTier: 'ProtonDB {tier}',
hltbMain: 'Main {n}h',
hltbExtra: 'Extra {n}h',
hltbComplete: 'Complete {n}h',
players: 'Joueurs',
playersOnline: '{n} en jeu',
unifiedRatingTitle: 'Note Plus',
unifiedRatingHint: 'Moyenne Steam, Metacritic, OpenCritic et Backloggd',
unifiedRatingOutOf: 'sur 5',
unifiedRatingSources: '{n} sources',
unifiedRatingLoading: 'Collecte des notes…',
unifiedRatingSteam: 'Steam',
unifiedRatingMetacritic: 'Metacritic',
unifiedRatingOpenCritic: 'OpenCritic',
unifiedRatingBackloggd: 'Backloggd',
unifiedRatingMissing: '—',
},
ja: {
menuSettings: 'Backloggd Plus — 設定',
panelTitle: 'Backloggd Plus',
panelSubtitle: 'Steam · スコア · Deck/Proton · HLTB · バッジ · リンク',
close: '閉じる',
cancel: 'キャンセル',
save: '保存',
saveReload: '保存して再読み込み',
repoLink: 'GitHub',
repoAbout: 'ソースコード、更新、Issue報告',
sectionGame: 'ゲームページ',
sectionLists: 'リストとカード',
sectionGeneral: '一般',
sectionCache: 'キャッシュ',
sectionDebug: 'デバッグ',
debugMode: 'デバッグモード',
debugModeHint:
'ゲームページにデバッグパネルを1つ表示。各URLの用途と完全なダンプ。',
debugReason: '理由',
debugResponse: 'レスポンス',
debugPanelTitle: 'デバッグ',
debugSources: 'ソース',
debugDump: '完全ダンプ',
debugSectionPage: 'ページ',
debugSectionSteam: 'Steam',
debugSectionSteamDb: 'SteamDB / プレイヤー',
debugSectionGameStatus: 'GameStatus',
debugOwned: '所持',
debugSrcRequest: 'リクエスト',
debugSrcBackloggd: '現在の Backloggd ゲームページ',
debugSrcIgdb: 'ゲーム slug / Links 行(IGDB)',
debugSrcMetacritic: 'Links 行 — Metacritic URL(slug 推定)',
debugSrcSteamStore: 'Steam ストアページ — Links + Steam 行リンク',
debugSrcSteamDb: 'Links 行 — SteamDB アプリページ',
debugSrcSteamDbCharts: 'SteamDB charts(参考;スクレイプしない)',
debugSrcSteamDbInfo: 'SteamDB info(参考;スクレイプしない)',
debugSrcSteamTiny: '小さいカプセル — アイコン用フォールバック',
debugSrcSteamHeader: 'appdetails のヘッダー画像',
debugSrcSteamSearch: 'storesearch — Steam App ID の解決',
debugSrcSteamDetails: 'appdetails — 価格・無料・Metacritic・header',
debugSrcSteamReviews: 'appreviews — Steam 行のレビュー概要',
debugSrcSteamTags: 'GetItems — Steam 行の人気タグ',
debugSrcSteamTagMap: 'populartags — タグ id → 名前',
debugSrcSteamOwned: 'dynamicstore/userdata — ライブラリ所持確認',
debugSrcSteamDbPage: 'SteamDB HTML — icon / logo / プレイヤー',
debugSrcSteamPlayersApi: 'GetNumberOfCurrentPlayers — プレイヤー数フォールバック',
debugSrcIcon: 'タイトル横アイコン(SteamDB / CDN)',
debugSrcLogo: 'Change cover 下のカバー logo',
debugSrcGameStatusApi: 'GameStatus API — 状態',
debugSrcGameStatusPage: '一致した slug の GameStatus ページ',
uiLanguage: '表示言語',
uiLanguageHint: '自動はブラウザ言語に従います。保存後の再読み込みで反映。',
uiLanguageAuto: '自動(ブラウザ)',
steamCountry: 'Steamストア地域',
steamCountryHint: 'Steam Store APIの価格通貨に影響します。',
showSteam: 'Steamの価格とレビューを表示',
showMetacritic: 'Metacriticスコアを表示',
showOpenCritic: 'OpenCriticスコアを表示',
showOpenCriticHint: 'OpenCriticのTierバッジ(タイトル検索)。',
showHltb: 'HowLongToBeatの時間を表示',
showHltbHint: 'HowLongToBeatのMain / Extra / Complete時間。',
showDeckProton: 'Steam DeckとProtonDBを表示',
showDeckProtonHint: 'Steam DeckのVerified/PlayableとProtonDBティア(Steam App IDが必要)。',
showGameStatus: 'GameStatusの状態を表示',
showGameStatusHint: 'GameStatus.info の状態(Steam一致が必要)。',
showLinks: 'リンク行を表示',
showSteamOwned: 'Steam所持を表示',
showSteamOwnedHint:
'ライブラリにある場合「所持」を表示します。このブラウザでSteamにログインしている必要があります。',
showSteamWishlist: 'Steamウィッシュリストを表示',
showSteamWishlistHint:
'ウィッシュリストにある場合「ウィッシュリスト」を表示(所持中は非表示)。このブラウザでSteamログインが必要です。',
showSteamTags: 'Steamタグを表示',
showSteamTagsHint: 'Steamストアの人気コミュニティタグ(Open World、RPG など)。',
showSteamCategories: 'Steamカテゴリを表示',
showSteamCategoriesHint:
'Steamアプリ詳細のストアカテゴリ(シングルプレイヤー、マルチプレイヤー、Steam実績など)。',
showSteamPageLink: 'SteamにBackloggdボタン',
showSteamPageLinkHint: 'Steamのゲームページ Other site info にSteamDB風ボタンを追加します。',
showSteamDbPageLink: 'SteamDBにBackloggdボタン',
showSteamDbPageLinkHint: 'SteamDBのapp-linksでStore / IGDBの横にボタンを追加します。',
showSteamDbIcon: 'タイトル前に SteamDB アイコン',
showSteamDbIconHint: 'SteamDB ページ見出しのアプリアイコン。失敗時は Steam カプセル。',
showSteamDbCover: 'Change cover の下に SteamDB ロゴ',
showSteamDbCoverHint: 'カバー / Change cover の下に SteamDB のカバー画像。',
showSteamDbGallery: 'Steam スクリーンショットギャラリー',
showSteamDbGalleryHint:
'統計ブロックの下に横スクロールのスクショ(Steam Store アセット、SteamDB と同じ)。クリックで拡大。',
steamGalleryTitle: 'スクリーンショット',
steamGalleryOpen: 'スクリーンショットを開く',
steamGalleryClose: '閉じる',
steamGalleryPrev: '前へ',
steamGalleryNext: '次へ',
viewerZoomIn: '拡大',
viewerZoomOut: '縮小',
viewerZoomReset: 'ズームをリセット',
viewerOf: '{current} / {total}',
viewerOpenCover: 'カバーを開く',
showSteamPlayers: 'オンラインプレイヤー数を表示',
showSteamPlayersHint: 'Steam GetNumberOfCurrentPlayers の現在のプレイヤー数。',
showCardBadges: 'リストのカバーにバッジを表示',
showCardBadgesHint:
'browse / 検索 / journal などのカバーに価格・Steam評価%・所持 / ウィッシュリスト・GameStatusを表示(ゲームページ以外)。',
steamBackloggdTooltip: 'Backloggdで見る',
steamDbBackloggdLabel: 'Backloggd',
steamOwned: '所持',
steamWishlist: 'ウィッシュリスト',
steamFixMatch: '一致を修正',
steamFixMatchTitle: 'Steam App ID',
steamFixMatchHint:
'このBackloggdページの自動Steamストア検索を上書きします。Steam URLの数字App IDを貼り付けてください。',
steamFixMatchPlaceholder: '例: 1245620',
steamFixMatchClear: '自動一致を使う',
steamFixMatchInvalid: '有効な数字のSteam App IDを入力してください',
steamMatchManual: '手動',
steamMatchAuto: '自動',
steamMatchLabel: 'App {id} · {source}',
sectionLinks: 'クイックリンク',
sectionLinksHint: 'Links行に表示するサイトを選択します。',
navSettings: 'Plus',
navSettingsTitle: 'Backloggd Plus 設定',
cacheHours: 'キャッシュ時間(時間)',
cacheHoursHint: 'Steam / GameStatus照会の再利用時間。0で無効。',
clearCache: 'キャッシュを消去',
cacheCleared: 'キャッシュを消去しました({count})',
cacheEmpty: 'キャッシュは空です',
cacheBarFull: '完全',
cacheBarPartial: '部分',
cacheBarFree: '空き',
cacheBarLegend: '{label}: {count} · {size}',
cacheBarUsed: '{used} / {limit}',
cacheBarHint:
'目安の容量上限。完全=利用可能な照会、部分=不完全または期限切れでまだ残っている項目。',
cacheBarAria: 'キャッシュ使用量: 完全 {full}、部分 {partial}、空き {free}',
cacheClearHint: 'このブラウザプロファイルのSteam / GameStatus照会を削除します。',
on: 'ON',
off: 'OFF',
loading: '読み込み中…',
notOnSteam: 'Steamで見つかりません',
loadError: 'Steamデータを読み込めませんでした',
steam: 'Steam',
metacritic: 'Metacritic',
gameStatus: 'GameStatus',
gsReady: '対応済み',
gsPending: '未対応',
gsPartial: '一部対応',
gsReleaseToday: '本日リリース',
gsUnknown: '不明',
gsNotInDatabase: 'データベースになし',
reviews: 'レビュー',
price: '価格',
free: '無料',
discount: '-{n}%',
steamUsFallback: 'USストア経由で検出({cc} の検索は結果なし)',
recommendations: 'おすすめ {n}',
links: 'リンク',
linkIgdb: 'IGDB',
linkSteam: 'Steam',
linkSteamDb: 'SteamDB',
linkMetacritic: 'Metacritic',
linkOpencritic: 'OpenCritic',
linkHltb: 'HLTB',
discountEnds: '{date}まで',
linkPcgamingwiki: 'PCGamingWiki',
linkItad: 'IsThereAnyDeal',
linkGogdb: 'GOG DB',
openCritic: 'OpenCritic',
hltb: 'HLTB',
deckProton: 'Deck / Proton',
deckVerified: 'Verified',
deckPlayable: 'Playable',
deckUnsupported: 'Unsupported',
deckUnknown: 'Unknown',
protonTier: 'ProtonDB {tier}',
hltbMain: 'Main {n}時間',
hltbExtra: 'Extra {n}時間',
hltbComplete: 'Complete {n}時間',
players: 'プレイヤー',
playersOnline: '{n} 人がプレイ中',
unifiedRatingTitle: 'Plusスコア',
unifiedRatingHint: 'Steam・Metacritic・OpenCritic・Backloggdの平均',
unifiedRatingOutOf: '/ 5',
unifiedRatingSources: '{n}件のソース',
unifiedRatingLoading: 'スコアを集計中…',
unifiedRatingSteam: 'Steam',
unifiedRatingMetacritic: 'Metacritic',
unifiedRatingOpenCritic: 'OpenCritic',
unifiedRatingBackloggd: 'Backloggd',
unifiedRatingMissing: '—',
},
ko: {
menuSettings: 'Backloggd Plus — 설정',
panelTitle: 'Backloggd Plus',
panelSubtitle: 'Steam · 점수 · Deck/Proton · HLTB · 배지 · 링크',
close: '닫기',
cancel: '취소',
save: '저장',
saveReload: '저장 후 새로고침',
repoLink: 'GitHub',
repoAbout: '소스 코드, 업데이트, 이슈 보고',
sectionGame: '게임 페이지',
sectionLists: '목록 및 카드',
sectionGeneral: '일반',
sectionCache: '캐시',
sectionDebug: '디버그',
debugMode: '디버그 모드',
debugModeHint:
'게임 페이지에 디버그 패널 하나: 각 URL 용도와 전체 덤프.',
debugReason: '이유',
debugResponse: '응답',
debugPanelTitle: '디버그',
debugSources: '소스',
debugDump: '전체 덤프',
debugSectionPage: '페이지',
debugSectionSteam: 'Steam',
debugSectionSteamDb: 'SteamDB / 플레이어',
debugSectionGameStatus: 'GameStatus',
debugOwned: '보유',
debugSrcRequest: '요청',
debugSrcBackloggd: '현재 Backloggd 게임 페이지',
debugSrcIgdb: '게임 slug / Links 행 (IGDB)',
debugSrcMetacritic: 'Links 행 — Metacritic URL (slug 추정)',
debugSrcSteamStore: 'Steam 스토어 페이지 — Links + Steam 행 링크',
debugSrcSteamDb: 'Links 행 — SteamDB 앱 페이지',
debugSrcSteamDbCharts: 'SteamDB charts (참고; 스크래핑 안 함)',
debugSrcSteamDbInfo: 'SteamDB info (참고; 스크래핑 안 함)',
debugSrcSteamTiny: '작은 캡슐 — 아이콘 폴백',
debugSrcSteamHeader: 'appdetails 헤더 이미지',
debugSrcSteamSearch: 'storesearch — Steam App ID 해석',
debugSrcSteamDetails: 'appdetails — 가격, 무료, Metacritic, header',
debugSrcSteamReviews: 'appreviews — Steam 행 리뷰 요약',
debugSrcSteamTags: 'GetItems — Steam 행 인기 태그',
debugSrcSteamTagMap: 'populartags — 태그 id → 이름',
debugSrcSteamOwned: 'dynamicstore/userdata — 라이브러리 보유 확인',
debugSrcSteamDbPage: 'SteamDB HTML — icon, logo, 플레이어',
debugSrcSteamPlayersApi: 'GetNumberOfCurrentPlayers — 플레이어 폴백',
debugSrcIcon: '제목 아이콘 (SteamDB / CDN)',
debugSrcLogo: 'Change cover 아래 커버 logo',
debugSrcGameStatusApi: 'GameStatus API — 상태',
debugSrcGameStatusPage: '일치한 slug의 GameStatus 페이지',
uiLanguage: '인터페이스 언어',
uiLanguageHint: '자동은 브라우저 언어를 따릅니다. 저장 후 새로고침 시 적용.',
uiLanguageAuto: '자동 (브라우저)',
steamCountry: 'Steam 스토어 지역',
steamCountryHint: 'Steam Store API 가격 통화에 영향을 줍니다.',
showSteam: 'Steam 가격 및 리뷰 표시',
showMetacritic: 'Metacritic 점수 표시',
showOpenCritic: 'OpenCritic 점수 표시',
showOpenCriticHint: 'OpenCritic Tier 배지(제목 검색).',
showHltb: 'HowLongToBeat 시간 표시',
showHltbHint: 'HowLongToBeat의 Main / Extra / Complete 시간.',
showDeckProton: 'Steam Deck 및 ProtonDB 표시',
showDeckProtonHint: 'Steam Deck Verified/Playable과 ProtonDB 등급(Steam App ID 필요).',
showGameStatus: 'GameStatus 상태 표시',
showGameStatusHint: 'GameStatus.info의 상태(Steam 일치 필요).',
showLinks: '링크 행 표시',
showSteamOwned: 'Steam 보유 표시',
showSteamOwnedHint:
'라이브러리에 있으면 “보유”를 표시합니다. 이 브라우저에서 Steam 로그인이 필요합니다.',
showSteamWishlist: 'Steam 위시리스트 상태 표시',
showSteamWishlistHint:
'위시리스트에 있으면 “위시리스트”를 표시합니다(보유 중이면 숨김). 이 브라우저에서 Steam 로그인이 필요합니다.',
showSteamTags: 'Steam 태그 표시',
showSteamTagsHint: 'Steam 스토어의 인기 커뮤니티 태그(Open World, RPG 등).',
showSteamCategories: 'Steam 카테고리 표시',
showSteamCategoriesHint:
'Steam 앱 상세의 스토어 카테고리(싱글플레이어, 멀티플레이어, Steam 도전 과제 등).',
showSteamPageLink: 'Steam에 Backloggd 버튼',
showSteamPageLinkHint: 'Steam 게임 페이지 Other site info에 SteamDB 스타일 버튼을 추가합니다.',
showSteamDbPageLink: 'SteamDB에 Backloggd 버튼',
showSteamDbPageLinkHint: 'SteamDB app-links에서 Store / IGDB 옆에 버튼을 추가합니다.',
showSteamDbIcon: '제목 앞에 SteamDB 아이콘',
showSteamDbIconHint: 'SteamDB 페이지 헤더 앱 아이콘. 실패 시 Steam 캡슐.',
showSteamDbCover: 'Change cover 아래 SteamDB 로고',
showSteamDbCoverHint: '커버 / Change cover 아래 SteamDB 커버 이미지.',
showSteamDbGallery: 'Steam 스크린샷 갤러리',
showSteamDbGalleryHint:
'통계 블록 아래 가로 스크린샷 스트립(Steam 스토어 에셋, SteamDB와 동일). 클릭하면 확대.',
steamGalleryTitle: '스크린샷',
steamGalleryOpen: '스크린샷 열기',
steamGalleryClose: '닫기',
steamGalleryPrev: '이전',
steamGalleryNext: '다음',
viewerZoomIn: '확대',
viewerZoomOut: '축소',
viewerZoomReset: '줌 초기화',
viewerOf: '{current} / {total}',
viewerOpenCover: '커버 열기',
showSteamPlayers: '온라인 플레이어 수 표시',
showSteamPlayersHint: 'Steam GetNumberOfCurrentPlayers의 현재 플레이어 수.',
showCardBadges: '목록 커버에 배지 표시',
showCardBadgesHint:
'browse, 검색, journal 등 커버 그리드에 가격, Steam 평가 %, 보유/위시리스트, GameStatus 표시(게임 페이지 제외).',
steamBackloggdTooltip: 'Backloggd에서 보기',
steamDbBackloggdLabel: 'Backloggd',
steamOwned: '보유',
steamWishlist: '위시리스트',
steamFixMatch: '매치 수정',
steamFixMatchTitle: 'Steam App ID',
steamFixMatchHint:
'이 Backloggd 페이지의 자동 Steam 스토어 검색을 덮어씁니다. Steam URL의 숫자 App ID를 붙여넣으세요.',
steamFixMatchPlaceholder: '예: 1245620',
steamFixMatchClear: '자동 매치 사용',
steamFixMatchInvalid: '유효한 숫자 Steam App ID를 입력하세요',
steamMatchManual: '수동',
steamMatchAuto: '자동',
steamMatchLabel: 'App {id} · {source}',
sectionLinks: '빠른 링크',
sectionLinksHint: 'Links 행에 표시할 사이트를 선택합니다.',
navSettings: 'Plus',
navSettingsTitle: 'Backloggd Plus 설정',
cacheHours: '캐시 시간(시간)',
cacheHoursHint: 'Steam / GameStatus 조회 재사용 시간. 0은 캐시 비활성.',
clearCache: '캐시 비우기',
cacheCleared: '캐시 비움 ({count})',
cacheEmpty: '캐시가 비어 있음',
cacheBarFull: '완전',
cacheBarPartial: '부분',
cacheBarFree: '여유',
cacheBarLegend: '{label}: {count} · {size}',
cacheBarUsed: '{used} / {limit}',
cacheBarHint:
'소프트 저장 한도. 완전 = 사용 가능한 조회, 부분 = 불완전하거나 만료됐지만 아직 남아 있는 항목.',
cacheBarAria: '캐시 사용량: 완전 {full}, 부분 {partial}, 여유 {free}',
cacheClearHint: '이 브라우저 프로필의 Steam / GameStatus 조회를 삭제합니다.',
on: '켜짐',
off: '꺼짐',
loading: '로딩 중…',
notOnSteam: 'Steam에서 찾을 수 없음',
loadError: 'Steam 데이터를 불러오지 못함',
steam: 'Steam',
metacritic: 'Metacritic',
gameStatus: 'GameStatus',
gsReady: '준비됨',
gsPending: '대기',
gsPartial: '부분',
gsReleaseToday: '오늘 출시',
gsUnknown: '알 수 없음',
gsNotInDatabase: '데이터베이스 없음',
reviews: '리뷰',
price: '가격',
free: '무료',
discount: '-{n}%',
steamUsFallback: 'US 스토어로 찾음 ({cc} 검색 결과 없음)',
recommendations: '추천 {n}',
links: '링크',
linkIgdb: 'IGDB',
linkSteam: 'Steam',
linkSteamDb: 'SteamDB',
linkMetacritic: 'Metacritic',
linkOpencritic: 'OpenCritic',
linkHltb: 'HLTB',
discountEnds: '{date}까지',
linkPcgamingwiki: 'PCGamingWiki',
linkItad: 'IsThereAnyDeal',
linkGogdb: 'GOG DB',
openCritic: 'OpenCritic',
hltb: 'HLTB',
deckProton: 'Deck / Proton',
deckVerified: 'Verified',
deckPlayable: 'Playable',
deckUnsupported: 'Unsupported',
deckUnknown: 'Unknown',
protonTier: 'ProtonDB {tier}',
hltbMain: 'Main {n}시간',
hltbExtra: 'Extra {n}시간',
hltbComplete: 'Complete {n}시간',
players: '플레이어',
playersOnline: '{n}명 플레이 중',
unifiedRatingTitle: 'Plus 평점',
unifiedRatingHint: 'Steam, Metacritic, OpenCritic, Backloggd 평균',
unifiedRatingOutOf: '/ 5',
unifiedRatingSources: '출처 {n}개',
unifiedRatingLoading: '평점 집계 중…',
unifiedRatingSteam: 'Steam',
unifiedRatingMetacritic: 'Metacritic',
unifiedRatingOpenCritic: 'OpenCritic',
unifiedRatingBackloggd: 'Backloggd',
unifiedRatingMissing: '—',
},
pl: {
menuSettings: 'Backloggd Plus — Ustawienia',
panelTitle: 'Backloggd Plus',
panelSubtitle: 'Steam · oceny · Deck/Proton · HLTB · odznaki · linki',
close: 'Zamknij',
cancel: 'Anuluj',
save: 'Zapisz',
saveReload: 'Zapisz i przeładuj',
repoLink: 'GitHub',
repoAbout: 'Kod źródłowy, aktualizacje i zgłoszenia',
sectionGame: 'Strona gry',
sectionLists: 'Listy i karty',
sectionGeneral: 'Ogólne',
sectionCache: 'Cache',
sectionDebug: 'Debug',
debugMode: 'Tryb debug',
debugModeHint:
'Jeden panel debug na stronie gry: źródła (do czego służy każdy URL) i pełny dump.',
debugReason: 'Powód',
debugResponse: 'Odpowiedź',
debugPanelTitle: 'Debug',
debugSources: 'Źródła',
debugDump: 'Pełny dump',
debugSectionPage: 'Strona',
debugSectionSteam: 'Steam',
debugSectionSteamDb: 'SteamDB / gracze',
debugSectionGameStatus: 'GameStatus',
debugOwned: 'Posiadane',
debugSrcRequest: 'żądanie',
debugSrcBackloggd: 'Bieżąca strona gry na Backloggd',
debugSrcIgdb: 'Slug gry / wiersz Links (IGDB)',
debugSrcMetacritic: 'Wiersz Links — URL Metacritic (po slugu)',
debugSrcSteamStore: 'Strona Steam — Links + link w wierszu Steam',
debugSrcSteamDb: 'Wiersz Links — strona aplikacji SteamDB',
debugSrcSteamDbCharts: 'SteamDB charts (referencja; bez scrapowania)',
debugSrcSteamDbInfo: 'SteamDB info (referencja; bez scrapowania)',
debugSrcSteamTiny: 'Mała kapsuła — fallback ikony',
debugSrcSteamHeader: 'Obraz header z appdetails',
debugSrcSteamSearch: 'storesearch — rozwiązanie Steam App ID',
debugSrcSteamDetails: 'appdetails — cena, free, Metacritic, header',
debugSrcSteamReviews: 'appreviews — podsumowanie recenzji dla Steam',
debugSrcSteamTags: 'GetItems — popularne tagi dla Steam',
debugSrcSteamTagMap: 'populartags — mapa id → nazwa',
debugSrcSteamOwned: 'dynamicstore/userdata — sprawdzenie biblioteki',
debugSrcSteamDbPage: 'HTML SteamDB — ikona, logo, gracze',
debugSrcSteamPlayersApi: 'GetNumberOfCurrentPlayers — fallback graczy',
debugSrcIcon: 'Ikona tytułu (SteamDB / CDN)',
debugSrcLogo: 'Logo okładki pod Change cover',
debugSrcGameStatusApi: 'API GameStatus — status',
debugSrcGameStatusPage: 'Strona GameStatus dla znalezionego sluga',
uiLanguage: 'Język interfejsu',
uiLanguageHint: 'Auto podąża za językiem przeglądarki. Działa po przeładowaniu.',
uiLanguageAuto: 'Auto (przeglądarka)',
steamCountry: 'Region Steam Store',
steamCountryHint: 'Wpływa na walutę ceny z API Steam Store.',
showSteam: 'Pokaż cenę i opinie Steam',
showMetacritic: 'Pokaż wynik Metacritic',
showOpenCritic: 'Pokaż wynik OpenCritic',
showOpenCriticHint: 'Odznaka tier OpenCritic (wyszukiwanie po tytule).',
showHltb: 'Pokaż czasy HowLongToBeat',
showHltbHint: 'Godziny Main / Extra / Complete z HowLongToBeat.',
showDeckProton: 'Pokaż Steam Deck i ProtonDB',
showDeckProtonHint: 'Verified/Playable Steam Deck oraz poziom ProtonDB (wymaga Steam App ID).',
showGameStatus: 'Pokaż status GameStatus',
showGameStatusHint: 'Status z GameStatus.info (wymaga dopasowania Steam).',
showLinks: 'Pokaż wiersz linków',
showSteamOwned: 'Pokaż status posiadania Steam',
showSteamOwnedHint:
'Pokazuje „Posiadane”, jeśli gra jest w bibliotece Steam. Wymaga zalogowania do Steam w tej przeglądarce.',
showSteamWishlist: 'Pokaż status wishlisty Steam',
showSteamWishlistHint:
'Pokazuje „Wishlist”, jeśli gra jest na liście życzeń Steam (ukryte, gdy już posiadane). Wymaga logowania Steam.',
showSteamTags: 'Pokaż tagi Steam',
showSteamTagsHint: 'Popularne tagi społeczności ze Steam Store (Open World, RPG, …).',
showSteamCategories: 'Pokaż kategorie Steam',
showSteamCategoriesHint:
'Kategorie ze szczegółów aplikacji Steam (Single-player, Multi-player, Steam Achievements, …).',
showSteamPageLink: 'Przycisk Backloggd na Steam',
showSteamPageLinkHint: 'Dodaje przycisk w stylu SteamDB w Other site info na stronach Steam.',
showSteamDbPageLink: 'Przycisk Backloggd na SteamDB',
showSteamDbPageLinkHint: 'Dodaje przycisk obok Store / IGDB w app-links na SteamDB.',
showSteamDbIcon: 'Ikona SteamDB przed tytułem',
showSteamDbIconHint: 'Ikona aplikacji z nagłówka SteamDB; zapasowo kapsuła Steam.',
showSteamDbCover: 'Logo SteamDB pod Change cover',
showSteamDbCoverHint: 'Okładka ze SteamDB pod okładką / Change cover.',
showSteamDbGallery: 'Galeria screenshotów Steam',
showSteamDbGalleryHint:
'Poziomy pasek screenshotów po statystykach (assetty Steam Store, jak na SteamDB). Kliknij, by powiększyć.',
steamGalleryTitle: 'Screenshoty',
steamGalleryOpen: 'Otwórz screenshot',
steamGalleryClose: 'Zamknij',
steamGalleryPrev: 'Poprzedni',
steamGalleryNext: 'Następny',
viewerZoomIn: 'Powiększ',
viewerZoomOut: 'Pomniejsz',
viewerZoomReset: 'Resetuj zoom',
viewerOf: '{current} / {total}',
viewerOpenCover: 'Otwórz okładkę',
showSteamPlayers: 'Pokaż graczy online',
showSteamPlayersHint: 'Bieżąca liczba graczy z Steam GetNumberOfCurrentPlayers.',
showCardBadges: 'Pokaż odznaki na okładkach list',
showCardBadgesHint:
'Cena, % recenzji Steam, Posiadane / Wishlist i GameStatus na browse, wyszukiwaniu, journal i innych siatkach (nie na stronie gry).',
steamBackloggdTooltip: 'Zobacz na Backloggd',
steamDbBackloggdLabel: 'Backloggd',
steamOwned: 'Posiadane',
steamWishlist: 'Wishlist',
steamFixMatch: 'Popraw dopasowanie',
steamFixMatchTitle: 'Steam App ID',
steamFixMatchHint:
'Nadpisuje automatyczne wyszukiwanie Steam Store dla tej strony Backloggd. Wklej liczbowe App ID z URL Steam.',
steamFixMatchPlaceholder: 'np. 1245620',
steamFixMatchClear: 'Użyj automatycznego dopasowania',
steamFixMatchInvalid: 'Podaj prawidłowe liczbowe Steam App ID',
steamMatchManual: 'ręcznie',
steamMatchAuto: 'auto',
steamMatchLabel: 'App {id} · {source}',
sectionLinks: 'Szybkie linki',
sectionLinksHint: 'Wybierz witryny widoczne w wierszu Links.',
navSettings: 'Plus',
navSettingsTitle: 'Ustawienia Backloggd Plus',
cacheHours: 'Czas cache (godziny)',
cacheHoursHint: 'Jak długo ponownie używać zapytań Steam / GameStatus. 0 wyłącza cache.',
clearCache: 'Wyczyść cache',
cacheCleared: 'Cache wyczyszczony ({count})',
cacheEmpty: 'Cache jest pusty',
cacheBarFull: 'Pełny',
cacheBarPartial: 'Częściowy',
cacheBarFree: 'Wolne',
cacheBarLegend: '{label}: {count} · {size}',
cacheBarUsed: '{used} / {limit}',
cacheBarHint:
'Miękki limit pamięci. Pełny = użyteczne wpisy; częściowy = niekompletne lub wygasłe, nadal zapisane.',
cacheBarAria: 'Użycie cache: {full} pełny, {partial} częściowy, {free} wolne',
cacheClearHint: 'Usuwa zapisane zapytania Steam / GameStatus z tego profilu.',
on: 'WŁ',
off: 'WYŁ',
loading: 'Ładowanie…',
notOnSteam: 'Nie znaleziono na Steam',
loadError: 'Nie udało się wczytać danych Steam',
steam: 'Steam',
metacritic: 'Metacritic',
gameStatus: 'GameStatus',
gsReady: 'Gotowe',
gsPending: 'Oczekuje',
gsPartial: 'Częściowo',
gsReleaseToday: 'Premiera dziś',
gsUnknown: 'Nieznany',
gsNotInDatabase: 'Brak w bazie',
reviews: 'Opinie',
price: 'Cena',
free: 'Za darmo',
discount: '-{n}%',
steamUsFallback: 'Znaleziono przez sklep US (wyszukiwanie {cc} bez wyników)',
recommendations: '{n} rekomendacji',
links: 'Linki',
linkIgdb: 'IGDB',
linkSteam: 'Steam',
linkSteamDb: 'SteamDB',
linkMetacritic: 'Metacritic',
linkOpencritic: 'OpenCritic',
linkHltb: 'HLTB',
discountEnds: 'do {date}',
linkPcgamingwiki: 'PCGamingWiki',
linkItad: 'IsThereAnyDeal',
linkGogdb: 'GOG DB',
openCritic: 'OpenCritic',
hltb: 'HLTB',
deckProton: 'Deck / Proton',
deckVerified: 'Verified',
deckPlayable: 'Playable',
deckUnsupported: 'Unsupported',
deckUnknown: 'Unknown',
protonTier: 'ProtonDB {tier}',
hltbMain: 'Main {n}h',
hltbExtra: 'Extra {n}h',
hltbComplete: 'Complete {n}h',
players: 'Gracze',
playersOnline: '{n} gra',
unifiedRatingTitle: 'Ocena Plus',
unifiedRatingHint: 'Średnia Steam, Metacritic, OpenCritic i Backloggd',
unifiedRatingOutOf: 'z 5',
unifiedRatingSources: '{n} źródeł',
unifiedRatingLoading: 'Zbieranie ocen…',
unifiedRatingSteam: 'Steam',
unifiedRatingMetacritic: 'Metacritic',
unifiedRatingOpenCritic: 'OpenCritic',
unifiedRatingBackloggd: 'Backloggd',
unifiedRatingMissing: '—',
},
};
const LOCALE_NATIVE_NAMES = {
en: 'English',
ru: 'Русский',
zh: '中文',
es: 'Español',
pt: 'Português',
de: 'Deutsch',
fr: 'Français',
ja: '日本語',
ko: '한국어',
pl: 'Polski',
};
function detectLocale() {
const raw = String(navigator.language || 'en').toLowerCase();
const short = raw.slice(0, 2);
return SUPPORTED_LOCALES.includes(short) ? short : 'en';
}
function resolveLocale(pref) {
const value = pref || 'auto';
if (value !== 'auto' && SUPPORTED_LOCALES.includes(value)) return value;
return detectLocale();
}
function fmt(template, vars) {
return String(template).replace(/\{(\w+)\}/g, (_, k) =>
vars[k] == null ? '' : String(vars[k])
);
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function escapeAttr(str) {
return escapeHtml(str);
}
function loadSettings() {
try {
const raw = GM_getValue(SETTINGS_KEY, null);
if (!raw || typeof raw !== 'object') {
return {
...DEFAULT_SETTINGS,
links: { ...DEFAULT_SETTINGS.links },
};
}
return {
...DEFAULT_SETTINGS,
...raw,
links: { ...DEFAULT_SETTINGS.links, ...(raw.links || {}) },
};
} catch (_) {
return {
...DEFAULT_SETTINGS,
links: { ...DEFAULT_SETTINGS.links },
};
}
}
function saveSettings(next) {
const merged = {
...DEFAULT_SETTINGS,
...next,
links: { ...DEFAULT_SETTINGS.links, ...(next.links || {}) },
};
GM_setValue(SETTINGS_KEY, merged);
}
function isLinkEnabled(key) {
return Boolean(settings.showLinks && settings.links && settings.links[key] !== false);
}
function linkLabelKey(key) {
const map = {
igdb: 'linkIgdb',
steam: 'linkSteam',
steamdb: 'linkSteamDb',
metacritic: 'linkMetacritic',
opencritic: 'linkOpencritic',
hltb: 'linkHltb',
pcgamingwiki: 'linkPcgamingwiki',
itad: 'linkItad',
gogdb: 'linkGogdb',
};
return map[key] || key;
}
let settings = loadSettings();
let locale = resolveLocale(settings.uiLocale);
let t = TRANSLATIONS[locale] || TRANSLATIONS.en;
let cacheStore = null;
let cachePersistTimer = 0;
const inflight = new Map();
/** In-memory Steam resolve misses for this page session (do not persist to GM cache). */
const steamResolveMissMemory = new Set();
function readCacheStore() {
if (cacheStore) return cacheStore;
try {
const raw = GM_getValue(CACHE_KEY, null);
cacheStore = raw && typeof raw === 'object' ? raw : {};
} catch (_) {
cacheStore = {};
}
return cacheStore;
}
function persistCacheSoon() {
clearTimeout(cachePersistTimer);
cachePersistTimer = setTimeout(() => {
try {
GM_setValue(CACHE_KEY, readCacheStore());
} catch (_) {
/* ignore */
}
}, 400);
}
function cacheTtlMs() {
const hours = Number(settings.cacheHours);
if (!Number.isFinite(hours) || hours <= 0) return 0;
return Math.min(hours, CACHE_HOURS_MAX) * 3600 * 1000;
}
function getCached(key) {
const ttl = cacheTtlMs();
if (!ttl) return null;
const entry = readCacheStore()[key];
if (!entry || !entry.ts) return null;
if (Date.now() - entry.ts > ttl) return null;
return entry.data;
}
function setCached(key, data) {
if (!cacheTtlMs()) return;
readCacheStore()[key] = { ts: Date.now(), data };
persistCacheSoon();
}
function userdataCacheTtlMs(empty) {
if (empty) return USERDATA_EMPTY_TTL_MS;
const ttl = cacheTtlMs();
return ttl > 0 ? ttl : USERDATA_FALLBACK_TTL_MS;
}
function getUserdataCached() {
const entry = readCacheStore()[USERDATA_CACHE_KEY];
if (!entry?.ts || !entry.data) return null;
if (!Array.isArray(entry.data.appIds) || !Array.isArray(entry.data.wishlistAppIds)) {
return null;
}
const empty = entry.data.appIds.length === 0 && entry.data.wishlistAppIds.length === 0;
if (Date.now() - entry.ts > userdataCacheTtlMs(empty)) return null;
return entry.data;
}
function setUserdataCached(data) {
readCacheStore()[USERDATA_CACHE_KEY] = { ts: Date.now(), data };
persistCacheSoon();
}
function loadSteamOverrides() {
try {
const raw = GM_getValue(STEAM_OVERRIDES_KEY, null);
return raw && typeof raw === 'object' ? raw : {};
} catch (_) {
return {};
}
}
function saveSteamOverrides(map) {
try {
GM_setValue(STEAM_OVERRIDES_KEY, map && typeof map === 'object' ? map : {});
} catch (_) {
/* ignore */
}
}
function getSteamOverride(slug) {
const key = String(slug || '')
.toLowerCase()
.replace(/^\/+|\/+$/g, '');
if (!key) return null;
const id = Number(loadSteamOverrides()[key]);
return Number.isFinite(id) && id > 0 ? id : null;
}
function setSteamOverride(slug, appId) {
const key = String(slug || '')
.toLowerCase()
.replace(/^\/+|\/+$/g, '');
if (!key) return;
const id = Number(appId);
const map = loadSteamOverrides();
if (Number.isFinite(id) && id > 0) map[key] = id;
else delete map[key];
saveSteamOverrides(map);
steamResolveMissMemory.clear();
}
function clearSteamOverride(slug) {
setSteamOverride(slug, null);
}
function getLongCached(key, ttlMs) {
const entry = readCacheStore()[key];
if (!entry?.ts || entry.data == null) return null;
if (Date.now() - entry.ts > ttlMs) return null;
return entry.data;
}
function setLongCached(key, data) {
readCacheStore()[key] = { ts: Date.now(), data };
persistCacheSoon();
}
async function fetchSteamPopularTagMap() {
const cached = getLongCached(TAG_MAP_CACHE_KEY, TAG_MAP_TTL_MS);
if (cached && typeof cached === 'object') return cached;
if (inflight.has(TAG_MAP_CACHE_KEY)) return inflight.get(TAG_MAP_CACHE_KEY);
const task = (async () => {
const list = await gmRequest({ url: STEAM_POPULAR_TAGS_URL });
const map = {};
if (Array.isArray(list)) {
for (const item of list) {
if (item?.tagid != null && item.name) map[item.tagid] = item.name;
}
}
setLongCached(TAG_MAP_CACHE_KEY, map);
return map;
})();
inflight.set(TAG_MAP_CACHE_KEY, task);
try {
return await task;
} finally {
inflight.delete(TAG_MAP_CACHE_KEY);
}
}
function parseSteamStoreAssets(appId, assets) {
if (!assets || typeof assets !== 'object') return null;
const id = Number(appId);
const hash = String(assets.community_icon || '').trim();
const iconUrl = hash ? `${STEAM_CDN_COMMUNITY_ICONS}/${id}/${hash}.jpg` : '';
const headerFile = String(assets.header || 'header.jpg').trim();
let logoUrl = '';
if (assets.asset_url_format && headerFile) {
logoUrl = `${STEAM_CDN_STORE_ASSETS}/${String(assets.asset_url_format).replace(
'${FILENAME}',
headerFile
)}`;
}
if (!logoUrl && headerFile) logoUrl = steamCdnAsset(id, headerFile);
if (!iconUrl && !logoUrl) return null;
return { iconUrl, logoUrl, source: 'steam-assets' };
}
function parseSteamPurchaseExtras(item) {
const opt = item?.best_purchase_option || null;
if (!opt || typeof opt !== 'object') {
return { discountEndDate: null };
}
let discountEndDate = null;
const discounts = Array.isArray(opt.active_discounts) ? opt.active_discounts : [];
for (const d of discounts) {
const end = Number(d?.discount_end_date);
if (Number.isFinite(end) && end > 0) {
discountEndDate = end;
break;
}
}
return { discountEndDate };
}
function parseSteamDeckCompat(item) {
const cat = Number(item?.platforms?.steam_deck_compat_category);
return Number.isFinite(cat) ? cat : null;
}
async function fetchSteamStoreItem(appId, country) {
const id = Number(appId);
const cc = String(country || 'US').toUpperCase();
const inflightKey = `steam:storeitem:${id}:${cc}`;
const tagsKey = `steam:tags:${id}`;
const assetsKey = `steam:assets:${id}`;
const extrasKey = `steam:extras:${id}:${cc}`;
const debugOn = Boolean(settings.debugMode);
const cachedTags = !debugOn ? getCached(tagsKey) : null;
const cachedAssets = !debugOn ? getCached(assetsKey) : null;
const cachedExtras = !debugOn ? getCached(extrasKey) : null;
if (cachedTags && cachedAssets && cachedExtras) {
return { tags: cachedTags, assets: cachedAssets, extras: cachedExtras };
}
if (inflight.has(inflightKey)) return inflight.get(inflightKey);
const task = (async () => {
try {
const input = JSON.stringify({
ids: [{ appid: id }],
context: {
language: 'english',
country_code: cc,
steam_realm: 1,
},
data_request: {
include_tag_count: STEAM_TAGS_MAX,
include_assets: true,
include_platforms: true,
include_all_purchase_options: true,
},
});
const [root, map] = await Promise.all([
gmRequest({
url: `${STEAM_STORE_ITEMS_URL}?input_json=${encodeURIComponent(input)}`,
}),
fetchSteamPopularTagMap(),
]);
const item = root?.response?.store_items?.[0];
const rawTags = Array.isArray(item?.tags) ? item.tags : [];
const tags = rawTags
.slice()
.sort((a, b) => (b.weight || 0) - (a.weight || 0))
.map((tag) => ({
id: tag.tagid,
name: map[tag.tagid] || null,
weight: tag.weight || 0,
}))
.filter((tag) => tag.name)
.slice(0, STEAM_TAGS_MAX);
const assets = parseSteamStoreAssets(id, item?.assets);
const purchase = parseSteamPurchaseExtras(item);
const extras = {
deckCompat: parseSteamDeckCompat(item),
...purchase,
};
if (!debugOn) {
setCached(tagsKey, tags);
if (assets) setCached(assetsKey, assets);
setCached(extrasKey, extras);
}
return {
tags: tags.length ? tags : cachedTags || [],
assets: assets || cachedAssets || null,
extras: extras || cachedExtras || null,
};
} catch (_) {
return {
tags: cachedTags || [],
assets: cachedAssets || null,
extras: cachedExtras || null,
};
}
})();
inflight.set(inflightKey, task);
try {
return await task;
} finally {
inflight.delete(inflightKey);
}
}
async function fetchSteamAppTags(appId, country) {
const { tags } = await fetchSteamStoreItem(appId, country);
return tags;
}
async function fetchSteamStoreAssets(appId, country) {
const { assets } = await fetchSteamStoreItem(appId, country);
return assets;
}
const HLTB_UA =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
const TITLE_MATCH_MIN_SCORE = 85;
let hltbTokenCache = null;
function formatHoursCompact(hours) {
if (!Number.isFinite(hours) || hours <= 0) return null;
const rounded = Math.round(hours * 10) / 10;
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
}
function secondsToHours(seconds) {
const n = Number(seconds);
if (!Number.isFinite(n) || n <= 0) return null;
return n / 3600;
}
function pickBestTitleMatch(candidates, title, getName) {
let best = null;
let bestScore = 0;
for (const item of candidates || []) {
const name = getName(item);
const score = scoreSteamTitleMatch(name, title);
if (score > bestScore) {
bestScore = score;
best = item;
}
}
if (!best || bestScore < TITLE_MATCH_MIN_SCORE) return null;
return { item: best, score: bestScore };
}
async function fetchHltbAuthToken(force = false) {
if (!force && hltbTokenCache && Date.now() < hltbTokenCache.expiresAt) {
return hltbTokenCache;
}
const data = await gmRequest({
url: `${HLTB_INIT_URL}?t=${Date.now()}`,
headers: {
Accept: 'application/json',
'User-Agent': HLTB_UA,
Origin: HLTB_SITE,
Referer: `${HLTB_SITE}/`,
},
timeout: 15000,
});
const token = data?.token;
const hpKey = data?.hpKey;
const hpVal = data?.hpVal;
if (
typeof token !== 'string' ||
!token ||
typeof hpKey !== 'string' ||
!hpKey ||
typeof hpVal !== 'string' ||
!hpVal
) {
throw new Error('Invalid HLTB token response');
}
hltbTokenCache = {
token,
hpKey,
hpVal,
expiresAt: Date.now() + 50 * 60 * 1000,
};
return hltbTokenCache;
}
function buildHltbSearchPayload(query, auth) {
return {
searchType: 'games',
searchTerms: String(query || '')
.trim()
.split(/\s+/)
.filter(Boolean),
searchPage: 1,
size: 10,
searchOptions: {
games: {
userId: 0,
platform: '',
sortCategory: 'popular',
rangeCategory: 'main',
rangeTime: { min: null, max: null },
gameplay: { perspective: '', flow: '', genre: '', difficulty: '' },
rangeYear: { min: '', max: '' },
modifier: '',
},
users: { sortCategory: 'postcount' },
lists: { sortCategory: 'follows' },
filter: '',
sort: 0,
randomizer: 0,
},
useCache: true,
[auth.hpKey]: auth.hpVal,
};
}
async function fetchHltb(title) {
const q = String(title || '').trim();
if (!q) return null;
const cacheKey = `hltb:${normalizeTitle(q)}`;
const debugOn = Boolean(settings.debugMode);
if (!debugOn) {
const cached = getCached(cacheKey);
if (cached) return cached;
}
if (inflight.has(cacheKey)) return inflight.get(cacheKey);
const task = (async () => {
const runSearch = async (forceToken) => {
const auth = await fetchHltbAuthToken(forceToken);
return gmRequest({
method: 'POST',
url: HLTB_SEARCH_URL,
data: buildHltbSearchPayload(q, auth),
headers: {
'Content-Type': 'application/json',
Accept: '*/*',
'User-Agent': HLTB_UA,
Origin: HLTB_SITE,
Referer: `${HLTB_SITE}/`,
'x-auth-token': auth.token,
'x-hp-key': auth.hpKey,
'x-hp-val': auth.hpVal,
},
timeout: 20000,
});
};
let root;
try {
root = await runSearch(false);
} catch (err) {
const msg = String(err?.message || err);
if (/HTTP 401|HTTP 403/.test(msg)) {
hltbTokenCache = null;
root = await runSearch(true);
} else {
throw err;
}
}
const list = Array.isArray(root?.data) ? root.data : [];
const picked = pickBestTitleMatch(list, q, (g) => g.game_name);
if (!picked) return null;
const raw = picked.item;
const gameId = raw.game_id;
const payload = {
id: gameId != null ? String(gameId) : null,
name: raw.game_name || q,
url: gameId
? `${HLTB_SITE}/game/${gameId}`
: `${HLTB_SITE}/?q=${encodeURIComponent(q)}`,
main: secondsToHours(raw.comp_main),
extra: secondsToHours(raw.comp_plus),
complete: secondsToHours(raw.comp_100),
matchScore: picked.score,
};
if (!payload.main && !payload.extra && !payload.complete) return null;
if (!debugOn) setCached(cacheKey, payload);
return payload;
})().catch(() => null);
inflight.set(cacheKey, task);
try {
return await task;
} finally {
inflight.delete(cacheKey);
}
}
async function fetchOpenCritic(title) {
const q = String(title || '').trim();
if (!q) return null;
const cacheKey = `opencritic:${normalizeTitle(q)}`;
const debugOn = Boolean(settings.debugMode);
if (!debugOn) {
const cached = getCached(cacheKey);
// Bust legacy HTML caches that kept tier but dropped the numeric score.
if (cached && (cached.score != null || cached.missing || cached.scoreParseV2)) return cached;
}
if (inflight.has(cacheKey)) return inflight.get(cacheKey);
const task = (async () => {
const fromApi = await fetchOpenCriticViaApi(q).catch(() => null);
if (fromApi) {
if (!debugOn) setCached(cacheKey, fromApi);
return fromApi;
}
const fromHtml = await fetchOpenCriticViaHtml(q).catch(() => null);
if (fromHtml) {
if (!debugOn) setCached(cacheKey, fromHtml);
return fromHtml;
}
return null;
})().catch(() => null);
inflight.set(cacheKey, task);
try {
return await task;
} finally {
inflight.delete(cacheKey);
}
}
function pickOpenCriticSearchMatch(list, title) {
if (!Array.isArray(list) || !list.length) return null;
const scored = list
.map((item) => {
const name = item?.name || item?.distilledName || '';
return {
item,
titleScore: scoreSteamTitleMatch(name, title),
dist: Number(item?.dist),
};
})
.filter((row) => row.item?.id != null);
if (!scored.length) return null;
scored.sort(
(a, b) =>
b.titleScore - a.titleScore ||
(Number.isFinite(a.dist) ? a.dist : 99) - (Number.isFinite(b.dist) ? b.dist : 99)
);
const best = scored[0];
if (best.titleScore >= TITLE_MATCH_MIN_SCORE) {
return { item: best.item, score: best.titleScore };
}
// OpenCritic search ranks by dist; accept a very close hit when title scoring is strict.
if (Number.isFinite(best.dist) && best.dist <= 0.12) {
return { item: best.item, score: Math.max(best.titleScore, 80) };
}
return null;
}
async function fetchOpenCriticViaApi(title) {
const searchUrl = `${OPENCRITIC_API_BASE}/game/search?criteria=${encodeURIComponent(title)}`;
const results = await gmRequest({
url: searchUrl,
headers: {
Accept: 'application/json',
Origin: OPENCRITIC_SITE,
Referer: `${OPENCRITIC_SITE}/`,
},
timeout: 15000,
});
const list = Array.isArray(results) ? results : [];
const picked = pickOpenCriticSearchMatch(list, title);
if (!picked?.item?.id) return null;
const id = Number(picked.item.id);
let detail = null;
try {
detail = await gmRequest({
url: `${OPENCRITIC_API_BASE}/game/${id}`,
headers: {
Accept: 'application/json',
Origin: OPENCRITIC_SITE,
Referer: `${OPENCRITIC_SITE}/`,
},
timeout: 15000,
});
} catch (_) {
detail = null;
}
return buildOpenCriticPayload(detail || picked.item, picked.item, title, picked.score);
}
function coerceOpenCriticScore(...candidates) {
for (const raw of candidates) {
if (raw == null || raw === '') continue;
const n = typeof raw === 'number' ? raw : Number(String(raw).trim().replace(',', '.'));
if (!Number.isFinite(n) || n <= 0 || n > 100) continue;
return Math.round(n);
}
return null;
}
function buildOpenCriticPayload(detail, searchItem, title, matchScore) {
if (!detail || typeof detail !== 'object') return null;
const tier =
(typeof detail.tier === 'string' && detail.tier) ||
(typeof searchItem?.tier === 'string' && searchItem.tier) ||
null;
if (!tier) return null;
const id = Number(detail.id || searchItem?.id);
const slug = detail.slug || searchItem?.slug;
return {
id,
name: detail.name || searchItem?.name || title,
tier,
score: coerceOpenCriticScore(
detail.topCriticScore,
detail.medianScore,
searchItem?.topCriticScore,
searchItem?.medianScore
),
url: slug
? `${OPENCRITIC_SITE}/game/${id}/${encodeURIComponent(slug)}`
: Number.isFinite(id)
? `${OPENCRITIC_SITE}/game/${id}`
: `${OPENCRITIC_SITE}/search?q=${encodeURIComponent(title)}`,
matchScore: matchScore || null,
source: 'api',
scoreParseV2: true,
};
}
function extractOpenCriticGameCandidates(html) {
const found = [];
const push = (id, slug) => {
const num = Number(id);
const s = String(slug || '')
.toLowerCase()
.replace(/[^a-z0-9-]+/g, '')
.replace(/^-+|-+$/g, '');
if (!Number.isFinite(num) || num <= 0 || !s) return;
if (found.some((f) => f.id === num)) return;
found.push({
id: num,
slug: s,
url: `${OPENCRITIC_SITE}/game/${num}/${s}`,
name: s.replace(/-/g, ' '),
});
};
const directRe = /opencritic\.com\/game\/(\d+)\/([a-z0-9-]+)/gi;
let m;
while ((m = directRe.exec(html))) {
push(m[1], m[2]);
}
const uddgRe = /[?&]uddg=([^&"']+)/gi;
while ((m = uddgRe.exec(html))) {
try {
const decoded = decodeURIComponent(m[1]);
const mm = decoded.match(/opencritic\.com\/game\/(\d+)\/([a-z0-9-]+)/i);
if (mm) push(mm[1], mm[2]);
} catch (_) {
/* ignore */
}
}
return found;
}
async function resolveOpenCriticUrlViaDdg(title) {
const q = `${title} site:opencritic.com/game`;
const html = await gmRequest({
url: `${DDG_HTML_SEARCH}?q=${encodeURIComponent(q)}`,
responseType: 'text',
headers: {
Accept: 'text/html',
},
timeout: 15000,
});
const candidates = extractOpenCriticGameCandidates(String(html || ''));
if (!candidates.length) return null;
const picked = pickOpenCriticSearchMatch(candidates, title);
if (picked?.item) return picked.item;
// First organic hit if the slug roughly contains the title tokens.
const target = normalizeTitle(title);
const loose = candidates.find((c) => {
const name = normalizeTitle(c.name);
return name === target || name.includes(target) || target.includes(name);
});
return loose || candidates[0] || null;
}
function parseOpenCriticScoreFromHtml(text) {
const fromJson = text.match(/"topCriticScore"\s*:\s*([0-9]+(?:\.[0-9]+)?)/);
if (fromJson) {
const n = coerceOpenCriticScore(fromJson[1]);
if (n != null) return n;
}
// Page order is usually: big number, then "Top Critic Average".
const beforeLabel = text.match(/(\d{2,3})(?:\s*<[^>]*>|\s| |<!--.*?-->){0,12}\s*Top Critic Average/i);
if (beforeLabel) {
const n = coerceOpenCriticScore(beforeLabel[1]);
if (n != null) return n;
}
const afterLabel = text.match(/Top Critic Average[\s\S]{0,160}?(\d{2,3})/i);
if (afterLabel) {
const n = coerceOpenCriticScore(afterLabel[1]);
if (n != null) return n;
}
return null;
}
function parseOpenCriticGameHtml(html, fallbackUrl) {
const text = String(html || '');
if (!text) return null;
let tier = null;
let name = null;
let id = null;
let slug = null;
const score = parseOpenCriticScoreFromHtml(text);
const tierJson = text.match(/"tier"\s*:\s*"(Mighty|Strong|Fair|Weak)"/i);
if (tierJson) tier = tierJson[1];
const nameJson = text.match(/"name"\s*:\s*"([^"\\]+)"/);
if (nameJson) name = nameJson[1];
const idJson = text.match(/"id"\s*:\s*(\d+)/);
if (idJson) id = Number(idJson[1]);
const slugJson = text.match(/"slug"\s*:\s*"([a-z0-9-]+)"/i);
if (slugJson) slug = slugJson[1];
if (!tier) {
const tierText = text.match(/\b(Mighty|Strong|Fair|Weak)\b/);
if (tierText) tier = tierText[1];
}
if (!name) {
const titleTag = text.match(/<title[^>]*>\s*([^|<]+?)\s*(?:Reviews)?\s*[-–|]/i);
if (titleTag) name = titleTag[1].trim();
}
const path = String(fallbackUrl || '').match(/opencritic\.com\/game\/(\d+)\/([a-z0-9-]+)/i);
if (path) {
if (!id) id = Number(path[1]);
if (!slug) slug = path[2];
}
if (!tier) return null;
return {
id,
name: name || slug || null,
tier,
score,
url:
id && slug
? `${OPENCRITIC_SITE}/game/${id}/${encodeURIComponent(slug)}`
: fallbackUrl || OPENCRITIC_SITE,
source: 'html',
scoreParseV2: true,
};
}
async function fetchOpenCriticViaHtml(title) {
const hit = await resolveOpenCriticUrlViaDdg(title);
if (!hit?.url) return null;
const html = await gmRequest({
url: hit.url,
responseType: 'text',
headers: {
Accept: 'text/html',
Referer: `${OPENCRITIC_SITE}/`,
},
timeout: 20000,
});
const parsed = parseOpenCriticGameHtml(html, hit.url);
if (!parsed) return null;
if (parsed.name) {
const score = scoreSteamTitleMatch(parsed.name, title);
// Reject obvious wrong pages from search noise.
if (score > 0 && score < 50) return null;
}
return parsed;
}
async function fetchProtonDb(appId) {
const id = Number(appId);
if (!Number.isFinite(id) || id <= 0) return null;
const cacheKey = `protondb:${id}`;
const debugOn = Boolean(settings.debugMode);
if (!debugOn) {
const cached = getCached(cacheKey);
if (cached) return cached;
}
if (inflight.has(cacheKey)) return inflight.get(cacheKey);
const task = (async () => {
const data = await gmRequest({
url: `${PROTONDB_SUMMARY_URL}/${id}.json`,
allow404: true,
headers: { Accept: 'application/json' },
timeout: 15000,
});
if (!data || typeof data !== 'object') return null;
const tier = typeof data.tier === 'string' ? data.tier : null;
if (!tier) return null;
const payload = {
tier,
bestReportedTier: data.bestReportedTier || null,
confidence: data.confidence || null,
score: data.score != null ? Number(data.score) : null,
total: data.total != null ? Number(data.total) : null,
url: `https://www.protondb.com/app/${id}`,
};
if (!debugOn) setCached(cacheKey, payload);
return payload;
})().catch(() => null);
inflight.set(cacheKey, task);
try {
return await task;
} finally {
inflight.delete(cacheKey);
}
}
function steamTagUrl(name) {
return `https://store.steampowered.com/tags/en/${encodeURIComponent(name)}/`;
}
function steamCategoryUrl(id) {
return `https://store.steampowered.com/search/?category2=${encodeURIComponent(id)}`;
}
function normalizeSteamCategories(details) {
if (!Array.isArray(details?.categories)) return [];
return details.categories
.map((c) => ({
id: Number(c.id),
description: String(c.description || '').trim(),
}))
.filter((c) => c.description && Number.isFinite(c.id) && c.id > 0);
}
function parseSteamIdList(list) {
return (Array.isArray(list) ? list : [])
.map(Number)
.filter((id) => Number.isFinite(id) && id > 0);
}
async function fetchSteamUserdata() {
const cached = getUserdataCached();
if (cached) {
return {
owned: new Set(cached.appIds),
wishlist: new Set(cached.wishlistAppIds),
};
}
if (inflight.has(USERDATA_CACHE_KEY)) return inflight.get(USERDATA_CACHE_KEY);
const task = (async () => {
try {
const data = await gmRequest({
url: `${STEAM_USERDATA_URL}?t=${Date.now()}`,
anonymous: false,
headers: { 'Cache-Control': 'no-cache' },
});
const appIds = parseSteamIdList(data?.rgOwnedApps);
const wishlistAppIds = parseSteamIdList(data?.rgWishlist);
setUserdataCached({ appIds, wishlistAppIds });
return {
owned: new Set(appIds),
wishlist: new Set(wishlistAppIds),
};
} catch (_) {
return { owned: new Set(), wishlist: new Set() };
}
})();
inflight.set(USERDATA_CACHE_KEY, task);
try {
return await task;
} finally {
inflight.delete(USERDATA_CACHE_KEY);
}
}
async function fetchSteamOwnedSet() {
const data = await fetchSteamUserdata();
return data.owned;
}
function isSteamGameResolveKey(key) {
return key.startsWith('steam:id:') || /^steam:[A-Z]{2}:/.test(key);
}
function cacheEntryTtlMs(key, entry) {
if (Number(entry?.ttlMs) > 0) return Number(entry.ttlMs);
if (key === TAG_MAP_CACHE_KEY) return TAG_MAP_TTL_MS;
if (key === USERDATA_CACHE_KEY) {
const data = entry?.data;
const empty =
!data ||
((!Array.isArray(data.appIds) || data.appIds.length === 0) &&
(!Array.isArray(data.wishlistAppIds) || data.wishlistAppIds.length === 0));
return userdataCacheTtlMs(empty);
}
return cacheTtlMs();
}
function isCacheEntryExpired(key, entry) {
if (!entry?.ts) return true;
const ttl = cacheEntryTtlMs(key, entry);
if (!ttl) return true;
return Date.now() - entry.ts > ttl;
}
function isCacheEntryPartial(key, entry) {
if (isCacheEntryExpired(key, entry)) return true;
if (!isSteamGameResolveKey(key)) return false;
const data = entry?.data;
if (!data?.found) return true;
// Lite list/card resolves are stored without tags.
return data.tagsLoaded !== true;
}
function cacheEntryByteSize(key, entry) {
try {
const raw = JSON.stringify(entry);
if (typeof TextEncoder !== 'undefined') {
const enc = new TextEncoder();
return enc.encode(String(key)).length + enc.encode(raw).length;
}
return String(key).length + String(raw).length;
} catch (_) {
return 0;
}
}
function formatCacheBytes(n) {
const bytes = Math.max(0, Number(n) || 0);
if (bytes < 1024) return `${Math.round(bytes)} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
}
function getCacheUsageStats() {
const store = readCacheStore();
let fullBytes = 0;
let partialBytes = 0;
let fullCount = 0;
let partialCount = 0;
for (const key of Object.keys(store)) {
const entry = store[key];
const bytes = cacheEntryByteSize(key, entry);
if (isCacheEntryPartial(key, entry)) {
partialBytes += bytes;
partialCount += 1;
} else {
fullBytes += bytes;
fullCount += 1;
}
}
const usedBytes = fullBytes + partialBytes;
const limitBytes = CACHE_SOFT_LIMIT_BYTES;
const freeBytes = Math.max(0, limitBytes - usedBytes);
return {
fullBytes,
partialBytes,
freeBytes,
usedBytes,
limitBytes,
fullCount,
partialCount,
totalCount: fullCount + partialCount,
};
}
function getCacheEntryCount() {
return getCacheUsageStats().totalCount;
}
function cacheMeterPct(part, denom) {
if (!denom) return 0;
return Math.max(0, Math.min(100, (part / denom) * 100));
}
function buildCacheMeterHtml(stats) {
const s = stats || getCacheUsageStats();
const denom = Math.max(s.usedBytes, s.limitBytes, 1);
const fullPct = cacheMeterPct(s.fullBytes, denom);
const partialPct = cacheMeterPct(s.partialBytes, denom);
const freePct = cacheMeterPct(s.freeBytes, denom);
const aria = fmt(t.cacheBarAria, {
full: formatCacheBytes(s.fullBytes),
partial: formatCacheBytes(s.partialBytes),
free: formatCacheBytes(s.freeBytes),
});
const legendFull = fmt(t.cacheBarLegend, {
label: t.cacheBarFull,
count: s.fullCount,
size: formatCacheBytes(s.fullBytes),
});
const legendPartial = fmt(t.cacheBarLegend, {
label: t.cacheBarPartial,
count: s.partialCount,
size: formatCacheBytes(s.partialBytes),
});
const freeLabel = `${t.cacheBarFree}: ${formatCacheBytes(s.freeBytes)}`;
return `
<div class="blp-cache-meter" data-blp-cache-meter>
<div class="blp-cache-meter__head">
<span class="blp-cache-meter__used">${escapeHtml(
fmt(t.cacheBarUsed, {
used: formatCacheBytes(s.usedBytes),
limit: formatCacheBytes(s.limitBytes),
})
)}</span>
</div>
<div class="blp-cache-meter__bar" role="img" aria-label="${escapeAttr(aria)}">
<span class="blp-cache-meter__seg blp-cache-meter__seg--full" style="width:${fullPct}%"></span>
<span class="blp-cache-meter__seg blp-cache-meter__seg--partial" style="width:${partialPct}%"></span>
<span class="blp-cache-meter__seg blp-cache-meter__seg--free" style="width:${freePct}%"></span>
</div>
<ul class="blp-cache-meter__legend">
<li><span class="blp-cache-meter__swatch blp-cache-meter__swatch--full"></span>${escapeHtml(legendFull)}</li>
<li><span class="blp-cache-meter__swatch blp-cache-meter__swatch--partial"></span>${escapeHtml(legendPartial)}</li>
<li><span class="blp-cache-meter__swatch blp-cache-meter__swatch--free"></span>${escapeHtml(freeLabel)}</li>
</ul>
<p class="blp-hint">${escapeHtml(t.cacheBarHint)}</p>
</div>
`;
}
function paintCacheMeter(root) {
const current = root?.querySelector?.('[data-blp-cache-meter]');
if (!current) return;
const wrap = document.createElement('div');
wrap.innerHTML = buildCacheMeterHtml(getCacheUsageStats()).trim();
const next = wrap.firstElementChild;
if (next) current.replaceWith(next);
}
function clearCache() {
const count = getCacheEntryCount();
cacheStore = {};
try {
GM_setValue(CACHE_KEY, {});
} catch (_) {
/* ignore */
}
return count;
}
/** Wipe lookup cache when the userscript version changes (stale payloads / schema). */
function migrateCacheForScriptVersion() {
let stored = null;
try {
stored = GM_getValue(CACHE_VERSION_KEY, null);
} catch (_) {
stored = null;
}
if (stored === SCRIPT_VERSION) return false;
clearCache();
try {
GM_setValue(CACHE_VERSION_KEY, SCRIPT_VERSION);
} catch (_) {
/* ignore */
}
return true;
}
function gmRequest(options) {
return new Promise((resolve, reject) => {
const req = {
method: options.method || 'GET',
url: options.url,
headers: options.headers || {},
responseType: options.responseType || 'json',
timeout: options.timeout || 20000,
onload(res) {
if (options.allow404 && res.status === 404) {
resolve(null);
return;
}
if (res.status >= 200 && res.status < 300) {
const type = options.responseType || 'json';
// Tampermonkey/Violentmonkey: text bodies are reliable on responseText.
if (type === 'text') {
resolve(
res.responseText != null && res.responseText !== ''
? res.responseText
: res.response
);
} else if (type === 'json') {
if (res.response != null && typeof res.response === 'object') {
resolve(res.response);
return;
}
const raw = res.responseText;
if (typeof raw === 'string' && raw.trim()) {
try {
resolve(JSON.parse(raw));
return;
} catch (_) {
/* fall through */
}
}
resolve(res.response);
} else {
resolve(res.response);
}
} else {
reject(new Error(`HTTP ${res.status}`));
}
},
onerror: () => reject(new Error('Network error')),
ontimeout: () => reject(new Error('Timeout')),
};
if (typeof options.anonymous === 'boolean') {
req.anonymous = options.anonymous;
}
if (options.data != null) {
req.data =
typeof options.data === 'string' ? options.data : JSON.stringify(options.data);
}
GM_xmlhttpRequest(req);
});
}
function injectStyles() {
GM_addStyle(`
:root {
--blp-accent: #3db89a;
--blp-text: #e8eaed;
--blp-muted: #9aa0a6;
--blp-border: rgba(255, 255, 255, 0.08);
--blp-mc-high: #6c3;
--blp-mc-mid: #fc3;
--blp-mc-low: #f67;
--blp-rev-overwhelming: #66c0f4;
--blp-rev-positive: #66c0f4;
--blp-rev-mixed: #b9a404;
--blp-rev-negative: #c35c2c;
--blp-owned: #beee11;
--blp-owned-bg: #3d4f1a;
--blp-owned-border: rgba(190, 238, 17, 0.35);
--blp-wishlist: #67c1f5;
--blp-wishlist-bg: #2a4a63;
--blp-wishlist-border: rgba(103, 193, 245, 0.4);
--blp-gs-ready: #beee11;
--blp-gs-ready-bg: #4c6b22;
--blp-gs-partial: #ffb321;
--blp-gs-partial-bg: #5a4630;
--blp-gs-pending: #ffb321;
--blp-gs-pending-bg: #5a4630;
--blp-gs-old: #fecaca;
--blp-gs-old-bg: #5a3030;
--blp-gs-today: #67c1f5;
--blp-gs-today-bg: #2a4a63;
--blp-gs-unknown: #b0aeac;
--blp-gs-unknown-bg: #3a4149;
--blp-skel: rgba(128, 128, 128, 0.22);
--blp-skel-shine: rgba(255, 255, 255, 0.18);
}
[${ENRICH_ATTR}] .blp-ext-link,
.blp-settings .blp-toggle__label {
display: inline-flex;
align-items: center;
gap: 0.35em;
vertical-align: middle;
}
[${ENRICH_ATTR}] .blp-favicon,
.blp-settings .blp-favicon {
width: 14px;
height: 14px;
border-radius: 2px;
flex: 0 0 auto;
object-fit: contain;
}
#blp-steam-backloggd-btn {
margin-right: 2px;
}
#blp-steam-backloggd-btn img.ico16 {
width: 16px;
height: 16px;
vertical-align: middle;
border-radius: 2px;
}
#blp-nav-settings {
white-space: nowrap;
vertical-align: middle;
}
#add-a-game + #blp-nav-settings {
min-height: var(--blp-nav-btn-h, unset);
}
#blp-nav-settings.nav-item,
li.nav-item > #blp-nav-settings {
margin-left: 0.5rem;
}
#game-profile h1 .blp-title-icon-wrap,
.game-title-section h1 .blp-title-icon-wrap,
h1 .blp-title-icon-wrap {
display: inline-block;
position: relative;
width: 32px;
height: 32px;
vertical-align: middle;
margin-right: 0.45em;
border-radius: 5px;
overflow: hidden;
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.14);
background: rgba(128, 128, 128, 0.18);
}
.blp-title-icon-wrap .blp-title-icon-skel {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
border-radius: 5px;
background: linear-gradient(
90deg,
var(--blp-skel) 0%,
var(--blp-skel-shine) 45%,
var(--blp-skel) 90%
);
background-size: 200% 100%;
animation: blp-shimmer 1.1s ease-in-out infinite;
transition: opacity 0.28s ease;
}
.blp-title-icon-wrap:not(.is-loading) .blp-title-icon-skel {
opacity: 0;
pointer-events: none;
}
#game-profile h1 .blp-title-icon,
.game-title-section h1 .blp-title-icon,
h1 .blp-title-icon {
position: relative;
display: block;
width: 32px;
height: 32px;
border-radius: 5px;
object-fit: cover;
opacity: 0;
transition: opacity 0.35s ease;
}
#game-profile h1 .blp-title-icon-wrap .blp-title-icon.is-ready,
.game-title-section h1 .blp-title-icon-wrap .blp-title-icon.is-ready,
h1 .blp-title-icon-wrap .blp-title-icon.is-ready,
.blp-title-icon-wrap .blp-title-icon.is-ready {
opacity: 1;
}
.col-cover .blp-steamdb-cover,
#logging-sidebar-section .blp-steamdb-cover,
.blp-steamdb-cover {
position: relative;
margin-top: 0.75rem;
width: 100%;
max-width: 100%;
line-height: 0;
border-radius: 6px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(0, 0, 0, 0.25);
}
.blp-steamdb-cover.is-loading {
aspect-ratio: 460 / 215;
min-height: 72px;
}
.blp-steamdb-cover .blp-steamdb-cover-skel {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
border-radius: 0;
background: linear-gradient(
90deg,
var(--blp-skel) 0%,
var(--blp-skel-shine) 45%,
var(--blp-skel) 90%
);
background-size: 200% 100%;
animation: blp-shimmer 1.1s ease-in-out infinite;
transition: opacity 0.32s ease;
z-index: 1;
}
.blp-steamdb-cover:not(.is-loading) .blp-steamdb-cover-skel {
opacity: 0;
pointer-events: none;
}
.blp-steamdb-cover img {
display: block;
width: 100%;
height: auto;
max-height: none;
object-fit: cover;
object-position: center top;
background: #1a1d24;
opacity: 0;
transition: opacity 0.4s ease;
}
.blp-steamdb-cover img.is-ready {
opacity: 1;
}
.blp-steamdb-cover img.blp-steamdb-cover__logo {
max-height: 110px;
object-fit: contain;
object-position: center;
padding: 0.65rem 0.5rem;
background: radial-gradient(circle at 50% 40%, rgba(255,255,255,0.06), transparent 65%);
}
.blp-steam-gallery {
margin-top: 1rem;
width: 100%;
}
.blp-steam-gallery__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.45rem;
}
.blp-steam-gallery__title {
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--blp-muted);
}
.blp-steam-gallery__link {
font-size: 0.78rem;
color: var(--back-interact-secondary, #839df9);
text-decoration: none;
}
.blp-steam-gallery__link:hover {
color: var(--back-text, #badefc);
text-decoration: underline;
}
.blp-steam-gallery__track {
display: flex;
gap: 0.5rem;
overflow-x: auto;
overflow-y: hidden;
padding-bottom: 0.35rem;
scroll-snap-type: x proximity;
-webkit-overflow-scrolling: touch;
}
.blp-steam-gallery__track::-webkit-scrollbar {
height: 6px;
}
.blp-steam-gallery__track::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.18);
border-radius: 999px;
}
.blp-steam-gallery__skel {
flex: 0 0 auto;
width: min(42vw, 220px);
aspect-ratio: 16 / 9;
border-radius: 6px;
background: linear-gradient(
90deg,
var(--blp-skel) 0%,
var(--blp-skel-shine) 45%,
var(--blp-skel) 90%
);
background-size: 200% 100%;
animation: blp-shimmer 1.1s ease-in-out infinite;
}
.blp-steamdb-cover.is-gallery {
cursor: zoom-in;
}
.blp-steamdb-cover.is-gallery:focus-visible {
outline: 2px solid var(--back-interact-secondary, #839df9);
outline-offset: 2px;
}
#interaction-sidebar .col-cover img.blp-cover-zoomable,
img.blp-cover-zoomable {
cursor: zoom-in;
}
#interaction-sidebar .col-cover img.blp-cover-zoomable:focus-visible,
img.blp-cover-zoomable:focus-visible {
outline: 2px solid var(--back-interact-secondary, #839df9);
outline-offset: 2px;
}
.blp-steam-gallery__item {
flex: 0 0 auto;
width: min(42vw, 220px);
aspect-ratio: 16 / 9;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
overflow: hidden;
background: #12151c;
cursor: zoom-in;
scroll-snap-align: start;
transition: border-color 0.15s ease, transform 0.15s ease;
}
.blp-steam-gallery__item--cover {
aspect-ratio: 460 / 215;
}
.blp-steam-gallery__item:hover,
.blp-steam-gallery__item:focus-visible {
border-color: var(--back-interact-secondary, #839df9);
transform: translateY(-1px);
outline: none;
}
.blp-steam-gallery__item img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
transition: opacity 0.35s ease;
}
.blp-steam-gallery__item img.is-ready {
opacity: 1;
}
.blp-viewer {
position: fixed;
inset: 0;
z-index: 1000001;
display: flex;
flex-direction: column;
background: color-mix(in srgb, var(--back-primary, #16181c) 88%, transparent);
backdrop-filter: blur(4px);
color: var(--back-text, #badefc);
font: 13px/1.35 system-ui, sans-serif;
user-select: none;
-webkit-user-select: none;
}
.blp-viewer__chrome {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.65rem 0.85rem;
background: linear-gradient(
180deg,
color-mix(in srgb, var(--back-primary, #16181c) 75%, transparent),
transparent
);
z-index: 2;
}
.blp-viewer__chrome-left,
.blp-viewer__chrome-right {
display: flex;
align-items: center;
gap: 0.4rem;
min-width: 0;
}
.blp-viewer__title {
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
font-size: 0.72rem;
color: var(--back-text-secondary, #8f9ca7);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 28vw;
}
.blp-viewer__counter {
font-variant-numeric: tabular-nums;
color: var(--back-text, #badefc);
padding: 0.2rem 0.55rem;
border-radius: 999px;
background: var(--back-field, #30394c);
border: 1px solid var(--back-field-border, #3b414e);
}
.blp-viewer__tool {
width: 36px;
height: 36px;
border: 1px solid var(--back-field-border, #3b414e);
border-radius: 8px;
background: var(--back-secondary, #242832);
color: var(--back-text, #badefc);
font-size: 1.05rem;
line-height: 1;
cursor: pointer;
}
.blp-viewer__tool:hover,
.blp-viewer__tool:focus-visible {
background: var(--back-field-highlight, #3b4760);
border-color: var(--back-field-border-hover, #3f4b64);
color: var(--back-btn-toggled-text, #fff);
outline: none;
}
.blp-viewer__tool:disabled {
opacity: 0.35;
cursor: default;
}
.blp-viewer__body {
flex: 1 1 auto;
position: relative;
min-height: 0;
display: grid;
grid-template-columns: auto 1fr auto;
align-items: stretch;
gap: 0.25rem;
padding: 0 0.35rem;
}
.blp-viewer__nav {
align-self: center;
width: 44px;
height: 44px;
border: 1px solid var(--back-field-border, #3b414e);
border-radius: 999px;
background: var(--back-secondary, #242832);
color: var(--back-text, #badefc);
font-size: 1.45rem;
line-height: 1;
cursor: pointer;
z-index: 2;
}
.blp-viewer__nav:hover,
.blp-viewer__nav:focus-visible {
background: var(--back-interact, #4a5e8d);
border-color: var(--back-interact-secondary, #839df9);
color: var(--back-btn-toggled-text, #fff);
outline: none;
}
.blp-viewer__nav[hidden] {
visibility: hidden;
pointer-events: none;
}
.blp-viewer__stage {
position: relative;
min-width: 0;
min-height: 0;
overflow: hidden;
cursor: grab;
touch-action: none;
}
.blp-viewer__stage.is-dragging {
cursor: grabbing;
}
.blp-viewer__frame {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
transform-origin: center center;
will-change: transform;
}
.blp-viewer__img {
max-width: min(92vw, 1280px);
max-height: calc(100% - 8px);
width: auto;
height: auto;
object-fit: contain;
border-radius: 6px;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.55);
background: var(--back-field-background, #272c37);
pointer-events: auto;
opacity: 1;
transition: opacity 0.2s ease;
}
.blp-viewer__stage.is-loading .blp-viewer__img {
opacity: 0.28;
}
.blp-viewer__spinner {
position: absolute;
top: 50%;
left: 50%;
width: 42px;
height: 42px;
margin: -21px 0 0 -21px;
border-radius: 50%;
border: 3px solid var(--back-field-border, #3b414e);
border-top-color: var(--back-field-selected, var(--back-pink-dk, #ea377a));
opacity: 0;
pointer-events: none;
z-index: 3;
transition: opacity 0.15s ease;
}
.blp-viewer__stage.is-loading .blp-viewer__spinner {
opacity: 1;
animation: blp-viewer-spin 0.7s linear infinite;
}
@keyframes blp-viewer-spin {
to { transform: rotate(360deg); }
}
.blp-viewer__film {
flex: 0 0 auto;
display: flex;
justify-content: safe center;
justify-content: center;
gap: 0.45rem;
overflow-x: auto;
overflow-y: hidden;
padding: 0.55rem 0.85rem 0.85rem;
scroll-snap-type: x proximity;
background: color-mix(in srgb, var(--back-secondary, #242832) 88%, transparent);
border-top: 1px solid var(--back-dropdown-divider, #3f4b64);
z-index: 2;
}
.blp-viewer__film::-webkit-scrollbar {
height: 6px;
}
.blp-viewer__film::-webkit-scrollbar-thumb {
background: var(--back-field-border-hover, #3f4b64);
border-radius: 999px;
}
.blp-viewer__thumb {
flex: 0 0 auto;
width: 88px;
aspect-ratio: 16 / 9;
padding: 0;
border: 2px solid transparent;
border-radius: 6px;
overflow: hidden;
background: var(--back-field-background, #272c37);
cursor: pointer;
opacity: 0.55;
scroll-snap-align: center;
transition: opacity 0.15s ease, border-color 0.15s ease, transform 0.15s ease;
}
.blp-viewer__thumb--cover {
aspect-ratio: 460 / 215;
}
.blp-viewer__thumb:hover,
.blp-viewer__thumb:focus-visible {
opacity: 0.9;
border-color: var(--back-field-border-hover, #3f4b64);
outline: none;
}
.blp-viewer__thumb.is-active {
opacity: 1;
border-color: var(--back-field-selected, var(--back-pink-dk, #ea377a));
transform: translateY(-2px);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--back-pink-dk, #ea377a) 45%, transparent);
}
.blp-viewer__thumb.is-near {
opacity: 0.78;
border-color: var(--back-field-border, #3b414e);
}
.blp-viewer__thumb img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
pointer-events: none;
}
[${ENRICH_ATTR}="players"] .blp-players-badge {
display: inline-flex;
align-items: center;
gap: 0.35em;
padding: 0.12em 0.55em 0.14em;
border-radius: 4px;
font-size: 0.82em;
font-weight: 700;
line-height: 1.45;
text-decoration: none !important;
color: #67c1f5 !important;
background: linear-gradient(180deg, #355a78 0%, #2a4a63 100%);
border: 1px solid rgba(103, 193, 245, 0.35);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1);
width: fit-content;
white-space: nowrap;
}
[${ENRICH_ATTR}="players"] .blp-players-badge__dot {
width: 0.45em;
height: 0.45em;
border-radius: 50%;
background: currentColor;
flex: 0 0 auto;
box-shadow: 0 0 6px currentColor;
}
[${ENRICH_ATTR}] .blp-mc-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.85em;
padding: 0.18em 0.42em;
border-radius: 3px;
font-size: 0.88em;
font-weight: 800;
font-variant-numeric: tabular-nums;
letter-spacing: 0.01em;
line-height: 1.2;
text-decoration: none !important;
vertical-align: middle;
white-space: nowrap;
width: fit-content;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.22);
}
[${ENRICH_ATTR}] .blp-mc-badge--high {
background: var(--blp-mc-high);
color: #14200a !important;
border: 1px solid rgba(102, 204, 51, 0.55);
}
[${ENRICH_ATTR}] .blp-mc-badge--mid {
background: var(--blp-mc-mid);
color: #2a2200 !important;
border: 1px solid rgba(255, 204, 51, 0.55);
}
[${ENRICH_ATTR}] .blp-mc-badge--low {
background: var(--blp-mc-low);
color: #2a0a12 !important;
border: 1px solid rgba(255, 102, 119, 0.5);
}
[${ENRICH_ATTR}] .blp-oc-badge,
[${ENRICH_ATTR}] .blp-deck-badge,
[${ENRICH_ATTR}] .blp-proton-badge,
[${ENRICH_ATTR}] .blp-hltb-chip {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.18em 0.5em;
border-radius: 3px;
font-size: 0.82em;
font-weight: 700;
line-height: 1.2;
text-decoration: none !important;
white-space: nowrap;
vertical-align: middle;
}
[${ENRICH_ATTR}] .blp-oc-badge--mighty {
background: #fc4;
color: #1a1400 !important;
border: 1px solid rgba(255, 204, 68, 0.55);
}
[${ENRICH_ATTR}] .blp-oc-badge--strong {
background: #6c3;
color: #14200a !important;
border: 1px solid rgba(102, 204, 51, 0.55);
}
[${ENRICH_ATTR}] .blp-oc-badge--fair {
background: #fc3;
color: #2a2200 !important;
border: 1px solid rgba(255, 204, 51, 0.55);
}
[${ENRICH_ATTR}] .blp-oc-badge--weak {
background: #f67;
color: #2a0a12 !important;
border: 1px solid rgba(255, 102, 119, 0.5);
}
[${ENRICH_ATTR}] .blp-oc-badge:not([class*='blp-oc-badge--']) {
background: #3a4149;
color: #e8eaed !important;
border: 1px solid rgba(255, 255, 255, 0.12);
}
[${ENRICH_ATTR}] .blp-hltb-chips,
[${ENRICH_ATTR}] .blp-deck-proton-chips,
[${ENRICH_ATTR}] .blp-oc-chips {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
justify-content: flex-end;
align-items: center;
}
@media (min-width: 768px) {
[${ENRICH_ATTR}] .blp-hltb-chips,
[${ENRICH_ATTR}] .blp-deck-proton-chips,
[${ENRICH_ATTR}] .blp-oc-chips {
justify-content: flex-start;
}
}
[${ENRICH_ATTR}] .blp-hltb-chip {
background: #1b2838;
color: #c6d4df !important;
border: 1px solid rgba(198, 212, 223, 0.28);
}
[${ENRICH_ATTR}] .blp-deck-badge--verified {
background: #1a9fff;
color: #061018 !important;
border: 1px solid rgba(26, 159, 255, 0.55);
}
[${ENRICH_ATTR}] .blp-deck-badge--playable {
background: #ffc82c;
color: #1a1400 !important;
border: 1px solid rgba(255, 200, 44, 0.55);
}
[${ENRICH_ATTR}] .blp-deck-badge--unsupported,
[${ENRICH_ATTR}] .blp-deck-badge--unknown {
background: #3a4149;
color: #e8eaed !important;
border: 1px solid rgba(255, 255, 255, 0.12);
}
[${ENRICH_ATTR}] .blp-proton-badge--platinum {
background: #e5e4e2;
color: #1a1a1a !important;
border: 1px solid rgba(229, 228, 226, 0.6);
}
[${ENRICH_ATTR}] .blp-proton-badge--gold {
background: #d4af37;
color: #1a1400 !important;
border: 1px solid rgba(212, 175, 55, 0.55);
}
[${ENRICH_ATTR}] .blp-proton-badge--silver {
background: #c0c0c0;
color: #1a1a1a !important;
border: 1px solid rgba(192, 192, 192, 0.55);
}
[${ENRICH_ATTR}] .blp-proton-badge--bronze {
background: #cd7f32;
color: #1a0e04 !important;
border: 1px solid rgba(205, 127, 50, 0.55);
}
[${ENRICH_ATTR}] .blp-proton-badge--borked {
background: #c35c2c;
color: #fff !important;
border: 1px solid rgba(195, 92, 44, 0.55);
}
[${ENRICH_ATTR}] .blp-discount-ends {
opacity: 0.7;
margin-left: 0.25em;
font-size: 0.9em;
font-weight: 500;
}
[${ENRICH_ATTR}] .blp-review--overwhelming,
[${ENRICH_ATTR}] .blp-review--very-positive { color: var(--blp-rev-overwhelming) !important; font-weight: 600; }
[${ENRICH_ATTR}] .blp-review--positive,
[${ENRICH_ATTR}] .blp-review--mostly-positive { color: var(--blp-rev-positive) !important; font-weight: 600; }
[${ENRICH_ATTR}] .blp-review--mixed { color: var(--blp-rev-mixed) !important; font-weight: 600; }
[${ENRICH_ATTR}] .blp-review--mostly-negative,
[${ENRICH_ATTR}] .blp-review--negative,
[${ENRICH_ATTR}] .blp-review--very-negative,
[${ENRICH_ATTR}] .blp-review--overwhelmingly-negative { color: var(--blp-rev-negative) !important; font-weight: 600; }
[${ENRICH_ATTR}] .blp-discount {
opacity: 0.75;
margin-left: 0.15em;
}
[${ENRICH_ATTR}] .blp-steam-note {
opacity: 0.65;
font-size: 0.85em;
}
[${ENRICH_ATTR}] .blp-steam-tags,
[${ENRICH_ATTR}] .blp-steam-categories {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
justify-content: flex-end;
}
@media (min-width: 768px) {
[${ENRICH_ATTR}] .blp-steam-tags,
[${ENRICH_ATTR}] .blp-steam-categories {
justify-content: flex-start;
}
}
[${ENRICH_ATTR}] .blp-steam-tag {
background: #1b2838;
color: #66c0f4 !important;
border: 1px solid rgba(102, 192, 244, 0.28);
text-decoration: none !important;
}
[${ENRICH_ATTR}] .blp-steam-tag:hover {
background: #2a475e;
color: #fff !important;
}
[${ENRICH_ATTR}] .blp-steam-category {
background: rgba(255, 255, 255, 0.06);
color: #c7d5e0 !important;
border: 1px solid rgba(255, 255, 255, 0.14);
text-decoration: none !important;
}
[${ENRICH_ATTR}] .blp-steam-category:hover {
background: rgba(255, 255, 255, 0.12);
color: #fff !important;
}
[${ENRICH_ATTR}="steam"] {
align-items: flex-start;
}
[${ENRICH_ATTR}="steam"] > [class*="col"] {
margin-top: 0.35rem !important;
margin-bottom: 0.35rem !important;
}
[${ENRICH_ATTR}="steam"] [data-blp-values] {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.4rem;
}
@media (min-width: 768px) {
[${ENRICH_ATTR}="steam"] [data-blp-values] {
align-items: flex-start;
}
}
[${ENRICH_ATTR}="steam"] .blp-steam-line {
display: block;
line-height: 1.35;
}
[${ENRICH_ATTR}] .blp-owned-badge {
display: inline-flex;
align-items: center;
gap: 0.28em;
padding: 0.1em 0.5em 0.12em;
border-radius: 4px;
border: 1px solid var(--blp-owned-border);
background: linear-gradient(180deg, #4c6b22 0%, var(--blp-owned-bg) 100%);
color: var(--blp-owned) !important;
font-size: 0.78em;
font-weight: 700;
letter-spacing: 0.03em;
line-height: 1.45;
vertical-align: middle;
white-space: nowrap;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12);
width: fit-content;
}
[${ENRICH_ATTR}] .blp-owned-badge__icon {
width: 0.85em;
height: 0.85em;
flex: 0 0 auto;
}
[${ENRICH_ATTR}] .blp-wishlist-badge {
display: inline-flex;
align-items: center;
gap: 0.28em;
padding: 0.1em 0.5em 0.12em;
border-radius: 4px;
border: 1px solid var(--blp-wishlist-border);
background: linear-gradient(180deg, #3a5f7d 0%, var(--blp-wishlist-bg) 100%);
color: var(--blp-wishlist) !important;
font-size: 0.78em;
font-weight: 700;
letter-spacing: 0.03em;
line-height: 1.45;
vertical-align: middle;
white-space: nowrap;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12);
width: fit-content;
}
[${ENRICH_ATTR}] .blp-steam-match {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem 0.55rem;
margin-top: 0.15rem;
font-size: 0.72rem;
color: var(--blp-muted);
}
[${ENRICH_ATTR}] .blp-steam-match__btn,
.blp-fix-match-backdrop .blp-btn {
appearance: none;
border: 1px solid var(--blp-border);
background: rgba(255, 255, 255, 0.06);
color: var(--blp-text);
border-radius: 4px;
padding: 0.15rem 0.45rem;
font-size: 0.72rem;
line-height: 1.3;
cursor: pointer;
}
[${ENRICH_ATTR}] .blp-steam-match__btn:hover,
.blp-fix-match-backdrop .blp-btn:hover {
border-color: rgba(61, 184, 154, 0.45);
color: #fff;
}
.blp-fix-match-backdrop {
position: fixed;
inset: 0;
z-index: 100000;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: rgba(0, 0, 0, 0.55);
}
.blp-fix-match-dialog {
width: min(420px, 100%);
background: #1a1d24;
border: 1px solid var(--blp-border);
border-radius: 8px;
padding: 1rem 1.1rem;
color: var(--blp-text);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
}
.blp-fix-match-dialog h3 {
margin: 0 0 0.35rem;
font-size: 1rem;
}
.blp-fix-match-dialog .blp-hint {
margin: 0 0 0.75rem;
color: var(--blp-muted);
font-size: 0.78rem;
line-height: 1.4;
}
.blp-fix-match-dialog input {
width: 100%;
margin-bottom: 0.65rem;
padding: 0.4rem 0.5rem;
border-radius: 4px;
border: 1px solid var(--blp-border);
background: #12151b;
color: var(--blp-text);
}
.blp-fix-match-dialog .blp-actions {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
justify-content: flex-end;
}
.blp-fix-match-dialog .blp-error {
color: #f67;
font-size: 0.75rem;
margin: 0 0 0.5rem;
}
.game-cover {
position: relative;
}
.game-cover .blp-card-badges {
position: absolute;
top: 0.28rem;
left: 0.28rem;
right: 0.28rem;
z-index: 6;
display: flex;
flex-wrap: wrap;
gap: 0.2rem;
pointer-events: none;
opacity: 1;
transition: opacity 0.15s ease;
}
.game-cover:hover .blp-card-badges {
opacity: 0.25;
pointer-events: none;
}
.game-cover .blp-card-badges a,
.game-cover .blp-card-badges button {
pointer-events: auto;
}
.game-cover:hover .blp-card-badges a,
.game-cover:hover .blp-card-badges button {
pointer-events: none;
}
.blp-card-badge {
display: inline-flex;
align-items: center;
gap: 0.2em;
max-width: 100%;
padding: 0.08em 0.38em;
border-radius: 3px;
font-size: 0.62rem;
font-weight: 700;
line-height: 1.35;
letter-spacing: 0.02em;
white-space: nowrap;
text-decoration: none !important;
color: #fff !important;
background: rgba(20, 24, 30, 0.82);
border: 1px solid rgba(255, 255, 255, 0.12);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.35);
}
.blp-card-badge--price {
background: rgba(26, 40, 56, 0.9);
}
.blp-card-badge--discount {
color: #beee11 !important;
border-color: rgba(190, 238, 17, 0.35);
}
.blp-card-badge--review {
font-variant-numeric: tabular-nums;
}
.blp-card-badge--review.blp-review--overwhelming,
.blp-card-badge--review.blp-review--very-positive,
.blp-card-badge--review.blp-review--positive,
.blp-card-badge--review.blp-review--mostly-positive {
color: var(--blp-rev-overwhelming) !important;
border-color: rgba(102, 192, 244, 0.4);
background: rgba(42, 74, 99, 0.92);
}
.blp-card-badge--review.blp-review--mixed {
color: var(--blp-rev-mixed) !important;
border-color: rgba(185, 164, 4, 0.4);
background: rgba(74, 64, 24, 0.92);
}
.blp-card-badge--review.blp-review--mostly-negative,
.blp-card-badge--review.blp-review--negative,
.blp-card-badge--review.blp-review--very-negative,
.blp-card-badge--review.blp-review--overwhelmingly-negative {
color: #fecaca !important;
border-color: rgba(195, 92, 44, 0.45);
background: rgba(90, 48, 40, 0.92);
}
.blp-card-badge--mc {
font-variant-numeric: tabular-nums;
font-weight: 800;
}
.blp-card-badge--mc-high {
color: #1a1d24 !important;
background: var(--blp-mc-high);
border-color: transparent;
}
.blp-card-badge--mc-mid {
color: #1a1d24 !important;
background: var(--blp-mc-mid);
border-color: transparent;
}
.blp-card-badge--mc-low {
color: #fff !important;
background: var(--blp-mc-low);
border-color: transparent;
}
.blp-card-badge--owned {
color: var(--blp-owned) !important;
background: rgba(61, 79, 26, 0.92);
border-color: var(--blp-owned-border);
}
.blp-card-badge--wishlist {
color: var(--blp-wishlist) !important;
background: rgba(42, 74, 99, 0.92);
border-color: var(--blp-wishlist-border);
}
.blp-card-badge--gs {
text-transform: none;
}
.blp-card-badge--gs.blp-gs-badge--ready {
color: var(--blp-gs-ready) !important;
background: rgba(76, 107, 34, 0.92);
}
.blp-card-badge--gs.blp-gs-badge--partial,
.blp-card-badge--gs.blp-gs-badge--pending-recent {
color: var(--blp-gs-pending) !important;
background: rgba(90, 70, 48, 0.92);
}
.blp-card-badge--gs.blp-gs-badge--pending-old {
color: var(--blp-gs-old) !important;
background: rgba(90, 48, 48, 0.92);
}
.blp-card-badge--gs.blp-gs-badge--release-today {
color: var(--blp-gs-today) !important;
background: rgba(42, 74, 99, 0.92);
}
.blp-card-badges.is-loading .blp-card-badge {
min-width: 2.4rem;
min-height: 0.95rem;
background: linear-gradient(
90deg,
var(--blp-skel),
var(--blp-skel-shine),
var(--blp-skel)
);
background-size: 200% 100%;
animation: blp-shimmer 1.1s ease-in-out infinite;
border: 0;
}
[${ENRICH_ATTR}="gamestatus"] {
align-items: flex-start;
}
[${ENRICH_ATTR}="gamestatus"] > [class*="col"] {
margin-top: 0.35rem !important;
margin-bottom: 0.35rem !important;
}
[${ENRICH_ATTR}="gamestatus"] [data-blp-values] {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.4rem;
}
@media (min-width: 768px) {
[${ENRICH_ATTR}="gamestatus"] [data-blp-values] {
align-items: flex-start;
}
}
[${ENRICH_ATTR}] .blp-gs-badge {
display: inline-flex;
align-items: center;
gap: 0.35em;
padding: 0.12em 0.55em 0.14em;
border-radius: 4px;
font-size: 0.82em;
font-weight: 700;
line-height: 1.45;
text-decoration: none !important;
width: fit-content;
white-space: nowrap;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
[${ENRICH_ATTR}] .blp-gs-badge__dot {
width: 0.45em;
height: 0.45em;
border-radius: 50%;
background: currentColor;
flex: 0 0 auto;
}
[${ENRICH_ATTR}] .blp-gs-badge--ready {
color: var(--blp-gs-ready) !important;
background: linear-gradient(180deg, #5a7d28 0%, var(--blp-gs-ready-bg) 100%);
border: 1px solid rgba(190, 238, 17, 0.35);
}
[${ENRICH_ATTR}] .blp-gs-badge--partial,
[${ENRICH_ATTR}] .blp-gs-badge--pending-recent {
color: var(--blp-gs-partial) !important;
background: linear-gradient(180deg, #6a5538 0%, var(--blp-gs-partial-bg) 100%);
border: 1px solid rgba(255, 179, 33, 0.35);
}
[${ENRICH_ATTR}] .blp-gs-badge--pending-old {
color: var(--blp-gs-old) !important;
background: linear-gradient(180deg, #6a3838 0%, var(--blp-gs-old-bg) 100%);
border: 1px solid rgba(254, 202, 202, 0.3);
}
[${ENRICH_ATTR}] .blp-gs-badge--release-today {
color: var(--blp-gs-today) !important;
background: linear-gradient(180deg, #355a78 0%, var(--blp-gs-today-bg) 100%);
border: 1px solid rgba(103, 193, 245, 0.35);
}
[${ENRICH_ATTR}] .blp-gs-badge--unknown,
[${ENRICH_ATTR}] .blp-gs-badge--missing {
color: var(--blp-gs-unknown) !important;
background: linear-gradient(180deg, #4a5158 0%, var(--blp-gs-unknown-bg) 100%);
border: 1px solid rgba(176, 174, 172, 0.25);
}
[${ENRICH_ATTR}] .blp-gs-chips {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
justify-content: flex-end;
}
@media (min-width: 768px) {
[${ENRICH_ATTR}] .blp-gs-chips {
justify-content: flex-start;
}
}
[${ENRICH_ATTR}] .blp-gs-chip {
display: inline-flex;
align-items: center;
padding: 0.08em 0.45em;
border-radius: 3px;
font-size: 0.72em;
font-weight: 600;
line-height: 1.4;
white-space: nowrap;
}
[${ENRICH_ATTR}] .blp-gs-chip--protection {
background: #2a3644;
color: #acb2b8;
}
[${ENRICH_ATTR}] .blp-gs-chip--highlight {
background: #6b1f1f;
color: #ff6b6b;
border: 1px solid rgba(255, 107, 107, 0.35);
}
[${ENRICH_ATTR}] .blp-gs-chip--ready {
background: var(--blp-gs-ready-bg);
color: var(--blp-gs-ready);
}
[${ENRICH_ATTR}] .blp-gs-chip--partial,
[${ENRICH_ATTR}] .blp-gs-chip--pending-recent {
background: var(--blp-gs-partial-bg);
color: var(--blp-gs-partial);
}
[${ENRICH_ATTR}] .blp-gs-chip--pending-old {
background: var(--blp-gs-old-bg);
color: var(--blp-gs-old);
}
[${ENRICH_ATTR}] .blp-gs-chip--release-today {
background: var(--blp-gs-today-bg);
color: var(--blp-gs-today);
}
[${ENRICH_ATTR}] .blp-gs-chip--unknown {
background: var(--blp-gs-unknown-bg);
color: var(--blp-gs-unknown);
}
[${ENRICH_ATTR}] .blp-gs-chip--aaa {
background: linear-gradient(to bottom, #66c0f4 5%, #417a9b 95%);
color: #fff;
font-weight: 700;
}
[${ENRICH_ATTR}] .blp-empty {
opacity: 0.55;
}
[${ENRICH_ATTR}] .blp-skeleton {
display: inline-block;
height: 0.95em;
border-radius: 4px;
vertical-align: middle;
background: linear-gradient(
90deg,
var(--blp-skel) 0%,
var(--blp-skel-shine) 45%,
var(--blp-skel) 90%
);
background-size: 200% 100%;
animation: blp-shimmer 1.1s ease-in-out infinite;
}
[${ENRICH_ATTR}] .blp-skeleton--sm { width: 4.5rem; }
[${ENRICH_ATTR}] .blp-skeleton--md { width: 8rem; }
[${ENRICH_ATTR}] .blp-skeleton--lg { width: 12rem; }
[${ENRICH_ATTR}] .blp-skeleton--link {
width: 4.25rem;
height: 1.1em;
margin-right: 0.35rem;
}
@keyframes blp-shimmer {
0% { background-position: 100% 0; }
100% { background-position: -100% 0; }
}
.blp-settings-backdrop {
position: fixed;
inset: 0;
z-index: 1000000;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.blp-settings {
width: min(440px, 100%);
max-height: min(90vh, 720px);
display: flex;
flex-direction: column;
overflow: hidden;
background: #151921;
color: var(--blp-text);
border: 1px solid var(--blp-border);
border-radius: 12px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.45);
font: 14px/1.45 system-ui, sans-serif;
}
.blp-settings__head {
flex: 0 0 auto;
padding: 18px 18px 12px;
border-bottom: 1px solid var(--blp-border);
background: #151921;
}
.blp-settings__body {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
padding: 14px 18px;
}
.blp-settings__foot {
flex: 0 0 auto;
padding: 12px 18px 14px;
border-top: 1px solid var(--blp-border);
background: #151921;
}
.blp-settings h2 {
margin: 0 0 4px;
font-size: 18px;
}
.blp-settings__sub {
margin: 0;
color: var(--blp-muted);
font-size: 13px;
}
.blp-settings section {
margin-bottom: 14px;
padding-bottom: 12px;
border-bottom: 1px solid var(--blp-border);
}
.blp-settings__body > section:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.blp-settings h3 {
margin: 0 0 10px;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--blp-muted);
}
.blp-field {
margin-bottom: 10px;
}
.blp-field label {
display: block;
margin-bottom: 4px;
font-size: 13px;
}
.blp-field select,
.blp-field input[type="number"] {
width: 100%;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid var(--blp-border);
background: #0f1319;
color: var(--blp-text);
}
.blp-hint {
margin: 4px 0 0;
font-size: 12px;
color: var(--blp-muted);
}
.blp-btn {
display: inline-flex;
align-items: center;
justify-content: center;
margin-top: 8px;
padding: 8px 12px;
border-radius: 8px;
border: 1px solid var(--blp-border);
background: #0f1319;
color: var(--blp-text);
cursor: pointer;
font: inherit;
font-size: 13px;
font-weight: 500;
line-height: 1.2;
}
.blp-btn:hover {
border-color: rgba(61, 184, 154, 0.45);
color: var(--blp-accent);
}
.blp-toggle {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 0;
}
.blp-toggle button {
min-width: 52px;
padding: 4px 8px;
border-radius: 6px;
border: 1px solid var(--blp-border);
background: #0f1319;
color: var(--blp-text);
cursor: pointer;
font-size: 12px;
font-weight: 600;
}
.blp-toggle button.is-on {
border-color: rgba(61, 184, 154, 0.6);
color: var(--blp-accent);
}
.blp-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
margin-top: 8px;
}
.blp-settings__foot .blp-actions {
margin-top: 0;
}
.blp-actions button {
padding: 8px 12px;
border-radius: 8px;
border: 1px solid var(--blp-border);
background: #0f1319;
color: var(--blp-text);
cursor: pointer;
font: inherit;
font-size: 13px;
}
.blp-actions button.blp-primary {
background: var(--blp-accent);
border-color: transparent;
color: #0b1210;
font-weight: 600;
}
.blp-settings__ver {
margin-left: 0.4em;
font-size: 0.72em;
font-weight: 500;
color: var(--blp-muted);
vertical-align: middle;
}
.blp-settings__footer {
margin-top: 10px;
font-size: 12px;
color: var(--blp-muted);
}
.blp-settings__footer a {
color: var(--blp-accent);
}
.blp-cache-msg {
margin-top: 6px;
font-size: 12px;
color: var(--blp-accent);
}
.blp-cache-meter {
margin: 0 0 12px;
}
.blp-cache-meter__head {
display: flex;
justify-content: flex-end;
margin-bottom: 6px;
}
.blp-cache-meter__used {
font-size: 12px;
font-variant-numeric: tabular-nums;
color: var(--blp-muted);
}
.blp-cache-meter__bar {
display: flex;
height: 10px;
border-radius: 6px;
overflow: hidden;
background: rgba(255, 255, 255, 0.06);
border: 1px solid var(--blp-border);
}
.blp-cache-meter__seg {
display: block;
height: 100%;
min-width: 0;
transition: width 0.2s ease;
}
.blp-cache-meter__seg--full {
background: var(--blp-accent);
}
.blp-cache-meter__seg--partial {
background: var(--blp-gs-partial);
}
.blp-cache-meter__seg--free {
background: transparent;
}
.blp-cache-meter__legend {
list-style: none;
margin: 8px 0 0;
padding: 0;
display: grid;
gap: 4px;
font-size: 12px;
color: var(--blp-muted);
font-variant-numeric: tabular-nums;
}
.blp-cache-meter__legend li {
display: flex;
align-items: center;
gap: 6px;
}
.blp-cache-meter__swatch {
width: 8px;
height: 8px;
border-radius: 2px;
flex: 0 0 auto;
}
.blp-cache-meter__swatch--full {
background: var(--blp-accent);
}
.blp-cache-meter__swatch--partial {
background: var(--blp-gs-partial);
}
.blp-cache-meter__swatch--free {
background: rgba(255, 255, 255, 0.12);
border: 1px solid var(--blp-border);
}
.blp-debug-panel {
margin: 1rem 0 1.25rem;
padding: 0.75rem 0.85rem;
border-radius: 8px;
border: 1px dashed rgba(255, 179, 33, 0.45);
background: rgba(0, 0, 0, 0.32);
text-align: left;
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
.blp-debug-panel__title {
margin: 0 0 0.55rem;
font-size: 0.85rem;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
color: #ffb321;
}
.blp-debug-panel__section {
margin-bottom: 0.65rem;
}
.blp-debug-panel__label {
display: block;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #ffb321;
margin-bottom: 0.25rem;
}
.blp-debug-panel__reason {
display: block;
font-size: 0.82rem;
color: var(--blp-text);
margin-bottom: 0.35rem;
word-break: break-word;
}
.blp-debug-panel__sources {
list-style: none;
margin: 0 0 0.45rem;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.blp-debug-panel__source-group {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.blp-debug-panel__source-group-title {
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #c9a227;
margin: 0;
}
.blp-debug-panel__source-group-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.blp-debug-panel__source-group-list li {
display: flex;
flex-direction: column;
gap: 0.12rem;
}
.blp-debug-panel__source-group-list li.is-request {
padding-left: 0.4rem;
border-left: 2px solid #ffb321;
}
.blp-debug-panel__source-head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.25rem 0.45rem;
font-size: 0.78rem;
line-height: 1.35;
color: var(--blp-text);
}
.blp-debug-panel__source-head strong {
color: #ffb321;
font-weight: 700;
}
.blp-debug-panel__source-badge {
display: inline-block;
flex-shrink: 0;
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
color: #1a1d24;
background: #ffb321;
padding: 0.08rem 0.32rem;
border-radius: 3px;
line-height: 1.2;
}
.blp-debug-panel__source-purpose {
color: var(--blp-muted);
font-size: 0.74rem;
}
.blp-debug-panel__sources a {
color: #67c1f5 !important;
text-decoration: underline !important;
word-break: break-all;
font-size: 0.74rem;
line-height: 1.35;
}
.blp-debug-panel__sources a:hover {
color: #9ad4f8 !important;
}
.blp-debug-panel__meta {
font-size: 0.75rem;
color: var(--blp-muted);
margin-bottom: 0.35rem;
}
.blp-debug-panel__pre {
margin: 0;
padding: 0.5rem 0.55rem;
border-radius: 5px;
background: rgba(0, 0, 0, 0.4);
color: #b0aeac;
font-size: 0.68rem;
line-height: 1.35;
overflow: auto;
max-height: 28rem;
white-space: pre-wrap;
word-break: break-word;
}
`);
}
function getPageContext() {
const path = location.pathname || '/';
const gameMatch = path.match(/^\/games\/([^/]+)\/?/i);
return {
path,
isGamePage: Boolean(gameMatch) && !!document.getElementById('game-body'),
slug: gameMatch ? gameMatch[1].toLowerCase() : '',
isProfile: /^\/u\/[^/]+\/?/.test(path),
isSearch: path.startsWith('/search'),
};
}
function getGameTitle() {
const h1 = document.querySelector('#game-profile h1, #center-content h1, main h1');
return (h1?.textContent || '').trim();
}
function getIgdbUrl(slug) {
const link = document.querySelector('a[href*="igdb.com/games/"]');
if (link?.href) return link.href.split('?')[0];
if (slug) return `https://www.igdb.com/games/${encodeURIComponent(slug)}`;
return '';
}
/**
* Valid roman numeral token (1–3999). Used so "Baldur's Gate III" ≈ "Baldur's Gate 3".
* Bare "i" is only converted when it is not the first token (avoids "I Am Alive" → "1 am alive").
*/
const ROMAN_TOKEN_RE =
/^(?=.*[ivxlcdm])(?=[mdclxvi]+$)m{0,4}(cm|cd|d?c{0,3})(xc|xl|l?x{0,3})(ix|iv|v?i{0,3})$/;
function romanTokenToArabic(token, { allowLoneI = false } = {}) {
const t = String(token || '').toLowerCase();
if (!ROMAN_TOKEN_RE.test(t)) return null;
if (t === 'i' && !allowLoneI) return null;
const map = { i: 1, v: 5, x: 10, l: 50, c: 100, d: 500, m: 1000 };
let total = 0;
for (let i = 0; i < t.length; i += 1) {
const value = map[t[i]];
const next = map[t[i + 1]];
total += next > value ? -value : value;
}
return total > 0 ? total : null;
}
function normalizeTitle(name) {
const base = String(name || '')
.toLowerCase()
.replace(/[™®©]/g, '')
.replace(/[^a-z0-9]+/g, ' ')
.trim();
if (!base) return '';
const parts = base.split(/\s+/).filter(Boolean);
return parts
.map((tok, idx) => {
const arabic = romanTokenToArabic(tok, { allowLoneI: idx > 0 });
return arabic != null ? String(arabic) : tok;
})
.join(' ');
}
function titleTokens(name) {
return normalizeTitle(name).split(/\s+/).filter(Boolean);
}
function isLikelyExtra(name) {
return /\b(dlc|soundtrack|ost|bundle|pack|edition upgrade|cosmetic)\b/i.test(name);
}
function isBenignTitlePrefixToken(token) {
return /^(the|a|an|official|new)$/i.test(token);
}
/** Tokens/phrases that are editions or platforms — not a different game. */
const EDITION_SUFFIX_RE =
/^(game of the year|goty|definitive( edition)?|complete( edition)?|remastered|remake|deluxe( edition)?|ultimate( edition)?|gold( edition)?|standard( edition)?|premium( edition)?|collector'?s?( edition)?|anniversary( edition)?|enhanced( edition)?|director'?s cut|redux|legacy( edition)?|hd|vr|launcher|for windows( 10)?|pc( edition)?|windows|edition|collection)$/i;
function isEditionOnlySuffix(text) {
const raw = normalizeTitle(text);
if (!raw) return true;
if (EDITION_SUFFIX_RE.test(raw)) return true;
// Allow chained edition words: "goty edition", "definitive edition"
const parts = raw.split(/\s+/);
let i = 0;
while (i < parts.length) {
let matched = false;
for (let len = Math.min(4, parts.length - i); len >= 1; len -= 1) {
const chunk = parts.slice(i, i + len).join(' ');
if (EDITION_SUFFIX_RE.test(chunk)) {
i += len;
matched = true;
break;
}
}
if (!matched) return false;
}
return true;
}
function stripEditionSuffix(normalized) {
const tokens = String(normalized || '')
.split(/\s+/)
.filter(Boolean);
while (tokens.length) {
let stripped = false;
for (let len = Math.min(4, tokens.length); len >= 1; len -= 1) {
const chunk = tokens.slice(tokens.length - len).join(' ');
if (EDITION_SUFFIX_RE.test(chunk)) {
tokens.splice(tokens.length - len, len);
stripped = true;
break;
}
}
if (!stripped) break;
}
return tokens.join(' ');
}
function indexOfTokenSequence(haystack, needle) {
if (!needle.length || needle.length > haystack.length) return -1;
for (let i = 0; i <= haystack.length - needle.length; i += 1) {
let ok = true;
for (let j = 0; j < needle.length; j += 1) {
if (haystack[i + j] !== needle[j]) {
ok = false;
break;
}
}
if (ok) return i;
}
return -1;
}
/**
* Score how well a Steam app title matches the Backloggd title.
* Rejects spin-offs that merely start with the name (Minecraft → Minecraft Dungeons).
*/
function scoreSteamTitleMatch(steamName, targetTitle) {
const steam = normalizeTitle(steamName);
const target = normalizeTitle(targetTitle);
if (!steam || !target) return 0;
if (steam === target) return 100;
const steamCore = stripEditionSuffix(steam);
const targetCore = stripEditionSuffix(target);
if (steamCore && targetCore && steamCore === targetCore) return 95;
// "Celeste" + "goty" / "definitive edition" — OK. "Minecraft" + "dungeons" — not OK.
if (steam.startsWith(`${target} `) && isEditionOnlySuffix(steam.slice(target.length).trim())) {
return 90;
}
if (
steamCore &&
targetCore &&
steamCore.startsWith(`${targetCore} `) &&
isEditionOnlySuffix(steamCore.slice(targetCore.length).trim())
) {
return 88;
}
const st = titleTokens(steamCore || steam);
const tt = titleTokens(targetCore || target);
// Multi-word titles: allow leading "the"/"a" and trailing edition-only tokens.
if (tt.length >= 2) {
const idx = indexOfTokenSequence(st, tt);
if (idx >= 0) {
const before = st.slice(0, idx);
const after = st.slice(idx + tt.length);
if (before.every(isBenignTitlePrefixToken) && isEditionOnlySuffix(after.join(' '))) {
return 85;
}
}
}
return 0;
}
const STEAM_TITLE_MATCH_MIN_SCORE = 85;
function pickSteamSearchItem(items, title) {
const list = (items || []).filter((i) => i && i.type === 'app' && i.id);
if (!list.length) return null;
const nonExtra = list.filter((i) => !isLikelyExtra(i.name));
const pool = nonExtra.length ? nonExtra : list;
let best = null;
let bestScore = 0;
for (const item of pool) {
const score = scoreSteamTitleMatch(item.name, title);
if (score > bestScore) {
bestScore = score;
best = item;
}
}
return bestScore >= STEAM_TITLE_MATCH_MIN_SCORE ? best : null;
}
function mcScoreTier(score) {
const n = Number(score);
if (!Number.isFinite(n)) return '';
if (n >= 75) return 'high';
if (n >= 50) return 'mid';
return 'low';
}
function renderMetacriticBadge(score, url) {
const tier = mcScoreTier(score);
const cls = `blp-mc-badge${tier ? ` blp-mc-badge--${tier}` : ''}`;
const label = escapeHtml(String(score));
if (url) {
return `<a class="${cls} blp-ext-link" href="${escapeAttr(url)}" target="_blank" rel="noopener noreferrer">${label}</a>`;
}
return `<span class="${cls}">${label}</span>`;
}
function ocTierClass(tier) {
const key = String(tier || '')
.toLowerCase()
.replace(/\s+/g, '');
if (key === 'mighty') return 'blp-oc-badge--mighty';
if (key === 'strong') return 'blp-oc-badge--strong';
if (key === 'fair') return 'blp-oc-badge--fair';
if (key === 'weak') return 'blp-oc-badge--weak';
return '';
}
function renderOpenCriticValues(oc) {
if (!oc || oc.missing || !oc.tier) return '';
const cls = `blp-oc-badge ${ocTierClass(oc.tier)}`.trim();
return `<span class="blp-oc-chips"><a class="${cls} blp-ext-link" href="${escapeAttr(oc.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(oc.tier)}</a></span>`;
}
function renderHltbValues(hltb) {
if (!hltb || hltb.missing) return '';
const chips = [];
const main = formatHoursCompact(hltb.main);
const extra = formatHoursCompact(hltb.extra);
const complete = formatHoursCompact(hltb.complete);
if (main) {
chips.push(
`<a class="blp-hltb-chip blp-ext-link" href="${escapeAttr(hltb.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(fmt(t.hltbMain, { n: main }))}</a>`
);
}
if (extra) {
chips.push(
`<a class="blp-hltb-chip blp-ext-link" href="${escapeAttr(hltb.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(fmt(t.hltbExtra, { n: extra }))}</a>`
);
}
if (complete) {
chips.push(
`<a class="blp-hltb-chip blp-ext-link" href="${escapeAttr(hltb.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(fmt(t.hltbComplete, { n: complete }))}</a>`
);
}
return chips.length ? `<span class="blp-hltb-chips">${chips.join('')}</span>` : '';
}
function deckCompatLabel(category) {
const cat = Number(category);
if (cat === 3) return { key: 'verified', label: t.deckVerified, cls: 'blp-deck-badge--verified' };
if (cat === 2) return { key: 'playable', label: t.deckPlayable, cls: 'blp-deck-badge--playable' };
if (cat === 1) return { key: 'unsupported', label: t.deckUnsupported, cls: 'blp-deck-badge--unsupported' };
if (cat === 0) return { key: 'unknown', label: t.deckUnknown, cls: 'blp-deck-badge--unknown' };
return null;
}
function protonTierClass(tier) {
const key = String(tier || '').toLowerCase();
if (key === 'platinum') return 'blp-proton-badge--platinum';
if (key === 'gold') return 'blp-proton-badge--gold';
if (key === 'silver') return 'blp-proton-badge--silver';
if (key === 'bronze') return 'blp-proton-badge--bronze';
if (key === 'borked') return 'blp-proton-badge--borked';
return '';
}
function renderDeckProtonValues({ steam, proton }) {
const parts = [];
const deck = deckCompatLabel(steam?.deckCompat);
if (deck) {
const href = steam?.appId
? `https://store.steampowered.com/app/${steam.appId}/`
: steam?.storeUrl || '#';
parts.push(
`<a class="blp-deck-badge ${deck.cls} blp-ext-link" href="${escapeAttr(href)}" target="_blank" rel="noopener noreferrer" title="Steam Deck">${escapeHtml(deck.label)}</a>`
);
}
if (proton?.tier) {
const tierLabel = proton.tier.charAt(0).toUpperCase() + proton.tier.slice(1);
const cls = `blp-proton-badge ${protonTierClass(proton.tier)}`.trim();
parts.push(
`<a class="${cls} blp-ext-link" href="${escapeAttr(proton.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(fmt(t.protonTier, { tier: tierLabel }))}</a>`
);
}
return parts.length ? `<span class="blp-deck-proton-chips">${parts.join('')}</span>` : '';
}
function reviewScoreClass(summary) {
const score = Number(summary?.review_score);
if (!Number.isFinite(score) || score <= 0) {
// Fallback from positive % when score is missing
if (!summary?.total_reviews) return '';
const pct = (summary.total_positive / summary.total_reviews) * 100;
if (pct >= 80) return 'blp-review--very-positive';
if (pct >= 70) return 'blp-review--mostly-positive';
if (pct >= 40) return 'blp-review--mixed';
return 'blp-review--mostly-negative';
}
const map = {
1: 'blp-review--overwhelmingly-negative',
2: 'blp-review--very-negative',
3: 'blp-review--negative',
4: 'blp-review--mostly-negative',
5: 'blp-review--mixed',
6: 'blp-review--mostly-positive',
7: 'blp-review--positive',
8: 'blp-review--very-positive',
9: 'blp-review--overwhelming',
};
return map[score] || '';
}
function formatReviewPercent(summary) {
if (!summary || !summary.total_reviews) return null;
const pct = Math.round((summary.total_positive / summary.total_reviews) * 100);
const desc = summary.review_score_desc || '';
return desc ? `${desc} (${pct}%)` : `${pct}%`;
}
function formatReviewPercentCompact(summary) {
if (!summary || !summary.total_reviews) return null;
return `${Math.round((summary.total_positive / summary.total_reviews) * 100)}%`;
}
function faviconForUrl(url) {
try {
const host = new URL(url).hostname.replace(/^www\./, '');
return FAVICON_URL.replace('{domain}', encodeURIComponent(host));
} catch (_) {
return '';
}
}
function formatPriceText(steam) {
if (!steam?.found) return null;
if (steam.isFree) return t.free;
if (!steam.price) return null;
let priceText = steam.price.final_formatted || '';
if (!priceText && Number.isFinite(steam.price.final)) {
priceText = (steam.price.final / 100).toFixed(2);
if (steam.price.currency) priceText = `${priceText} ${steam.price.currency}`;
}
return priceText || null;
}
function formatDiscountEndDate(unixSeconds) {
const sec = Number(unixSeconds);
if (!Number.isFinite(sec) || sec <= 0) return null;
try {
return new Date(sec * 1000).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
} catch (_) {
return null;
}
}
async function hydrateSteamApp({
appId,
country,
hit = null,
anonymous = true,
usedUsFallback = false,
requestedCountry,
onPartial,
includeTags = true,
debugBase = null,
cacheKey = '',
manualOverride = false,
}) {
const id = Number(appId);
const detailsCountry = String(country || 'US').toUpperCase();
const reqCountry = String(requestedCountry || detailsCountry).toUpperCase();
const debugOn = Boolean(settings.debugMode);
const debug = debugBase || {
appId: id,
country: reqCountry,
cacheKey,
cacheSkipped: debugOn,
manualOverride,
};
const detailsUrl =
`${STEAM_DETAILS_URL}?appids=${id}` +
`&cc=${encodeURIComponent(detailsCountry)}&l=english`;
const reviewsUrl =
`${STEAM_REVIEWS_URL}/${id}?json=1&language=all` +
`&purchase_type=all&num_per_page=0`;
debug.detailsUrl = detailsUrl;
debug.reviewsUrl = reviewsUrl;
debug.manualOverride = Boolean(manualOverride);
const needTags = includeTags && settings.showSteamTags !== false;
const needExtras = includeTags;
if (needTags || needExtras) {
const tagsInput = JSON.stringify({
ids: [{ appid: id }],
context: {
language: 'english',
country_code: detailsCountry,
steam_realm: 1,
},
data_request: {
include_tag_count: STEAM_TAGS_MAX,
include_assets: true,
include_platforms: true,
include_all_purchase_options: true,
},
});
debug.tagsUrl = `${STEAM_STORE_ITEMS_URL}?input_json=${encodeURIComponent(tagsInput)}`;
debug.tagMapUrl = STEAM_POPULAR_TAGS_URL;
}
const detailsPromise = gmRequest({ url: detailsUrl, anonymous });
const reviewsPromise = gmRequest({ url: reviewsUrl, anonymous }).catch((err) => {
debug.reviewsError = String(err?.message || err);
return null;
});
const storeItemPromise =
needTags || needExtras
? fetchSteamStoreItem(id, detailsCountry).catch((err) => {
debug.tagsError = String(err?.message || err);
return { tags: [], assets: null, extras: null };
})
: Promise.resolve({ tags: [], assets: null, extras: null });
let detailsRoot = null;
try {
detailsRoot = await detailsPromise;
} catch (err) {
debug.reason = `Steam details failed: ${err?.message || err}`;
return { found: false, manualOverride: Boolean(manualOverride), _debug: debug };
}
let reviews = null;
let tags = [];
let extras = null;
let canEmit = false;
const buildPayload = () => {
const details = detailsRoot?.[id]?.success ? detailsRoot[id].data : null;
const sourceBits = manualOverride
? ['manual App ID', detailsCountry]
: [
anonymous ? 'guest items' : 'session items',
usedUsFallback ? `US fallback (requested ${reqCountry})` : detailsCountry,
];
debug.reason = details
? `Matched Steam app ${id} (${sourceBits.join(', ')})`
: `App ${id} resolved, but appdetails success=false`;
debug.detailsSuccess = Boolean(detailsRoot?.[id]?.success);
debug.detailsCountry = detailsCountry;
debug.reviews = reviews?.query_summary || null;
debug.tags = tags;
debug.categories = normalizeSteamCategories(details);
debug.extras = extras;
return {
found: Boolean(details) || Boolean(hit),
appId: id,
name: details?.name || hit?.name || `App ${id}`,
storeUrl: `https://store.steampowered.com/app/${id}/`,
isFree: Boolean(details?.is_free),
price: details?.price_overview || hit?.price || null,
metacritic:
details?.metacritic || (hit?.metascore ? { score: Number(hit.metascore) } : null),
recommendations: details?.recommendations?.total || null,
reviews: reviews?.query_summary || null,
tags,
categories: normalizeSteamCategories(details),
deckCompat: extras?.deckCompat ?? null,
discountEndDate: extras?.discountEndDate ?? null,
tinyImage: hit?.tiny_image || hit?.small_capsule || null,
headerImage: details?.header_image || null,
usedUsFallback: Boolean(usedUsFallback),
requestedCountry: reqCountry,
searchCountry: detailsCountry,
manualOverride: Boolean(manualOverride),
_debug: debug,
};
};
const emitPartial = () => {
if (!canEmit || typeof onPartial !== 'function') return;
onPartial(buildPayload());
};
const reviewsReady = reviewsPromise.then((value) => {
reviews = value;
emitPartial();
return value;
});
const storeReady = storeItemPromise.then((value) => {
tags = needTags ? value?.tags || [] : [];
extras = value?.extras || null;
emitPartial();
return value;
});
canEmit = true;
emitPartial();
await Promise.all([reviewsReady, storeReady]);
const payload = buildPayload();
if (!detailsRoot?.[id]?.success && !hit) {
payload.found = false;
}
// Persist Steam resolves. Lite (list cards) may write tagsLoaded=false, but must not
// overwrite a still-valid full entry (tagsLoaded=true) — that caused missing tags after
// visiting a list before the game page.
if (!debugOn && cacheKey && payload.found) {
persistSteamResolveCache(cacheKey, payload, includeTags);
}
return payload;
}
function persistSteamResolveCache(cacheKey, payload, includeTags) {
if (!cacheKey || !payload?.found || !cacheTtlMs()) return;
const { _debug, ...store } = payload;
if (includeTags) {
store.tagsLoaded = true;
setCached(cacheKey, store);
return;
}
store.tagsLoaded = false;
const existing = readCacheStore()[cacheKey];
if (
existing?.data?.found &&
existing.data.tagsLoaded === true &&
!isCacheEntryExpired(cacheKey, existing)
) {
return;
}
setCached(cacheKey, store);
}
function cachedSteamNeedsTagBackfill(cached, includeTags) {
if (!includeTags || settings.showSteamTags === false) return false;
if (!cached?.found || cached.appId == null) return false;
return cached.tagsLoaded !== true;
}
async function backfillSteamTags(cached, country) {
const tags = await fetchSteamAppTags(cached.appId, country).catch(() => []);
return {
...cached,
tags: Array.isArray(tags) ? tags : [],
tagsLoaded: true,
};
}
async function readSteamCacheOrBackfill(cacheKey, { includeTags, country, manualOverride }) {
const cached = getCached(cacheKey);
if (!cached) return null;
let result = {
...cached,
manualOverride: Boolean(manualOverride || cached.manualOverride),
};
if (cachedSteamNeedsTagBackfill(result, includeTags)) {
result = await backfillSteamTags(result, country);
const { _debug, ...store } = result;
setCached(cacheKey, store);
}
return result;
}
async function fetchSteamByAppId(appId, country, { onPartial, includeTags = true, manualOverride = false } = {}) {
const id = Number(appId);
if (!Number.isFinite(id) || id <= 0) {
return { found: false, _debug: { reason: 'Invalid Steam App ID' } };
}
const requestedCountry = String(country || 'US').toUpperCase();
const cacheKey = `steam:id:${requestedCountry}:${id}`;
const inflightKey = includeTags ? cacheKey : `${cacheKey}:lite`;
const debugOn = Boolean(settings.debugMode);
if (!debugOn) {
const cached = await readSteamCacheOrBackfill(cacheKey, {
includeTags,
country: requestedCountry,
manualOverride,
});
if (cached) return cached;
}
if (inflight.has(inflightKey)) return inflight.get(inflightKey);
const task = hydrateSteamApp({
appId: id,
country: requestedCountry,
requestedCountry,
anonymous: true,
onPartial,
includeTags,
cacheKey,
manualOverride,
debugBase: {
appId: id,
country: requestedCountry,
cacheKey,
cacheSkipped: debugOn,
manualOverride: Boolean(manualOverride),
searches: [],
},
});
inflight.set(inflightKey, task);
try {
return await task;
} finally {
inflight.delete(inflightKey);
}
}
function steamMissKey(title, slug, country) {
const cc = String(country || 'US').toUpperCase();
const overrideId = getSteamOverride(slug);
if (overrideId) return `id:${cc}:${overrideId}`;
return `title:${cc}:${normalizeTitle(title)}`;
}
async function resolveSteamForGame({ title, slug, country, onPartial, includeTags = true }) {
const missKey = steamMissKey(title, slug, country);
if (steamResolveMissMemory.has(missKey)) {
return {
found: false,
_debug: { reason: 'Steam miss (session memory)', missKey },
};
}
const overrideId = getSteamOverride(slug);
let result;
if (overrideId) {
result = await fetchSteamByAppId(overrideId, country, {
onPartial,
includeTags,
manualOverride: true,
});
} else {
result = await fetchSteamBundle(title, country, { onPartial, includeTags });
}
if (!result?.found) steamResolveMissMemory.add(missKey);
else steamResolveMissMemory.delete(missKey);
return result;
}
async function fetchSteamBundle(title, country, { onPartial, includeTags = true } = {}) {
const requestedCountry = String(country || 'US').toUpperCase();
const cacheKey = `steam:${requestedCountry}:${normalizeTitle(title)}`;
const inflightKey = includeTags ? cacheKey : `${cacheKey}:lite`;
const debugOn = Boolean(settings.debugMode);
if (!debugOn) {
const cached = await readSteamCacheOrBackfill(cacheKey, {
includeTags,
country: requestedCountry,
manualOverride: false,
});
if (cached) return cached;
}
if (inflight.has(inflightKey)) return inflight.get(inflightKey);
const task = (async () => {
const debug = {
title,
country: requestedCountry,
cacheKey,
cacheSkipped: debugOn,
searches: [],
};
const buildSearchUrl = (cc) =>
`${STEAM_SEARCH_URL}?term=${encodeURIComponent(title)}` +
`&l=english&cc=${encodeURIComponent(cc)}`;
const searchAs = async (cc, anonymous) => {
const url = buildSearchUrl(cc);
try {
const search = await gmRequest({ url, anonymous });
const items = Array.isArray(search?.items) ? search.items : [];
return {
ok: true,
anonymous,
country: cc,
url,
total: search?.total ?? items.length,
items,
summary: {
anonymous,
country: cc,
url,
total: search?.total ?? items.length,
items: items.slice(0, 8).map((i) => ({
id: i.id,
name: i.name,
type: i.type,
})),
},
};
} catch (err) {
return {
ok: false,
anonymous,
country: cc,
url,
items: [],
summary: {
anonymous,
country: cc,
url,
error: String(err?.message || err),
},
};
}
};
const mergeSearchResults = (sessionResult, guestResult) => {
const byId = new Map();
for (const item of [...(sessionResult.items || []), ...(guestResult.items || [])]) {
if (!item?.id) continue;
if (!byId.has(item.id)) byId.set(item.id, item);
}
const mergedItems = [...byId.values()];
const hit = pickSteamSearchItem(mergedItems, title) || null;
const sessionIds = new Set(
(sessionResult.items || []).map((i) => i?.id).filter(Boolean)
);
const anonymous = hit
? !sessionIds.has(hit.id)
: Boolean(guestResult.ok && !sessionResult.ok);
return { hit, mergedItems, anonymous };
};
const runParallelSearch = async (cc) => {
const [sessionResult, guestResult] = await Promise.all([
searchAs(cc, false),
searchAs(cc, true),
]);
debug.searches.push(sessionResult.summary, guestResult.summary);
return {
...mergeSearchResults(sessionResult, guestResult),
sessionResult,
guestResult,
country: cc,
};
};
let searchCountry = requestedCountry;
let usedUsFallback = false;
let round = await runParallelSearch(searchCountry);
if (!round.hit && searchCountry !== 'US') {
usedUsFallback = true;
searchCountry = 'US';
debug.usFallback = {
from: requestedCountry,
to: 'US',
reason: `No matches for ${requestedCountry}; retrying US`,
};
round = await runParallelSearch('US');
}
const { hit, anonymous } = round;
debug.searchCountry = searchCountry;
debug.usedUsFallback = usedUsFallback;
debug.anonymous = anonymous;
if (!hit) {
debug.reason = usedUsFallback
? `No Steam search match for ${requestedCountry} or US (session + guest, parallel)`
: 'No Steam search match (session + guest, parallel)';
return { found: false, _debug: debug };
}
debug.picked = { id: hit.id, name: hit.name, type: hit.type };
return hydrateSteamApp({
appId: hit.id,
country: searchCountry,
hit,
anonymous,
usedUsFallback,
requestedCountry,
onPartial,
includeTags,
debugBase: debug,
cacheKey,
manualOverride: false,
});
})();
inflight.set(inflightKey, task);
try {
return await task;
} finally {
inflight.delete(inflightKey);
}
}
function steamCdnAsset(appId, file) {
return `${STEAM_CDN_APPS}/${Number(appId)}/${file}`;
}
async function fetchSteamPlayers(appId) {
const cacheKey = `steam:players:${appId}`;
const cached = getCached(cacheKey);
if (cached && typeof cached.players === 'number') return cached.players;
try {
const data = await gmRequest({
url: `${STEAM_PLAYERS_URL}?appid=${encodeURIComponent(appId)}`,
anonymous: true,
});
const players = Number(data?.response?.player_count);
if (!Number.isFinite(players)) return null;
const store = readCacheStore();
store[cacheKey] = { ts: Date.now(), data: { players }, ttlMs: PLAYERS_CACHE_TTL_MS };
persistCacheSoon();
return players;
} catch (_) {
return null;
}
}
/**
* Icon / cover / screenshots / players via Steam APIs only.
* SteamDB has no public JSON API (internal /api/* is extension-only + Cloudflare).
* Media: GetItems community_icon + header. Screenshots: appdetails.
* Players: GetNumberOfCurrentPlayers.
*/
async function fetchSteamDbExtras(appId, { onPartial, country } = {}) {
const id = Number(appId);
if (!Number.isFinite(id) || id <= 0) return null;
const needMedia = settings.showSteamDbIcon || settings.showSteamDbCover;
const needGallery = settings.showSteamDbGallery;
const needPlayers = settings.showSteamPlayers;
if (!needMedia && !needGallery && !needPlayers) return null;
const mediaKey = `steamdb:media:${id}`;
const shotsKey = `steam:screenshots:${id}`;
const debugOn = Boolean(settings.debugMode);
let media = !debugOn ? getCached(mediaKey) : null;
let screenshots = needGallery && !debugOn ? getCached(shotsKey) : null;
if (screenshots && !Array.isArray(screenshots)) screenshots = null;
let latestPlayers = null;
let latestPlayersSource = null;
let playersApiUrl = null;
const cc = country || settings.steamCountry || 'US';
const emit = (payload) => {
if (typeof onPartial === 'function') onPartial(payload);
};
const resolveMediaUrls = () => {
const iconUrl = media?.iconUrl || '';
const logoUrl =
media?.logoUrl || steamCdnAsset(id, 'header.jpg') || steamCdnAsset(id, 'library_600x900.jpg');
const logoIsPortrait = Boolean(logoUrl && /library_600x900/i.test(logoUrl) && !media?.logoUrl);
return { iconUrl, logoUrl, logoIsPortrait };
};
const buildResult = () => {
const { iconUrl, logoUrl, logoIsPortrait } = resolveMediaUrls();
return {
appId: id,
iconUrl: needMedia && settings.showSteamDbIcon ? iconUrl : '',
logoUrl: needMedia && settings.showSteamDbCover ? logoUrl : '',
logoIsPortrait: Boolean(logoIsPortrait),
screenshots: needGallery ? (Array.isArray(screenshots) ? screenshots : null) : null,
players: needPlayers ? latestPlayers : null,
source: media?.source || 'steam',
_debug: debugOn
? {
reason: [
media?.source === 'steam-assets'
? 'Steam GetItems (community_icon + header)'
: media?.source
? `Media: ${media.source}`
: needMedia
? 'Waiting for Steam assets'
: null,
needGallery
? Array.isArray(screenshots)
? `Screenshots: ${screenshots.length} from appdetails`
: 'Screenshots pending'
: null,
latestPlayersSource === 'steam-api'
? 'Players from GetNumberOfCurrentPlayers'
: latestPlayersSource === 'cache'
? 'Players from cache'
: needPlayers
? 'Players pending'
: null,
]
.filter(Boolean)
.join(' · '),
chartsUrl: `${STEAMDB_APP_URL}/${id}/charts/`,
playersApiUrl,
playersSource: latestPlayersSource,
media,
screenshots,
players: latestPlayers,
iconUrl,
logoUrl,
}
: undefined,
};
};
if (needMedia && !media) {
media = {
iconUrl: '',
logoUrl: steamCdnAsset(id, 'header.jpg'),
source: 'steam-cdn-early',
};
emit(buildResult());
} else if (needMedia || needGallery) {
emit(buildResult());
}
if (needPlayers) {
const entry = readCacheStore()[`steam:players:${id}`];
if (entry?.ts && typeof entry.data?.players === 'number') {
const ttl = entry.ttlMs || PLAYERS_CACHE_TTL_MS;
if (Date.now() - entry.ts <= ttl) {
latestPlayers = entry.data.players;
latestPlayersSource = 'cache';
emit(buildResult());
}
}
}
const assetsPromise = needMedia
? fetchSteamStoreAssets(id, cc).catch(() => null)
: Promise.resolve(null);
const shotsPromise =
needGallery && !Array.isArray(screenshots)
? fetchSteamScreenshots(id).then((shots) => {
screenshots = Array.isArray(shots) ? shots : [];
emit(buildResult());
return screenshots;
})
: Promise.resolve(screenshots);
const playersPromise =
needPlayers && latestPlayers == null
? (async () => {
playersApiUrl = `${STEAM_PLAYERS_URL}?appid=${encodeURIComponent(id)}`;
const players = await fetchSteamPlayers(id);
if (players != null) {
latestPlayers = players;
latestPlayersSource = 'steam-api';
emit(buildResult());
}
})()
: Promise.resolve();
const [storeAssets] = await Promise.all([assetsPromise, shotsPromise, playersPromise]);
if (storeAssets && (storeAssets.iconUrl || storeAssets.logoUrl)) {
media = {
iconUrl: storeAssets.iconUrl || media?.iconUrl || '',
logoUrl: storeAssets.logoUrl || media?.logoUrl || '',
source: 'steam-assets',
};
if (!debugOn) setCached(mediaKey, media);
emit(buildResult());
}
if (needMedia && settings.showSteamDbIcon && media && !media.iconUrl) {
media = { ...media, iconUrl: steamCdnAsset(id, 'capsule_sm_120.jpg') };
}
if (needGallery && !Array.isArray(screenshots)) screenshots = [];
const result = buildResult();
emit(result);
return result;
}
function parseSteamScreenshots(details) {
const list = Array.isArray(details?.screenshots) ? details.screenshots : [];
const out = [];
for (const shot of list) {
const thumb = String(shot?.path_thumbnail || '').trim();
const full = String(shot?.path_full || thumb).trim();
if (!thumb && !full) continue;
out.push({
id: shot?.id != null ? Number(shot.id) : out.length,
thumb: thumb || full,
full: full || thumb,
});
}
return out;
}
async function fetchSteamScreenshots(appId) {
const id = Number(appId);
if (!Number.isFinite(id) || id <= 0) return [];
const cacheKey = `steam:screenshots:${id}`;
const debugOn = Boolean(settings.debugMode);
if (!debugOn) {
const cached = getCached(cacheKey);
if (Array.isArray(cached)) return cached;
}
if (inflight.has(cacheKey)) return inflight.get(cacheKey);
const task = (async () => {
try {
const url =
`${STEAM_DETAILS_URL}?appids=${encodeURIComponent(id)}` +
`&filters=screenshots&l=english`;
const root = await gmRequest({ url, anonymous: true });
const shots = parseSteamScreenshots(root?.[id]?.success ? root[id].data : null);
if (shots.length && !debugOn) setCached(cacheKey, shots);
return shots;
} catch (_) {
return [];
}
})();
inflight.set(cacheKey, task);
try {
return await task;
} finally {
inflight.delete(cacheKey);
}
}
function removeSteamDbUi() {
closeSteamGalleryLightbox();
steamGalleryItems = [];
document.querySelectorAll(`[${STEAMDB_ATTR}]`).forEach((el) => el.remove());
}
function desktopTitleHeading() {
return (
document.querySelector(
'#game-body > div:nth-child(2) > div.row.d-none.d-sm-flex.mx-n1.game-title-section h1'
) ||
document.querySelector(
'#game-body .row.d-none.d-sm-flex.game-title-section h1'
) ||
document.querySelector('#game-body .game-title-section.d-none.d-sm-flex h1')
);
}
function loggingSidebarMount() {
return (
document.querySelector('#logging-sidebar-section > div > div') ||
document.querySelector('#logging-sidebar-section .col.col-md-5') ||
document.querySelector('#logging-sidebar-section .col-md-5') ||
document.querySelector('#logging-sidebar-section .col')
);
}
function isSteamLogoAsset(url) {
return /(?:^|\/)logo\.(png|jpe?g)(?:$|\?)/i.test(String(url || ''));
}
function revealSteamDbMediaImage(host, img, url) {
if (!host || !img || !url) return;
const current = img.getAttribute('src') || '';
if (current === url && img.classList.contains('is-ready') && img.complete && img.naturalWidth) {
host.classList.remove('is-loading');
return;
}
const markReady = () => {
if ((img.getAttribute('src') || '') !== url) return;
img.classList.add('is-ready');
host.classList.remove('is-loading');
};
// Same URL still loading — attach handler, don't restart fade/gen.
if (current === url) {
img.addEventListener('load', markReady, { once: true });
if (img.complete && img.naturalWidth) markReady();
return;
}
const upgrading = img.classList.contains('is-ready') && Boolean(current);
const gen = String((Number(img.dataset.blpRevealGen || '0') || 0) + 1);
img.dataset.blpRevealGen = gen;
const markReadyGen = () => {
if (img.dataset.blpRevealGen !== gen) return;
markReady();
};
if (upgrading) {
// Soft swap: fade out → new src → fade in, keep skeleton hidden.
img.classList.remove('is-ready');
window.setTimeout(() => {
if (img.dataset.blpRevealGen !== gen) return;
img.addEventListener('load', markReadyGen, { once: true });
img.src = url;
if (img.complete && img.naturalWidth) markReadyGen();
}, 160);
return;
}
img.classList.remove('is-ready');
host.classList.add('is-loading');
img.addEventListener('load', markReadyGen, { once: true });
img.src = url;
if (img.complete && img.naturalWidth) markReadyGen();
}
function ensureSteamDbTitleIconMount(token = '') {
if (!settings.showSteamDbIcon) {
document.querySelectorAll(`[${STEAMDB_ATTR}="icon"]`).forEach((el) => el.remove());
return null;
}
let wrap = document.querySelector(`[${STEAMDB_ATTR}="icon"]`);
if (wrap) {
if (token) wrap.setAttribute('data-blp-token', token);
return wrap;
}
const h1 = desktopTitleHeading();
if (!h1) return null;
wrap = document.createElement('span');
wrap.setAttribute(STEAMDB_ATTR, 'icon');
wrap.className = 'blp-title-icon-wrap is-loading';
if (token) wrap.setAttribute('data-blp-token', token);
wrap.innerHTML =
'<span class="blp-title-icon-skel" aria-hidden="true"></span>' +
'<img class="blp-title-icon" alt="" width="32" height="32" decoding="async" referrerpolicy="no-referrer" />';
const img = wrap.querySelector('img');
if (img) {
img.addEventListener('error', () => {
if (img.dataset.blpFallback === '1') {
wrap.remove();
return;
}
img.dataset.blpFallback = '1';
const id = wrap.getAttribute('data-blp-appid');
if (!id) {
wrap.remove();
return;
}
revealSteamDbMediaImage(wrap, img, steamCdnAsset(id, 'capsule_sm_120.jpg'));
});
}
h1.insertAdjacentElement('afterbegin', wrap);
return wrap;
}
function ensureSteamDbCoverMount(token = '') {
if (!settings.showSteamDbCover) {
document.querySelectorAll(`[${STEAMDB_ATTR}="cover"]`).forEach((el) => el.remove());
return null;
}
let box = document.querySelector(`[${STEAMDB_ATTR}="cover"]`);
if (box) {
if (token) box.setAttribute('data-blp-token', token);
return box;
}
const mount = loggingSidebarMount();
if (!mount) return null;
box = document.createElement('div');
box.setAttribute(STEAMDB_ATTR, 'cover');
box.className = 'blp-steamdb-cover is-loading';
if (token) box.setAttribute('data-blp-token', token);
box.innerHTML =
'<span class="blp-steamdb-cover-skel" aria-hidden="true"></span>' +
'<img alt="" decoding="async" referrerpolicy="no-referrer" />';
const img = box.querySelector('img');
if (img) {
img.addEventListener('error', () => {
const fallbacks = String(box.dataset.blpFallbacks || '')
.split('\n')
.filter(Boolean);
let idx = Number(box.dataset.blpFallbackIdx || '0') + 1;
box.dataset.blpFallbackIdx = String(idx);
if (idx < fallbacks.length) {
const next = fallbacks[idx];
img.classList.toggle('blp-steamdb-cover__logo', isSteamLogoAsset(next));
revealSteamDbMediaImage(box, img, next);
return;
}
box.remove();
});
}
mount.appendChild(box);
return box;
}
function mountSteamDbSkeletons(token = '') {
ensureSteamDbTitleIconMount(token);
ensureSteamDbCoverMount(token);
ensureSteamGalleryMount(token);
}
function gameStatsMountAnchor() {
return document.querySelector('turbo-frame#game-stats');
}
function ensureSteamGalleryMount(token = '') {
if (!settings.showSteamDbGallery) {
document.querySelectorAll(`[${STEAMDB_ATTR}="gallery"]`).forEach((el) => el.remove());
return null;
}
let host = document.querySelector(`[${STEAMDB_ATTR}="gallery"]`);
if (host) {
if (token) host.setAttribute('data-blp-token', token);
return host;
}
const anchor = gameStatsMountAnchor();
if (!anchor) return null;
host = document.createElement('section');
host.setAttribute(STEAMDB_ATTR, 'gallery');
host.className = 'blp-steam-gallery is-loading';
if (token) host.setAttribute('data-blp-token', token);
host.innerHTML = `
<div class="blp-steam-gallery__head">
<span class="blp-steam-gallery__title">${escapeHtml(t.steamGalleryTitle)}</span>
</div>
<div class="blp-steam-gallery__track" data-blp-gallery-track>
<span class="blp-steam-gallery__skel" aria-hidden="true"></span>
<span class="blp-steam-gallery__skel" aria-hidden="true"></span>
<span class="blp-steam-gallery__skel" aria-hidden="true"></span>
<span class="blp-steam-gallery__skel" aria-hidden="true"></span>
</div>
`;
anchor.insertAdjacentElement('afterend', host);
return host;
}
let steamGalleryItems = [];
function buildSteamGalleryItems(screenshots, coverUrl) {
const items = [];
const cover = String(coverUrl || '').trim();
if (cover) {
items.push({ thumb: cover, full: cover, kind: 'cover' });
}
if (Array.isArray(screenshots)) {
for (const shot of screenshots) {
const thumb = String(shot?.thumb || '').trim();
const full = String(shot?.full || thumb).trim();
if (!thumb && !full) continue;
items.push({
id: shot?.id,
thumb: thumb || full,
full: full || thumb,
kind: 'screenshot',
});
}
}
return items;
}
function bindSteamDbCoverGallery(items) {
const box = document.querySelector(`[${STEAMDB_ATTR}="cover"]`);
if (!box) return;
const coverIdx = Array.isArray(items) ? items.findIndex((s) => s.kind === 'cover') : -1;
if (!settings.showSteamDbCover || coverIdx < 0 || !items.length) {
box.classList.remove('is-gallery');
box.removeAttribute('role');
box.removeAttribute('tabindex');
box.removeAttribute('aria-label');
box.onclick = null;
box.onkeydown = null;
return;
}
box.classList.add('is-gallery');
box.setAttribute('role', 'button');
box.setAttribute('tabindex', '0');
box.setAttribute('aria-label', t.steamGalleryOpen);
const open = () => openSteamGalleryLightbox(items, coverIdx);
box.onclick = (ev) => {
ev.preventDefault();
open();
};
box.onkeydown = (ev) => {
if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
open();
}
};
}
const GAME_COVER_VIEWER_ATTR = 'data-blp-cover-viewer';
function gameCoverImageEl() {
return (
document.querySelector(
'#interaction-sidebar > div > div.col.col-cover.px-sm-0.my-auto.mx-auto.mb-0.mb-2.mb-mb-0 > div > div > img'
) ||
document.querySelector('#interaction-sidebar .col-cover .game-cover img') ||
document.querySelector('#interaction-sidebar .col-cover img')
);
}
function gameCoverViewerSrc(img) {
if (!img) return '';
const dataSrc = String(img.getAttribute('data-src') || '').trim();
const src = String(img.currentSrc || img.getAttribute('src') || '').trim();
// Prefer lazy hi-res (often t_cover_big_2x) when present.
return dataSrc || src;
}
function onGameCoverViewerClick(ev) {
ev.preventDefault();
ev.stopPropagation();
const img = ev.currentTarget;
if (!(img instanceof HTMLImageElement)) return;
openGameCoverViewer(img);
}
function onGameCoverViewerKeydown(ev) {
if (ev.key !== 'Enter' && ev.key !== ' ') return;
ev.preventDefault();
ev.stopPropagation();
onGameCoverViewerClick(ev);
}
function unbindGameCoverViewer() {
document.querySelectorAll(`img[${GAME_COVER_VIEWER_ATTR}]`).forEach((img) => {
img.removeEventListener('click', onGameCoverViewerClick);
img.removeEventListener('keydown', onGameCoverViewerKeydown);
img.removeAttribute(GAME_COVER_VIEWER_ATTR);
img.classList.remove('blp-cover-zoomable');
img.removeAttribute('role');
img.removeAttribute('tabindex');
img.removeAttribute('aria-label');
});
}
function openGameCoverViewer(img) {
const full = gameCoverViewerSrc(img);
const thumb = String(img.currentSrc || img.getAttribute('src') || full).trim();
if (!full && !thumb) return;
const coverItem = {
src: full || thumb,
thumb: thumb || full,
kind: 'cover',
alt: img.getAttribute('alt') || '',
};
const seen = new Set([coverItem.src, coverItem.thumb].filter(Boolean));
const extras = [];
for (const shot of steamGalleryItems || []) {
const src = String(shot.full || shot.src || shot.thumb || '').trim();
const tmb = String(shot.thumb || shot.full || shot.src || '').trim();
if (!src || seen.has(src)) continue;
seen.add(src);
extras.push({
src,
thumb: tmb || src,
kind: shot.kind || 'screenshot',
alt: shot.alt || '',
});
}
openBlpImageViewer({
items: [coverItem, ...extras],
index: 0,
title: getGameTitle() || t.viewerOpenCover,
});
}
function bindGameCoverViewer() {
const img = gameCoverImageEl();
if (!img) return;
if (img.getAttribute(GAME_COVER_VIEWER_ATTR) === '1') return;
img.setAttribute(GAME_COVER_VIEWER_ATTR, '1');
img.classList.add('blp-cover-zoomable');
img.setAttribute('role', 'button');
img.setAttribute('tabindex', '0');
img.setAttribute('aria-label', t.viewerOpenCover);
img.addEventListener('click', onGameCoverViewerClick);
img.addEventListener('keydown', onGameCoverViewerKeydown);
}
/**
* Reusable fullscreen image viewer (zoom / pan / filmstrip).
* openBlpImageViewer({ items, index, title })
* closeBlpImageViewer()
* isBlpImageViewerOpen()
*
* Item: { src|full, thumb?, alt?, kind? }
*/
const BlpImageViewer = (() => {
const MIN_SCALE = 1;
const MAX_SCALE = 5;
const ZOOM_STEP = 1.25;
let session = null;
function normalizeItems(raw) {
const out = [];
for (const item of raw || []) {
if (!item) continue;
if (typeof item === 'string') {
const src = item.trim();
if (src) out.push({ src, thumb: src, alt: '', kind: '' });
continue;
}
const src = String(item.src || item.full || item.url || '').trim();
const thumb = String(item.thumb || item.thumbnail || src).trim();
if (!src && !thumb) continue;
out.push({
src: src || thumb,
thumb: thumb || src,
alt: String(item.alt || ''),
kind: String(item.kind || ''),
});
}
return out;
}
function clamp(n, min, max) {
return Math.min(max, Math.max(min, n));
}
function isOpen() {
return Boolean(session?.root?.isConnected);
}
function close() {
if (!session) return;
const { root, onKeyDown, onClose, prevOverflow } = session;
document.removeEventListener('keydown', onKeyDown, true);
root?.remove();
if (prevOverflow != null) document.documentElement.style.overflow = prevOverflow;
session = null;
if (typeof onClose === 'function') {
try {
onClose();
} catch (_) {
/* ignore */
}
}
}
function applyTransform() {
if (!session) return;
const { frame, scale, tx, ty } = session;
frame.style.transform = `translate(${tx}px, ${ty}px) scale(${scale})`;
if (session.zoomOutBtn) session.zoomOutBtn.disabled = scale <= MIN_SCALE + 0.001;
if (session.zoomInBtn) session.zoomInBtn.disabled = scale >= MAX_SCALE - 0.001;
if (session.resetBtn) session.resetBtn.disabled = scale <= MIN_SCALE + 0.001 && Math.abs(tx) < 1 && Math.abs(ty) < 1;
}
function resetTransform() {
if (!session) return;
session.scale = MIN_SCALE;
session.tx = 0;
session.ty = 0;
applyTransform();
}
function zoomAt(nextScale, clientX, clientY) {
if (!session) return;
const stage = session.stage;
const rect = stage.getBoundingClientRect();
const cx = clientX == null ? rect.left + rect.width / 2 : clientX;
const cy = clientY == null ? rect.top + rect.height / 2 : clientY;
const px = cx - rect.left - rect.width / 2;
const py = cy - rect.top - rect.height / 2;
const prev = session.scale;
const scale = clamp(nextScale, MIN_SCALE, MAX_SCALE);
if (scale === prev) {
applyTransform();
return;
}
// Keep the point under the cursor stable while scaling.
session.tx = px - ((px - session.tx) * scale) / prev;
session.ty = py - ((py - session.ty) * scale) / prev;
session.scale = scale;
if (scale <= MIN_SCALE + 0.001) {
session.tx = 0;
session.ty = 0;
session.scale = MIN_SCALE;
}
applyTransform();
}
function zoomBy(factor, clientX, clientY) {
if (!session) return;
zoomAt(session.scale * factor, clientX, clientY);
}
function updateFilmHighlight() {
if (!session) return;
const { film, index, items } = session;
film.querySelectorAll('[data-blp-viewer-thumb]').forEach((btn) => {
const i = Number(btn.getAttribute('data-blp-viewer-thumb'));
btn.classList.toggle('is-active', i === index);
btn.classList.toggle('is-near', Math.abs(i - index) === 1);
btn.setAttribute('aria-current', i === index ? 'true' : 'false');
});
const active = film.querySelector(`[data-blp-viewer-thumb="${index}"]`);
active?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
if (session.counter) {
session.counter.textContent = fmt(t.viewerOf, {
current: index + 1,
total: items.length,
});
}
const multi = items.length > 1;
if (session.prevBtn) session.prevBtn.hidden = !multi;
if (session.nextBtn) session.nextBtn.hidden = !multi;
}
function setStageLoading(on) {
if (!session) return;
session.stage?.classList.toggle('is-loading', Boolean(on));
session.root?.setAttribute('aria-busy', on ? 'true' : 'false');
if (session.spinner) {
session.spinner.setAttribute('aria-hidden', on ? 'false' : 'true');
}
}
function prefetchAround(index) {
if (!session) return;
const { items } = session;
if (items.length < 2) return;
for (const offset of [-1, 1, 2]) {
const item = items[((index + offset) % items.length + items.length) % items.length];
const src = item?.src;
if (!src || session.prefetched.has(src)) continue;
session.prefetched.add(src);
const probe = new Image();
probe.decoding = 'async';
probe.referrerPolicy = 'no-referrer';
probe.src = src;
}
}
function show(index, { keepZoom = false } = {}) {
if (!session) return;
const { items, img } = session;
const next = ((index % items.length) + items.length) % items.length;
session.index = next;
const item = items[next];
if (!item || !img) return;
const gen = ++session.loadGen;
img.alt = item.alt || '';
if (!keepZoom) resetTransform();
updateFilmHighlight();
prefetchAround(next);
const src = item.src;
const already =
(img.getAttribute('src') || '') === src && img.complete && img.naturalWidth > 0;
if (already) {
setStageLoading(false);
return;
}
setStageLoading(true);
const finish = () => {
if (!session || session.loadGen !== gen) return;
setStageLoading(false);
};
img.addEventListener('load', finish, { once: true });
img.addEventListener('error', finish, { once: true });
img.src = src;
if (img.complete && img.naturalWidth > 0) finish();
}
function go(delta) {
if (!session) return;
show(session.index + delta);
}
function bindStageInteractions() {
const { stage, root } = session;
let dragging = false;
let moved = false;
let lastX = 0;
let lastY = 0;
let pointers = new Map();
let pinchStartDist = 0;
let pinchStartScale = 1;
const pointHitsImage = (clientX, clientY) => {
const img = session.img;
if (!img) return false;
const r = img.getBoundingClientRect();
return (
clientX >= r.left &&
clientX <= r.right &&
clientY >= r.top &&
clientY <= r.bottom
);
};
const onWheel = (ev) => {
ev.preventDefault();
const factor = ev.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP;
zoomBy(factor, ev.clientX, ev.clientY);
};
const onPointerDown = (ev) => {
if (ev.button != null && ev.button !== 0) return;
pointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY });
if (pointers.size === 2) {
const pts = [...pointers.values()];
pinchStartDist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y) || 1;
pinchStartScale = session.scale;
dragging = false;
stage.setPointerCapture?.(ev.pointerId);
return;
}
// Pan at any zoom, but only when grabbing the image (not empty backdrop).
if (!pointHitsImage(ev.clientX, ev.clientY)) return;
stage.setPointerCapture?.(ev.pointerId);
dragging = true;
moved = false;
lastX = ev.clientX;
lastY = ev.clientY;
stage.classList.add('is-dragging');
};
const onPointerMove = (ev) => {
if (pointers.has(ev.pointerId)) {
pointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY });
}
if (pointers.size === 2) {
const pts = [...pointers.values()];
const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y) || 1;
const midX = (pts[0].x + pts[1].x) / 2;
const midY = (pts[0].y + pts[1].y) / 2;
zoomAt(pinchStartScale * (dist / pinchStartDist), midX, midY);
return;
}
if (!dragging) return;
const dx = ev.clientX - lastX;
const dy = ev.clientY - lastY;
if (Math.abs(dx) > 1 || Math.abs(dy) > 1) moved = true;
lastX = ev.clientX;
lastY = ev.clientY;
session.tx += dx;
session.ty += dy;
applyTransform();
};
const endPointer = (ev) => {
pointers.delete(ev.pointerId);
if (pointers.size < 2) {
pinchStartDist = 0;
}
if (!dragging) return;
dragging = false;
stage.classList.remove('is-dragging');
stage.releasePointerCapture?.(ev.pointerId);
};
const onDblClick = (ev) => {
ev.preventDefault();
if (session.scale > MIN_SCALE + 0.01 || Math.abs(session.tx) > 1 || Math.abs(session.ty) > 1) {
resetTransform();
} else {
zoomAt(2.5, ev.clientX, ev.clientY);
}
};
stage.addEventListener('wheel', onWheel, { passive: false });
stage.addEventListener('pointerdown', onPointerDown);
stage.addEventListener('pointermove', onPointerMove);
stage.addEventListener('pointerup', endPointer);
stage.addEventListener('pointercancel', endPointer);
stage.addEventListener('dblclick', onDblClick);
// Close when clicking backdrop / empty stage around the image (not the image itself).
root.addEventListener('click', (ev) => {
if (ev.target.closest('[data-blp-viewer-close]')) {
close();
return;
}
if (
ev.target.closest(
'.blp-viewer__tool, .blp-viewer__nav, .blp-viewer__film, .blp-viewer__chrome, .blp-viewer__counter, .blp-viewer__title'
)
) {
return;
}
if (moved) {
moved = false;
return;
}
if (pointHitsImage(ev.clientX, ev.clientY)) return;
close();
});
}
function open(options = {}) {
const items = normalizeItems(options.items);
if (!items.length) return null;
close();
let index = Number(options.index);
if (!Number.isFinite(index)) index = 0;
index = clamp(index | 0, 0, items.length - 1);
const title = options.title || t.steamGalleryTitle || '';
const root = document.createElement('div');
root.className = 'blp-viewer';
root.setAttribute('role', 'dialog');
root.setAttribute('aria-modal', 'true');
root.setAttribute('aria-label', title || 'Image viewer');
root.innerHTML = `
<div class="blp-viewer__chrome">
<div class="blp-viewer__chrome-left">
<span class="blp-viewer__title">${escapeHtml(title)}</span>
<span class="blp-viewer__counter" data-blp-viewer-counter></span>
</div>
<div class="blp-viewer__chrome-right">
<button type="button" class="blp-viewer__tool" data-blp-viewer-zoom-out aria-label="${escapeAttr(t.viewerZoomOut)}">−</button>
<button type="button" class="blp-viewer__tool" data-blp-viewer-zoom-reset aria-label="${escapeAttr(t.viewerZoomReset)}">⟲</button>
<button type="button" class="blp-viewer__tool" data-blp-viewer-zoom-in aria-label="${escapeAttr(t.viewerZoomIn)}">+</button>
<button type="button" class="blp-viewer__tool" data-blp-viewer-close aria-label="${escapeAttr(t.steamGalleryClose)}">×</button>
</div>
</div>
<div class="blp-viewer__body">
<button type="button" class="blp-viewer__nav" data-blp-viewer-prev aria-label="${escapeAttr(t.steamGalleryPrev)}">‹</button>
<div class="blp-viewer__stage" data-blp-viewer-stage>
<div class="blp-viewer__spinner" data-blp-viewer-spinner aria-hidden="true" aria-label="${escapeAttr(t.loading)}"></div>
<div class="blp-viewer__frame" data-blp-viewer-frame>
<img class="blp-viewer__img" alt="" decoding="async" referrerpolicy="no-referrer" draggable="false" />
</div>
</div>
<button type="button" class="blp-viewer__nav" data-blp-viewer-next aria-label="${escapeAttr(t.steamGalleryNext)}">›</button>
</div>
<div class="blp-viewer__film" data-blp-viewer-film></div>
`;
const film = root.querySelector('[data-blp-viewer-film]');
film.innerHTML = items
.map((item, i) => {
const coverClass = item.kind === 'cover' ? ' blp-viewer__thumb--cover' : '';
return `
<button type="button" class="blp-viewer__thumb${coverClass}" data-blp-viewer-thumb="${i}" aria-label="${escapeAttr(t.steamGalleryOpen)}">
<img src="${escapeAttr(item.thumb || item.src)}" alt="" loading="lazy" decoding="async" referrerpolicy="no-referrer" draggable="false" />
</button>
`;
})
.join('');
const onKeyDown = (ev) => {
if (!session) return;
if (ev.key === 'Escape') {
ev.preventDefault();
close();
return;
}
if (ev.key === 'ArrowLeft') {
ev.preventDefault();
go(-1);
return;
}
if (ev.key === 'ArrowRight') {
ev.preventDefault();
go(1);
return;
}
if (ev.key === 'Home') {
ev.preventDefault();
show(0);
return;
}
if (ev.key === 'End') {
ev.preventDefault();
show(items.length - 1);
return;
}
if (ev.key === '+' || ev.key === '=') {
ev.preventDefault();
zoomBy(ZOOM_STEP);
return;
}
if (ev.key === '-' || ev.key === '_') {
ev.preventDefault();
zoomBy(1 / ZOOM_STEP);
return;
}
if (ev.key === '0') {
ev.preventDefault();
resetTransform();
}
};
session = {
root,
items,
index,
scale: MIN_SCALE,
tx: 0,
ty: 0,
loadGen: 0,
prefetched: new Set(),
prevOverflow: document.documentElement.style.overflow,
img: root.querySelector('.blp-viewer__img'),
frame: root.querySelector('[data-blp-viewer-frame]'),
stage: root.querySelector('[data-blp-viewer-stage]'),
spinner: root.querySelector('[data-blp-viewer-spinner]'),
film,
counter: root.querySelector('[data-blp-viewer-counter]'),
prevBtn: root.querySelector('[data-blp-viewer-prev]'),
nextBtn: root.querySelector('[data-blp-viewer-next]'),
zoomInBtn: root.querySelector('[data-blp-viewer-zoom-in]'),
zoomOutBtn: root.querySelector('[data-blp-viewer-zoom-out]'),
resetBtn: root.querySelector('[data-blp-viewer-zoom-reset]'),
onKeyDown,
onClose: options.onClose,
};
document.documentElement.style.overflow = 'hidden';
session.prevBtn?.addEventListener('click', (ev) => {
ev.stopPropagation();
go(-1);
});
session.nextBtn?.addEventListener('click', (ev) => {
ev.stopPropagation();
go(1);
});
session.zoomInBtn?.addEventListener('click', (ev) => {
ev.stopPropagation();
zoomBy(ZOOM_STEP);
});
session.zoomOutBtn?.addEventListener('click', (ev) => {
ev.stopPropagation();
zoomBy(1 / ZOOM_STEP);
});
session.resetBtn?.addEventListener('click', (ev) => {
ev.stopPropagation();
resetTransform();
});
film.querySelectorAll('[data-blp-viewer-thumb]').forEach((btn) => {
btn.addEventListener('click', (ev) => {
ev.stopPropagation();
show(Number(btn.getAttribute('data-blp-viewer-thumb')));
});
});
bindStageInteractions();
document.body.appendChild(root);
document.addEventListener('keydown', onKeyDown, true);
show(index);
return session;
}
return {
open,
close,
isOpen,
go,
show,
zoomBy,
resetTransform,
};
})();
function openBlpImageViewer(options) {
return BlpImageViewer.open(options);
}
function closeBlpImageViewer() {
BlpImageViewer.close();
}
function isBlpImageViewerOpen() {
return BlpImageViewer.isOpen();
}
function closeSteamGalleryLightbox() {
closeBlpImageViewer();
}
function openSteamGalleryLightbox(shots, startIndex) {
openBlpImageViewer({
items: shots,
index: startIndex,
title: t.steamGalleryTitle,
});
}
function applySteamGallery(screenshots, appId, token = '', { final = false, coverUrl = '' } = {}) {
if (!settings.showSteamDbGallery) {
steamGalleryItems = [];
document.querySelectorAll(`[${STEAMDB_ATTR}="gallery"]`).forEach((el) => el.remove());
bindSteamDbCoverGallery([]);
return;
}
const shotsPending = !Array.isArray(screenshots);
const items = buildSteamGalleryItems(shotsPending ? [] : screenshots, coverUrl);
// While screenshots load, still show the cover in the strip if we have it.
if (shotsPending && !coverUrl) {
ensureSteamGalleryMount(token);
return;
}
if (!items.length) {
steamGalleryItems = [];
if (final) {
document.querySelectorAll(`[${STEAMDB_ATTR}="gallery"]`).forEach((el) => el.remove());
} else {
ensureSteamGalleryMount(token);
}
bindSteamDbCoverGallery([]);
return;
}
const host = ensureSteamGalleryMount(token);
if (!host) return;
const readyKey = `${appId || ''}|${items.length}|${items[0]?.full || ''}|${shotsPending ? 'p' : 'd'}`;
if (host.dataset.blpGalleryReady === '1' && host.dataset.blpGalleryKey === readyKey) {
if (token) host.setAttribute('data-blp-token', token);
steamGalleryItems = items;
bindSteamDbCoverGallery(items);
return;
}
if (appId) host.setAttribute('data-blp-appid', String(appId));
if (token) host.setAttribute('data-blp-token', token);
host.dataset.blpGalleryReady = '1';
host.dataset.blpGalleryKey = readyKey;
host.dataset.blpGalleryCount = String(items.length);
steamGalleryItems = items;
const steamDbUrl = appId
? `${STEAMDB_APP_URL}/${appId}/screenshots/`
: `${STEAMDB_APP_URL}/`;
const htmlItems = items
.map((shot, i) => {
const src = escapeAttr(shot.thumb || shot.full);
const coverClass = shot.kind === 'cover' ? ' blp-steam-gallery__item--cover' : '';
return `
<button type="button" class="blp-steam-gallery__item${coverClass}" data-blp-gallery-index="${i}" aria-label="${escapeAttr(t.steamGalleryOpen)}">
<img src="${src}" alt="" loading="lazy" decoding="async" referrerpolicy="no-referrer" />
</button>
`;
})
.join('');
host.classList.remove('is-loading');
host.innerHTML = `
<div class="blp-steam-gallery__head">
<span class="blp-steam-gallery__title">${escapeHtml(t.steamGalleryTitle)}</span>
<a class="blp-steam-gallery__link" href="${escapeAttr(steamDbUrl)}" target="_blank" rel="noopener noreferrer">SteamDB</a>
</div>
<div class="blp-steam-gallery__track" data-blp-gallery-track>${htmlItems}</div>
`;
host.querySelectorAll('.blp-steam-gallery__item img').forEach((img) => {
const mark = () => img.classList.add('is-ready');
img.addEventListener('load', mark, { once: true });
if (img.complete && img.naturalWidth) mark();
});
host.querySelectorAll('[data-blp-gallery-index]').forEach((btn) => {
btn.addEventListener('click', () => {
const idx = Number(btn.getAttribute('data-blp-gallery-index'));
openSteamGalleryLightbox(items, idx);
});
});
bindSteamDbCoverGallery(items);
}
function injectSteamDbTitleIcon(url, appId, token = '') {
if (!settings.showSteamDbIcon) {
document.querySelectorAll(`[${STEAMDB_ATTR}="icon"]`).forEach((el) => el.remove());
return;
}
if (!url) return;
const wrap = ensureSteamDbTitleIconMount(token);
if (!wrap) return;
if (appId) wrap.setAttribute('data-blp-appid', String(appId));
const img = wrap.querySelector('img.blp-title-icon');
if (!img) return;
delete img.dataset.blpFallback;
revealSteamDbMediaImage(wrap, img, url);
}
function injectSteamDbCover(url, appId, { logoIsPortrait = false, token = '' } = {}) {
if (!settings.showSteamDbCover) {
document.querySelectorAll(`[${STEAMDB_ATTR}="cover"]`).forEach((el) => el.remove());
return;
}
if (!url) return;
const box = ensureSteamDbCoverMount(token);
if (!box) return;
const fallbacks = [];
const push = (u) => {
if (u && !fallbacks.includes(u)) fallbacks.push(u);
};
push(url);
push(steamCdnAsset(appId, 'header.jpg'));
push(steamCdnAsset(appId, 'library_600x900.jpg'));
push(steamCdnAsset(appId, 'logo.png'));
box.dataset.blpFallbacks = fallbacks.join('\n');
box.dataset.blpFallbackIdx = '0';
if (appId) box.setAttribute('data-blp-appid', String(appId));
const img = box.querySelector('img');
if (!img) return;
const firstIsLogo = isSteamLogoAsset(fallbacks[0]) && !logoIsPortrait;
img.classList.toggle('blp-steamdb-cover__logo', firstIsLogo);
revealSteamDbMediaImage(box, img, fallbacks[0]);
}
function applySteamDbUi(steamDb, token = '', { final = false } = {}) {
if (!steamDb) {
removeSteamDbUi();
return;
}
// Keep skeleton mounts; only reveal when a URL is ready (empty URL = still loading).
if (settings.showSteamDbIcon) ensureSteamDbTitleIconMount(token);
if (settings.showSteamDbCover) ensureSteamDbCoverMount(token);
injectSteamDbTitleIcon(steamDb.iconUrl, steamDb.appId, token);
injectSteamDbCover(steamDb.logoUrl, steamDb.appId, {
logoIsPortrait: Boolean(steamDb.logoIsPortrait),
token,
});
applySteamGallery(steamDb.screenshots, steamDb.appId, token, {
final,
coverUrl: steamDb.logoUrl || '',
});
if (
steamDb.source === 'steamdb' &&
steamDb.logoUrl &&
isSteamLogoAsset(steamDb.logoUrl)
) {
document
.querySelector(`[${STEAMDB_ATTR}="cover"] img`)
?.classList.add('blp-steamdb-cover__logo');
}
if (token) {
document.querySelectorAll(`[${STEAMDB_ATTR}]`).forEach((el) => {
el.setAttribute('data-blp-token', token);
});
}
}
function getApiLanguage() {
if (locale === 'zh') return 'zh-CN';
if (locale === 'pt') return 'pt-BR';
if (locale === 'en') return 'en-US';
return locale || 'en-US';
}
function gsSlugify(text) {
return String(text || '')
.toLowerCase()
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[™®©'’":]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/-+/g, '-');
}
function extractSteamSlugFromHref(href) {
const match = String(href || '').match(/\/app\/\d+\/([^/?#]+)/i);
if (!match) return null;
return gsSlugify(match[1].replace(/_/g, '-'));
}
function isValidGsSlug(slug) {
if (!slug || slug.length < 2) return false;
if (!/[a-z]/.test(slug)) return false;
if (GS_INVALID_SLUG_RE.test(slug)) return false;
if (/^\d+(-\d+)+$/.test(slug)) return false;
const segments = slug.split('-').filter(Boolean);
if (!segments.length) return false;
if (segments.every((part) => /^\d+$/.test(part))) return false;
const numericParts = segments.filter((part) => /^\d+$/.test(part)).length;
return numericParts / segments.length <= 0.5;
}
/**
* Backloggd remake disambiguator: resident-evil-2--1 → resident-evil-2-remake
* (GameStatus uses -remake; plain gsSlugify would yield -1).
*/
function gsRemakeSlugFromPageSlug(pageSlug) {
const raw = String(pageSlug || '').replace(/^\/+|\/+$/g, '');
if (!/--1$/i.test(raw)) return null;
return gsSlugify(raw.replace(/--1$/i, '-remake'));
}
function buildGsSlugCandidates({ storeUrl, name, title, pageSlug }) {
const candidates = [];
const addSlug = (slug) => {
if (slug && isValidGsSlug(slug) && !candidates.includes(slug)) {
candidates.push(slug);
}
};
const addFromText = (text) => {
const normalized = String(text || '').replace(/\s+/g, ' ').trim();
if (!normalized) return;
addSlug(gsSlugify(normalized));
addSlug(gsSlugify(normalized.replace(/\s*[-–—:|].*$/, '')));
};
// Prefer Backloggd --1 → -remake before store/title fallbacks.
addSlug(gsRemakeSlugFromPageSlug(pageSlug));
addSlug(extractSteamSlugFromHref(storeUrl));
addFromText(name);
addFromText(title);
addSlug(gsSlugify(pageSlug));
return candidates;
}
function isMatchingGsGame(data, appId) {
if (!data) return false;
if (!data.steam_prod_id) return true;
return String(data.steam_prod_id) === String(appId);
}
function buildGsApiUrl(slug) {
return `${GAMESTATUS_API_BASE}/${encodeURIComponent(slug)}/`;
}
async function fetchGsBySlug(slug, appId) {
const data = await gmRequest({
url: buildGsApiUrl(slug),
allow404: true,
headers: {
Accept: 'application/json',
'Accept-Language': getApiLanguage(),
},
timeout: 15000,
});
if (!data) {
return { data: null, match: false, outcome: '404' };
}
const match = isMatchingGsGame(data, appId);
return {
data,
match,
outcome: match ? 'match' : 'steam_prod_id_mismatch',
steam_prod_id: data.steam_prod_id ?? null,
readable_status: data.readable_status || null,
title: data.title || null,
slug: data.slug || slug,
};
}
async function fetchGameStatus({ appId, storeUrl, name, title, pageSlug }) {
const debugOn = Boolean(settings.debugMode);
if (!appId) {
return {
missing: true,
data: null,
slug: null,
_debug: { reason: 'No Steam appId — GameStatus skipped', appId: null },
};
}
const cacheKey = `gs:${appId}`;
if (!debugOn) {
const cached = getCached(cacheKey);
if (cached) return cached;
}
if (inflight.has(cacheKey)) return inflight.get(cacheKey);
const task = (async () => {
const slugs = buildGsSlugCandidates({ storeUrl, name, title, pageSlug });
const tried = slugs.slice(0, GAMESTATUS_MAX_SLUG_ATTEMPTS);
const debug = {
appId,
cacheKey,
cacheSkipped: debugOn,
candidates: slugs,
tried,
attempts: [],
};
for (const slug of tried) {
try {
const result = await fetchGsBySlug(slug, appId);
debug.attempts.push({
slug,
url: buildGsApiUrl(slug),
outcome: result.outcome,
steam_prod_id: result.steam_prod_id,
title: result.title,
readable_status: result.readable_status,
});
if (result.match && result.data) {
debug.reason = `Matched slug "${result.slug || slug}" (steam_prod_id=${result.steam_prod_id ?? 'empty'})`;
const entry = {
missing: false,
data: result.data,
slug: result.data.slug || slug,
_debug: debug,
};
if (!debugOn) {
const { _debug, ...store } = entry;
setCached(cacheKey, store);
}
return entry;
}
} catch (err) {
debug.attempts.push({
slug,
url: buildGsApiUrl(slug),
outcome: 'error',
error: String(err?.message || err),
});
}
}
debug.reason = tried.length
? 'No GameStatus match for tried slugs'
: 'No valid GameStatus slug candidates';
const miss = { missing: true, data: null, slug: tried[0] || null, _debug: debug };
return miss;
})();
inflight.set(cacheKey, task);
try {
return await task;
} finally {
inflight.delete(cacheKey);
}
}
function parseGsDate(value) {
if (!value) return null;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
function getGsPendingVariant(game) {
const release = parseGsDate(game.release_date);
if (!release) return 'pending-recent';
const monthAgo = new Date();
monthAgo.setMonth(monthAgo.getMonth() - 1);
return release > monthAgo ? 'pending-recent' : 'pending-old';
}
function getGsStatusType(game) {
if (!game) return 'missing';
const status = String(game.readable_status || '').toLowerCase();
const groups = String(game.hacked_groups_en || game.hacked_groups || '').toLowerCase();
if (/release today|релиз сегодня|выходит сегодня/.test(status)) return 'release-today';
if (/bypass|обход|hypervisor/.test(groups) || /bypass|обход/.test(status)) return 'partial';
if (/not cracked|не взлом|не взломан|unbroken|unreleased crack/.test(status)) {
return getGsPendingVariant(game);
}
if (game.crack_date || /cracked|взлом/.test(status)) return 'ready';
return 'unknown';
}
function getGsStatusLabel(game, type) {
if (!game) return t.gsNotInDatabase;
if (game.readable_status) return game.readable_status;
if (type === 'ready') return t.gsReady;
if (type === 'pending-recent' || type === 'pending-old') return t.gsPending;
if (type === 'partial') return t.gsPartial;
if (type === 'release-today') return t.gsReleaseToday;
return t.gsUnknown;
}
function cleanGsChipToken(value) {
return String(value || '')
.replace(/^[\s\[\(\{"'`]+|[\]\)\}"'`\s]+$/g, '')
.replace(/^["'`]+|["'`]+$/g, '')
.trim();
}
function splitGsChipValues(value) {
if (value == null || value === '') return [];
if (Array.isArray(value)) {
return value.map(cleanGsChipToken).filter(Boolean);
}
const raw = String(value).trim();
if (!raw) return [];
if (/^[\[{"]/.test(raw)) {
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
return parsed.map(cleanGsChipToken).filter(Boolean);
}
if (typeof parsed === 'string') {
return [cleanGsChipToken(parsed)].filter(Boolean);
}
} catch (_) {
/* fall through to delimiter split */
}
}
return raw
.split(/[,;/|]+/)
.map(cleanGsChipToken)
.filter(Boolean);
}
function gsProtectionChipClass(name) {
return /denuvo/i.test(name) ? 'blp-gs-chip--highlight' : 'blp-gs-chip--protection';
}
function renderGameStatusValues(entry) {
const game = entry?.data;
if (!game) return '';
const type = getGsStatusType(game);
const label = getGsStatusLabel(game, type);
const slug = game.slug || entry.slug;
const href = slug ? `${GAMESTATUS_SITE_BASE}/${encodeURIComponent(slug)}` : GAMESTATUS_SITE_BASE;
const chips = [];
for (const prot of splitGsChipValues(game.protections)) {
chips.push(
`<span class="blp-gs-chip ${gsProtectionChipClass(prot)}">${escapeHtml(prot)}</span>`
);
}
for (const group of splitGsChipValues(game.hacked_groups_en || game.hacked_groups)) {
const groupType = /bypass|обход|hypervisor/i.test(group) ? 'partial' : type;
chips.push(
`<span class="blp-gs-chip blp-gs-chip--${escapeAttr(groupType)}">${escapeHtml(group)}</span>`
);
}
if (game.is_AAA) {
chips.push(`<span class="blp-gs-chip blp-gs-chip--aaa">AAA</span>`);
}
const lines = [
`<span class="blp-steam-line"><a class="blp-gs-badge blp-gs-badge--${escapeAttr(type)} blp-ext-link" href="${escapeAttr(href)}" target="_blank" rel="noopener noreferrer"><span class="blp-gs-badge__dot" aria-hidden="true"></span>${escapeHtml(label)}</a></span>`,
];
if (chips.length) {
lines.push(`<span class="blp-steam-line blp-gs-chips">${chips.join('')}</span>`);
}
return lines.join('');
}
function buildExternalLinks({ title, slug, igdbUrl, steam }) {
const q = encodeURIComponent(title || slug || '');
const links = [];
if (igdbUrl) links.push({ key: 'igdb', label: t.linkIgdb, url: igdbUrl });
if (steam?.storeUrl) {
links.push({ key: 'steam', label: t.linkSteam, url: steam.storeUrl });
} else if (q) {
links.push({
key: 'steam',
label: t.linkSteam,
url: `https://store.steampowered.com/search/?term=${q}`,
});
}
if (steam?.appId) {
links.push({
key: 'steamdb',
label: t.linkSteamDb,
url: `https://steamdb.info/app/${steam.appId}/`,
});
} else if (q) {
links.push({
key: 'steamdb',
label: t.linkSteamDb,
url: `https://steamdb.info/search/?a=app&q=${q}`,
});
}
const mcUrl = metacriticGameUrl(title, slug);
if (mcUrl) {
links.push({ key: 'metacritic', label: t.linkMetacritic, url: mcUrl });
}
if (q) {
links.push({
key: 'opencritic',
label: t.linkOpencritic,
url: `https://opencritic.com/search?q=${q}`,
});
links.push({
key: 'hltb',
label: t.linkHltb,
url: `https://howlongtobeat.com/?q=${q}`,
});
links.push({
key: 'pcgamingwiki',
label: t.linkPcgamingwiki,
url: `${PCGW_SITE}/wiki/Special:Search?search=${q}`,
});
if (steam?.appId) {
links.push({
key: 'itad',
label: t.linkItad,
url: `${ITAD_SITE}/steam/app/${steam.appId}/`,
});
} else {
links.push({
key: 'itad',
label: t.linkItad,
url: `${ITAD_SITE}/search/?q=${q}`,
});
}
links.push({
key: 'gogdb',
label: t.linkGogdb,
url: `${GOGDB_SITE}/?q=${q}`,
});
}
return links.filter((l) => isLinkEnabled(l.key));
}
function removeEnrichment() {
document.querySelectorAll(`[${ENRICH_ATTR}]`).forEach((el) => el.remove());
document.querySelectorAll('[data-blp-debug]').forEach((el) => el.remove());
removeSteamDbUi();
unbindGameCoverViewer();
}
function makeDetailRow(key, headerText) {
const row = document.createElement('div');
row.className = 'row mt-2';
row.setAttribute(ENRICH_ATTR, key);
row.innerHTML = `
<div class="col-3 col-md-2 my-auto">
<p class="game-details-header">${escapeHtml(headerText)}</p>
</div>
<div class="col-auto col-md ml-auto my-auto text-right text-md-left" data-blp-values></div>
`;
return row;
}
function skeletonHtml(kind) {
if (kind === 'links') {
return [0, 1, 2, 3, 4]
.map(() => '<span class="blp-skeleton blp-skeleton--link"></span>')
.join('');
}
if (kind === 'steam') {
return `
<span class="blp-skeleton blp-skeleton--sm"></span>
<span class="blp-skeleton blp-skeleton--md"></span>
<span class="blp-skeleton blp-skeleton--lg"></span>
`;
}
if (kind === 'metacritic') return '<span class="blp-skeleton blp-skeleton--sm"></span>';
if (kind === 'opencritic') return '<span class="blp-skeleton blp-skeleton--sm"></span>';
if (kind === 'hltb') {
return `
<span class="blp-skeleton blp-skeleton--md"></span>
<span class="blp-skeleton blp-skeleton--md"></span>
<span class="blp-skeleton blp-skeleton--md"></span>
`;
}
if (kind === 'deckproton') {
return `
<span class="blp-skeleton blp-skeleton--md"></span>
<span class="blp-skeleton blp-skeleton--md"></span>
`;
}
if (kind === 'players') return '<span class="blp-skeleton blp-skeleton--sm"></span>';
if (kind === 'gamestatus') {
return `
<span class="blp-skeleton blp-skeleton--md"></span>
<span class="blp-skeleton blp-skeleton--sm"></span>
`;
}
return '<span class="blp-skeleton blp-skeleton--md"></span>';
}
function ensureEnrichmentRows() {
const platforms = document.querySelector('#game-body #game-page-platforms, #game-page-platforms');
if (!platforms) return null;
let anchor = platforms;
const existing = document.querySelector(`[${ENRICH_ATTR}]`);
if (existing) {
return {
steam: document.querySelector(`[${ENRICH_ATTR}="steam"]`),
metacritic: document.querySelector(`[${ENRICH_ATTR}="metacritic"]`),
opencritic: document.querySelector(`[${ENRICH_ATTR}="opencritic"]`),
hltb: document.querySelector(`[${ENRICH_ATTR}="hltb"]`),
deckproton: document.querySelector(`[${ENRICH_ATTR}="deckproton"]`),
players: document.querySelector(`[${ENRICH_ATTR}="players"]`),
gamestatus: document.querySelector(`[${ENRICH_ATTR}="gamestatus"]`),
links: document.querySelector(`[${ENRICH_ATTR}="links"]`),
};
}
const rows = {};
const plan = [];
if (settings.showSteam) plan.push(['steam', t.steam]);
if (settings.showMetacritic) plan.push(['metacritic', t.metacritic]);
if (settings.showOpenCritic) plan.push(['opencritic', t.openCritic]);
if (settings.showHltb) plan.push(['hltb', t.hltb]);
if (settings.showDeckProton) plan.push(['deckproton', t.deckProton]);
if (settings.showSteamPlayers) plan.push(['players', t.players]);
if (settings.showGameStatus) plan.push(['gamestatus', t.gameStatus]);
if (settings.showLinks) plan.push(['links', t.links]);
for (const [key, label] of plan) {
const row = makeDetailRow(key, label);
row.querySelector('[data-blp-values]').innerHTML = skeletonHtml(key);
anchor.insertAdjacentElement('afterend', row);
anchor = row;
rows[key] = row;
}
return rows;
}
function setRowValues(row, html) {
const slot = row?.querySelector('[data-blp-values]');
if (slot) slot.innerHTML = html;
}
function hideRow(row) {
if (row) row.hidden = true;
}
function showRow(row) {
if (row) row.hidden = false;
}
function renderExtLink(link, { last = false } = {}) {
const icon = faviconForUrl(link.url);
const img = icon
? `<img class="blp-favicon" src="${escapeAttr(icon)}" alt="" width="14" height="14" loading="lazy" decoding="async" referrerpolicy="no-referrer" />`
: '';
const sep = last ? '' : '<span class="separator">•</span>';
return `
<span class="game-detail">
<a class="game-details-value blp-ext-link" href="${escapeAttr(link.url)}" target="_blank" rel="noopener noreferrer">
${img}${escapeHtml(link.label)}
</a>${sep}
</span>
`;
}
function renderLibraryBadge(kind) {
if (kind === 'owned') {
return `<span class="blp-owned-badge" title="${escapeAttr(t.steamOwned)}"><svg class="blp-owned-badge__icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path fill="currentColor" d="M6.4 11.3 2.9 7.8l1.2-1.2 2.3 2.3 5-5.1 1.2 1.2z"/></svg>${escapeHtml(t.steamOwned)}</span>`;
}
if (kind === 'wishlist') {
return `<span class="blp-wishlist-badge" title="${escapeAttr(t.steamWishlist)}"><svg class="blp-owned-badge__icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path fill="currentColor" d="M8 2.2 9.7 6l4 .3-3.1 2.6 1 3.9L8 10.6 4.4 12.8l1-3.9L2.3 6.3l4-.3z"/></svg>${escapeHtml(t.steamWishlist)}</span>`;
}
return '';
}
function renderSteamMatchControl(steam, slug) {
if (!slug) return '';
const overrideId = getSteamOverride(slug);
const appId = steam?.appId || overrideId || '';
if (!appId && !settings.showSteam) return '';
const source = overrideId || steam?.manualOverride ? t.steamMatchManual : t.steamMatchAuto;
const label = appId
? fmt(t.steamMatchLabel, { id: appId, source })
: t.steamFixMatch;
return `<span class="blp-steam-match">
<span class="blp-steam-match__label">${escapeHtml(label)}</span>
<button type="button" class="blp-steam-match__btn" data-blp-fix-match data-blp-slug="${escapeAttr(slug)}" data-blp-appid="${escapeAttr(appId || '')}">${escapeHtml(t.steamFixMatch)}</button>
</span>`;
}
function renderSteamValues(steam, { owned = false, wishlist = false, slug = '' } = {}) {
const parts = [];
if (owned) {
parts.push({ html: renderLibraryBadge('owned') });
} else if (wishlist) {
parts.push({ html: renderLibraryBadge('wishlist') });
}
const priceText = formatPriceText(steam);
const discount =
steam.price?.discount_percent > 0
? ` <span class="blp-discount">${escapeHtml(fmt(t.discount, { n: steam.price.discount_percent }))}</span>`
: '';
const endsLabel = formatDiscountEndDate(steam.discountEndDate);
const discountEnds =
steam.price?.discount_percent > 0 && endsLabel
? ` <span class="blp-discount-ends">${escapeHtml(fmt(t.discountEnds, { date: endsLabel }))}</span>`
: '';
if (priceText) {
parts.push({
html: `<a class="game-details-value blp-ext-link" href="${escapeAttr(steam.storeUrl)}" target="_blank" rel="noopener noreferrer">${escapeHtml(priceText)}${discount}${discountEnds}</a>`,
});
}
const reviewText = formatReviewPercent(steam.reviews);
const reviewClass = reviewScoreClass(steam.reviews);
if (reviewText) {
parts.push({
html: `<a class="game-details-value ${reviewClass}" href="${escapeAttr(steam.storeUrl)}#app_reviews_hash" target="_blank" rel="noopener noreferrer">${escapeHtml(reviewText)}</a>`,
});
} else if (steam.recommendations) {
parts.push({
html: `<span class="game-details-value ${reviewClass}">${escapeHtml(fmt(t.recommendations, { n: steam.recommendations.toLocaleString() }))}</span>`,
});
}
if (steam.usedUsFallback) {
parts.push({
html: `<span class="game-details-value blp-steam-note">${escapeHtml(fmt(t.steamUsFallback, { cc: steam.requestedCountry || '' }))}</span>`,
});
}
if (settings.showSteamCategories !== false && steam.categories?.length) {
const chips = steam.categories
.map(
(cat) =>
`<a class="blp-gs-chip blp-steam-category" href="${escapeAttr(steamCategoryUrl(cat.id))}" target="_blank" rel="noopener noreferrer">${escapeHtml(cat.description)}</a>`
)
.join('');
parts.push({
html: `<span class="blp-steam-categories">${chips}</span>`,
});
}
if (settings.showSteamTags !== false && steam.tags?.length) {
const chips = steam.tags
.map(
(tag) =>
`<a class="blp-gs-chip blp-steam-tag" href="${escapeAttr(steamTagUrl(tag.name))}" target="_blank" rel="noopener noreferrer">${escapeHtml(tag.name)}</a>`
)
.join('');
parts.push({
html: `<span class="blp-steam-tags">${chips}</span>`,
});
}
const matchHtml = renderSteamMatchControl(steam, slug);
if (matchHtml) parts.push({ html: matchHtml });
if (!parts.length) {
return `<a class="game-details-value blp-ext-link" href="${escapeAttr(steam.storeUrl)}" target="_blank" rel="noopener noreferrer">${escapeHtml(t.steam)}</a>`;
}
return parts.map((part) => `<span class="blp-steam-line">${part.html}</span>`).join('');
}
function debugLink(label, url, purpose, request) {
if (!url) return '';
const badge = request
? `<span class="blp-debug-panel__source-badge">${escapeHtml(t.debugSrcRequest)}</span>`
: '';
const purposeHtml = purpose
? `<span class="blp-debug-panel__source-purpose">${escapeHtml(purpose)}</span>`
: '';
return `<li class="${request ? 'is-request' : ''}">
<div class="blp-debug-panel__source-head">
${badge}<strong>${escapeHtml(label)}</strong>${purposeHtml}
</div>
<a href="${escapeAttr(url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(url)}</a>
</li>`;
}
function collectDebugSources({ steam, steamDb, gamestatus, title, slug }) {
const items = [];
const add = (module, label, url, purpose, request = false) => {
if (!url || items.some((x) => x.url === url && x.label === label && x.module === module)) {
return;
}
items.push({
module,
label,
url,
purpose: purpose || '',
request: Boolean(request),
});
};
add('page', 'Backloggd', location.href, t.debugSrcBackloggd);
if (slug) add('page', 'IGDB', getIgdbUrl(slug), t.debugSrcIgdb);
const mc = metacriticGameUrl(title, slug);
if (mc) add('page', 'Metacritic', mc, t.debugSrcMetacritic);
if (steam?.storeUrl) add('steam', 'Steam Store', steam.storeUrl, t.debugSrcSteamStore);
if (steam?.tinyImage) {
add('steam', 'Steam tiny image', steam.tinyImage, t.debugSrcSteamTiny);
}
if (steam?.headerImage) {
add('steam', 'Steam header image', steam.headerImage, t.debugSrcSteamHeader);
}
if (settings.showSteamOwned || settings.showSteamWishlist) {
add('steam', 'Steam userdata', STEAM_USERDATA_URL, t.debugSrcSteamOwned, true);
}
const steamDbg = steam?._debug || {};
for (const search of steamDbg.searches || []) {
if (!search?.url) continue;
const kind = search.anonymous ? 'guest' : 'session';
add(
'steam',
`Steam search (${kind}, ${search.country || '?'})`,
search.url,
t.debugSrcSteamSearch,
true
);
}
if (steamDbg.detailsUrl) {
add('steam', 'Steam appdetails', steamDbg.detailsUrl, t.debugSrcSteamDetails, true);
}
if (steamDbg.reviewsUrl) {
add('steam', 'Steam appreviews', steamDbg.reviewsUrl, t.debugSrcSteamReviews, true);
}
if (steamDbg.tagsUrl) {
add('steam', 'Steam GetItems (tags)', steamDbg.tagsUrl, t.debugSrcSteamTags, true);
}
if (steamDbg.tagMapUrl) {
add('steam', 'Steam populartags', steamDbg.tagMapUrl, t.debugSrcSteamTagMap, true);
}
if (steam?.appId) {
add('steamdb', 'SteamDB', `${STEAMDB_APP_URL}/${steam.appId}/`, t.debugSrcSteamDb);
add(
'steamdb',
'SteamDB charts',
`${STEAMDB_APP_URL}/${steam.appId}/charts/`,
t.debugSrcSteamDbCharts
);
add(
'steamdb',
'SteamDB info',
`${STEAMDB_APP_URL}/${steam.appId}/info/`,
t.debugSrcSteamDbInfo
);
}
const sdb = steamDb?._debug || {};
if (sdb.chartsUrl) {
add('steamdb', 'SteamDB charts', sdb.chartsUrl, t.debugSrcSteamDbCharts);
}
if (sdb.playersApiUrl) {
add('steamdb', 'Steam players API', sdb.playersApiUrl, t.debugSrcSteamPlayersApi, true);
}
if (sdb.iconUrl || steamDb?.iconUrl) {
add('steamdb', 'Icon URL', sdb.iconUrl || steamDb.iconUrl, t.debugSrcIcon);
}
if (sdb.logoUrl || steamDb?.logoUrl) {
add('steamdb', 'Logo URL', sdb.logoUrl || steamDb.logoUrl, t.debugSrcLogo);
}
const gs = gamestatus?._debug || {};
for (const attempt of gs.attempts || []) {
if (attempt?.url) {
add(
'gamestatus',
`GameStatus API (${attempt.slug || '?'})`,
attempt.url,
t.debugSrcGameStatusApi,
true
);
}
}
if (gamestatus?.slug) {
add(
'gamestatus',
'GameStatus page',
`${GAMESTATUS_SITE_BASE}/${encodeURIComponent(gamestatus.slug)}`,
t.debugSrcGameStatusPage
);
}
return items;
}
function renderDebugSourcesHtml(sources) {
const groups = [
{ key: 'page', label: t.debugSectionPage },
{ key: 'steam', label: t.debugSectionSteam },
{ key: 'steamdb', label: t.debugSectionSteamDb },
{ key: 'gamestatus', label: t.debugSectionGameStatus },
];
const blocks = [];
for (const group of groups) {
const list = sources
.filter((s) => s.module === group.key)
.sort((a, b) => Number(b.request) - Number(a.request));
if (!list.length) continue;
const itemsHtml = list.map((s) => debugLink(s.label, s.url, s.purpose, s.request)).join('');
blocks.push(`
<div class="blp-debug-panel__source-group">
<div class="blp-debug-panel__source-group-title">${escapeHtml(group.label)}</div>
<ul class="blp-debug-panel__source-group-list">${itemsHtml}</ul>
</div>
`);
}
return blocks.join('') || `<div>${escapeHtml('—')}</div>`;
}
function renderUnifiedDebugPanel({
steam,
steamDb,
gamestatus,
owned = false,
wishlist = false,
error = false,
title = '',
slug = '',
}) {
document.querySelectorAll('[data-blp-debug]').forEach((el) => el.remove());
if (!settings.debugMode) return;
const sources = collectDebugSources({ steam, steamDb, gamestatus, title, slug });
const sourcesHtml = renderDebugSourcesHtml(sources);
const sections = [];
const pushSection = (label, reason, dumpObj) => {
let dump = '';
try {
dump = JSON.stringify(dumpObj, null, 2);
} catch (_) {
dump = String(dumpObj);
}
sections.push(`
<div class="blp-debug-panel__section">
<span class="blp-debug-panel__label">${escapeHtml(label)}</span>
<span class="blp-debug-panel__reason">${escapeHtml(reason || '—')}</span>
<pre class="blp-debug-panel__pre">${escapeHtml(dump)}</pre>
</div>
`);
};
pushSection(t.debugSectionPage, `${title || '—'} / ${slug || '—'}`, {
href: location.href,
title,
slug,
settings: {
steamCountry: settings.steamCountry,
showSteam: settings.showSteam,
showSteamOwned: settings.showSteamOwned,
showSteamWishlist: settings.showSteamWishlist,
showSteamTags: settings.showSteamTags,
showSteamCategories: settings.showSteamCategories,
showMetacritic: settings.showMetacritic,
showOpenCritic: settings.showOpenCritic,
showHltb: settings.showHltb,
showDeckProton: settings.showDeckProton,
showGameStatus: settings.showGameStatus,
showSteamDbIcon: settings.showSteamDbIcon,
showSteamDbCover: settings.showSteamDbCover,
showSteamDbGallery: settings.showSteamDbGallery,
showSteamPlayers: settings.showSteamPlayers,
showCardBadges: settings.showCardBadges,
showLinks: settings.showLinks,
links: settings.links,
steamOverride: getSteamOverride(slug),
},
owned,
wishlist,
error,
SCRIPT_VERSION,
});
pushSection(
t.debugSectionSteam,
steam?._debug?.reason || (steam?.found ? `appId=${steam.appId}` : t.notOnSteam),
steam?._debug || steam || { reason: 'Steam payload missing' }
);
pushSection(
t.debugSectionSteamDb,
steamDb?._debug?.reason || (steamDb ? `source=${steamDb.source}` : 'SteamDB extras not fetched'),
steamDb?._debug || steamDb || { reason: 'SteamDB payload missing' }
);
pushSection(
t.debugSectionGameStatus,
gamestatus?._debug?.reason ||
(gamestatus && !gamestatus.missing
? `slug=${gamestatus.slug}`
: t.gsNotInDatabase),
gamestatus?._debug || gamestatus || { reason: 'GameStatus payload missing' }
);
const fullDump = {
page: { href: location.href, title, slug, owned, wishlist, error, SCRIPT_VERSION },
steam: steam?._debug || steam || null,
steamDb: steamDb?._debug || steamDb || null,
gamestatus: gamestatus?._debug || gamestatus || null,
sources,
};
let fullJson = '';
try {
fullJson = JSON.stringify(fullDump, null, 2);
} catch (_) {
fullJson = String(fullDump);
}
const panel = document.createElement('div');
panel.className = 'blp-debug-panel';
panel.setAttribute('data-blp-debug', '1');
panel.innerHTML = `
<h3 class="blp-debug-panel__title">${escapeHtml(t.debugPanelTitle)} · v${escapeHtml(SCRIPT_VERSION)}</h3>
<div class="blp-debug-panel__meta">${escapeHtml(t.debugOwned)}: ${owned ? t.on : t.off}</div>
<div class="blp-debug-panel__section">
<span class="blp-debug-panel__label">${escapeHtml(t.debugSources)}</span>
<div class="blp-debug-panel__sources">${sourcesHtml}</div>
</div>
${sections.join('')}
<div class="blp-debug-panel__section">
<span class="blp-debug-panel__label">${escapeHtml(t.debugDump)}</span>
<pre class="blp-debug-panel__pre">${escapeHtml(fullJson)}</pre>
</div>
`;
const lastRow = document.querySelector(`[${ENRICH_ATTR}]:last-of-type`);
const platforms = document.querySelector('#game-body #game-page-platforms, #game-page-platforms');
const anchor = lastRow || platforms;
if (anchor) anchor.insertAdjacentElement('afterend', panel);
else document.querySelector('#game-body')?.appendChild(panel);
}
function renderEnrichment(rows, { steam, links, error, owned = false, wishlist = false, gamestatus = null, title = '', slug = '', steamDb = null, opencritic = null, hltb = null, proton = null, skipDebug = false }) {
const debugOn = Boolean(settings.debugMode);
if (rows.steam) {
if (error) {
setRowValues(
rows.steam,
`<span class="game-details-value blp-empty">${escapeHtml(t.loadError)}</span>${renderSteamMatchControl(steam, slug)}`
);
showRow(rows.steam);
} else if (!steam?.found) {
const missHtml = debugOn
? `<span class="game-details-value blp-empty">${escapeHtml(t.notOnSteam)}</span>`
: '';
const matchHtml = renderSteamMatchControl(steam, slug);
if (missHtml || matchHtml) {
setRowValues(
rows.steam,
`${missHtml}${missHtml && matchHtml ? '' : ''}${matchHtml ? `<span class="blp-steam-line">${matchHtml}</span>` : ''}`
);
showRow(rows.steam);
} else {
hideRow(rows.steam);
}
} else {
setRowValues(rows.steam, renderSteamValues(steam, { owned, wishlist, slug }));
showRow(rows.steam);
}
}
if (rows.metacritic) {
const score = steam?.metacritic?.score;
if (score != null && !error) {
setRowValues(rows.metacritic, renderMetacriticBadge(score, metacriticGameUrl(title, slug)));
showRow(rows.metacritic);
} else {
hideRow(rows.metacritic);
}
}
if (rows.opencritic) {
if (opencritic == null) {
// Still loading — keep skeleton visible; do not hide.
showRow(rows.opencritic);
} else {
const html = renderOpenCriticValues(opencritic);
if (html && !error) {
setRowValues(rows.opencritic, html);
showRow(rows.opencritic);
} else {
hideRow(rows.opencritic);
}
}
}
if (rows.hltb) {
if (hltb == null) {
showRow(rows.hltb);
} else {
const html = renderHltbValues(hltb);
if (html && !error) {
setRowValues(rows.hltb, html);
showRow(rows.hltb);
} else {
hideRow(rows.hltb);
}
}
}
if (rows.deckproton) {
const html = renderDeckProtonValues({ steam, proton });
if (html && !error) {
setRowValues(rows.deckproton, html);
showRow(rows.deckproton);
} else if (debugOn && steam?.found) {
setRowValues(rows.deckproton, `<span class="game-details-value blp-empty">—</span>`);
showRow(rows.deckproton);
} else {
hideRow(rows.deckproton);
}
}
if (rows.players) {
const count = steamDb?.players;
if (count != null && !error) {
const href = steamDb?.appId
? `${STEAMDB_APP_URL}/${steamDb.appId}/charts/`
: `${STEAMDB_APP_URL}/`;
const label = fmt(t.playersOnline, { n: Number(count).toLocaleString() });
setRowValues(
rows.players,
`<a class="blp-players-badge blp-ext-link" href="${escapeAttr(href)}" target="_blank" rel="noopener noreferrer"><span class="blp-players-badge__dot" aria-hidden="true"></span>${escapeHtml(label)}</a>`
);
showRow(rows.players);
} else if (debugOn) {
setRowValues(rows.players, `<span class="game-details-value blp-empty">—</span>`);
showRow(rows.players);
} else {
hideRow(rows.players);
}
}
if (rows.gamestatus) {
if (gamestatus && !gamestatus.missing && gamestatus.data) {
setRowValues(rows.gamestatus, renderGameStatusValues(gamestatus));
showRow(rows.gamestatus);
} else {
hideRow(rows.gamestatus);
}
}
if (rows.links) {
if (links?.length) {
const html = links
.map((l, i) => renderExtLink(l, { last: i === links.length - 1 }))
.join('');
setRowValues(rows.links, html);
showRow(rows.links);
} else {
hideRow(rows.links);
}
}
if (!skipDebug) {
renderUnifiedDebugPanel({ steam, steamDb, gamestatus, owned, wishlist, error, title, slug });
}
}
let gamePageToken = 0;
async function enrichGamePage() {
const ctx = getPageContext();
if (!ctx.isGamePage || !ctx.slug) {
removeEnrichment();
return;
}
const title = getGameTitle();
if (!title) return;
const token = `${ctx.slug}|${title}|${settings.steamCountry}|${settings.showSteam}|${settings.showSteamOwned}|${settings.showSteamWishlist}|${settings.showSteamTags}|${settings.showSteamCategories}|${settings.showMetacritic}|${settings.showOpenCritic}|${settings.showHltb}|${settings.showDeckProton}|${settings.showGameStatus}|${settings.showLinks}|${settings.showSteamDbIcon}|${settings.showSteamDbCover}|${settings.showSteamDbGallery}|${settings.showSteamPlayers}|${getSteamOverride(ctx.slug) || ''}|${settings.debugMode}|${JSON.stringify(settings.links)}`;
const marker = document.querySelector(`[${ENRICH_ATTR}]`);
// Same page/settings: keep the in-flight (or finished) mount. Remounting while
// skeletons remain caused OpenCritic/HLTB/etc. to flicker on every MutationObserver pass.
if (marker?.getAttribute('data-blp-token') === token) {
ensureUnifiedRatingWidget(token);
bindGameCoverViewer();
return;
}
removeEnrichment();
const rows = ensureEnrichmentRows();
if (!rows) return;
Object.values(rows).forEach((row) => row?.setAttribute('data-blp-token', token));
ensureUnifiedRatingWidget(token);
bindGameCoverViewer();
const runId = ++gamePageToken;
const igdbUrl = getIgdbUrl(ctx.slug);
const stillHere = () => runId === gamePageToken && getPageContext().isGamePage;
const needSteamDb =
settings.showSteamDbIcon ||
settings.showSteamDbCover ||
settings.showSteamDbGallery ||
settings.showSteamPlayers;
const needSteamDbMedia =
settings.showSteamDbIcon || settings.showSteamDbCover || settings.showSteamDbGallery;
if (needSteamDbMedia) mountSteamDbSkeletons(token);
const needSteam =
settings.showSteam ||
settings.showMetacritic ||
settings.showDeckProton ||
settings.showGameStatus ||
needSteamDb ||
(settings.showLinks && (settings.links?.itad !== false || settings.links?.steamdb !== false));
const needUserdata =
settings.showSteam && (settings.showSteamOwned || settings.showSteamWishlist);
const state = {
steam: null,
owned: false,
wishlist: false,
userdata: null,
gamestatus: null,
steamDb: null,
opencritic: null,
hltb: null,
proton: null,
error: false,
};
const paintLinks = (steam) => {
if (!stillHere() || !rows.links) return;
const links = buildExternalLinks({ title, slug: ctx.slug, igdbUrl, steam });
renderEnrichment(
{ links: rows.links },
{ steam, links, error: state.error, skipDebug: true }
);
};
const syncLibrary = () => {
if (state.userdata && state.steam?.found && state.steam.appId != null) {
const id = Number(state.steam.appId);
state.owned = settings.showSteamOwned !== false && state.userdata.owned.has(id);
state.wishlist =
!state.owned &&
settings.showSteamWishlist !== false &&
state.userdata.wishlist.has(id);
} else {
state.owned = false;
state.wishlist = false;
}
};
const paintSteamBlock = () => {
if (!stillHere() || state.steam == null) return;
syncLibrary();
renderEnrichment(
{ steam: rows.steam, metacritic: rows.metacritic, deckproton: rows.deckproton },
{
steam: state.steam,
proton: state.proton,
error: state.error,
owned: state.owned,
wishlist: state.wishlist,
title,
slug: ctx.slug,
skipDebug: true,
}
);
paintLinks(state.steam);
updateUnifiedRatingWidget(state);
};
const paintOpenCritic = () => {
if (!stillHere()) return;
if (rows.opencritic) {
renderEnrichment(
{ opencritic: rows.opencritic },
{ opencritic: state.opencritic, error: state.error, skipDebug: true }
);
}
updateUnifiedRatingWidget(state);
};
const paintHltb = () => {
if (!stillHere() || !rows.hltb) return;
renderEnrichment(
{ hltb: rows.hltb },
{ hltb: state.hltb, error: state.error, skipDebug: true }
);
};
const paintDeckProton = () => {
if (!stillHere() || !rows.deckproton) return;
renderEnrichment(
{ deckproton: rows.deckproton },
{
steam: state.steam,
proton: state.proton,
error: state.error,
skipDebug: true,
}
);
};
const paintPlayers = () => {
if (!stillHere() || !rows.players) return;
renderEnrichment(
{ players: rows.players },
{ steamDb: state.steamDb, error: state.error, skipDebug: true }
);
};
const paintGameStatus = () => {
if (!stillHere() || !rows.gamestatus) return;
renderEnrichment(
{ gamestatus: rows.gamestatus },
{ gamestatus: state.gamestatus, skipDebug: true }
);
};
const paintFinal = () => {
if (!stillHere()) return;
syncLibrary();
const links = buildExternalLinks({
title,
slug: ctx.slug,
igdbUrl,
steam: state.steam,
});
renderEnrichment(rows, {
steam: state.steam,
links,
error: state.error,
owned: state.owned,
wishlist: state.wishlist,
gamestatus: state.gamestatus,
title,
slug: ctx.slug,
steamDb: state.steamDb,
opencritic: state.opencritic,
hltb: state.hltb,
proton: state.proton,
});
if (state.steamDb) applySteamDbUi(state.steamDb, token, { final: true });
updateUnifiedRatingWidget(state);
};
// Links that don't depend on Steam can appear immediately.
paintLinks(null);
let dependentsStarted = false;
let dependentsPromise = Promise.resolve();
const gsSkippedPayload = (steam, reason) => ({
missing: true,
data: null,
slug: null,
_debug: settings.debugMode
? {
reason,
steamFound: Boolean(steam?.found),
steamDebug: steam?._debug || null,
}
: undefined,
});
const startDependents = (steam) => {
if (dependentsStarted || !steam?.found || steam.appId == null) return;
dependentsStarted = true;
const jobs = [];
if (needSteamDb) {
jobs.push(
fetchSteamDbExtras(steam.appId, {
country: settings.steamCountry || 'US',
onPartial: (partial) => {
if (!stillHere()) return;
state.steamDb = partial;
applySteamDbUi(partial, token);
// Keep players skeleton until a count arrives (final paint clears misses).
if (partial?.players != null) paintPlayers();
},
})
.then((full) => {
if (!stillHere() || !full) return;
state.steamDb = full;
applySteamDbUi(full, token, { final: true });
paintPlayers();
})
.catch((err) => {
if (!stillHere()) return;
state.steamDb = {
appId: steam.appId,
iconUrl: '',
logoUrl: '',
players: null,
_debug: settings.debugMode
? { reason: `SteamDB error: ${err?.message || err}` }
: undefined,
};
removeSteamDbUi();
paintPlayers();
})
);
}
if (settings.showGameStatus) {
jobs.push(
fetchGameStatus({
appId: steam.appId,
storeUrl: steam.storeUrl,
name: steam.name,
title,
pageSlug: ctx.slug,
})
.then((gs) => {
if (!stillHere()) return;
state.gamestatus = gs;
paintGameStatus();
})
.catch((err) => {
if (!stillHere()) return;
state.gamestatus = gsSkippedPayload(
steam,
`GameStatus error: ${err?.message || err}`
);
paintGameStatus();
})
);
}
if (settings.showDeckProton) {
jobs.push(
fetchProtonDb(steam.appId)
.then((proton) => {
if (!stillHere()) return;
state.proton = proton;
paintDeckProton();
})
.catch(() => {
if (!stillHere()) return;
paintDeckProton();
})
);
}
dependentsPromise = Promise.all(jobs);
};
try {
const scoreJobs = [];
if (settings.showOpenCritic) {
scoreJobs.push(
fetchOpenCritic(title)
.then((oc) => {
if (!stillHere()) return;
state.opencritic = oc || { missing: true };
paintOpenCritic();
})
.catch(() => {
if (!stillHere()) return;
state.opencritic = { missing: true };
paintOpenCritic();
})
);
}
if (settings.showHltb) {
scoreJobs.push(
fetchHltb(title)
.then((hltb) => {
if (!stillHere()) return;
state.hltb = hltb || { missing: true };
paintHltb();
})
.catch(() => {
if (!stillHere()) return;
state.hltb = { missing: true };
paintHltb();
})
);
}
const userdataPromise = needUserdata
? fetchSteamUserdata()
.then((data) => {
if (!stillHere()) return null;
state.userdata = data;
if (state.steam) paintSteamBlock();
return data;
})
.catch(() => null)
: Promise.resolve(null);
const steamPromise = needSteam
? resolveSteamForGame({
title,
slug: ctx.slug,
country: settings.steamCountry || 'US',
onPartial: (partial) => {
if (!stillHere()) return;
state.steam = partial;
paintSteamBlock();
startDependents(partial);
},
})
: Promise.resolve(null);
const [steamResult] = await Promise.all([steamPromise, userdataPromise, ...scoreJobs]);
if (!stillHere()) return;
state.steam = steamResult;
if (steamResult?.found) {
paintSteamBlock();
startDependents(steamResult);
} else if (needSteam) {
state.steam = steamResult || { found: false };
paintSteamBlock();
if (needSteamDbMedia) removeSteamDbUi();
}
if (settings.showGameStatus && !dependentsStarted) {
state.gamestatus = gsSkippedPayload(
state.steam,
state.steam?.found
? 'GameStatus enabled but Steam appId missing'
: 'GameStatus skipped — Steam app not found'
);
paintGameStatus();
}
await dependentsPromise;
} catch (err) {
state.error = true;
state.steam = state.steam || {
found: false,
_debug: { reason: `Enrichment error: ${err?.message || err}` },
};
state.owned = false;
state.wishlist = false;
state.gamestatus = state.gamestatus || {
missing: true,
data: null,
slug: null,
_debug: { reason: `Enrichment error: ${err?.message || err}` },
};
if (needSteamDbMedia && !state.steamDb) removeSteamDbUi();
paintSteamBlock();
paintOpenCritic();
paintHltb();
paintDeckProton();
paintGameStatus();
paintPlayers();
}
if (!stillHere()) return;
paintFinal();
}
function openSettings() {
if (document.querySelector('.blp-settings-backdrop')) return;
const draft = {
...settings,
links: { ...DEFAULT_SETTINGS.links, ...(settings.links || {}) },
};
const linkToggles = LINK_KEYS.map((key) => {
const on = draft.links[key] !== false;
const label = t[linkLabelKey(key)] || key;
const domain = LINK_DOMAINS[key] || key;
const icon = FAVICON_URL.replace('{domain}', encodeURIComponent(domain));
return `
<div class="blp-toggle">
<span class="blp-toggle__label">
<img class="blp-favicon" src="${escapeAttr(icon)}" alt="" width="14" height="14" />
${escapeHtml(label)}
</span>
<button type="button" data-blp-link="${escapeAttr(key)}" class="${on ? 'is-on' : ''}">${on ? t.on : t.off}</button>
</div>
`;
}).join('');
const backdrop = document.createElement('div');
backdrop.className = 'blp-settings-backdrop';
backdrop.innerHTML = `
<div class="blp-settings" role="dialog" aria-modal="true" aria-label="${escapeAttr(t.panelTitle)}">
<div class="blp-settings__head">
<h2>${escapeHtml(t.panelTitle)} <span class="blp-settings__ver">v${escapeHtml(SCRIPT_VERSION)}</span></h2>
<p class="blp-settings__sub">${escapeHtml(t.panelSubtitle)}</p>
</div>
<div class="blp-settings__body">
<section>
<h3>${escapeHtml(t.sectionGeneral)}</h3>
<div class="blp-field">
<label for="blp-ui-locale">${escapeHtml(t.uiLanguage)}</label>
<select id="blp-ui-locale">
<option value="auto" ${(draft.uiLocale || 'auto') === 'auto' ? 'selected' : ''}>${escapeHtml(t.uiLanguageAuto)}</option>
${SUPPORTED_LOCALES.map((code) => {
const selected = draft.uiLocale === code ? 'selected' : '';
const name = LOCALE_NATIVE_NAMES[code] || code;
return `<option value="${code}" ${selected}>${escapeHtml(name)}</option>`;
}).join('')}
</select>
<p class="blp-hint">${escapeHtml(t.uiLanguageHint)}</p>
</div>
</section>
<section>
<h3>${escapeHtml(t.sectionGame)}</h3>
<div class="blp-field">
<label for="blp-steam-cc">${escapeHtml(t.steamCountry)}</label>
<select id="blp-steam-cc">
${['US', 'GB', 'DE', 'FR', 'RU', 'BR', 'JP', 'KR', 'CN', 'AU', 'CA', 'PL', 'ES', 'IT', 'TR', 'UA']
.map(
(cc) =>
`<option value="${cc}" ${draft.steamCountry === cc ? 'selected' : ''}>${cc}</option>`
)
.join('')}
</select>
<p class="blp-hint">${escapeHtml(t.steamCountryHint)}</p>
</div>
<div class="blp-toggle">
<span>${escapeHtml(t.showSteam)}</span>
<button type="button" data-blp-toggle="showSteam" class="${draft.showSteam ? 'is-on' : ''}">${draft.showSteam ? t.on : t.off}</button>
</div>
<div class="blp-toggle">
<span>${escapeHtml(t.showSteamOwned)}</span>
<button type="button" data-blp-toggle="showSteamOwned" class="${draft.showSteamOwned ? 'is-on' : ''}">${draft.showSteamOwned ? t.on : t.off}</button>
</div>
<p class="blp-hint">${escapeHtml(t.showSteamOwnedHint)}</p>
<div class="blp-toggle">
<span>${escapeHtml(t.showSteamWishlist)}</span>
<button type="button" data-blp-toggle="showSteamWishlist" class="${draft.showSteamWishlist ? 'is-on' : ''}">${draft.showSteamWishlist ? t.on : t.off}</button>
</div>
<p class="blp-hint">${escapeHtml(t.showSteamWishlistHint)}</p>
<div class="blp-toggle">
<span>${escapeHtml(t.showSteamTags)}</span>
<button type="button" data-blp-toggle="showSteamTags" class="${draft.showSteamTags ? 'is-on' : ''}">${draft.showSteamTags ? t.on : t.off}</button>
</div>
<p class="blp-hint">${escapeHtml(t.showSteamTagsHint)}</p>
<div class="blp-toggle">
<span>${escapeHtml(t.showSteamCategories)}</span>
<button type="button" data-blp-toggle="showSteamCategories" class="${draft.showSteamCategories ? 'is-on' : ''}">${draft.showSteamCategories ? t.on : t.off}</button>
</div>
<p class="blp-hint">${escapeHtml(t.showSteamCategoriesHint)}</p>
<div class="blp-toggle">
<span>${escapeHtml(t.showMetacritic)}</span>
<button type="button" data-blp-toggle="showMetacritic" class="${draft.showMetacritic ? 'is-on' : ''}">${draft.showMetacritic ? t.on : t.off}</button>
</div>
<div class="blp-toggle">
<span>${escapeHtml(t.showOpenCritic)}</span>
<button type="button" data-blp-toggle="showOpenCritic" class="${draft.showOpenCritic ? 'is-on' : ''}">${draft.showOpenCritic ? t.on : t.off}</button>
</div>
<p class="blp-hint">${escapeHtml(t.showOpenCriticHint)}</p>
<div class="blp-toggle">
<span>${escapeHtml(t.showHltb)}</span>
<button type="button" data-blp-toggle="showHltb" class="${draft.showHltb ? 'is-on' : ''}">${draft.showHltb ? t.on : t.off}</button>
</div>
<p class="blp-hint">${escapeHtml(t.showHltbHint)}</p>
<div class="blp-toggle">
<span>${escapeHtml(t.showDeckProton)}</span>
<button type="button" data-blp-toggle="showDeckProton" class="${draft.showDeckProton ? 'is-on' : ''}">${draft.showDeckProton ? t.on : t.off}</button>
</div>
<p class="blp-hint">${escapeHtml(t.showDeckProtonHint)}</p>
<div class="blp-toggle">
<span>${escapeHtml(t.showGameStatus)}</span>
<button type="button" data-blp-toggle="showGameStatus" class="${draft.showGameStatus ? 'is-on' : ''}">${draft.showGameStatus ? t.on : t.off}</button>
</div>
<p class="blp-hint">${escapeHtml(t.showGameStatusHint)}</p>
<div class="blp-toggle">
<span>${escapeHtml(t.showLinks)}</span>
<button type="button" data-blp-toggle="showLinks" class="${draft.showLinks ? 'is-on' : ''}">${draft.showLinks ? t.on : t.off}</button>
</div>
<div class="blp-toggle">
<span>${escapeHtml(t.showSteamPageLink)}</span>
<button type="button" data-blp-toggle="showSteamPageLink" class="${draft.showSteamPageLink ? 'is-on' : ''}">${draft.showSteamPageLink ? t.on : t.off}</button>
</div>
<p class="blp-hint">${escapeHtml(t.showSteamPageLinkHint)}</p>
<div class="blp-toggle">
<span>${escapeHtml(t.showSteamDbPageLink)}</span>
<button type="button" data-blp-toggle="showSteamDbPageLink" class="${draft.showSteamDbPageLink ? 'is-on' : ''}">${draft.showSteamDbPageLink ? t.on : t.off}</button>
</div>
<p class="blp-hint">${escapeHtml(t.showSteamDbPageLinkHint)}</p>
<div class="blp-toggle">
<span>${escapeHtml(t.showSteamDbIcon)}</span>
<button type="button" data-blp-toggle="showSteamDbIcon" class="${draft.showSteamDbIcon ? 'is-on' : ''}">${draft.showSteamDbIcon ? t.on : t.off}</button>
</div>
<p class="blp-hint">${escapeHtml(t.showSteamDbIconHint)}</p>
<div class="blp-toggle">
<span>${escapeHtml(t.showSteamDbCover)}</span>
<button type="button" data-blp-toggle="showSteamDbCover" class="${draft.showSteamDbCover ? 'is-on' : ''}">${draft.showSteamDbCover ? t.on : t.off}</button>
</div>
<p class="blp-hint">${escapeHtml(t.showSteamDbCoverHint)}</p>
<div class="blp-toggle">
<span>${escapeHtml(t.showSteamDbGallery)}</span>
<button type="button" data-blp-toggle="showSteamDbGallery" class="${draft.showSteamDbGallery ? 'is-on' : ''}">${draft.showSteamDbGallery ? t.on : t.off}</button>
</div>
<p class="blp-hint">${escapeHtml(t.showSteamDbGalleryHint)}</p>
<div class="blp-toggle">
<span>${escapeHtml(t.showSteamPlayers)}</span>
<button type="button" data-blp-toggle="showSteamPlayers" class="${draft.showSteamPlayers ? 'is-on' : ''}">${draft.showSteamPlayers ? t.on : t.off}</button>
</div>
<p class="blp-hint">${escapeHtml(t.showSteamPlayersHint)}</p>
</section>
<section>
<h3>${escapeHtml(t.sectionLists)}</h3>
<div class="blp-toggle">
<span>${escapeHtml(t.showCardBadges)}</span>
<button type="button" data-blp-toggle="showCardBadges" class="${draft.showCardBadges ? 'is-on' : ''}">${draft.showCardBadges ? t.on : t.off}</button>
</div>
<p class="blp-hint">${escapeHtml(t.showCardBadgesHint)}</p>
</section>
<section>
<h3>${escapeHtml(t.sectionLinks)}</h3>
<p class="blp-hint" style="margin-bottom:10px">${escapeHtml(t.sectionLinksHint)}</p>
${linkToggles}
</section>
<section>
<h3>${escapeHtml(t.sectionCache)}</h3>
${buildCacheMeterHtml()}
<div class="blp-field">
<label for="blp-cache-hours">${escapeHtml(t.cacheHours)}</label>
<input id="blp-cache-hours" type="number" min="0" max="${CACHE_HOURS_MAX}" value="${Number(draft.cacheHours) || 0}" />
<p class="blp-hint">${escapeHtml(t.cacheHoursHint)}</p>
</div>
<button type="button" class="blp-btn" data-blp-clear>${escapeHtml(t.clearCache)}</button>
<p class="blp-hint">${escapeHtml(t.cacheClearHint)}</p>
<div class="blp-cache-msg" data-blp-cache-msg hidden></div>
</section>
<section>
<h3>${escapeHtml(t.sectionDebug)}</h3>
<div class="blp-toggle">
<span>${escapeHtml(t.debugMode)}</span>
<button type="button" data-blp-toggle="debugMode" class="${draft.debugMode ? 'is-on' : ''}">${draft.debugMode ? t.on : t.off}</button>
</div>
<p class="blp-hint">${escapeHtml(t.debugModeHint)}</p>
</section>
</div>
<div class="blp-settings__foot">
<div class="blp-actions">
<button type="button" data-blp-cancel>${escapeHtml(t.cancel)}</button>
<button type="button" class="blp-primary" data-blp-save>${escapeHtml(t.saveReload)}</button>
</div>
<div class="blp-settings__footer">
<a href="${escapeAttr(REPO_URL)}" target="_blank" rel="noopener noreferrer">${escapeHtml(t.repoLink)}</a>
— ${escapeHtml(t.repoAbout)}
</div>
</div>
</div>
`;
const close = () => backdrop.remove();
backdrop.addEventListener('click', (e) => {
if (e.target === backdrop) close();
});
backdrop.querySelector('[data-blp-cancel]')?.addEventListener('click', close);
backdrop.querySelectorAll('[data-blp-toggle]').forEach((btn) => {
btn.addEventListener('click', () => {
const key = btn.getAttribute('data-blp-toggle');
draft[key] = !draft[key];
btn.classList.toggle('is-on', draft[key]);
btn.textContent = draft[key] ? t.on : t.off;
});
});
backdrop.querySelectorAll('[data-blp-link]').forEach((btn) => {
btn.addEventListener('click', () => {
const key = btn.getAttribute('data-blp-link');
draft.links[key] = !draft.links[key];
btn.classList.toggle('is-on', draft.links[key]);
btn.textContent = draft.links[key] ? t.on : t.off;
});
});
backdrop.querySelector('[data-blp-clear]')?.addEventListener('click', () => {
const count = clearCache();
const msg = backdrop.querySelector('[data-blp-cache-msg]');
if (msg) {
msg.hidden = false;
msg.textContent = count
? fmt(t.cacheCleared, { count })
: t.cacheEmpty;
}
paintCacheMeter(backdrop);
});
backdrop.querySelector('[data-blp-save]')?.addEventListener('click', () => {
const cc = backdrop.querySelector('#blp-steam-cc')?.value || 'US';
const hours = Number(backdrop.querySelector('#blp-cache-hours')?.value);
const uiLocale = backdrop.querySelector('#blp-ui-locale')?.value || 'auto';
draft.uiLocale =
uiLocale === 'auto' || SUPPORTED_LOCALES.includes(uiLocale) ? uiLocale : 'auto';
draft.steamCountry = String(cc).toUpperCase();
draft.cacheHours = Number.isFinite(hours)
? Math.max(0, Math.min(CACHE_HOURS_MAX, hours))
: DEFAULT_SETTINGS.cacheHours;
saveSettings(draft);
settings = loadSettings();
location.reload();
});
document.body.appendChild(backdrop);
}
const NAV_BTN_ID = 'blp-nav-settings';
function ensureNavSettingsButton() {
if (document.getElementById(NAV_BTN_ID)) return;
const btn = document.createElement('button');
btn.id = NAV_BTN_ID;
btn.type = 'button';
btn.className = 'btn btn-main mb-2 my-sm-0 py-0';
btn.title = t.navSettingsTitle;
// fa-gear is in Backloggd’s FA set (fa-sliders often is not → empty icon + short button)
btn.innerHTML = `<i class="fa-solid fa-gear fa-xs" aria-hidden="true"></i> ${escapeHtml(t.navSettings)}`;
btn.addEventListener('click', (e) => {
e.preventDefault();
openSettings();
});
// Same slot as native #add-a-game ("Log a Game")
const logGame = document.getElementById('add-a-game');
if (logGame?.parentElement) {
btn.classList.add('ml-2');
const h = logGame.getBoundingClientRect().height;
if (h > 0) document.documentElement.style.setProperty('--blp-nav-btn-h', `${Math.round(h)}px`);
logGame.insertAdjacentElement('afterend', btn);
return;
}
const logSlot = document.querySelector(
'#navbarSupportedContent .col.my-auto, #primary-nav .col.my-auto'
);
if (logSlot) {
btn.classList.add('ml-2');
logSlot.appendChild(btn);
return;
}
const nav =
document.querySelector('#navbarSupportedContent > ul.navbar-nav') ||
document.querySelector('#primary-nav ul.navbar-nav.ml-auto');
if (!nav) return;
const li = document.createElement('li');
li.className = 'nav-item my-auto';
li.appendChild(btn);
nav.appendChild(li);
}
function debounce(fn, wait) {
let timer = 0;
return function debounced(...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), wait);
};
}
function openFixMatchDialog(slug, currentAppId) {
if (!slug || document.querySelector('.blp-fix-match-backdrop')) return;
const backdrop = document.createElement('div');
backdrop.className = 'blp-fix-match-backdrop';
backdrop.innerHTML = `
<div class="blp-fix-match-dialog" role="dialog" aria-modal="true" aria-label="${escapeAttr(t.steamFixMatchTitle)}">
<h3>${escapeHtml(t.steamFixMatchTitle)}</h3>
<p class="blp-hint">${escapeHtml(t.steamFixMatchHint)}</p>
<div class="blp-error" data-blp-fix-error hidden></div>
<input id="blp-fix-appid" type="text" inputmode="numeric" placeholder="${escapeAttr(t.steamFixMatchPlaceholder)}" value="${escapeAttr(currentAppId || getSteamOverride(slug) || '')}" />
<div class="blp-actions">
<button type="button" class="blp-btn" data-blp-fix-clear>${escapeHtml(t.steamFixMatchClear)}</button>
<button type="button" class="blp-btn" data-blp-fix-cancel>${escapeHtml(t.cancel)}</button>
<button type="button" class="blp-btn blp-primary" data-blp-fix-save>${escapeHtml(t.save)}</button>
</div>
</div>
`;
const close = () => backdrop.remove();
const errEl = () => backdrop.querySelector('[data-blp-fix-error]');
const input = () => backdrop.querySelector('#blp-fix-appid');
backdrop.addEventListener('click', (e) => {
if (e.target === backdrop) close();
});
backdrop.querySelector('[data-blp-fix-cancel]')?.addEventListener('click', close);
backdrop.querySelector('[data-blp-fix-clear]')?.addEventListener('click', () => {
clearSteamOverride(slug);
close();
removeEnrichment();
enrichGamePage();
scheduleCardBadges(true);
});
backdrop.querySelector('[data-blp-fix-save]')?.addEventListener('click', () => {
const raw = String(input()?.value || '').trim();
const id = Number(raw.replace(/[^\d]/g, ''));
if (!Number.isFinite(id) || id <= 0) {
const box = errEl();
if (box) {
box.hidden = false;
box.textContent = t.steamFixMatchInvalid;
}
return;
}
setSteamOverride(slug, id);
close();
removeEnrichment();
enrichGamePage();
scheduleCardBadges(true);
});
document.body.appendChild(backdrop);
input()?.focus();
input()?.select();
}
function bindFixMatchClicks() {
if (document.documentElement.hasAttribute('data-blp-fix-bound')) return;
document.documentElement.setAttribute('data-blp-fix-bound', '1');
document.addEventListener(
'click',
(e) => {
const btn = e.target?.closest?.('[data-blp-fix-match]');
if (!btn) return;
e.preventDefault();
e.stopPropagation();
openFixMatchDialog(
btn.getAttribute('data-blp-slug') || getPageContext().slug,
btn.getAttribute('data-blp-appid') || ''
);
},
true
);
}
function cardBadgeToken() {
return [
settings.steamCountry || 'US',
settings.showSteam ? 1 : 0,
settings.showSteamOwned ? 1 : 0,
settings.showSteamWishlist ? 1 : 0,
settings.showGameStatus ? 1 : 0,
settings.showCardBadges ? 1 : 0,
].join('|');
}
function parseCardSlug(cover) {
const roots = [
cover,
cover.parentElement,
cover.closest('a[href*="/games/"]'),
cover.closest('[game_id], .result, .col, .row'),
].filter(Boolean);
for (const root of roots) {
const link =
root.matches?.('a[href*="/games/"]')
? root
: root.querySelector?.('a[href*="/games/"]');
const href = link?.getAttribute?.('href') || '';
const m = href.match(/\/games\/([^/?#]+)/i);
if (m) return m[1].toLowerCase();
}
return '';
}
function parseCardTitle(cover, slug) {
const img = cover.querySelector('img[alt]');
if (img?.alt) return img.alt.trim();
const nearby = cover
.closest('.result, .row, .col, [game_id]')
?.querySelector('.game-name h3, h3, .game-name a');
if (nearby?.textContent) {
return nearby.textContent.replace(/\s+\d{4}\s*$/, '').trim();
}
return slug ? slug.replace(/-/g, ' ') : '';
}
function ensureCardBadgeMount(cover) {
let mount = cover.querySelector('.blp-card-badges');
if (mount) return mount;
const host = cover.querySelector('.overflow-wrapper') || cover;
if (getComputedStyle(host).position === 'static') {
host.style.position = 'relative';
}
mount = document.createElement('div');
mount.className = 'blp-card-badges is-loading';
mount.innerHTML = '<span class="blp-card-badge"></span><span class="blp-card-badge"></span>';
host.appendChild(mount);
return mount;
}
function renderCardBadgesHtml({ steam, owned, wishlist, gamestatus }) {
const chips = [];
if (settings.showSteam !== false && steam?.found) {
const priceText = formatPriceText(steam);
if (priceText) {
const discount =
steam.price?.discount_percent > 0
? ` <span class="blp-card-badge--discount">${escapeHtml(fmt(t.discount, { n: steam.price.discount_percent }))}</span>`
: '';
chips.push(
`<a class="blp-card-badge blp-card-badge--price" href="${escapeAttr(steam.storeUrl)}" target="_blank" rel="noopener noreferrer">${escapeHtml(priceText)}${discount}</a>`
);
}
const reviewCompact = formatReviewPercentCompact(steam.reviews);
const reviewFull = formatReviewPercent(steam.reviews);
const reviewClass = reviewScoreClass(steam.reviews);
if (reviewCompact) {
chips.push(
`<a class="blp-card-badge blp-card-badge--review ${escapeAttr(reviewClass)}" href="${escapeAttr(steam.storeUrl)}#app_reviews_hash" target="_blank" rel="noopener noreferrer" title="${escapeAttr(reviewFull || reviewCompact)}">${escapeHtml(reviewCompact)}</a>`
);
} else if (steam.metacritic?.score != null) {
const mc = steam.metacritic.score;
const tier = mcScoreTier(mc);
chips.push(
`<a class="blp-card-badge blp-card-badge--mc${tier ? ` blp-card-badge--mc-${tier}` : ''}" href="${escapeAttr(steam.storeUrl)}" target="_blank" rel="noopener noreferrer" title="${escapeAttr(t.metacritic)}">${escapeHtml(String(mc))}</a>`
);
}
}
if (owned) {
chips.push(`<span class="blp-card-badge blp-card-badge--owned">${escapeHtml(t.steamOwned)}</span>`);
} else if (wishlist) {
chips.push(
`<span class="blp-card-badge blp-card-badge--wishlist">${escapeHtml(t.steamWishlist)}</span>`
);
}
if (settings.showGameStatus && gamestatus && !gamestatus.missing && gamestatus.data) {
const type = getGsStatusType(gamestatus.data);
const label = getGsStatusLabel(gamestatus.data, type);
const gsSlug = gamestatus.data.slug || gamestatus.slug;
const href = gsSlug
? `${GAMESTATUS_SITE_BASE}/${encodeURIComponent(gsSlug)}`
: GAMESTATUS_SITE_BASE;
chips.push(
`<a class="blp-card-badge blp-card-badge--gs blp-gs-badge--${escapeAttr(type)}" href="${escapeAttr(href)}" target="_blank" rel="noopener noreferrer">${escapeHtml(label)}</a>`
);
}
return chips.join('');
}
const cardQueue = [];
let cardActive = 0;
let cardObserver = null;
function pumpCardQueue() {
while (cardActive < CARD_CONCURRENCY && cardQueue.length) {
const job = cardQueue.shift();
cardActive += 1;
Promise.resolve()
.then(job.fn)
.then(job.resolve, job.reject)
.finally(() => {
cardActive -= 1;
pumpCardQueue();
});
}
}
function enqueueCardJob(fn) {
return new Promise((resolve, reject) => {
cardQueue.push({ fn, resolve, reject });
pumpCardQueue();
});
}
function cardIsSettled(cover, token) {
if (cover.getAttribute(CARD_ATTR) !== token) return false;
const state = cover.getAttribute(CARD_STATE_ATTR);
return state === 'done' || state === 'empty' || state === 'loading';
}
function markCardState(cover, token, state) {
cover.setAttribute(CARD_ATTR, token);
cover.setAttribute(CARD_STATE_ATTR, state);
}
function clearCardMarkers(cover) {
cover.removeAttribute(CARD_ATTR);
cover.removeAttribute(CARD_STATE_ATTR);
cover.querySelector('.blp-card-badges')?.remove();
}
async function enrichGameCard(cover) {
const token = cardBadgeToken();
if (cardIsSettled(cover, token)) return;
const slug = parseCardSlug(cover);
const title = parseCardTitle(cover, slug);
if (!slug || !title) {
clearCardMarkers(cover);
markCardState(cover, token, 'empty');
return;
}
markCardState(cover, token, 'loading');
const mount = ensureCardBadgeMount(cover);
mount.classList.add('is-loading');
await enqueueCardJob(async () => {
if (cover.getAttribute(CARD_ATTR) !== token) return;
const needUserdata = settings.showSteamOwned || settings.showSteamWishlist;
const [steam, userdata] = await Promise.all([
resolveSteamForGame({
title,
slug,
country: settings.steamCountry || 'US',
includeTags: false,
}).catch(() => ({ found: false })),
needUserdata ? fetchSteamUserdata().catch(() => null) : Promise.resolve(null),
]);
let gamestatus = null;
if (settings.showGameStatus && steam?.found && steam.appId != null) {
gamestatus = await fetchGameStatus({
appId: steam.appId,
storeUrl: steam.storeUrl,
name: steam.name,
title,
pageSlug: slug,
}).catch(() => null);
}
if (cover.getAttribute(CARD_ATTR) !== token) return;
let owned = false;
let wishlist = false;
if (userdata && steam?.found && steam.appId != null) {
const id = Number(steam.appId);
owned = settings.showSteamOwned !== false && userdata.owned.has(id);
wishlist =
!owned &&
settings.showSteamWishlist !== false &&
userdata.wishlist.has(id);
}
const html = renderCardBadgesHtml({ steam, owned, wishlist, gamestatus });
mount.classList.remove('is-loading');
if (!html) {
// Mark empty before DOM remove so MutationObserver → scanPage does not re-queue.
markCardState(cover, token, 'empty');
mount.remove();
return;
}
mount.innerHTML = html;
markCardState(cover, token, 'done');
});
}
function scheduleCardBadges(force = false) {
if (!settings.showCardBadges) {
document.querySelectorAll('.blp-card-badges').forEach((el) => el.remove());
document.querySelectorAll(`[${CARD_ATTR}]`).forEach((el) => {
el.removeAttribute(CARD_ATTR);
el.removeAttribute(CARD_STATE_ATTR);
});
cardObserver?.disconnect();
cardObserver = null;
return;
}
const ctx = getPageContext();
if (ctx.isGamePage) return;
const token = cardBadgeToken();
if (!cardObserver) {
cardObserver = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const cover = entry.target;
cardObserver.unobserve(cover);
enrichGameCard(cover);
}
},
{ rootMargin: CARD_ROOT_MARGIN }
);
}
document.querySelectorAll('.game-cover').forEach((cover) => {
if (cover.closest(CARD_SKIP_ANCESTOR)) return;
if (!force && cardIsSettled(cover, token)) return;
if (force) clearCardMarkers(cover);
cardObserver.observe(cover);
});
}
function scanPage() {
ensureNavSettingsButton();
bindFixMatchClicks();
enrichGamePage();
scheduleCardBadges();
}
function isBlpManagedElement(el) {
if (!el || el.nodeType !== 1) return false;
if (
el.hasAttribute?.(ENRICH_ATTR) ||
el.hasAttribute?.(STEAMDB_ATTR) ||
el.hasAttribute?.(CARD_ATTR) ||
el.hasAttribute?.(CARD_STATE_ATTR) ||
el.hasAttribute?.('data-blp-debug') ||
el.hasAttribute?.('data-blp-token') ||
el.id === 'blp-nav-settings' ||
el.id === 'blp-steam-backloggd-btn' ||
el.id === 'blp-steamdb-backloggd-btn'
) {
return true;
}
if (
el.classList?.contains('blp-card-badges') ||
el.classList?.contains('blp-settings-backdrop') ||
el.classList?.contains('blp-fix-match-backdrop') ||
el.classList?.contains('blp-debug-panel') ||
el.classList?.contains('blp-steamdb-cover') ||
el.classList?.contains('blp-steam-gallery') ||
el.classList?.contains('blp-viewer') ||
el.classList?.contains('blp-title-icon-wrap')
) {
return true;
}
return Boolean(
el.closest?.(
`[${ENRICH_ATTR}], [${STEAMDB_ATTR}], [${CARD_ATTR}], .blp-card-badges, .blp-settings-backdrop, .blp-fix-match-backdrop, [data-blp-debug], #blp-nav-settings`
)
);
}
function shouldIgnoreDomMutations(mutations) {
for (const m of mutations) {
for (const node of m.addedNodes) {
if (node.nodeType !== 1) continue;
if (!isBlpManagedElement(node)) return false;
}
}
return true;
}
function observeDom(onChange) {
const scheduled = debounce(onChange, SCAN_DEBOUNCE_MS);
const observer = new MutationObserver((mutations) => {
if (shouldIgnoreDomMutations(mutations)) return;
scheduled();
});
observer.observe(document.documentElement, { childList: true, subtree: true });
return observer;
}
function bindSpaNavigation(onNavigate) {
const run = debounce(onNavigate, 50);
['turbo:load', 'turbo:render', 'turbo:frame-load', 'popstate'].forEach((evt) => {
document.addEventListener(evt, run, true);
window.addEventListener(evt, run, true);
});
let prev = location.href;
setInterval(() => {
if (location.href !== prev) {
prev = location.href;
run();
}
}, 500);
}
/* ── Steam store / community: Backloggd button in .apphub_OtherSiteInfo ── */
const STEAM_BTN_ID = 'blp-steam-backloggd-btn';
const BACKLOGGD_SITE = 'https://www.backloggd.com';
function isSteamHost() {
const host = location.hostname;
return (
host === 'store.steampowered.com' ||
host.endsWith('.steampowered.com') ||
host === 'steamcommunity.com' ||
host.endsWith('.steamcommunity.com')
);
}
function isBackloggdHost() {
return /(^|\.)backloggd\.com$/i.test(location.hostname);
}
function getSteamAppIdFromPage() {
const m = location.pathname.match(/\/app\/(\d+)/i);
return m ? m[1] : '';
}
function getSteamPageTitle() {
return (
document.getElementById('appHubAppName')?.textContent?.trim() ||
document.querySelector('.apphub_AppName')?.textContent?.trim() ||
document.title.replace(/\s+on\s+Steam.*$/i, '').replace(/\s+::\s+.*$/i, '').trim()
);
}
function slugifyForBackloggd(name) {
return String(name || '')
.toLowerCase()
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/&/g, ' and ')
.replace(/[^a-z0-9]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}
/** Metacritic `/game/{slug}/` — drop apostrophes (Assassin's → assassins), not hyphenate them. */
function slugifyForMetacritic(name) {
return String(name || '')
.toLowerCase()
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[''`´]/g, '')
.replace(/&/g, ' and ')
.replace(/[^a-z0-9]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}
function metacriticGameUrl(title, slug) {
// Backloggd disambiguators: meccha-chameleon--1 → meccha-chameleon
const raw =
String(slug || '')
.replace(/^\/+|\/+$/g, '')
.replace(/--\d+$/i, '') || slugifyForMetacritic(title);
if (!raw) return '';
return `https://www.metacritic.com/game/${encodeURIComponent(raw)}/`;
}
function getSteamPathSlug() {
const m = location.pathname.match(/\/app\/\d+\/([^/?#]+)/i);
if (!m) return '';
return slugifyForBackloggd(m[1].replace(/_/g, '-'));
}
function resolveBackloggdUrlFromSteam() {
const pathSlug = getSteamPathSlug();
if (pathSlug) return `${BACKLOGGD_SITE}/games/${encodeURIComponent(pathSlug)}/`;
const titleSlug = slugifyForBackloggd(getSteamPageTitle());
if (titleSlug) return `${BACKLOGGD_SITE}/games/${encodeURIComponent(titleSlug)}/`;
return BACKLOGGD_SITE;
}
function createSteamBackloggdButton(url) {
const a = document.createElement('a');
a.id = STEAM_BTN_ID;
a.className = 'btnv6_blue_hoverfade btn_medium';
a.href = url;
a.target = '_blank';
a.rel = 'noopener noreferrer';
a.innerHTML = `
<span data-tooltip-text="${escapeAttr(t.steamBackloggdTooltip)}">
<img class="ico16" src="${escapeAttr(`${BACKLOGGD_SITE}/favicon.ico`)}" alt="Backloggd" width="16" height="16" />
</span>
`;
return a;
}
function injectSteamBackloggdButton() {
if (!settings.showSteamPageLink) {
document.getElementById(STEAM_BTN_ID)?.remove();
return;
}
const appId = getSteamAppIdFromPage();
if (!appId) return;
const anchor = document.querySelector('.apphub_OtherSiteInfo');
if (!anchor) return;
const url = resolveBackloggdUrlFromSteam();
const existing = document.getElementById(STEAM_BTN_ID);
if (existing) {
existing.href = url;
return;
}
const btn = createSteamBackloggdButton(url);
// Place like SteamDB: before Community Hub / Store Page, after other extension icons when possible
const hub =
anchor.querySelector('a.btnv6_blue_hoverfade[href*="steamcommunity.com/app/"]') ||
anchor.querySelector('a.btnv6_blue_hoverfade[href*="store.steampowered.com/app/"]') ||
[...anchor.querySelectorAll('a.btnv6_blue_hoverfade')].find((el) =>
/community hub|store page/i.test(el.textContent || '')
);
if (hub) {
hub.insertAdjacentElement('beforebegin', btn);
// SteamDB leaves a space text node before hub — keep spacing
if (hub.previousSibling === btn) {
btn.insertAdjacentText('afterend', ' ');
}
} else {
anchor.appendChild(btn);
}
}
function scanSteamPage() {
injectSteamBackloggdButton();
}
/* ── SteamDB: Backloggd button in nav.app-links ── */
const STEAMDB_BTN_ID = 'blp-steamdb-backloggd-btn';
function isSteamDbHost() {
return /(^|\.)steamdb\.info$/i.test(location.hostname);
}
function getSteamDbAppId() {
const m = location.pathname.match(/\/app\/(\d+)/i);
return m ? m[1] : '';
}
function getIgdbSlugFromPage() {
const link = document.querySelector(
'.app-links a[href*="igdb.com/games/"], a[href*="igdb.com/games/"]'
);
if (!link?.href) return '';
const m = String(link.href).match(/igdb\.com\/games\/([^/?#]+)/i);
return m ? decodeURIComponent(m[1]).toLowerCase() : '';
}
function getSteamDbPageTitle() {
const header =
document.querySelector('.app-page header h1, .header-title h1, h1[itemprop="name"]') ||
document.querySelector('h1');
let name = (header?.textContent || '').trim();
// "Stardew Valley" often followed by meta; strip AppID suffixes from document.title fallback
if (!name) {
name = document.title.replace(/\s*[·•|].*$/, '').replace(/\s*AppID.*$/i, '').trim();
} else {
name = name.split(/\n/)[0].trim();
}
return name;
}
function resolveBackloggdUrlFromSteamDb() {
const igdbSlug = getIgdbSlugFromPage();
if (igdbSlug) return `${BACKLOGGD_SITE}/games/${encodeURIComponent(igdbSlug)}/`;
const titleSlug = slugifyForBackloggd(getSteamDbPageTitle());
if (titleSlug) return `${BACKLOGGD_SITE}/games/${encodeURIComponent(titleSlug)}/`;
return BACKLOGGD_SITE;
}
function createSteamDbBackloggdButton(url) {
const a = document.createElement('a');
a.id = STEAMDB_BTN_ID;
a.className = 'btn tooltipped tooltipped-n';
a.href = url;
a.target = '_blank';
a.rel = 'noopener noreferrer';
a.setAttribute('aria-label', t.steamBackloggdTooltip);
a.textContent = t.steamDbBackloggdLabel;
return a;
}
function injectSteamDbBackloggdButton() {
if (!settings.showSteamDbPageLink) {
document.getElementById(STEAMDB_BTN_ID)?.remove();
return;
}
if (!getSteamDbAppId()) return;
const nav = document.querySelector('nav.app-links');
if (!nav) return;
const url = resolveBackloggdUrlFromSteamDb();
const existing = document.getElementById(STEAMDB_BTN_ID);
if (existing) {
existing.href = url;
return;
}
const btn = createSteamDbBackloggdButton(url);
// Immediately after Steam Store button (like Hub follows Store)
const store = nav.querySelector('a.btn[href*="store.steampowered.com"]');
if (store) {
store.insertAdjacentElement('afterend', btn);
} else {
const igdb = nav.querySelector('a.btn[href*="igdb.com"]');
if (igdb) igdb.insertAdjacentElement('beforebegin', btn);
else nav.appendChild(btn);
}
}
function scanSteamDbPage() {
injectSteamDbBackloggdButton();
}
function injectUnifiedRatingStyles() {
GM_addStyle(`
.blp-unified-rating-row {
margin-top: 1rem;
margin-bottom: 0.15rem;
}
@media (min-width: 768px) {
.blp-unified-rating-row {
margin-top: 0.85rem;
}
}
.blp-unified-rating {
position: relative;
overflow: hidden;
padding: 0.85rem 1rem 0.9rem !important;
border: 1px solid rgba(61, 184, 154, 0.32) !important;
background:
radial-gradient(110% 120% at 100% 0%, rgba(61, 184, 154, 0.14), transparent 52%),
radial-gradient(90% 90% at 0% 100%, rgba(102, 192, 244, 0.08), transparent 48%),
var(--back-container, #1a1d24) !important;
}
.blp-unified-rating[data-grade="high"] {
border-color: rgba(190, 238, 17, 0.4) !important;
}
.blp-unified-rating[data-grade="mid"] {
border-color: rgba(61, 184, 154, 0.38) !important;
}
.blp-unified-rating[data-grade="low"] {
border-color: rgba(255, 102, 119, 0.32) !important;
}
.blp-unified-rating__layout {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 0.85rem 1.25rem;
align-items: stretch;
width: 100%;
}
@media (max-width: 575.98px) {
.blp-unified-rating__layout {
grid-template-columns: 1fr;
gap: 0.7rem;
}
}
.blp-unified-rating__score-block {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-width: 6.5rem;
padding: 0.35rem 0.75rem;
border-radius: 10px;
background: rgba(0, 0, 0, 0.22);
border: 1px solid rgba(255, 255, 255, 0.08);
text-align: center;
aspect-ratio: 1 / 1;
}
.blp-unified-rating__label {
margin: 0;
font-size: 0.78rem;
font-weight: 700;
color: var(--blp-muted, #9aa0a6);
white-space: nowrap;
}
.blp-unified-rating__num {
margin: 0.1rem 0 0;
font-size: clamp(2.4rem, 4.5vw, 3.1rem);
font-weight: 900;
line-height: 1;
letter-spacing: -0.04em;
color: #fff;
}
.blp-unified-rating[data-grade="high"] .blp-unified-rating__num {
color: #beee11;
}
.blp-unified-rating[data-grade="mid"] .blp-unified-rating__num {
color: #7ee0c5;
}
.blp-unified-rating[data-grade="low"] .blp-unified-rating__num {
color: #ff8a98;
}
.blp-unified-rating__denom {
margin-top: 0.15rem;
font-size: 0.75rem;
font-weight: 700;
color: var(--blp-muted, #9aa0a6);
}
.blp-unified-rating__main {
display: flex;
flex-direction: column;
justify-content: space-between;
min-width: 0;
gap: 0.55rem;
}
.blp-unified-rating__head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: 0.25rem 0.75rem;
}
.blp-unified-rating__title {
margin: 0;
font-size: 1rem;
font-weight: 800;
color: var(--blp-text, #e8eaed);
}
.blp-unified-rating__hint {
margin: 0;
font-size: 0.75rem;
color: var(--blp-muted, #9aa0a6);
}
.blp-unified-rating__meter {
height: 7px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
overflow: hidden;
}
.blp-unified-rating__meter-fill {
height: 100%;
width: 0%;
border-radius: 999px;
background: linear-gradient(90deg, #66c0f4 0%, #3db89a 52%, #beee11 100%);
transition: width 0.4s ease;
}
.blp-unified-rating__grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.45rem;
width: 100%;
}
@media (max-width: 575.98px) {
.blp-unified-rating__grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.blp-unified-rating__cell {
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.12rem;
min-width: 0;
padding: 0.55rem 0.65rem;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(0, 0, 0, 0.22);
}
.blp-unified-rating__cell.is-missing {
opacity: 0.5;
}
.blp-unified-rating__cell-label {
font-size: 0.68rem;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--blp-muted, #9aa0a6);
}
.blp-unified-rating__cell-value {
font-size: 1.15rem;
font-weight: 800;
line-height: 1.15;
color: #fff;
}
.blp-unified-rating__cell-sub {
font-size: 0.72rem;
color: var(--blp-muted, #9aa0a6);
}
.blp-unified-rating.is-loading .blp-unified-rating__num {
display: flex;
justify-content: center;
align-items: center;
min-height: 2.8rem;
}
.blp-unified-rating.is-loading .blp-unified-rating__skel-num {
display: block;
width: 3.4rem;
height: 2.5rem;
border-radius: 8px;
}
.blp-unified-rating.is-loading .blp-unified-rating__skel-denom {
display: block;
width: 3.6rem;
height: 0.7rem;
margin: 0.2rem auto 0;
border-radius: 4px;
}
.blp-unified-rating.is-loading .blp-unified-rating__skel-value {
display: block;
width: 2.5rem;
height: 1.1rem;
margin-top: 0.1rem;
border-radius: 4px;
}
.blp-unified-rating.is-loading .blp-unified-rating__skel-sub {
display: block;
width: 2rem;
height: 0.65rem;
margin-top: 0.15rem;
border-radius: 4px;
}
.blp-unified-rating__meter--loading .blp-unified-rating__meter-fill {
width: 36%;
opacity: 0.55;
animation: blp-unified-meter-indeterminate 1.35s ease-in-out infinite;
}
@keyframes blp-unified-meter-indeterminate {
0% { transform: translateX(-120%); }
100% { transform: translateX(320%); }
}
`);
}
function ensureUnifiedRatingWidget(token = '') {
document.querySelectorAll(`#game-rating [${ENRICH_ATTR}="unified-rating"]`).forEach((el) => el.remove());
const anchor = gameStatsMountAnchor();
if (!anchor) return null;
let row = document.querySelector(`.blp-unified-rating-row[${ENRICH_ATTR}="unified-rating"]`);
if (row) {
if (token) row.setAttribute('data-blp-token', token);
if (row.nextElementSibling !== anchor) {
anchor.insertAdjacentElement('beforebegin', row);
}
return row;
}
const skeletonCells = [
t.unifiedRatingSteam,
t.unifiedRatingMetacritic,
t.unifiedRatingOpenCritic,
t.unifiedRatingBackloggd,
]
.map(
(label) => `<div class="blp-unified-rating__cell">
<span class="blp-unified-rating__cell-label">${escapeHtml(label)}</span>
<span class="blp-skeleton blp-unified-rating__skel-value"></span>
<span class="blp-skeleton blp-unified-rating__skel-sub"></span>
</div>`
)
.join('');
row = document.createElement('div');
row.className = 'row blp-unified-rating-row';
row.setAttribute(ENRICH_ATTR, 'unified-rating');
if (token) row.setAttribute('data-blp-token', token);
row.innerHTML = `
<div class="col">
<div class="backloggd-container center-container blp-unified-rating is-loading" data-grade="mid">
<div class="blp-unified-rating__layout">
<div class="blp-unified-rating__score-block">
<p class="blp-unified-rating__label">${escapeHtml(t.unifiedRatingTitle)}</p>
<p class="blp-unified-rating__num"><span class="blp-skeleton blp-unified-rating__skel-num"></span></p>
<span class="blp-unified-rating__denom"><span class="blp-skeleton blp-unified-rating__skel-denom"></span></span>
</div>
<div class="blp-unified-rating__main">
<div class="blp-unified-rating__head">
<p class="blp-unified-rating__title">${escapeHtml(t.unifiedRatingHint)}</p>
<p class="blp-unified-rating__hint">${escapeHtml(t.unifiedRatingLoading)}</p>
</div>
<div class="blp-unified-rating__meter blp-unified-rating__meter--loading">
<div class="blp-unified-rating__meter-fill"></div>
</div>
<div class="blp-unified-rating__grid">${skeletonCells}</div>
</div>
</div>
</div>
</div>
`;
anchor.insertAdjacentElement('beforebegin', row);
return row;
}
function sanitize0to100Score(n) {
const v = Number(n);
if (!Number.isFinite(v) || v < 0 || v > 100) return null;
return v;
}
function steamReviewsToPercent100(summary) {
if (!summary || !summary.total_reviews) return null;
const pct = (summary.total_positive / summary.total_reviews) * 100;
if (!Number.isFinite(pct)) return null;
return Math.round(pct);
}
function getBackloggdAvgRating5FromDom() {
const nodes = document.querySelectorAll('#game-rating h1, .game-rating h1');
for (const h1 of nodes) {
const raw = (h1?.textContent || '').trim().replace(',', '.');
const n = Number(raw);
if (Number.isFinite(n) && n > 0) return Math.max(0, Math.min(5, n));
}
return null;
}
function formatUnifiedScore5(n) {
return (Math.round(n * 10) / 10).toFixed(1);
}
function updateUnifiedRatingWidget(state) {
const row = document.querySelector(`.blp-unified-rating-row[${ENRICH_ATTR}="unified-rating"]`);
if (!row) return;
const tile = row.querySelector('.blp-unified-rating');
if (!tile) return;
const backloggd5 = getBackloggdAvgRating5FromDom();
const steamPct100 = state?.steam?.reviews ? steamReviewsToPercent100(state.steam.reviews) : null;
const metacritic100 =
state?.steam?.metacritic?.score != null ? sanitize0to100Score(state.steam.metacritic.score) : null;
const opencritic100 =
state?.opencritic?.score != null ? sanitize0to100Score(state.opencritic.score) : null;
const providers = [
{
key: 'steam',
label: t.unifiedRatingSteam,
display: steamPct100 != null ? `${steamPct100}%` : null,
score5: steamPct100 != null ? steamPct100 / 20 : null,
},
{
key: 'metacritic',
label: t.unifiedRatingMetacritic,
display: metacritic100 != null ? String(metacritic100) : null,
score5: metacritic100 != null ? metacritic100 / 20 : null,
},
{
key: 'opencritic',
label: t.unifiedRatingOpenCritic,
display: opencritic100 != null ? String(opencritic100) : null,
score5: opencritic100 != null ? opencritic100 / 20 : null,
},
{
key: 'backloggd',
label: t.unifiedRatingBackloggd,
display: backloggd5 != null ? formatUnifiedScore5(backloggd5) : null,
score5: backloggd5,
},
];
const present = providers.filter((p) => p.score5 != null);
const externalPresent = present.filter((p) => p.key !== 'backloggd');
// Hide when there are no scores outside Backloggd (solo Backloggd ≠ Plus rating).
if (!externalPresent.length) {
const settled =
state?.steam != null &&
(state?.opencritic != null || settings.showOpenCritic === false);
if (settled) row.style.display = 'none';
return;
}
row.style.display = '';
const avg5 = present.reduce((s, p) => s + p.score5, 0) / present.length;
const avg5Rounded = Math.round(avg5 * 10) / 10;
const grade = avg5Rounded >= 4 ? 'high' : avg5Rounded >= 3 ? 'mid' : 'low';
tile.classList.remove('is-loading');
tile.setAttribute('data-grade', grade);
const barWidth = Math.max(0, Math.min(100, (avg5Rounded / 5) * 100));
const cells = providers
.map((p) => {
const missing = p.score5 == null;
const value = missing ? t.unifiedRatingMissing : escapeHtml(p.display);
const sub = missing
? ''
: `<span class="blp-unified-rating__cell-sub">${escapeHtml(formatUnifiedScore5(p.score5))} ★</span>`;
return `<div class="blp-unified-rating__cell${missing ? ' is-missing' : ''}">
<span class="blp-unified-rating__cell-label">${escapeHtml(p.label)}</span>
<span class="blp-unified-rating__cell-value">${value}</span>
${sub}
</div>`;
})
.join('');
tile.innerHTML = `
<div class="blp-unified-rating__layout">
<div class="blp-unified-rating__score-block">
<p class="blp-unified-rating__label">${escapeHtml(t.unifiedRatingTitle)}</p>
<p class="blp-unified-rating__num">${escapeHtml(formatUnifiedScore5(avg5Rounded))}</p>
<span class="blp-unified-rating__denom">${escapeHtml(t.unifiedRatingOutOf)}</span>
</div>
<div class="blp-unified-rating__main">
<div class="blp-unified-rating__head">
<p class="blp-unified-rating__title">${escapeHtml(t.unifiedRatingHint)}</p>
<p class="blp-unified-rating__hint">${escapeHtml(fmt(t.unifiedRatingSources, { n: present.length }))}</p>
</div>
<div class="blp-unified-rating__meter"><div class="blp-unified-rating__meter-fill" style="width:${escapeHtml(String(barWidth))}%"></div></div>
<div class="blp-unified-rating__grid">${cells}</div>
</div>
</div>
`;
}
function init() {
if (document.documentElement.hasAttribute(ROOT_ATTR)) return;
document.documentElement.setAttribute(ROOT_ATTR, '1');
settings = loadSettings();
locale = resolveLocale(settings.uiLocale);
t = TRANSLATIONS[locale] || TRANSLATIONS.en;
migrateCacheForScriptVersion();
injectStyles();
injectUnifiedRatingStyles();
if (typeof GM_registerMenuCommand === 'function') {
GM_registerMenuCommand(t.menuSettings, openSettings);
}
if (isSteamHost()) {
scanSteamPage();
observeDom(scanSteamPage);
return;
}
if (isSteamDbHost()) {
scanSteamDbPage();
observeDom(scanSteamDbPage);
return;
}
if (isBackloggdHost()) {
ensureNavSettingsButton();
scanPage();
observeDom(scanPage);
bindSpaNavigation(scanPage);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init, { once: true });
} else {
init();
}
})();