// ── Games page ──────────────────────────────────────────────────────────────── async function loadGames() { try { const library = await window.pork.listLibraryTitles({ limit: 1000 }); state.games = window.chimeraLibraryModel.items(library); window.pork.shadowMountStatus?.().then(result => { const byGame = new Map(); for (const entry of (result.model?.entries || [])) { if (!entry.titleId) continue; const current = byGame.get(entry.titleId) || []; current.push(entry); byGame.set(entry.titleId, current); } state.games = state.games.map(game => { const matches = byGame.get(game.game_id) || []; if (!matches.length) return game; return { ...game, shadow_mount: { state: matches.some(entry => entry.state === 'installed') ? 'installed' : matches[0].state, sources: matches.length, experimental: matches.some(entry => entry.format?.support === 'experimental'), formats: [...new Set(matches.map(entry => entry.format?.id).filter(Boolean))], } }; }); renderGames(); }).catch(() => {}); // Refresh hash summaries (fire-and-forget so badges update when ready) window.pork.hashGameSummary().then(summaries => { _populateHashMaps(summaries); renderGames(); }).catch(() => {}); renderGames(); await refreshFirmwareFilters(); await refreshConvQueuePanel(); const conversionQueue = $('conv-queue-panel'); const gamesPage = $('page-games'); if (conversionQueue && gamesPage) gamesPage.append(conversionQueue); await refreshGameCatalogHealth(); } catch (e) { setStatus(e.message, 'error'); } } async function refreshGameCatalogHealth() { if (!window.pork.gameCatalogHealth || !$('game-catalog-health')) return; try { const health = await window.pork.gameCatalogHealth(); $('catalog-health-complete').textContent = `${Number(health.complete || 0)} / ${Number(health.total || 0)}`; $('catalog-health-missing').textContent = String(Number(health.missing_metadata || 0)); $('catalog-health-artwork').textContent = String(Number(health.missing_artwork || 0)); const scan = health.scan || {}; $('catalog-health-scan').textContent = scan.running ? 'Scanning…' : (scan.lastError ? 'Scan issue' : 'Automatic'); $('catalog-health-scan-detail').textContent = scan.lastError ? String(scan.lastError) : (scan.lastCompleted ? window.tf('games.health.lastScan', 'last scan {date}', { date: new Date(scan.lastCompleted).toLocaleString(window.i18n?.current || 'en') }) : window.t('games.health.interval', 'source check every 15 min')); } catch (_) {} } window.pork.on('local:scan:started', refreshGameCatalogHealth); window.pork.on('local:scan:complete', () => refreshGameCatalogHealth()); window.pork.on('prospero:queue:done', () => refreshGameCatalogHealth()); function gamePlatform(game) { const explicit = String(game?.platform || '').toLowerCase(); if (explicit === 'ps4' || explicit === 'ps5') return explicit; const identity = [ game?.game_id, game?.content_id, game?.prospero_content_id, ].filter(Boolean).join(' ').toUpperCase(); if (/CUSA\d{5}/.test(identity)) return 'ps4'; if (/PPSA\d{5}/.test(identity)) return 'ps5'; return 'unknown'; } function versionedArtworkUrl(url, fetchedAt) { const source = String(url || ''); if (!source.startsWith('/pork-cache/')) return source; const revision = Number(fetchedAt || 0); return revision > 0 ? `${source}?v=${Math.trunc(revision)}` : source; } function disconnectedDriveLabels(game) { const labels = []; for (const copy of Array.isArray(game?.localCopies) ? game.localCopies : []) { const device = copy.device || copy.storageDevice || copy.volume?.device || {}; const state = String(device.availabilityState || copy.volume?.availabilityState || copy.availabilityState || '').toLowerCase(); if (!['unavailable', 'disconnected', 'offline', 'missing'].includes(state)) continue; labels.push(device.friendlyLabel || device.displayName || device.label || device.stableFingerprint || copy.volume?.stableVolumeKey || 'external drive'); } return [...new Set(labels.map(String))]; } function renderGamePlatformFilters() { const counts = { all: state.games.length, ps4: 0, ps5: 0, unknown: 0 }; for (const game of state.games) counts[gamePlatform(game)] += 1; for (const platform of Object.keys(counts)) { const count = $(`games-platform-count-${platform}`); if (count) count.textContent = String(counts[platform]); } document.querySelectorAll('[data-game-platform]').forEach(button => { const platform = button.dataset.gamePlatform; const active = platform === state.platformFilter; button.classList.toggle('active', active); button.setAttribute('aria-pressed', String(active)); if (platform === 'unknown') button.hidden = counts.unknown === 0 && !active; }); } function getFilteredGames() { return state.games.filter(g => { const q = state.search.toLowerCase(); if (q && !(g.title || '').toLowerCase().includes(q) && !(g.game_id || '').toLowerCase().includes(q) && !(g.prospero_name || '').toLowerCase().includes(q)) return false; if (state.platformFilter !== 'all' && gamePlatform(g) !== state.platformFilter) return false; // Status and firmware are independent filter dimensions — compute status as a // boolean so both can compose (previously the status branches returned early, // silently disabling the firmware filter). let statusOk = true; if (state.filter === 'installed') statusOk = window.chimeraLibraryModel.isInstalled(g); else if (state.filter === 'backed_up') statusOk = window.chimeraLibraryModel.hasLocalCopy(g); else if (state.filter === 'not_backed') statusOk = window.chimeraLibraryModel.needsProtection(g); if (!statusOk) return false; if (state.firmwareFilter !== 'all') { const labels = (g.firmware_labels || '').split(',').filter(Boolean); if (!labels.includes(state.firmwareFilter)) return false; } return true; }).sort((a, b) => { const sortValue = game => state.sortCol === 'title' ? (game.prospero_name || game.title || game.game_id || '') : (game[state.sortCol] ?? ''); let av = sortValue(a), bv = sortValue(b); if (typeof av === 'string') av = av.toLowerCase(); if (typeof bv === 'string') bv = bv.toLowerCase(); if (av === bv) return 0; return state.sortDir === 'asc' ? (av > bv ? 1 : -1) : (av < bv ? 1 : -1); }); } function renderGames() { renderGamePlatformFilters(); const allGames = getFilteredGames(); const list = allGames.slice(0, state.gameRenderLimit); const tbody = $('games-tbody'); const grid = $('games-grid'); const tableWrap = $('games-table-wrap'); const empty = $('games-empty'); const emptyDetail = $('games-empty-detail'); const emptyScan = $('btn-games-empty-scan'); const emptyReset = $('btn-games-empty-reset'); const loadMore = $('btn-games-load-more'); const resultCount = $('games-result-count'); const filtersActive = Boolean( state.games.length && ( state.search || state.filter !== 'all' || state.platformFilter !== 'all' || state.firmwareFilter !== 'all' ) ); tbody.innerHTML = ''; grid.innerHTML = ''; empty.hidden = allGames.length !== 0; emptyDetail.textContent = filtersActive ? window.t('games.empty.filtered', 'No games match the selected platform and filters.') : window.t('games.empty.detail', 'Connect the PS5 or scan the configured source folders.'); emptyScan.hidden = filtersActive; emptyReset.hidden = !filtersActive; grid.hidden = !allGames.length || state.gameView !== 'grid'; tableWrap.hidden = !allGames.length || state.gameView !== 'table'; loadMore.hidden = list.length >= allGames.length; resultCount.textContent = allGames.length ? window.tf('games.resultCount', '{visible} of {total}', { visible: list.length, total: allGames.length }) : ''; document.querySelectorAll('[data-game-view]').forEach(button => { const active = button.dataset.gameView === state.gameView; button.classList.toggle('active', active); button.setAttribute('aria-pressed', String(active)); }); for (const g of list) { const tr = document.createElement('tr'); tr.className = 'game-row'; tr.dataset.id = g.game_id; // Compact status cell: readable tags instead of cryptic symbols const patchesOnly = g.backup_count === 0 && g.backpork_count > 0; const _hs = _gameHashSummary.get(g.game_id); const hashTag = _hs ? _hs.community_mismatch ? `⚠ Hash Fail` : _hs.community_matches > 0 ? `✓ Verified` : `⧭ Hashed` : ''; const fwLabels = (g.firmware_labels || '').split(',').filter(Boolean); const patchedTag = fwLabels.length ? `🔧 ${escHtml(fwLabels.join(', '))}` : ''; const shadowTag = g.shadow_mount ? `◉ Mount ${escHtml(g.shadow_mount.state)}` : ''; const metadataTag = `Meta ${escHtml(g.metadata?.source || '?')}`; const variantTag = g.regional_variants?.length ? `${g.regional_variants.length} ${escHtml(g.regional_variants.length === 1 ? window.t('games.region', 'region') : window.t('games.regions', 'regions'))}` : ''; const platform = gamePlatform(g); const platformLabel = platform === 'unknown' ? window.t('games.platform.unknown', 'Unknown') : platform.toUpperCase(); const platformTag = `${escHtml(platformLabel)}`; const requiredDrives = disconnectedDriveLabels(g); const requiredDriveTag = requiredDrives.length ? `${escHtml(window.t('games.requiredDrive', 'Needs drive'))}: ${escHtml(requiredDrives.join(', '))}` : ''; const statusHtml = `
${platformTag} ${requiredDriveTag} ${window.chimeraLibraryModel.isInstalled(g) ? `${escHtml(window.t('games.status.installed', 'Installed'))}` : ''} ${g.backup_count > 0 ? `${g.backup_count} bkp` : ''} ${window.chimeraLibraryModel.hasLocalCopy(g) ? `✓` : ''} ${patchesOnly ? `${escHtml(window.t('games.status.patchesOnly', 'Patches only'))}` : ''} ${patchedTag} ${shadowTag} ${hashTag} ${metadataTag} ${variantTag}
`; const displayTitle = g.prospero_name || g.title || g.game_id; const metadataLine = [ g.prospero_publisher, g.prospero_region, g.content_id || g.prospero_content_id, ].filter(Boolean).join(' · '); const coverUrl = g.prospero_cover_url || ''; const renderedCoverUrl = versionedArtworkUrl(coverUrl, g.cover_fetched_at); const iconFallbackAttribute = g.prospero_icon_url ? ` data-fallback-src="${escHtml(g.prospero_icon_url)}"` : ''; const coverPlaceholder = ``; const cover = renderedCoverUrl ? `
${escHtml(displayTitle)} ${coverPlaceholder}
` : g.prospero_icon_url ? `
${escHtml(platformLabel)} ${escHtml(displayTitle)} ${coverPlaceholder}
${escHtml(displayTitle)}${escHtml(g.game_id)}
` : `
${escHtml(platformLabel)}${escHtml(displayTitle)}${escHtml(g.game_id || '?')}
`; const metadataSynced = Boolean(g.prospero_name && g.prospero_icon_url && g.prospero_cover_url); const metadataState = metadataSynced ? window.t('games.metadata.synced', 'Metadata: synced') : window.t('games.metadata.enriching', 'Metadata: enriching'); const card = document.createElement('article'); card.className = 'game-card'; card.dataset.id = g.game_id; card.dataset.platform = platform; card.style.setProperty('--card-hue', String(titleHue(g.game_id))); card.tabIndex = 0; card.innerHTML = `
${cover} ${metadataSynced ? '' : `${escHtml(metadataState)}`}
${patchesOnly ? '' : ``}
${escHtml(displayTitle)}
${escHtml([g.game_id, metadataLine].filter(Boolean).join(' · '))} ${statusHtml}
`; const nativeCover = card.querySelector('.game-card-cover-case.has-native-cover .game-card-cover'); if (nativeCover) { const applyNaturalCoverRatio = () => { const width = Number(nativeCover.naturalWidth || 0); const height = Number(nativeCover.naturalHeight || 0); const ratio = height > 0 ? width / height : 0; if (width >= 160 && height >= 240 && ratio >= 0.52 && ratio <= 0.86) { card.style.setProperty('--game-cover-ratio', `${width} / ${height}`); } }; if (nativeCover.complete) applyNaturalCoverRatio(); else nativeCover.addEventListener('load', applyNaturalCoverRatio, { once: true }); } grid.appendChild(card); if (patchesOnly) tr.classList.add('game-row-patches-only'); tr.innerHTML = `
${renderedCoverUrl || g.prospero_icon_url ? `${escHtml(window.t('games.coverFor', 'Cover for'))} ${escHtml(g.prospero_name || g.title || g.game_id)}` : '
'} ${renderedCoverUrl || g.prospero_icon_url ? '' : ''} ${escHtml(g.prospero_name || g.title || g.game_id)} ${escHtml([ g.prospero_publisher, g.prospero_region, g.content_id || g.prospero_content_id, ].filter(Boolean).join(' · ') || window.t('games.metadataQueued', 'Metadata is being enriched…'))}
${escHtml(g.game_id)} ${escHtml(g.version || g.prospero_version || '—')} ${g.backup_size ? fmt(g.backup_size) : (g.size ? fmt(g.size) : (g.prospero_size || '—'))} ${renderFwPills(g.firmware_labels)} ${statusHtml} ${patchesOnly ? `⚠ ${escHtml(window.t('games.noBackup', 'No game backup'))}` : ` ` } `; tbody.appendChild(tr); } // Click delegation: action buttons take priority; otherwise the card/row opens detail. const handleLibraryClick = async e => { const btn = e.target.closest('[data-action]'); if (btn) { try { if (btn.dataset.action === 'install') { await openInstallModal(btn.dataset.id, 'pfs'); } else if (btn.dataset.action === 'convert') { const s = await window.pork.getSettings(); const mode = (s.gameConversionMode === 'pfs' || !s.gameConversionMode) ? (window.pork.isWebMode ? 'ffpfsc' : 'ffpkg') : s.gameConversionMode; await openInstallModal(btn.dataset.id, mode); } else if (btn.dataset.action === 'metadata') { await editGameMetadata(btn.dataset.id); } } catch (error) { setStatus(error.message, 'error'); showToast(error.message, 'error', 9000); } return; } const item = e.target.closest('[data-id].game-row, [data-id].game-card'); if (item) openModal(item.dataset.id); }; tbody.onclick = handleLibraryClick; grid.onclick = handleLibraryClick; grid.onkeydown = e => { if ((e.key === 'Enter' || e.key === ' ') && !e.target.closest('button')) { e.preventDefault(); const card = e.target.closest('.game-card[data-id]'); if (card) openModal(card.dataset.id); } }; window.i18n?.apply(grid); window.i18n?.apply(tbody); } async function editGameMetadata(gameId) { const game = state.games.find(item => item.game_id === gameId); if (!game) return; const hasOverride = Boolean(game.metadata?.overriddenFields?.length); if (hasOverride && await showExactConfirmation('Clear manual metadata and return to online and local metadata management?', 'CLEAR METADATA')) { await window.pork.updateGameMetadata(gameId, {}); await loadGames(); setStatus(`Handmatige metadata voor ${gameId} gewist.`, 'ok'); return; } const ask = (label, value = '') => showPrompt(`${label} voor ${gameId}:`, String(value || '')); const title = await ask('Volledige titel', game.prospero_name || game.title); if (title == null) return; const publisher = await ask('Uitgever', game.prospero_publisher); if (publisher == null) return; const region = await ask('Regio', game.prospero_region); if (region == null) return; const iconUrl = await ask(window.t?.('games.metadata.iconUrl', 'HTTPS image URL') || 'HTTPS image URL', game.prospero_icon_url); if (iconUrl == null) return; if (iconUrl && !/^(https:\/\/|\/api\/local-artwork\/)/i.test(iconUrl)) { throw new Error(window.t?.('games.metadata.invalidImage', 'Use an HTTPS URL or a local Chimera image.') || 'Use an HTTPS URL or a local Chimera image.'); } const coverUrl = await ask(window.t?.('games.metadata.coverUrl', 'HTTPS portrait cover URL') || 'HTTPS portrait cover URL', game.prospero_cover_url); if (coverUrl == null) return; if (coverUrl && !/^(https:\/\/|\/api\/local-artwork\/)/i.test(coverUrl)) { throw new Error(window.t?.('games.metadata.invalidImage', 'Use an HTTPS URL or a local Chimera image.') || 'Use an HTTPS URL or a local Chimera image.'); } const description = await ask(window.t?.('games.metadata.description', 'Description') || 'Description', game.prospero_description); if (description == null) return; await window.pork.updateGameMetadata(gameId, { title, publisher, region, iconUrl, coverUrl, description }); await loadGames(); setStatus(`Metadata voor ${gameId} handmatig vastgezet.`, 'success'); } // Sorting document.querySelectorAll('#games-table th[data-col]').forEach(th => { th.addEventListener('click', () => { if (state.sortCol === th.dataset.col) state.sortDir = state.sortDir === 'asc' ? 'desc' : 'asc'; else { state.sortCol = th.dataset.col; state.sortDir = 'asc'; } localStorage.setItem('chimera.games.sortCol', state.sortCol); localStorage.setItem('chimera.games.sortDir', state.sortDir); renderGames(); }); }); // Filter buttons document.querySelectorAll('.fbtn').forEach(btn => btn.addEventListener('click', () => { document.querySelectorAll('.fbtn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); state.filter = btn.dataset.filter; state.gameRenderLimit = 160; localStorage.setItem('chimera.games.filter', state.filter); renderGames(); }) ); document.querySelectorAll('[data-game-platform]').forEach(button => { button.addEventListener('click', () => { state.platformFilter = ['ps4', 'ps5', 'unknown'].includes(button.dataset.gamePlatform) ? button.dataset.gamePlatform : 'all'; state.gameRenderLimit = 160; localStorage.setItem('chimera.games.platform', state.platformFilter); renderGames(); }); }); // Search $('games-search').addEventListener('input', e => { state.search = e.target.value; state.gameRenderLimit = 160; renderGames(); }); document.querySelectorAll('[data-game-view]').forEach(button => { button.addEventListener('click', () => { state.gameView = button.dataset.gameView === 'table' ? 'table' : 'grid'; localStorage.setItem('chimera.games.view', state.gameView); renderGames(); }); }); $('btn-games-load-more')?.addEventListener('click', () => { state.gameRenderLimit += 160; renderGames(); }); $('btn-games-empty-scan')?.addEventListener('click', () => $('btn-rescan-game-sources')?.click()); $('btn-games-empty-reset')?.addEventListener('click', () => { state.search = ''; state.filter = 'all'; state.platformFilter = 'all'; state.firmwareFilter = 'all'; state.gameRenderLimit = 160; $('games-search').value = ''; localStorage.setItem('chimera.games.filter', 'all'); localStorage.setItem('chimera.games.platform', 'all'); document.querySelectorAll('.fwbtn').forEach(button => { button.classList.toggle('active', button.dataset.fw === 'all'); }); renderGames(); }); document.querySelectorAll('.fbtn').forEach(button => { button.classList.toggle('active', button.dataset.filter === state.filter); }); window.addEventListener('chimera:language-changed', () => renderGames());