NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript==
// @name MobyGames Platform List Enhancer (Genres + Score + Year filters, Hide, Infinite Scroll)
// @namespace mg-enhancer
// @version 1.4.0
// @description Hide by GENRE terms (Genres column only), require minimum score, include only rows within a year range, per-row hide/unhide with persistence, and infinite-scroll the platform list.
// @match https://www.mobygames.com/platform/*/
// @match https://www.mobygames.com/platform/*/page:*/
// @run-at document-idle
// @grant none
// @license MIT
// ==/UserScript==
(function () {
'use strict';
// ---------- Utilities
const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
const on = (el, ev, fn, opts) => el && el.addEventListener(ev, fn, opts);
const debounce = (fn, ms = 300) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; };
const throttle = (fn, ms = 200) => { let last = 0; return (...a) => { const n = Date.now(); if (n - last >= ms) { last = n; fn(...a); } }; };
// ---------- Page anchors
const table = $('main table');
if (!table) return;
table.classList.add('mg-themed-table');
const theadRow = $('thead tr', table);
const tbody = $('tbody', table);
if (!theadRow || !tbody) return;
const overflowWrap = table.closest('.overflow-x-scroll') || table.parentElement;
const main = $('#main') || document.body;
// ---------- Platform/page parsing
const path = location.pathname.replace(/\/+$/, '') + '/';
const platformMatch = path.match(/\/platform\/([^/]+)\//);
const platformSlug = platformMatch ? platformMatch[1] : 'unknown';
const pageMatch = path.match(/\/page:(\d+)\//);
let currentPage = pageMatch ? parseInt(pageMatch[1], 10) : 0;
let nextPage = currentPage === 0 ? 1 : currentPage + 1;
let loading = false;
let noMore = false;
const LOAD_COOLDOWN_MS = 2000; // cooldown between page loads
let lastLoadTs = 0;
const basePlatformPath = `/platform/${platformSlug}/`;
// ---------- Storage keys
const LS_FILTER_GENRES = (slug) => `mgFilterTerms:${slug}`; // per-platform: CSV genre terms to HIDE
const LS_MIN_SCORE = (slug) => `mgScoreMin:${slug}`; // per-platform: minimum score (inclusive)
const LS_YEAR_MIN = (slug) => `mgYearMin:${slug}`; // per-platform: min year (inclusive)
const LS_YEAR_MAX = (slug) => `mgYearMax:${slug}`; // per-platform: max year (inclusive)
const LS_SHOW_HIDDEN = 'mgShowHidden'; // global
const LS_HIDDEN_SET = (slug) => `mgHiddenGames:${slug}`; // per-platform set
// ---------- State (defaults: genres "adventure, sports", score >= 0, year 1970–2030)
const savedGenresStr = localStorage.getItem(LS_FILTER_GENRES(platformSlug)) ?? 'adventure, sports';
let genreTerms = parseTerms(savedGenresStr);
let minScore = parseFloat(localStorage.getItem(LS_MIN_SCORE(platformSlug)) ?? '0');
if (Number.isNaN(minScore)) minScore = 0;
let yearMin = parseInt(localStorage.getItem(LS_YEAR_MIN(platformSlug) ) ?? '1970', 10);
let yearMax = parseInt(localStorage.getItem(LS_YEAR_MAX(platformSlug) ) ?? '2030', 10);
if (Number.isNaN(yearMin)) yearMin = null;
if (Number.isNaN(yearMax)) yearMax = null;
let showHidden = (localStorage.getItem(LS_SHOW_HIDDEN) ?? 'false') === 'true';
let hiddenSet = new Set(loadHiddenArray(platformSlug));
// ---------- Column indices (before we append "Actions")
const genresColIdx = findColIndex('genres', 2); // fallback to 2 (0-based)
const scoreColIdx = findColIndex('moby score', 3); // fallback to 3 (0-based)
const yearColIdx = findColIndex('released', 1); // fallback to 1 (0-based)
// ---------- Styles
injectStyles(`
:root{
--mg-border: var(--border-color-dark, #3a3a3f);
--mg-input-bg: rgba(255,255,255,.06);
--mg-input-bg-focus: rgba(255,255,255,.09);
--mg-btn-bg: rgba(255,255,255,.08);
--mg-btn-bg-hover: rgba(255,255,255,.12);
--mg-highlight: rgba(120,160,255,.35);
--mg-th-bg: rgba(255,255,255,.05);
--mg-hover-bg: rgba(255,255,255,.04);
}
.mg-controls{
display:grid; grid-template-columns: auto auto auto auto auto 1fr;
align-items:center; gap:.75rem; margin:.75rem 0 1rem 0;
}
.mg-field{ display:flex; flex-direction:column; gap:.35rem; }
.mg-field label{ font-size:.9em; opacity:.8; }
.mg-help{ font-size:.8em; opacity:.6; grid-column: 1 / span 3; }
.mg-input{
min-width:240px; padding:.5rem .65rem; border:1px solid var(--mg-border);
border-radius:8px; background:var(--mg-input-bg); color:inherit;
transition: background .15s ease, border-color .15s ease, box-shadow .15s ease;
}
.mg-input:focus{
background:var(--mg-input-bg-focus); border-color:var(--mg-highlight);
box-shadow:0 0 0 2px var(--mg-highlight); outline:none;
}
.mg-input-number{ width:110px; }
.mg-btn{
padding:.45rem .7rem; border:1px solid var(--mg-border);
border-radius:8px; background:var(--mg-btn-bg); color:inherit;
cursor:pointer; transition: background .15s ease, transform .02s;
}
.mg-btn:hover{ background:var(--mg-btn-bg-hover); }
.mg-btn:active{ transform:translateY(1px); }
.mg-status{ opacity:.75; font-size:.9em; justify-self:end; }
.mg-themed-table{ border-collapse:separate; border-spacing:0; width:100%; }
.mg-themed-table thead tr{ background:var(--mg-th-bg); }
.mg-themed-table th, .mg-themed-table td{ border-top:1px solid var(--mg-border); }
.mg-themed-table tbody tr:hover{ background:var(--mg-hover-bg); }
.mg-actions-cell{ white-space:nowrap; }
/* Hide types */
.mg-hidden-user{ display:none !important; }
.mg-hidden-filter-display{ display:none !important; }
/* 2px placeholders for filtered rows */
tr.mg-filter-placeholder{ height:2px; }
tr.mg-filter-placeholder > td{
padding:0 !important; border:0 !important; height:2px !important; line-height:0 !important; font-size:0 !important;
background:var(--mg-border);
}
.mg-bottom-loader{ text-align:center; margin:.5rem 0 .25rem; opacity:.8; display:none; }
`);
// ---------- Controls UI
const controls = document.createElement('div');
controls.className = 'mg-controls';
// Genres field
const fGenres = document.createElement('div');
fGenres.className = 'mg-field';
const lblGenres = document.createElement('label');
lblGenres.textContent = 'Genres to hide (CSV)';
const inpGenres = document.createElement('input');
inpGenres.type = 'text';
inpGenres.className = 'mg-input';
inpGenres.placeholder = 'adventure, sports';
inpGenres.value = savedGenresStr;
fGenres.append(lblGenres, inpGenres);
// Score min
const fScore = document.createElement('div');
fScore.className = 'mg-field';
const lblScore = document.createElement('label');
lblScore.textContent = 'Minimum score';
const inpScore = document.createElement('input');
inpScore.type = 'number';
inpScore.className = 'mg-input mg-input-number';
inpScore.min = '0'; inpScore.max = '10'; inpScore.step = '0.1';
inpScore.value = String(minScore);
fScore.append(lblScore, inpScore);
// Year min
const fYearMin = document.createElement('div');
fYearMin.className = 'mg-field';
const lblYearMin = document.createElement('label');
lblYearMin.textContent = 'Year from';
const inpYearMin = document.createElement('input');
inpYearMin.type = 'number';
inpYearMin.className = 'mg-input mg-input-number';
inpYearMin.placeholder = 'min';
inpYearMin.value = (yearMin ?? '').toString();
fYearMin.append(lblYearMin, inpYearMin);
// Year max
const fYearMax = document.createElement('div');
fYearMax.className = 'mg-field';
const lblYearMax = document.createElement('label');
lblYearMax.textContent = 'Year to';
const inpYearMax = document.createElement('input');
inpYearMax.type = 'number';
inpYearMax.className = 'mg-input mg-input-number';
inpYearMax.placeholder = 'max';
inpYearMax.value = (yearMax ?? '').toString();
fYearMax.append(lblYearMax, inpYearMax);
// Save + show hidden
const saveBtn = document.createElement('button');
saveBtn.className = 'mg-btn';
saveBtn.textContent = 'Save filters';
const showHiddenLbl = document.createElement('label');
showHiddenLbl.style.alignSelf = 'center';
showHiddenLbl.style.display = 'inline-flex';
showHiddenLbl.style.gap = '.5rem';
showHiddenLbl.style.alignItems = 'center';
const showHiddenCb = document.createElement('input');
showHiddenCb.type = 'checkbox';
showHiddenCb.checked = showHidden;
const showHiddenSpan = document.createElement('span');
showHiddenSpan.textContent = 'Show hidden games';
showHiddenLbl.append(showHiddenCb, showHiddenSpan);
// Help + status
const help = document.createElement('div');
help.className = 'mg-help';
help.textContent = 'Genre filter checks only the Genres column. Score filter keeps rows with score ≥ minimum. Year filter keeps rows with Release year between “from” and “to” (inclusive). If either year is empty, year filtering is disabled. Saved per platform.';
const status = document.createElement('span');
status.className = 'mg-status';
controls.append(fGenres, fScore, fYearMin, fYearMax, saveBtn, showHiddenLbl, help, status);
overflowWrap.parentElement.insertBefore(controls, overflowWrap);
// Bottom loader
const bottomLoader = document.createElement('div');
bottomLoader.className = 'mg-bottom-loader';
bottomLoader.textContent = 'Loading…';
overflowWrap.parentElement.insertBefore(bottomLoader, overflowWrap.nextSibling);
// ---------- Actions column header
const th = document.createElement('th');
th.textContent = 'Actions';
th.className = 'text-nowrap';
theadRow.appendChild(th);
// ---------- Wire control events
const persistAndApply = () => {
const rawGenres = (inpGenres.value || '').trim();
const rawScore = (inpScore.value || '').trim();
const rawYMin = (inpYearMin.value || '').trim();
const rawYMax = (inpYearMax.value || '').trim();
localStorage.setItem(LS_FILTER_GENRES(platformSlug), rawGenres);
localStorage.setItem(LS_MIN_SCORE(platformSlug), rawScore === '' ? '0' : rawScore);
if (rawYMin === '') localStorage.removeItem(LS_YEAR_MIN(platformSlug));
else localStorage.setItem(LS_YEAR_MIN(platformSlug), rawYMin);
if (rawYMax === '') localStorage.removeItem(LS_YEAR_MAX(platformSlug));
else localStorage.setItem(LS_YEAR_MAX(platformSlug), rawYMax);
genreTerms = parseTerms(rawGenres);
minScore = parseFloat(rawScore || '0') || 0;
yearMin = rawYMin === '' ? null : parseInt(rawYMin, 10);
yearMax = rawYMax === '' ? null : parseInt(rawYMax, 10);
applyAllVisibility();
flashStatus('Filters saved.');
};
on(saveBtn, 'click', persistAndApply);
// Also live-apply on typing (debounced) to feel responsive
const debouncedLiveApply = debounce(persistAndApply, 300);
on(inpGenres, 'input', debouncedLiveApply);
on(inpScore, 'input', debouncedLiveApply);
on(inpYearMin,'input', debouncedLiveApply);
on(inpYearMax,'input', debouncedLiveApply);
on(showHiddenCb, 'change', () => {
showHidden = !!showHiddenCb.checked;
localStorage.setItem(LS_SHOW_HIDDEN, String(showHidden));
applyAllVisibility();
});
// ---------- Decorate current rows, then apply visibility
$$('tbody tr', table).forEach(decorateRow);
applyAllVisibility();
// ---------- Infinite scroll + cooldown
const sentinel = document.createElement('div');
sentinel.className = 'mg-sentinel';
main.appendChild(sentinel);
const canLoadMore = () =>
!loading && !noMore && (Date.now() - lastLoadTs >= LOAD_COOLDOWN_MS);
const tryLoadMore = async () => {
if (!canLoadMore()) return;
loading = true;
showBottomLoader(true);
try {
const url = basePlatformPath + `page:${nextPage}/`;
const doc = await fetchHTML(url);
if (!doc) { noMore = true; return; }
const newRows = $$('tbody tr', doc);
if (!newRows.length) { noMore = true; return; }
for (const tr of newRows) {
tbody.appendChild(tr);
decorateRow(tr);
}
applyAllVisibility();
currentPage = nextPage;
nextPage += 1;
} catch (e) {
console.error('[MG Enhancer] load error', e);
} finally {
loading = false;
lastLoadTs = Date.now();
showBottomLoader(false);
}
};
const onScroll = throttle(() => {
if ((window.scrollY + window.innerHeight) >= (document.body.offsetHeight - 1200)) {
tryLoadMore();
}
}, 250);
on(window, 'scroll', onScroll);
if ('IntersectionObserver' in window) {
const io = new IntersectionObserver((entries) => {
if (entries.some(e => e.isIntersecting)) tryLoadMore();
}, { rootMargin: '1200px 0px' });
io.observe(sentinel);
}
// ---------- Core functions
function decorateRow(tr) {
if (tr.querySelector('.mg-actions-cell')) return;
const actionsTd = document.createElement('td');
actionsTd.className = 'mg-actions-cell';
const btn = document.createElement('button');
btn.className = 'mg-btn';
const id = extractGameId(tr) || extractHref(tr) || `row-${Math.random().toString(36).slice(2)}`;
tr.dataset.mgId = id;
const updateBtn = () => {
const isHidden = hiddenSet.has(id);
btn.textContent = isHidden ? 'Unhide' : 'Hide';
const badge = tr.querySelector('.mg-row-hidden-badge');
if (isHidden && showHidden && !badge) {
const b = document.createElement('span');
b.className = 'mg-row-hidden-badge';
b.style.cssText = 'opacity:.6;font-size:.85em;margin-left:.4rem;';
b.textContent = '(hidden)';
actionsTd.appendChild(b);
} else if ((!isHidden || !showHidden) && badge) {
badge.remove();
}
};
on(btn, 'click', () => {
if (hiddenSet.has(id)) hiddenSet.delete(id);
else hiddenSet.add(id);
saveHiddenArray(platformSlug, Array.from(hiddenSet));
updateBtn();
applyRowVisibility(tr);
updateCounts();
});
actionsTd.appendChild(btn);
tr.appendChild(actionsTd);
updateBtn();
}
function applyAllVisibility() {
$$('tbody tr', table)
.filter(tr => !tr.classList.contains('mg-filter-placeholder'))
.forEach(applyRowVisibility);
updateCounts();
}
function applyRowVisibility(tr) {
const id = tr.dataset.mgId || extractGameId(tr) || extractHref(tr);
// Pull column texts & numbers
const tds = tr.children;
const genresText = (tds[genresColIdx]?.innerText || '').toLowerCase();
const scoreText = (tds[scoreColIdx]?.innerText || '').replace(/[^\d.]/g, '');
const yearText = (tds[yearColIdx]?.innerText || '').trim();
const scoreVal = parseFloat(scoreText);
const yearVal = parseInt(yearText, 10);
// Genre filter: hide if ANY genreTerm is contained in Genres column
const hasGenreTerms = genreTerms.length > 0;
const genreHit = hasGenreTerms && genreTerms.some(term => genresText.includes(term));
// Score filter: keep only rows >= minScore
const scoreFail = Number.isFinite(scoreVal) ? (scoreVal < minScore) : false;
// Year filter: only apply if both bounds present; keep only in [yearMin, yearMax]
let yearFail = false;
if (yearMin != null && yearMax != null && Number.isFinite(yearVal)) {
yearFail = (yearVal < yearMin || yearVal > yearMax);
} else if ((yearMin == null) !== (yearMax == null)) {
// If either is missing → disable year filtering
yearFail = false;
}
// Should this row be filtered (hidden with 2px placeholder)?
const shouldFilter = genreHit || scoreFail || yearFail;
if (shouldFilter) {
tr.classList.add('mg-hidden-filter-display');
ensurePlaceholderAfter(tr, id);
} else {
tr.classList.remove('mg-hidden-filter-display');
removePlaceholderAfter(tr, id);
}
// USER-HIDDEN: hide unless "show hidden games" is on
const isUserHidden = id ? hiddenSet.has(id) : false;
const hideForUser = isUserHidden && !showHidden;
tr.classList.toggle('mg-hidden-user', hideForUser);
}
function ensurePlaceholderAfter(tr, id) {
let sib = tr.nextElementSibling;
if (sib && sib.classList.contains('mg-filter-placeholder') && sib.dataset.forId === id) return;
const ph = document.createElement('tr');
ph.className = 'mg-filter-placeholder';
ph.dataset.forId = id;
const td = document.createElement('td');
td.colSpan = theadRow.children.length;
ph.appendChild(td);
tr.parentNode.insertBefore(ph, tr.nextSibling);
}
function removePlaceholderAfter(tr, id) {
const sib = tr.nextElementSibling;
if (sib && sib.classList.contains('mg-filter-placeholder') && sib.dataset.forId === id) {
sib.remove();
}
}
function parseTerms(s) {
return (s || '')
.split(',')
.map(t => t.trim().toLowerCase())
.filter(Boolean);
}
function extractGameId(tr) {
const a = tr.querySelector('a[href*="/game/"]');
if (!a) return null;
const m = a.href.match(/\/game\/(\d+)\//);
return m ? m[1] : null;
}
function extractHref(tr) {
const a = tr.querySelector('a[href]');
return a ? a.getAttribute('href') : null;
}
function findColIndex(needle, fallbackIdx) {
const ths = Array.from(theadRow.children);
const idx = ths.findIndex(th => th.textContent.trim().toLowerCase().includes(needle));
return idx >= 0 ? idx : fallbackIdx;
}
function loadHiddenArray(slug) {
try { return JSON.parse(localStorage.getItem(LS_HIDDEN_SET(slug)) || '[]'); }
catch { return []; }
}
function saveHiddenArray(slug, arr) {
localStorage.setItem(LS_HIDDEN_SET(slug), JSON.stringify(arr));
}
async function fetchHTML(url) {
try {
const r = await fetch(url, { credentials: 'include' });
if (!r.ok) return null;
const html = await r.text();
const parser = new DOMParser();
return parser.parseFromString(html, 'text/html');
} catch {
return null;
}
}
function injectStyles(css) {
const style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
}
function flashStatus(msg) {
status.textContent = msg;
setTimeout(() => { if (status.textContent === msg) status.textContent = ''; }, 1200);
}
function updateCounts() {
const totalOriginalRows = $$('tbody tr', table)
.filter(tr => !tr.classList.contains('mg-filter-placeholder')).length;
const filteredCount = $$('tbody tr.mg-filter-placeholder', table).length;
const hiddenUserCount = $$('tbody tr.mg-hidden-user', table).length;
const visibleOriginal = $$('tbody tr', table).filter(tr =>
!tr.classList.contains('mg-filter-placeholder') &&
!tr.classList.contains('mg-hidden-user') &&
!tr.classList.contains('mg-hidden-filter-display')
).length;
status.textContent = `${visibleOriginal}/${totalOriginalRows} visible • filtered: ${filteredCount} • hidden: ${hiddenUserCount}`;
}
function showBottomLoader(on) {
bottomLoader.style.display = on ? 'block' : 'none';
bottomLoader.textContent = on ? 'Loading…' : '';
}
})();