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

708 lines
33 KiB
JavaScript

// ── PS Avatar ─────────────────────────────────────────────────────────────────
let _xavatarClient = null;
let _xavatarLastResult = null; // { buffer: ArrayBuffer, filename: string }
const _xavatarThumbCache = new Map(); // remotePath → blob URL
let _ps5ProfileInventory = null;
let _ps5ProfileSelectedUid = null;
let _ps5ProfileBound = false;
function ps5ProfileText(key, fallback) {
return typeof window.t === 'function' ? window.t(`ps5Profile.${key}`, fallback) : fallback;
}
function ps5ProfileSetAction(message, state = '') {
const target = $('ps5-profile-action-state');
if (!target) return;
target.textContent = message || '';
target.className = `ps5-profile-action-state${state ? ` is-${state}` : ''}`;
}
function ps5ProfileSelectedUser() {
return _ps5ProfileInventory?.users?.find(user => user.uid === _ps5ProfileSelectedUid) || null;
}
async function loadPs5ProfileRuntime() {
const card = document.querySelector('.ps5-profile-runtime');
const title = $('ps5-profile-runtime-title');
const state = $('ps5-profile-runtime-state');
const detail = $('ps5-profile-runtime-detail');
const button = $('btn-ps5-profile-start-payload');
if (!card || !state || !detail || !button || !window.pork?.ps5ProfileRuntimePreflight) return null;
card.classList.remove('is-ready');
button.disabled = true;
state.textContent = ps5ProfileText('runtimeCheckingShort', 'Checking…');
try {
const result = await window.pork.ps5ProfileRuntimePreflight();
if (title) title.textContent = `PS5Upload ${result.version || '5.4.19'}`;
const artifactsReady = result.engine?.verified && result.engine?.available && result.payload?.verified && result.console?.configured;
if (result.ready) {
card.classList.add('is-ready');
state.textContent = ps5ProfileText('runtimeReady', 'Ready to start');
detail.textContent = ps5ProfileText('runtimeReadyDetail', 'Exact engine and ELF hashes verified; the PS5 loader is reachable on port 9021.');
button.disabled = false;
} else if (artifactsReady && result.loader?.reason === 'unreachable') {
state.textContent = ps5ProfileText('runtimeLoaderClosed', 'Loader waiting');
detail.textContent = ps5ProfileText('runtimeLoaderHelp', 'Engine and ELF are verified. Start the jailbreak ELF loader on the PS5, then refresh this check.');
} else {
state.textContent = ps5ProfileText('runtimeBlocked', 'Preflight blocked');
detail.textContent = ps5ProfileText('runtimeBlockedDetail', 'A pinned artifact, configured PS5 host or local engine check failed.');
}
return result;
} catch (error) {
state.textContent = ps5ProfileText('runtimeBlocked', 'Preflight blocked');
detail.textContent = error.message;
return null;
}
}
function renderPs5ProfileOperations(operations = []) {
const target = $('ps5-profile-operations');
if (!target) return;
if (!operations.length) {
target.innerHTML = `<div class="ps5-profile-empty">${escHtml(ps5ProfileText('noActivity', 'No prepared profile changes yet.'))}</div>`;
return;
}
target.innerHTML = operations.slice().reverse().slice(0, 8).map(operation => {
const canRollback = ['verified', 'verification_failed', 'failed', 'interrupted'].includes(operation.status);
const label = operation.kind === 'rename' ? ps5ProfileText('renameActivity', 'Display name') : ps5ProfileText('avatarActivity', 'Profile image');
const detail = operation.kind === 'rename' ? `${operation.snapshot?.username || ''} → ${operation.desired?.username || ''}` : operation.uidHex;
return `<article class="ps5-profile-operation" data-profile-operation="${escHtml(operation.id)}" data-profile-uid="${escHtml(operation.uidHex)}">
<div><strong>${escHtml(label)} <span class="ps5-profile-operation-status">${escHtml(operation.status)}</span></strong><small>${escHtml(detail)} · ${escHtml(new Date(operation.updatedAt || operation.createdAt).toLocaleString())}</small></div>
${canRollback ? `<button type="button" class="btn btn-sm ps5-profile-rollback">${escHtml(ps5ProfileText('rollback', 'Restore previous state'))}</button>` : ''}
</article>`;
}).join('');
}
function selectPs5LocalProfile(uid) {
_ps5ProfileSelectedUid = Number(uid);
const user = ps5ProfileSelectedUser();
document.querySelectorAll('.ps5-profile-user').forEach(card => card.classList.toggle('is-selected', Number(card.dataset.profileUid) === _ps5ProfileSelectedUid));
const name = $('ps5-profile-name');
const rename = $('btn-ps5-profile-rename');
const applyAvatar = $('btn-ps5-profile-apply-avatar');
if (!user) return;
$('ps5-profile-selected-title').textContent = user.username;
$('ps5-profile-selected-uid').textContent = user.uidHex;
$('ps5-profile-avatar-initial').textContent = [...user.username][0]?.toUpperCase() || '?';
name.value = user.username;
name.disabled = false;
rename.disabled = false;
applyAvatar.disabled = !_xavatarClient?._masterCanvas;
ps5ProfileSetAction(user.avatar?.available
? ps5ProfileText('avatarObserved', 'Current profile image evidence was read successfully.')
: ps5ProfileText('avatarDefault', 'This user currently has no recoverable custom image. Applying is blocked until exact recovery is possible.'));
const preview = $('ps5-profile-avatar-preview');
const shell = preview?.closest('.ps5-profile-avatar-shell');
if (preview && shell && !_xavatarClient?._masterCanvas) {
shell.classList.remove('has-preview');
window.pork.ps5ProfileCurrentAvatar({ uid: user.uid }).then(result => {
if (_ps5ProfileSelectedUid !== user.uid || !result?.dataUrl || _xavatarClient?._masterCanvas) return;
const image = new Image();
image.onload = () => { const context = preview.getContext('2d'); context.clearRect(0, 0, preview.width, preview.height); context.drawImage(image, 0, 0, preview.width, preview.height); shell.classList.add('has-preview'); };
image.src = result.dataUrl;
}).catch(() => {});
}
}
async function loadPs5LocalProfiles() {
const provider = $('ps5-profile-provider-state');
const usersTarget = $('ps5-profile-users');
if (!provider || !usersTarget || !window.pork?.ps5ProfileInspect) return;
if (!_ps5ProfileBound) bindPs5ProfileWorkspace();
loadPs5ProfileRuntime();
provider.className = 'ps5-profile-provider-state';
provider.textContent = ps5ProfileText('checking', 'Checking the guarded profile bridge…');
usersTarget.innerHTML = `<div class="ps5-profile-empty">${escHtml(ps5ProfileText('loading', 'Reading local users…'))}</div>`;
try {
const [inventory, operations] = await Promise.all([window.pork.ps5ProfileInspect(), window.pork.ps5ProfileListOperations()]);
_ps5ProfileInventory = inventory;
renderPs5ProfileOperations(operations);
if (!inventory.available) {
provider.classList.add('is-blocked');
provider.textContent = ps5ProfileText('bridgeBlocked', 'Profile bridge unavailable · configure PS5 host and pinned PS5Upload 5.4.19');
usersTarget.innerHTML = `<div class="ps5-profile-empty">${escHtml(ps5ProfileText('bridgeHelp', 'Start the pinned loopback bridge and configure the PS5 host. No console change is possible while this gate is closed.'))}</div>`;
return;
}
provider.classList.add('is-ready');
provider.textContent = `${ps5ProfileText('bridgeReady', 'Verified bridge')} · ${inventory.provider?.version || '5.4.19'}`;
usersTarget.innerHTML = inventory.users.length ? inventory.users.map(user => `<button type="button" class="ps5-profile-user" data-profile-uid="${user.uid}">
<span class="ps5-profile-user-avatar">${escHtml([...user.username][0]?.toUpperCase() || '?')}</span><span><strong>${escHtml(user.username)}</strong><small>${escHtml(user.uidHex)}</small></span>${user.foreground ? `<em>${escHtml(ps5ProfileText('active', 'Active'))}</em>` : ''}
</button>`).join('') : `<div class="ps5-profile-empty">${escHtml(ps5ProfileText('noUsers', 'No local users were reported.'))}</div>`;
const preferred = inventory.users.find(user => user.uid === _ps5ProfileSelectedUid) || inventory.users.find(user => user.foreground) || inventory.users[0];
if (preferred) selectPs5LocalProfile(preferred.uid);
} catch (error) {
provider.classList.add('is-blocked');
provider.textContent = ps5ProfileText('bridgeError', 'Profile bridge check failed');
usersTarget.innerHTML = `<div class="ps5-profile-empty">${escHtml(error.message)}</div>`;
}
}
function bindPs5ProfileWorkspace() {
if (_ps5ProfileBound) return;
_ps5ProfileBound = true;
$('btn-ps5-profile-refresh')?.addEventListener('click', loadPs5LocalProfiles);
$('btn-ps5-profile-start-payload')?.addEventListener('click', async () => {
const button = $('btn-ps5-profile-start-payload');
const confirmation = await showExactConfirmation(
ps5ProfileText('startPayloadConfirm', 'Send only the verified PS5Upload 5.4.19 ELF to the configured PS5 loader? This payload remains active until rest mode or reboot.'),
'START PS5 PROFILE PAYLOAD',
);
if (!confirmation) return;
button.disabled = true;
ps5ProfileSetAction(ps5ProfileText('startingPayload', 'Sending the verified payload and checking the management service…'));
try {
const result = await window.pork.ps5ProfileStartPayload({ confirmation });
if (!result.managementReady) throw new Error(ps5ProfileText('payloadNotReady', 'The ELF was sent, but the PS5 management service did not become ready. Check the jailbreak and firmware compatibility.'));
ps5ProfileSetAction(ps5ProfileText('payloadReady', 'Verified payload active. Reading local profiles…'), 'ok');
await loadPs5LocalProfiles();
} catch (error) {
ps5ProfileSetAction(error.message, 'error');
await loadPs5ProfileRuntime();
}
});
$('ps5-profile-users')?.addEventListener('click', event => {
const card = event.target.closest('.ps5-profile-user');
if (card) selectPs5LocalProfile(card.dataset.profileUid);
});
$('btn-ps5-profile-rename')?.addEventListener('click', async () => {
const user = ps5ProfileSelectedUser();
if (!user) return;
ps5ProfileSetAction(ps5ProfileText('preparing', 'Creating recovery snapshot and checking fresh evidence…'));
try {
const operation = await window.pork.ps5ProfilePrepareRename({ uid: user.uid, name: $('ps5-profile-name').value });
const confirmation = await showExactConfirmation(ps5ProfileText('renameConfirm', 'Apply this local display name after the recovery snapshot and fresh-evidence check?'), operation.confirmation);
if (!confirmation) return void loadPs5LocalProfiles();
await window.pork.ps5ProfileApply({ id: operation.id, confirmation });
ps5ProfileSetAction(ps5ProfileText('verifiedRestart', 'Verified on the console. Restart the PS5 to show the change everywhere.'), 'ok');
await loadPs5LocalProfiles();
} catch (error) { ps5ProfileSetAction(error.message, 'error'); }
});
$('btn-ps5-profile-apply-avatar')?.addEventListener('click', async () => {
const user = ps5ProfileSelectedUser();
if (!user || !_xavatarClient?._masterCanvas) return;
ps5ProfileSetAction(ps5ProfileText('converting', 'Building the canonical profile image set…'));
try {
const converted = await _xavatarClient.convert();
const operation = await window.pork.ps5ProfilePrepareAvatar({ uid: user.uid, archive: converted.buffer });
const confirmation = await showExactConfirmation(ps5ProfileText('avatarConfirm', 'Apply this reviewed image after preserving the current profile image?'), operation.confirmation);
if (!confirmation) return void loadPs5LocalProfiles();
await window.pork.ps5ProfileApply({ id: operation.id, confirmation });
ps5ProfileSetAction(ps5ProfileText('verifiedRestart', 'Verified on the console. Restart the PS5 to show the change everywhere.'), 'ok');
await loadPs5LocalProfiles();
} catch (error) { ps5ProfileSetAction(error.message, 'error'); }
});
$('ps5-profile-operations')?.addEventListener('click', async event => {
const button = event.target.closest('.ps5-profile-rollback');
const row = button?.closest('.ps5-profile-operation');
if (!row) return;
const phrase = `ROLLBACK PS5 PROFILE ${row.dataset.profileUid}`;
const confirmation = await showExactConfirmation(ps5ProfileText('rollbackConfirm', 'Restore the exact local profile state captured before this operation?'), phrase);
if (!confirmation) return;
button.disabled = true;
try {
await window.pork.ps5ProfileRollback({ id: row.dataset.profileOperation, confirmation });
ps5ProfileSetAction(ps5ProfileText('rollbackVerified', 'Previous local profile state restored and verified.'), 'ok');
await loadPs5LocalProfiles();
} catch (error) { ps5ProfileSetAction(error.message, 'error'); button.disabled = false; }
});
}
async function loadXavatarCompanion() {
const status = $('xavatar-companion-status');
const button = $('btn-xavatar-install-companion');
if (!status || !button || !window.pork.xavatarCompanion) return;
try {
const companion = await window.pork.xavatarCompanion();
button.disabled = !companion.installerReady;
button.dataset.ready = companion.installerReady ? 'true' : 'false';
status.textContent = companion.installerReady
? `${companion.titleId} · v${companion.version} · ${(companion.size / 1024 / 1024).toFixed(1)} MB · etaHEN DPI ready`
: `${companion.titleId} · v${companion.version} · ${companion.installerError}`;
status.title = `Expected SHA-256: ${companion.sha256}`;
} catch (error) {
button.disabled = true;
status.textContent = `Installer check failed: ${error.message}`;
}
if (button.dataset.bound) return;
button.dataset.bound = 'true';
button.addEventListener('click', async () => {
const confirmation = await showExactConfirmation(
"Install Lapy's Avatar Changer through etaHEN Direct Package Installer? The PS5 downloads the fixed reviewed package URL; Chimera does not launch the app.",
'INSTALL XAVATAR',
);
if (!confirmation) return;
button.disabled = true;
status.textContent = 'Submitting package to etaHEN DPI…';
try {
await window.pork.xavatarInstallCompanion(confirmation);
status.textContent = 'Installation submitted to etaHEN DPI. Follow the package progress on your PS5.';
showToast('Avatar Changer installation submitted.', 'ok', 9000);
} catch (error) {
status.textContent = `Installation failed: ${error.message}`;
showToast(error.message, 'error', 10000);
}
await loadXavatarCompanion();
});
}
function loadXavatar() {
// Refresh the library on every navigate; only initialise the converter once.
loadXavatarLibrary();
loadXavatarCompanion();
loadPs5LocalProfiles();
if (_xavatarClient) return;
if (!window.XavatarClient) {
$('xavatar-status').textContent = 'XavatarClient not available — ensure the module script loaded.';
return;
}
// Build the API object — prefer the dedicated xavatarAPI bridge; fall back to
// the pork bridge channel so conversion still works even if the separate
// xavatarElectronModule preload failed to expose window.xavatarAPI.
let _xavApi = window.xavatarAPI;
if (!_xavApi) {
console.info('[Chimera] Dedicated XAvatar bridge unavailable; using the supported web compatibility path.');
_xavApi = {
async convertFromCanvas(canvas, opts = {}) {
const ctx = canvas.getContext('2d');
const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const rgba = new Uint8Array(imgData.data.buffer);
const res = await window.pork.xavatarConvertCanvas(
rgba, canvas.width, canvas.height, opts.filename || 'avatar'
);
if (!res || !res.ok) throw new Error((res && res.error) || 'Conversion failed');
return { buffer: res.buffer && res.buffer.buffer ? res.buffer.buffer : res.buffer, filename: res.filename };
}
};
}
_xavatarClient = new window.XavatarClient({ api: _xavApi });
const dropZone = $('xavatar-drop-zone');
const fileInput = $('xavatar-file-input');
const preview = $('xavatar-preview');
const status = $('xavatar-status');
const convertBtn = $('btn-xavatar-convert');
const sendBtn = $('btn-xavatar-send-ps5');
const convProgress = $('xavatar-conv-progress');
const convBar = $('xavatar-conv-bar');
function _xavatarSetProgress(pct, label) {
if (!convProgress) return;
convProgress.hidden = pct == null;
if (pct != null) {
convBar.style.width = `${pct}%`;
convBar.textContent = label || '';
}
}
_xavatarClient.on('image-ready', (canvas) => {
const ctx = preview.getContext('2d');
preview.width = canvas.width;
preview.height = canvas.height;
ctx.drawImage(canvas, 0, 0);
preview.hidden = false;
convertBtn.disabled = false;
sendBtn.disabled = false;
_xavatarLastResult = null; // new image — invalidate previous conversion
_xavatarSetProgress(null);
status.textContent = 'Image loaded — convert & download, or send directly to PS5';
dropZone.classList.add('xavatar-drop-zone--has-image');
const profilePreview = $('ps5-profile-avatar-preview');
const profileShell = profilePreview?.closest('.ps5-profile-avatar-shell');
if (profilePreview && profileShell) {
const profileContext = profilePreview.getContext('2d');
profileContext.clearRect(0, 0, profilePreview.width, profilePreview.height);
profileContext.drawImage(canvas, 0, 0, profilePreview.width, profilePreview.height);
profileShell.classList.add('has-preview');
$('btn-ps5-profile-apply-avatar').disabled = !ps5ProfileSelectedUser();
}
});
_xavatarClient.on('load-error', (err) => {
status.textContent = 'Failed to load image: ' + err.message;
});
_xavatarClient.on('convert-start', () => {
convertBtn.disabled = true;
convertBtn.textContent = 'Converting…';
status.textContent = 'Converting…';
});
_xavatarClient.on('convert-error', (err) => {
convertBtn.disabled = false;
convertBtn.textContent = 'Convert & Download';
_xavatarSetProgress(null);
status.textContent = 'Error: ' + err.message;
});
_xavatarClient.wireFileInput(fileInput);
_xavatarClient.wireDropZone(dropZone);
_xavatarClient.wirePaste(document);
$('btn-xavatar-browse').addEventListener('click', () => fileInput.click());
dropZone.addEventListener('click', (e) => {
if (e.target !== $('btn-xavatar-browse')) fileInput.click();
});
dropZone.addEventListener('dragover', () => dropZone.classList.add('xavatar-drop-zone--over'));
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('xavatar-drop-zone--over'));
dropZone.addEventListener('drop', () => dropZone.classList.remove('xavatar-drop-zone--over'));
convertBtn.addEventListener('click', async () => {
status.textContent = '';
_xavatarSetProgress(null);
try {
const result = await _xavatarClient.convert();
_xavatarLastResult = result;
convertBtn.disabled = false;
convertBtn.textContent = 'Convert & Download';
const saved = await window.pork.xavatarSaveFile(result.buffer, result.filename);
status.textContent = saved?.saved ? `Saved: ${result.filename}` : 'Converted — not saved.';
} catch (err) {
convertBtn.disabled = false;
convertBtn.textContent = 'Convert & Download';
if (!$('xavatar-status').textContent) {
status.textContent = 'Error: ' + err.message;
}
}
});
sendBtn.addEventListener('click', async () => {
if (!_xavatarClient?._masterCanvas) return;
convertBtn.disabled = true;
sendBtn.disabled = true;
sendBtn.textContent = 'Working…';
status.textContent = '';
try {
// ── Step 1: Convert ──────────────────────────────────────────────────
_xavatarSetProgress(10, 'Converting…');
status.textContent = 'Converting image to .xavatar…';
const result = await _xavatarClient.convert();
_xavatarLastResult = result;
// ── Step 2: Random filename ──────────────────────────────────────────
const pad = n => String(n).padStart(5, '0');
const rnd = pad(Math.floor(Math.random() * 100000));
const rndName = `chimera${rnd}.xavatar`;
_xavatarSetProgress(55, 'Uploading…');
status.textContent = `Queuing upload → /data/AVATARS/${rndName}…`;
// ── Step 3: Upload ──────────────────────────────────────────────────
await window.pork.xavatarUploadToPs5(result.buffer, rndName);
_xavatarSetProgress(100, 'Queued ✓');
status.textContent = `Queued → /data/AVATARS/${rndName}`;
sendBtn.textContent = '↗ Send to PS5';
sendBtn.disabled = false;
convertBtn.disabled = false;
convertBtn.textContent = 'Convert & Download';
if ($('xavatar-notify-toggle')?.checked) {
window.pork.psnotifySend('XAvatar Queued', rndName).catch(() => {});
}
setTimeout(() => _xavatarSetProgress(null), 3000);
} catch (e) {
_xavatarSetProgress(null);
convertBtn.disabled = false;
sendBtn.disabled = false;
sendBtn.textContent = '↗ Send to PS5';
convertBtn.textContent = 'Convert & Download';
status.textContent = 'Error: ' + e.message;
}
});
$('btn-xavatar-refresh-lib').addEventListener('click', loadXavatarLibrary);
}
function _xavatarFmtSize(bytes) {
if (!bytes) return '';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
async function loadXavatarLibrary() {
const listEl = $('xavatar-library-list');
const emptyEl = $('xavatar-library-empty');
if (!listEl) return;
listEl.innerHTML = '<div class="jb-list-placeholder" style="padding:18px 14px">Loading…</div>';
emptyEl.hidden = true;
try {
const ftpStatus = await window.pork.ftpStatus().catch(() => ({ connected: false }));
if (!ftpStatus?.connected) {
listEl.innerHTML = '';
emptyEl.hidden = false;
emptyEl.textContent = 'Connect to PS5 FTP to browse /data/AVATARS.';
return;
}
const files = await window.pork.ftpListDir('/data/AVATARS');
const xavs = files.filter(f => !f.isDir);
if (!xavs.length) {
listEl.innerHTML = '';
emptyEl.hidden = false;
return;
}
emptyEl.hidden = true;
listEl.innerHTML = '<div class="xavatar-lib-grid">' +
xavs.map(f => {
const rpath = `/data/AVATARS/${escHtml(f.name)}`;
return `<div class="xavatar-lib-card" data-xa-card-path="${rpath}" data-xa-card-name="${escHtml(f.name)}">
<div class="xavatar-thumb-wrap">
<img class="xavatar-thumb" data-xa-path="${rpath}" src="" alt="${escHtml(f.name)}">
<div class="xavatar-thumb-placeholder">&#128100;</div>
</div>
<div class="xavatar-lib-card-info">
<span class="xavatar-lib-card-name" title="${escHtml(f.name)}">${escHtml(f.name)}</span>
<span class="xavatar-lib-card-size">${_xavatarFmtSize(f.size)}</span>
</div>
<div class="xavatar-lib-card-actions">
<button class="btn btn-xs" title="Download .xavatar" data-xa="download" data-xa-path="${rpath}" data-xa-name="${escHtml(f.name)}">&#8659;</button>
<button class="btn btn-xs btn-danger" title="Delete from PS5" data-xa="delete" data-xa-path="${rpath}" data-xa-name="${escHtml(f.name)}">&#10005;</button>
</div>
</div>`;
}).join('') + '</div>';
_setupXavatarThumbObserver(listEl);
} catch (err) {
listEl.innerHTML = '';
emptyEl.hidden = false;
emptyEl.textContent = 'Failed to load library: ' + err.message;
}
}
/** Attach IntersectionObserver to lazily load thumbnails as cards scroll into view */
function _setupXavatarThumbObserver(container) {
const imgs = container.querySelectorAll('img.xavatar-thumb');
if (!imgs.length) return;
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
observer.unobserve(img);
_loadXavatarThumb(img, img.dataset.xaPath);
}
});
}, { rootMargin: '120px', threshold: 0.01 });
imgs.forEach(img => observer.observe(img));
}
/** Fetch + extract avatar.png from a .xavatar on FTP, cache as blob URL */
async function _getXavatarThumbUrl(rpath) {
let url = _xavatarThumbCache.get(rpath);
if (!url) {
const xavBuf = await window.pork.ftpDownloadFile(rpath);
const pngBuf = await window.pork.xavatarExtractPng(xavBuf);
const blob = new Blob([pngBuf], { type: 'image/png' });
url = URL.createObjectURL(blob);
_xavatarThumbCache.set(rpath, url);
}
return url;
}
/** Load one thumbnail into an img element */
async function _loadXavatarThumb(img, rpath) {
const card = img.closest?.('.xavatar-lib-card');
try {
const url = await _getXavatarThumbUrl(rpath);
img.src = url;
img.onload = () => card?.classList.add('xavatar-thumb-loaded');
img.onerror = () => card?.classList.add('xavatar-thumb-error');
} catch (_) {
card?.classList.add('xavatar-thumb-error');
}
}
// ── XAvatar full-size modal ───────────────────────────────────────────────────
let _xavatarModalPath = null;
function _xavatarOpenModal(rpath, name) {
_xavatarModalPath = rpath;
const modal = $('xavatar-thumb-modal');
const img = $('xavatar-modal-img');
const namEl = $('xavatar-modal-name');
img.src = '';
img.alt = 'Loading…';
namEl.textContent = name;
openAccessibleDialog(modal, $('btn-xavatar-modal-close'));
// reset modal send button
const sendBtn = $('btn-xavatar-modal-send');
if (sendBtn) { sendBtn.disabled = false; sendBtn.textContent = '↗ Send to PS5'; }
_getXavatarThumbUrl(rpath)
.then(url => { if (_xavatarModalPath === rpath) { img.src = url; img.alt = name; } })
.catch(() => { if (_xavatarModalPath === rpath) img.alt = 'Preview unavailable'; });
}
// Close modal on backdrop click or close button
document.addEventListener('click', e => {
const modal = $('xavatar-thumb-modal');
if (!modal || modal.hidden) return;
if (e.target === modal || e.target.id === 'btn-xavatar-modal-close') {
closeAccessibleDialog(modal);
_xavatarModalPath = null;
}
});
document.addEventListener('keydown', event => {
const modal = $('xavatar-thumb-modal');
if (event.key === 'Escape' && modal && !modal.hidden) {
closeAccessibleDialog(modal);
_xavatarModalPath = null;
}
});
// ── XAvatar: format picker dialog ──────────────────────────────────────────
function _xavatarPickFormat() {
return new Promise(resolve => {
const dlg = document.createElement('dialog');
dlg.style.cssText = [
'border:none',
'border-radius:10px',
'padding:22px 26px 18px',
'background:var(--bg2,#1e1e2e)',
'color:var(--fg,#cdd6f4)',
'box-shadow:0 8px 32px rgba(0,0,0,.65)',
'text-align:center',
'min-width:260px',
'position:fixed',
'top:50%',
'left:50%',
'transform:translate(-50%,-50%)',
'margin:0',
].join(';');
dlg.innerHTML = [
'<p id="_xa-fmt-title" style="margin:0 0 16px;font-size:14px;font-weight:600;">Choose download format</p>',
'<div style="display:flex;gap:10px;justify-content:center;">',
' <button class="btn btn-teal" id="_xa-fmt-xav" style="min-width:96px;">.xavatar</button>',
' <button class="btn" id="_xa-fmt-png" style="min-width:96px;">.png</button>',
' <button class="btn" id="_xa-fmt-cancel" style="min-width:74px;opacity:.7;">Cancel</button>',
'</div>',
].join('');
dlg.setAttribute('aria-labelledby', '_xa-fmt-title');
document.body.appendChild(dlg);
dlg.showModal();
const done = (val) => { dlg.removeEventListener('close', onClose); dlg.close(); dlg.remove(); resolve(val); };
const onClose = () => { dlg.remove(); resolve(null); };
dlg.addEventListener('close', onClose);
dlg.querySelector('#_xa-fmt-xav').addEventListener('click', () => done('xavatar'));
dlg.querySelector('#_xa-fmt-png').addEventListener('click', () => done('png'));
dlg.querySelector('#_xa-fmt-cancel').addEventListener('click', () => done(null));
});
}
// Modal action buttons
function _xavatarBindModalButtons() {
const modalDownload = $('btn-xavatar-modal-download');
const modalToPng = $('btn-xavatar-modal-topng');
const modalSend = $('btn-xavatar-modal-send');
if (!modalDownload) return;
modalDownload.addEventListener('click', async () => {
if (!_xavatarModalPath) return;
const fmt = await _xavatarPickFormat();
if (!fmt) return;
const name = $('xavatar-modal-name')?.textContent || 'avatar.xavatar';
modalDownload.disabled = true;
try {
const buf = await window.pork.ftpDownloadFile(_xavatarModalPath);
if (fmt === 'png') {
const pngName = name.replace(/\.xavatar$/i, '') + '.png';
const pngBuf = await window.pork.xavatarExtractPng(buf);
await window.pork.xavatarSaveFile(pngBuf, pngName);
} else {
await window.pork.xavatarSaveFile(buf, name);
}
} catch (err) { showToast('Download failed: ' + err.message, 'error'); }
modalDownload.disabled = false;
});
modalToPng.addEventListener('click', async () => {
if (!_xavatarModalPath) return;
const name = $('xavatar-modal-name')?.textContent || 'avatar';
const pngName = name.replace(/\.xavatar$/i, '') + '.png';
modalToPng.disabled = true;
try {
const xavBuf = await window.pork.ftpDownloadFile(_xavatarModalPath);
const pngBuf = await window.pork.xavatarExtractPng(xavBuf);
await window.pork.xavatarSaveFile(pngBuf, pngName);
} catch (err) { showToast('PNG save failed: ' + err.message, 'error'); }
modalToPng.disabled = false;
});
modalSend.addEventListener('click', async () => {
if (!_xavatarModalPath) return;
const name = $('xavatar-modal-name')?.textContent || 'avatar.xavatar';
modalSend.disabled = true;
modalSend.textContent = 'Sending…';
try {
const buf = await window.pork.ftpDownloadFile(_xavatarModalPath);
await window.pork.xavatarUploadToPs5(buf, name);
modalSend.textContent = '↗ Send to PS5';
modalSend.disabled = false;
showToast(`Queued: /data/AVATARS/${name}`, 'success');
} catch (err) {
showToast('Upload failed: ' + err.message, 'error');
modalSend.textContent = '↗ Send to PS5';
modalSend.disabled = false;
}
});
}
// Bind now if the DOM is already parsed, otherwise wait — this module may be
// loaded after DOMContentLoaded has already fired.
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', _xavatarBindModalButtons);
else _xavatarBindModalButtons();
// ── XAvatar: card thumbnail click → open modal ────────────────────────────────
document.addEventListener('click', e => {
const card = e.target.closest('.xavatar-lib-card');
if (card && !e.target.closest('[data-xa]')) {
_xavatarOpenModal(card.dataset.xaCardPath, card.dataset.xaCardName);
}
});
// ── XAvatar: library card action buttons (↓ download / ✕ delete) ─────────────
document.addEventListener('click', async e => {
const btn = e.target.closest('[data-xa]');
if (!btn) return;
const action = btn.dataset.xa;
const rpath = btn.dataset.xaPath;
const name = btn.dataset.xaName;
btn.disabled = true;
try {
if (action === 'download') {
const fmt = await _xavatarPickFormat();
if (!fmt) { btn.disabled = false; return; }
const buf = await window.pork.ftpDownloadFile(rpath);
if (fmt === 'png') {
const pngName = name.replace(/\.xavatar$/i, '') + '.png';
const pngBuf = await window.pork.xavatarExtractPng(buf);
await window.pork.xavatarSaveFile(pngBuf, pngName);
} else {
await window.pork.xavatarSaveFile(buf, name);
}
} else if (action === 'delete') {
// Native browser dialogs are blocked in this app; use the in-app modal.
const confirmation = await showExactConfirmation(`Delete /data/AVATARS/${name} from your PS5?`, 'VERWIJDER VAN PS5');
if (!confirmation) { btn.disabled = false; return; }
await window.pork.ftpDeleteFile(rpath, confirmation);
btn.closest('.xavatar-lib-card')?.remove();
_xavatarThumbCache.delete(rpath);
const grid = $('xavatar-library-list')?.querySelector('.xavatar-lib-grid');
if (grid && !grid.children.length) $('xavatar-library-empty').hidden = false;
}
} catch (err) {
showToast('XAvatar: ' + err.message, 'error');
}
btn.disabled = false;
});