708 lines
33 KiB
JavaScript
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">👤</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)}">⇓</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)}">✕</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;
|
|
});
|
|
|