'use strict'; // State const state = { connected: false, games: [], backups: [], sortCol: localStorage.getItem('chimera.games.sortCol') || 'title', sortDir: localStorage.getItem('chimera.games.sortDir') || 'asc', filter: localStorage.getItem('chimera.games.filter') || 'all', platformFilter: ['all', 'ps4', 'ps5', 'unknown'].includes(localStorage.getItem('chimera.games.platform')) ? localStorage.getItem('chimera.games.platform') : 'all', search: '', firmwareFilter: 'all', gameView: localStorage.getItem('chimera.games.view') || 'grid', gameRenderLimit: 160, currentGame: null, }; // Shared hash summary maps — populated by loadGames() and loadBackporks(). // _gameHashSummary: game_id → summary for hash_type='game' // _backporkHashSummary: 'game_id|firmware_label' → summary for hash_type='backpork' const _gameHashSummary = new Map(); const _backporkHashSummary = new Map(); const _pageLoadTimestamps = new Map(); const _pageLoadModes = new Map(); const _pageLoadInFlight = new Map(); let _pageWarmTimer = null; let _pageWarmRunning = false; // Feature code is route-loaded. Background refreshes only revisit modules that a user // already opened; they never download the entire application after startup. const _PAGE_WARM_ORDER = []; const _PAGE_STALE_MS = { dashboard: 15_000, transfers: 5_000, 'system-view': 20_000, media: 30_000, default: 45_000, }; function _populateHashMaps(summaries) { _gameHashSummary.clear(); _backporkHashSummary.clear(); for (const s of summaries) { if (s.hash_type === 'backpork') { _backporkHashSummary.set(s.game_id + '|' + (s.firmware_label || ''), s); } else { _gameHashSummary.set(s.game_id, s); } } } // System View state let _svStream = null; let _svMuted = false; let _svAudioCtx = null; // AudioContext for low-latency capture-card audio let _svAudioGain = null; // GainNode — controls mute without stopping the stream let _svHotkeys = { mute: 'm', gif: 'g', video: 'v', fullscreen: 'f', popout: 'p' }; let _svRecording = false; let _svRecFrames = []; // Uint8ClampedArray[] — one per captured frame let _svRecInterval = null; let _svRecTimer = null; let _svRecStart = 0; let _svRecCanvas = null; // lazy-created hidden 640×360 canvas let _svRecCtx = null; const GIF_W = 640, GIF_H = 360; // Video recording state let _svVidRecorder = null; let _svVidWritable = null; let _svVidChunks = null; let _svVidTimer = null; let _svVidStart = 0; let _svVidPaused = false; let _svVidBytes = 0; let _svVidMimeType = ''; let _svVidExt = ''; // Helpers const $ = id => document.getElementById(id); const _dialogReturnFocus = new WeakMap(); function dialogFocusable(container) { if (!container) return []; return [...container.querySelectorAll('a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])')] .filter(element => !element.hidden && element.getAttribute('aria-hidden') !== 'true' && element.getClientRects().length); } function openAccessibleDialog(overlay, initialFocus = null) { if (!overlay) return; _dialogReturnFocus.set(overlay, document.activeElement instanceof HTMLElement ? document.activeElement : null); overlay.hidden = false; overlay.setAttribute('aria-hidden', 'false'); document.body.classList.add('chimera-dialog-open'); requestAnimationFrame(() => (initialFocus || dialogFocusable(overlay)[0] || overlay.querySelector('[role="dialog"]'))?.focus?.()); } function closeAccessibleDialog(overlay) { if (!overlay) return; overlay.hidden = true; overlay.setAttribute('aria-hidden', 'true'); const returnFocus = _dialogReturnFocus.get(overlay); _dialogReturnFocus.delete(overlay); if (![...document.querySelectorAll('[role="dialog"][aria-modal="true"]')] .some(dialog => !dialog.closest('[hidden]') && dialog.getClientRects().length)) { document.body.classList.remove('chimera-dialog-open'); } if (returnFocus?.isConnected) requestAnimationFrame(() => returnFocus.focus()); } document.addEventListener('keydown', event => { const tab = event.target.closest?.('[role="tab"]'); if (tab && ['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) { const tabs = [...tab.closest('[role="tablist"]')?.querySelectorAll('[role="tab"]') || []].filter(item => !item.disabled && !item.hidden); if (tabs.length) { event.preventDefault(); const current = Math.max(0, tabs.indexOf(tab)); const next = event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : (current + (event.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length; tabs[next].focus(); tabs[next].click(); return; } } if (event.key !== 'Tab') return; const dialogs = [...document.querySelectorAll('[role="dialog"][aria-modal="true"]')] .filter(dialog => !dialog.closest('[hidden]') && dialog.getClientRects().length); const dialog = dialogs.at(-1); if (!dialog) return; const focusable = dialogFocusable(dialog); if (!focusable.length) { event.preventDefault(); dialog.focus(); return; } const first = focusable[0]; const last = focusable.at(-1); if (event.shiftKey && (document.activeElement === first || !dialog.contains(document.activeElement))) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } }); document.addEventListener('error', event => { const image = event.target; if (!(image instanceof HTMLImageElement) || !image.dataset.imageError) return; const fallbackSrc = image.dataset.fallbackSrc; if (fallbackSrc && image.dataset.fallbackAttempted !== 'true' && image.currentSrc !== fallbackSrc) { image.dataset.fallbackAttempted = 'true'; image.classList.add('is-fallback-artwork'); image.src = fallbackSrc; return; } image.hidden = true; if (image.dataset.imageError === 'fallback' && image.nextElementSibling) image.nextElementSibling.hidden = false; }, true); function setGameWorkspaceContext(gameId, target) { const normalized = String(gameId || '').trim().toUpperCase(); if (!/^(?:CUSA|PPSA)\d{5}$/.test(normalized)) return false; sessionStorage.setItem('chimera.gameWorkspace', JSON.stringify({ gameId: normalized, target: String(target || ''), at: Date.now() })); return true; } function consumeGameWorkspaceContext(target) { try { const raw = sessionStorage.getItem('chimera.gameWorkspace'); const context = raw ? JSON.parse(raw) : null; if (!context || context.target !== target || Date.now() - Number(context.at || 0) > 5 * 60 * 1000) return null; sessionStorage.removeItem('chimera.gameWorkspace'); return /^(?:CUSA|PPSA)\d{5}$/.test(String(context.gameId || '')) ? context.gameId : null; } catch (_) { sessionStorage.removeItem('chimera.gameWorkspace'); return null; } } function _isNarrowLayout() { return window.matchMedia('(max-width: 900px)').matches; } function setMobileNavOpen(open) { document.body.classList.toggle('mobile-nav-open', !!open && _isNarrowLayout()); const isOpen = document.body.classList.contains('mobile-nav-open'); const toggle = $('mobile-nav-toggle'); if (toggle) { toggle.setAttribute('aria-expanded', String(isOpen)); toggle.setAttribute('aria-label', isOpen ? 'Close navigation' : 'Open navigation'); toggle.title = isOpen ? 'Close navigation' : 'Open navigation'; toggle.textContent = isOpen ? '×' : '☰'; } const companionMore = $('chimera-companion-more'); if (companionMore) companionMore.setAttribute('aria-expanded', String(isOpen)); const scrim = $('sidebar-scrim'); if (scrim) scrim.hidden = !isOpen; const companionDock = $('chimera-companion-dock'); if (companionDock) { companionDock.toggleAttribute('inert', isOpen); if (isOpen) companionDock.setAttribute('aria-hidden', 'true'); else companionDock.removeAttribute('aria-hidden'); } if (isOpen) { requestAnimationFrame(() => document.querySelector('.chimera-route-link.active, .chimera-domain-home.active')?.scrollIntoView({ block: 'nearest' })); } } function closeMobileNav() { setMobileNavOpen(false); } function applyDensity(value) { const density = value === 'compact' ? 'compact' : 'comfortable'; document.body.dataset.density = density; return density; } function _pageLoader(page, background = false) { const loaders = { dashboard: () => loadDashboard(), games: () => loadGames(), packages: () => loadPackages(), backporks: () => loadBackporks(), backups: () => loadBackups(), database: () => loadDatabase(), settings: () => loadSettings({ background }), jailbreak: () => loadJailbreak(), psnotify: () => loadPsNotify(), xavatar: () => loadXavatar(), 'system-view': () => loadSystemView(background ? { passive: true } : {}), transfers: () => loadTransfers(), media: () => loadMedia(), cheats: () => loadCheats({ background }), garlic: () => loadGarlic(), 'elf-arsenal': () => (typeof loadElfArsenal === 'function' ? loadElfArsenal({ background }) : null), 'pfs-ripper': () => (typeof loadPfsRipper === 'function' ? loadPfsRipper({ background }) : null), savemgr: () => loadSaveMgr({ background }), ecosystem: () => (typeof loadEcosystem === 'function' ? loadEcosystem({ background }) : null), operations: () => (typeof loadOperations === 'function' ? loadOperations({ background }) : null), }; return loaders[page] || null; } function _pageStaleMs(page) { return _PAGE_STALE_MS[page] || _PAGE_STALE_MS.default; } function pageIsStale(page) { const last = _pageLoadTimestamps.get(page) || 0; return (Date.now() - last) > _pageStaleMs(page); } function ensurePageReady(page, { background = false, force = false } = {}) { const needsActiveHydrate = !background && _pageLoadModes.get(page) === 'background'; if (!force && !needsActiveHydrate && !pageIsStale(page)) return _pageLoadInFlight.get(page) || Promise.resolve(); if (_pageLoadInFlight.has(page)) return _pageLoadInFlight.get(page); const run = Promise.resolve() .then(() => window.ChimeraModuleLoader?.ensureForPage(page)) .then(() => { const loader = _pageLoader(page, background); return loader ? loader() : null; }) .then(() => { _pageLoadTimestamps.set(page, Date.now()); const priorMode = _pageLoadModes.get(page); _pageLoadModes.set(page, background && priorMode === 'active' ? 'active' : (background ? 'background' : 'active')); }) .catch(err => { if (background) { const method = isExpectedUnavailable(err) ? 'info' : 'warn'; console[method](`[page-load] ${page} was not preloaded: ${err.message}`); return; } throw err; }) .finally(() => { if (_pageLoadInFlight.get(page) === run) _pageLoadInFlight.delete(page); }); _pageLoadInFlight.set(page, run); return run; } function scheduleBackgroundWarm(delay = 600) { if (_pageWarmTimer || _pageWarmRunning) return; _pageWarmTimer = setTimeout(() => { _pageWarmTimer = null; startBackgroundWarm().catch(() => {}); }, delay); } async function startBackgroundWarm() { if (_pageWarmRunning) return; _pageWarmRunning = true; try { for (const page of _PAGE_WARM_ORDER) { const active = document.querySelector('.page.active')?.id?.replace('page-', ''); if (page === active) continue; await ensurePageReady(page, { background: true }); await new Promise(resolve => setTimeout(resolve, 250)); } } finally { _pageWarmRunning = false; scheduleBackgroundWarm(_PAGE_STALE_MS.default); } } function fmt(bytes) { const n = Number(bytes); if (!n || isNaN(n)) return '—'; const gb = n / 1073741824; return gb >= 1 ? `${gb.toFixed(2)} GB` : `${(n / 1048576).toFixed(1)} MB`; } function yesNo(v) { return v ? 'Yes' : 'No'; } function escHtml(s) { return String(s ?? '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } // Pinnable cards registry const PINNABLE_CARDS = [ { id: 'payload-update', label: 'Payload Update Checker', page: 'jailbreak', hint: 'Check sources for payload updates and push to PS5.' }, { id: 'payload-mgr', label: 'Payload Sender', page: 'jailbreak', type: 'widget', hint: 'Quick-send a payload to PS5 via TCP.' }, { id: 'psnotify', label: 'Send Notification', page: 'psnotify', type: 'widget', hint: 'Send a PS5 notification from the dashboard.' }, { id: 'xavatar-convert', label: 'XAvatar Quick Send', page: 'xavatar', type: 'widget', hint: 'Convert an image and send to PS5 as .xavatar.' }, { id: 'xavatar-library', label: 'XAvatar Library', page: 'xavatar', hint: 'Manage .xavatar files on your PS5.' }, { id: 'transfers', label: 'Transfer Manager', page: 'transfers', hint: 'Monitor active and queued FTP transfers.' }, { id: 'games', label: 'Games Library', page: 'games', hint: 'Browse and manage your PS5 game collection.' }, { id: 'cheats', label: 'Cheats', page: 'cheats', hint: 'Browse and install game cheats.' }, { id: 'media', label: 'Media', page: 'media', hint: 'Browse screenshots and video clips from PS5.' }, { id: 'system-view', label: 'System View', page: 'system-view', hint: 'View your PS5 via capture card stream.' }, { id: 'backups', label: 'Backups', page: 'backups', hint: 'Browse and manage game backup files.' }, // Other page widgets { id: 'dash-savemgr', label: 'Save Manager', page: 'savemgr', type: 'widget', hint: 'Browse and download PS5 save files.' }, ]; const _PIN_PAGE_LABELS = { jailbreak: 'Payload Workspace', psnotify: 'Notifications', xavatar: 'XAvatar', transfers: 'Transfers', games: 'Games', cheats: 'Cheats & patches', media: 'Media', 'system-view': 'System View', backups: 'Backups', savemgr: 'Local Save Manager', ecosystem: 'Console Status', operations: 'Operations', }; function setStatus(msg, type = '') { const el = $('sb-msg'); el.textContent = msg; el.style.color = type === 'error' ? 'var(--red)' : type === 'ok' ? 'var(--green)' : 'var(--yellow)'; if (msg) setTimeout(() => { if (el.textContent === msg) el.textContent = ''; }, 5000); } function titleHue(id){let h=0;for(const c of String(id||''))h=(h*31+c.charCodeAt(0))%360;return h;} // Server messages are Dutch; translated at display time via serverError.* keys. function friendlyMessage(text) { const message = String(text ?? '').trim(); if (!/^en(?:-|$)/i.test(document.documentElement.lang || 'en')) return message; const mapped = window.i18n?.strings?.['serverError.' + message]; if (mapped) return mapped; return message .replace(/niet bereikbaar/gi, 'unreachable') .replace(/niet beschikbaar/gi, 'unavailable') .replace(/niet geconfigureerd/gi, 'not configured') .replace(/ontbreekt/gi, 'is missing'); } function showToast(msg, type = 'info', duration = 5000) { const icons = { ok: '✓', error: '✕', info: '●' }; const safeType = Object.hasOwn(icons, type) ? type : 'info'; const message = friendlyMessage(msg); if (!message) return null; const container = $('toast-container'); const key = `${safeType}:${message.replace(/\s+/g, ' ').toLocaleLowerCase()}`; const existing = [...container.querySelectorAll('.toast')].find(item => item.dataset.toastKey === key); if (existing) { const count = Number(existing.dataset.toastCount || 1) + 1; existing.dataset.toastCount = String(count); let badge = existing.querySelector('.toast-count'); if (!badge) { badge = document.createElement('span'); badge.className = 'toast-count'; existing.querySelector('.toast-body')?.after(badge); } badge.textContent = `×${count}`; clearTimeout(existing._dismissTimer); existing._dismissTimer = setTimeout(() => existing._dismiss?.(), duration); return existing; } const toast = document.createElement('div'); toast.className = `toast toast-${safeType}`; toast.dataset.toastKey = key; toast.dataset.toastCount = '1'; toast.setAttribute('role', safeType === 'error' ? 'alert' : 'status'); const closeLabel = window.t?.('notifications.close', 'Close notification') || 'Close notification'; const icon = document.createElement('span'); icon.className = 'toast-icon'; icon.setAttribute('aria-hidden', 'true'); icon.textContent = icons[safeType]; const body = document.createElement('span'); body.className = 'toast-body'; body.textContent = message; const close = document.createElement('button'); close.className = 'toast-close'; close.type = 'button'; close.setAttribute('aria-label', closeLabel); close.textContent = '×'; toast.append(icon, body, close); while (container.children.length >= 4) container.firstElementChild?.remove(); container.appendChild(toast); const dismiss = () => { if (!toast.isConnected || toast.classList.contains('toast-out')) return; toast.classList.add('toast-out'); toast.addEventListener('animationend', () => toast.remove(), { once: true }); setTimeout(() => toast.remove(), 350); }; toast._dismiss = dismiss; close.addEventListener('click', dismiss); toast._dismissTimer = setTimeout(dismiss, duration); toast.addEventListener('mouseenter', () => clearTimeout(toast._dismissTimer)); toast.addEventListener('mouseleave', () => { toast._dismissTimer = setTimeout(dismiss, 2000); }); return toast; } // ── Navigation ──────────────────────────────────────────────────────────────── function navigate(page) { document.querySelectorAll('.nav-link').forEach(a => a.classList.toggle('active', a.dataset.page === page)); document.querySelectorAll('.page').forEach(p => p.classList.toggle('active', p.id === `page-${page}`)); // Embed pages need #content to be overflow:hidden so the iframe can fill available height const isEmbed = page === 'savemgr'; document.getElementById('content').classList.toggle('embed-page-active', isEmbed); ensurePageReady(page, { force: pageIsStale(page) }); scheduleBackgroundWarm(800); closeMobileNav(); syncPinStates(); } $('mobile-nav-toggle')?.addEventListener('click', () => { setMobileNavOpen(!document.body.classList.contains('mobile-nav-open')); }); $('sidebar-scrim')?.addEventListener('click', closeMobileNav); window.addEventListener('resize', () => { if (!_isNarrowLayout()) closeMobileNav(); }); async function syncPinStates() { try { const s = await window.pork.getSettings(); const pinned = new Set(s.pinnedCards || []); document.querySelectorAll('.btn-pin').forEach(btn => btn.classList.toggle('pinned', pinned.has(btn.dataset.pinId)) ); } catch (_) {} } async function loadJailbreak() { // Reset UI $('payload-check-status').textContent = ''; $('payload-sources-check-list').innerHTML = ''; if ($('payload-show-expert')) { $('payload-show-expert').checked = localStorage.getItem('chimera.payload.showExpert') === '1'; } try { await loadPayloadCatalog(); } catch (error) { $('payload-sources-check-list').innerHTML = `

