Files
chimera-Public/renderer/modules/state.js
T

486 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 ? '<span class="pill pill-yes">Yes</span>' : '<span class="pill pill-no">No</span>';
}
function escHtml(s) {
return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// 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 = `<p class="hint">${escHtml(window.t('payload.catalogLoadFailed', 'Catalog could not be loaded'))}: ${escHtml(error.message)}</p>`;
}
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 = `<p class="hint">${escHtml(window.t('services.configureProfile', 'Configure a console profile before checking services.'))}</p>`;
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 || ''));
}