${escHtml(window.t('payload.catalogLoadFailed', 'Catalog could not be loaded'))}: ${escHtml(error.message)}

`; } await renderLocalPayloads(); await renderPayloadSender(); await renderAutoloaderSnapshots(); try { const settings = await window.pork.getSettings(); if ($('pldmgr-port')) $('pldmgr-port').value = settings.payloadManagerPort || 8084; if (!settings.ftpHost) { if ($('service-matrix-summary')) $('service-matrix-summary').textContent = window.t('services.ps5NotConfigured', 'PS5 not configured'); if ($('service-matrix-grid')) $('service-matrix-grid').innerHTML = `

${escHtml(window.t('services.configureProfile', 'Configure a console profile before checking services.'))}

`; if ($('pldmgr-version')) $('pldmgr-version').textContent = 'offline'; if ($('pldmgr-status')) $('pldmgr-status').textContent = window.t('services.ps5NotConfigured', 'PS5 not configured'); return; } await loadPs5ServiceMatrix(); await connectPs5PayloadManager({ quiet: true }); } catch (_) {} } function isExpectedUnavailable(error) { if (error?.expected || ['NOT_CONFIGURED', 'UNAVAILABLE', 'UPSTREAM_UNAVAILABLE'].includes(error?.code)) return true; return /niet (?:bereikbaar|verbonden|beschikbaar|actief|geconfigureerd|gevonden)|maak eerst verbinding|not (?:reachable|connected|available|active|configured|found|mounted)|connect (?:to )?(?:the )?PS5|offline|ECONNREFUSED|timed? out|reageerde niet op tijd|HTTP (?:403|429|5\d\d)|rate.?limit|ontbreekt/i.test(String(error?.message || '')); }